feat: add token revoke --all and improve scopes documentation
- Add --all flag to `gordon auth token revoke` to revoke all tokens - Enhance token generate help with detailed admin scopes documentation - Add RevokeAllTokens method to auth service - Update tests and documentation
- commit
- 25f0ae52312efccee6c0bafcab73de5b52e80fc7
- author
- brice · · 2026-01-17 09:18 UTC
- parent
- 7da87b2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +326 -13
M internal/adapters/in/cli/auth.go +74 -11
| @@ -63,16 +63,38 @@ func newTokenGenerateCmd() *cobra.Command { | |||
| 63 | 63 | cmd := &cobra.Command{ | |
| 64 | 64 | Use: "generate", | |
| 65 | 65 | Short: "Generate a new authentication token", | |
| 66 | - | Long: `Generate a new JWT authentication token for registry access. | |
| 66 | + | Long: `Generate a new JWT authentication token for registry and/or admin access. | |
| 67 | 67 | ||
| 68 | 68 | The token will be stored in the configured secrets backend and can be used | |
| 69 | 69 | for docker login as: docker login -u <subject> -p <token> | |
| 70 | 70 | ||
| 71 | - | Expiry supports human-friendly durations: | |
| 72 | - | - d (days): 1d = 24 hours | |
| 73 | - | - w (weeks): 1w = 7 days | |
| 74 | - | - M (months): 1M = 30 days | |
| 75 | - | - y (years): 1y = 365 days | |
| 71 | + | SCOPES: | |
| 72 | + | ||
| 73 | + | Registry scopes (for Docker push/pull): | |
| 74 | + | push Push images to registry | |
| 75 | + | pull Pull images from registry | |
| 76 | + | ||
| 77 | + | Admin scopes (for remote CLI access): | |
| 78 | + | Format: admin:<resource>:<actions> | |
| 79 | + | ||
| 80 | + | Resources: routes, secrets, config, status, * (all) | |
| 81 | + | Actions: read, write, * (all) | |
| 82 | + | ||
| 83 | + | Examples: | |
| 84 | + | admin:*:* Full admin access (recommended for CLI) | |
| 85 | + | admin:routes:read Read-only routes access | |
| 86 | + | admin:status:read Read-only status access | |
| 87 | + | ||
| 88 | + | Combined examples: | |
| 89 | + | --scopes "push,pull" Registry only (default) | |
| 90 | + | --scopes "admin:*:*" Admin CLI only | |
| 91 | + | --scopes "push,pull,admin:*:*" Registry + full admin | |
| 92 | + | ||
| 93 | + | EXPIRY: | |
| 94 | + | ||
| 95 | + | Supports human-friendly durations: | |
| 96 | + | d (days): 1d = 24 hours w (weeks): 1w = 7 days | |
| 97 | + | M (months): 1M = 30 days y (years): 1y = 365 days | |
| 76 | 98 | ||
| 77 | 99 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | |
| 78 | 100 | Use --expiry=0 for a token that never expires (useful for CI).`, | |
| @@ -111,19 +133,33 @@ func newTokenListCmd() *cobra.Command { | |||
| 111 | 133 | ||
| 112 | 134 | // newTokenRevokeCmd creates the token revoke command. | |
| 113 | 135 | func newTokenRevokeCmd() *cobra.Command { | |
| 114 | - | var configPath string | |
| 136 | + | var ( | |
| 137 | + | configPath string | |
| 138 | + | all bool | |
| 139 | + | ) | |
| 115 | 140 | ||
| 116 | 141 | cmd := &cobra.Command{ | |
| 117 | - | Use: "revoke <token-id>", | |
| 118 | - | Short: "Revoke an authentication token", | |
| 119 | - | Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`, | |
| 120 | - | Args: cobra.ExactArgs(1), | |
| 142 | + | Use: "revoke [token-id]", | |
| 143 | + | Short: "Revoke authentication token(s)", | |
| 144 | + | Long: `Revoke a token by its ID, or all tokens with --all. | |
| 145 | + | ||
| 146 | + | Examples: | |
| 147 | + | gordon auth token revoke abc123-def456 Revoke specific token | |
| 148 | + | gordon auth token revoke --all Revoke all tokens`, | |
| 149 | + | Args: cobra.MaximumNArgs(1), | |
| 121 | 150 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 151 | + | if all { | |
| 152 | + | return runTokenRevokeAll(configPath) | |
| 153 | + | } | |
| 154 | + | if len(args) == 0 { | |
| 155 | + | return fmt.Errorf("token ID required (or use --all to revoke all tokens)") | |
| 156 | + | } | |
| 122 | 157 | return runTokenRevoke(args[0], configPath) | |
| 123 | 158 | }, | |
| 124 | 159 | } | |
| 125 | 160 | ||
| 126 | 161 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 162 | + | cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens") | |
| 127 | 163 | ||
| 128 | 164 | return cmd | |
| 129 | 165 | } | |
| @@ -314,6 +350,33 @@ func runTokenRevoke(tokenID, configPath string) error { | |||
| 314 | 350 | return nil | |
| 315 | 351 | } | |
| 316 | 352 | ||
| 353 | + | // runTokenRevokeAll revokes all tokens. | |
| 354 | + | func runTokenRevokeAll(configPath string) error { | |
| 355 | + | cfg, err := loadAuthConfig(configPath) | |
| 356 | + | if err != nil { | |
| 357 | + | return err | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |
| 361 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |
| 362 | + | if err != nil { | |
| 363 | + | return err | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | ctx := context.Background() | |
| 367 | + | count, err := authSvc.RevokeAllTokens(ctx) | |
| 368 | + | if err != nil { | |
| 369 | + | return fmt.Errorf("failed to revoke tokens: %w", err) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | if count == 0 { | |
| 373 | + | fmt.Println("No tokens to revoke.") | |
| 374 | + | } else { | |
| 375 | + | fmt.Printf("Revoked %d token(s).\n", count) | |
| 376 | + | } | |
| 377 | + | return nil | |
| 378 | + | } | |
| 379 | + | ||
| 317 | 380 | // runPasswordHash generates a bcrypt hash for a password. | |
| 318 | 381 | func runPasswordHash() error { | |
| 319 | 382 | fmt.Print("Enter password: ") | |
| @@ -63,16 +63,38 @@ func newTokenGenerateCmd() *cobra.Command { | |||||
| 63 | cmd := &cobra.Command{ | 63 | cmd := &cobra.Command{ | ||
| 64 | Use: "generate", | 64 | Use: "generate", | ||
| 65 | Short: "Generate a new authentication token", | 65 | Short: "Generate a new authentication token", | ||
| 66 | - | Long: `Generate a new JWT authentication token for registry access. | 66 | + | Long: `Generate a new JWT authentication token for registry and/or admin access. |
| 67 | 67 | ||||
| 68 | The token will be stored in the configured secrets backend and can be used | 68 | The token will be stored in the configured secrets backend and can be used | ||
| 69 | for docker login as: docker login -u <subject> -p <token> | 69 | for docker login as: docker login -u <subject> -p <token> | ||
| 70 | 70 | ||||
| 71 | - | Expiry supports human-friendly durations: | 71 | + | SCOPES: |
| 72 | - | - d (days): 1d = 24 hours | 72 | + | |
| 73 | - | - w (weeks): 1w = 7 days | 73 | + | Registry scopes (for Docker push/pull): |
| 74 | - | - M (months): 1M = 30 days | 74 | + | push Push images to registry |
| 75 | - | - y (years): 1y = 365 days | 75 | + | pull Pull images from registry |
| 76 | + | ||||
| 77 | + | Admin scopes (for remote CLI access): | |||
| 78 | + | Format: admin:<resource>:<actions> | |||
| 79 | + | ||||
| 80 | + | Resources: routes, secrets, config, status, * (all) | |||
| 81 | + | Actions: read, write, * (all) | |||
| 82 | + | ||||
| 83 | + | Examples: | |||
| 84 | + | admin:*:* Full admin access (recommended for CLI) | |||
| 85 | + | admin:routes:read Read-only routes access | |||
| 86 | + | admin:status:read Read-only status access | |||
| 87 | + | ||||
| 88 | + | Combined examples: | |||
| 89 | + | --scopes "push,pull" Registry only (default) | |||
| 90 | + | --scopes "admin:*:*" Admin CLI only | |||
| 91 | + | --scopes "push,pull,admin:*:*" Registry + full admin | |||
| 92 | + | ||||
| 93 | + | EXPIRY: | |||
| 94 | + | ||||
| 95 | + | Supports human-friendly durations: | |||
| 96 | + | d (days): 1d = 24 hours w (weeks): 1w = 7 days | |||
| 97 | + | M (months): 1M = 30 days y (years): 1y = 365 days | |||
| 76 | 98 | ||||
| 77 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | 99 | Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d | ||
| 78 | Use --expiry=0 for a token that never expires (useful for CI).`, | 100 | Use --expiry=0 for a token that never expires (useful for CI).`, | ||
| @@ -111,19 +133,33 @@ func newTokenListCmd() *cobra.Command { | |||||
| 111 | 133 | ||||
| 112 | // newTokenRevokeCmd creates the token revoke command. | 134 | // newTokenRevokeCmd creates the token revoke command. | ||
| 113 | func newTokenRevokeCmd() *cobra.Command { | 135 | func newTokenRevokeCmd() *cobra.Command { | ||
| 114 | - | var configPath string | 136 | + | var ( |
| 137 | + | configPath string | |||
| 138 | + | all bool | |||
| 139 | + | ) | |||
| 115 | 140 | ||||
| 116 | cmd := &cobra.Command{ | 141 | cmd := &cobra.Command{ | ||
| 117 | - | Use: "revoke <token-id>", | 142 | + | Use: "revoke [token-id]", |
| 118 | - | Short: "Revoke an authentication token", | 143 | + | Short: "Revoke authentication token(s)", |
| 119 | - | Long: `Revoke a token by its ID. The token will no longer be valid for authentication.`, | 144 | + | Long: `Revoke a token by its ID, or all tokens with --all. |
| 120 | - | Args: cobra.ExactArgs(1), | 145 | + | |
| 146 | + | Examples: | |||
| 147 | + | gordon auth token revoke abc123-def456 Revoke specific token | |||
| 148 | + | gordon auth token revoke --all Revoke all tokens`, | |||
| 149 | + | Args: cobra.MaximumNArgs(1), | |||
| 121 | RunE: func(cmd *cobra.Command, args []string) error { | 150 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 151 | + | if all { | |||
| 152 | + | return runTokenRevokeAll(configPath) | |||
| 153 | + | } | |||
| 154 | + | if len(args) == 0 { | |||
| 155 | + | return fmt.Errorf("token ID required (or use --all to revoke all tokens)") | |||
| 156 | + | } | |||
| 122 | return runTokenRevoke(args[0], configPath) | 157 | return runTokenRevoke(args[0], configPath) | ||
| 123 | }, | 158 | }, | ||
| 124 | } | 159 | } | ||
| 125 | 160 | ||||
| 126 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | 161 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | ||
| 162 | + | cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens") | |||
| 127 | 163 | ||||
| 128 | return cmd | 164 | return cmd | ||
| 129 | } | 165 | } | ||
| @@ -314,6 +350,33 @@ func runTokenRevoke(tokenID, configPath string) error { | |||||
| 314 | return nil | 350 | return nil | ||
| 315 | } | 351 | } | ||
| 316 | 352 | ||||
| 353 | + | // runTokenRevokeAll revokes all tokens. | |||
| 354 | + | func runTokenRevokeAll(configPath string) error { | |||
| 355 | + | cfg, err := loadAuthConfig(configPath) | |||
| 356 | + | if err != nil { | |||
| 357 | + | return err | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | log := zerowrap.New(zerowrap.Config{Level: "warn"}) | |||
| 361 | + | authSvc, err := createAuthServiceForCLI(cfg, log) | |||
| 362 | + | if err != nil { | |||
| 363 | + | return err | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | ctx := context.Background() | |||
| 367 | + | count, err := authSvc.RevokeAllTokens(ctx) | |||
| 368 | + | if err != nil { | |||
| 369 | + | return fmt.Errorf("failed to revoke tokens: %w", err) | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | if count == 0 { | |||
| 373 | + | fmt.Println("No tokens to revoke.") | |||
| 374 | + | } else { | |||
| 375 | + | fmt.Printf("Revoked %d token(s).\n", count) | |||
| 376 | + | } | |||
| 377 | + | return nil | |||
| 378 | + | } | |||
| 379 | + | ||||
| 317 | // runPasswordHash generates a bcrypt hash for a password. | 380 | // runPasswordHash generates a bcrypt hash for a password. | ||
| 318 | func runPasswordHash() error { | 381 | func runPasswordHash() error { | ||
| 319 | fmt.Print("Enter password: ") | 382 | fmt.Print("Enter password: ") | ||
M internal/adapters/in/cli/remotes.go +10 -1
| @@ -2,6 +2,7 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "gordon/internal/adapters/in/cli/remote" | |
| 7 | 8 | "gordon/internal/adapters/in/cli/ui/components" | |
| @@ -107,7 +108,7 @@ Examples: | |||
| 107 | 108 | Args: cobra.ExactArgs(2), | |
| 108 | 109 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 109 | 110 | name := args[0] | |
| 110 | - | url := args[1] | |
| 111 | + | url := normalizeURL(args[1]) | |
| 111 | 112 | ||
| 112 | 113 | // If token-env is provided, use that instead of token | |
| 113 | 114 | finalToken := token | |
| @@ -244,3 +245,11 @@ Examples: | |||
| 244 | 245 | }, | |
| 245 | 246 | } | |
| 246 | 247 | } | |
| 248 | + | ||
| 249 | + | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | |
| 250 | + | func normalizeURL(url string) string { | |
| 251 | + | if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { | |
| 252 | + | return url | |
| 253 | + | } | |
| 254 | + | return "https://" + url | |
| 255 | + | } | |
| @@ -2,6 +2,7 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "strings" | |||
| 5 | 6 | ||||
| 6 | "gordon/internal/adapters/in/cli/remote" | 7 | "gordon/internal/adapters/in/cli/remote" | ||
| 7 | "gordon/internal/adapters/in/cli/ui/components" | 8 | "gordon/internal/adapters/in/cli/ui/components" | ||
| @@ -107,7 +108,7 @@ Examples: | |||||
| 107 | Args: cobra.ExactArgs(2), | 108 | Args: cobra.ExactArgs(2), | ||
| 108 | RunE: func(cmd *cobra.Command, args []string) error { | 109 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 109 | name := args[0] | 110 | name := args[0] | ||
| 110 | - | url := args[1] | 111 | + | url := normalizeURL(args[1]) |
| 111 | 112 | ||||
| 112 | // If token-env is provided, use that instead of token | 113 | // If token-env is provided, use that instead of token | ||
| 113 | finalToken := token | 114 | finalToken := token | ||
| @@ -244,3 +245,11 @@ Examples: | |||||
| 244 | }, | 245 | }, | ||
| 245 | } | 246 | } | ||
| 246 | } | 247 | } | ||
| 248 | + | ||||
| 249 | + | // normalizeURL ensures the URL has a protocol scheme, defaulting to https. | |||
| 250 | + | func normalizeURL(url string) string { | |||
| 251 | + | if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { | |||
| 252 | + | return url | |||
| 253 | + | } | |||
| 254 | + | return "https://" + url | |||
| 255 | + | } | |||
M internal/adapters/in/cli/root.go +13 -0
| @@ -89,6 +89,10 @@ a running container. Running containers are never restarted to ensure 100% uptim | |||
| 89 | 89 | Use this command after editing config.toml to add new routes, or after pushing | |
| 90 | 90 | images to the registry when the route was not yet configured.`, | |
| 91 | 91 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 92 | + | client, isRemote := GetRemoteClient() | |
| 93 | + | if isRemote { | |
| 94 | + | return runReloadRemote(cmd.Context(), client) | |
| 95 | + | } | |
| 92 | 96 | return runReload() | |
| 93 | 97 | }, | |
| 94 | 98 | } | |
| @@ -112,6 +116,15 @@ func runReload() error { | |||
| 112 | 116 | return app.SendReloadSignal() | |
| 113 | 117 | } | |
| 114 | 118 | ||
| 119 | + | // runReloadRemote triggers a reload on a remote Gordon instance. | |
| 120 | + | func runReloadRemote(ctx context.Context, client *remote.Client) error { | |
| 121 | + | if err := client.Reload(ctx); err != nil { | |
| 122 | + | return fmt.Errorf("failed to reload: %w", err) | |
| 123 | + | } | |
| 124 | + | fmt.Println("Configuration reloaded successfully") | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | ||
| 115 | 128 | // newLogsCmd creates the logs command. | |
| 116 | 129 | func newLogsCmd() *cobra.Command { | |
| 117 | 130 | var follow bool | |
| @@ -89,6 +89,10 @@ a running container. Running containers are never restarted to ensure 100% uptim | |||||
| 89 | Use this command after editing config.toml to add new routes, or after pushing | 89 | Use this command after editing config.toml to add new routes, or after pushing | ||
| 90 | images to the registry when the route was not yet configured.`, | 90 | images to the registry when the route was not yet configured.`, | ||
| 91 | RunE: func(cmd *cobra.Command, args []string) error { | 91 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 92 | + | client, isRemote := GetRemoteClient() | |||
| 93 | + | if isRemote { | |||
| 94 | + | return runReloadRemote(cmd.Context(), client) | |||
| 95 | + | } | |||
| 92 | return runReload() | 96 | return runReload() | ||
| 93 | }, | 97 | }, | ||
| 94 | } | 98 | } | ||
| @@ -112,6 +116,15 @@ func runReload() error { | |||||
| 112 | return app.SendReloadSignal() | 116 | return app.SendReloadSignal() | ||
| 113 | } | 117 | } | ||
| 114 | 118 | ||||
| 119 | + | // runReloadRemote triggers a reload on a remote Gordon instance. | |||
| 120 | + | func runReloadRemote(ctx context.Context, client *remote.Client) error { | |||
| 121 | + | if err := client.Reload(ctx); err != nil { | |||
| 122 | + | return fmt.Errorf("failed to reload: %w", err) | |||
| 123 | + | } | |||
| 124 | + | fmt.Println("Configuration reloaded successfully") | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 115 | // newLogsCmd creates the logs command. | 128 | // newLogsCmd creates the logs command. | ||
| 116 | func newLogsCmd() *cobra.Command { | 129 | func newLogsCmd() *cobra.Command { | ||
| 117 | var follow bool | 130 | var follow bool | ||
M internal/adapters/in/http/middleware/auth_test.go +4 -0
| @@ -489,6 +489,10 @@ func (s stubAuthService) RevokeToken(context.Context, string) error { | |||
| 489 | 489 | return errors.New("not implemented") | |
| 490 | 490 | } | |
| 491 | 491 | ||
| 492 | + | func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) { | |
| 493 | + | return 0, errors.New("not implemented") | |
| 494 | + | } | |
| 495 | + | ||
| 492 | 496 | func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | |
| 493 | 497 | return nil, errors.New("not implemented") | |
| 494 | 498 | } | |
| @@ -489,6 +489,10 @@ func (s stubAuthService) RevokeToken(context.Context, string) error { | |||||
| 489 | return errors.New("not implemented") | 489 | return errors.New("not implemented") | ||
| 490 | } | 490 | } | ||
| 491 | 491 | ||||
| 492 | + | func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) { | |||
| 493 | + | return 0, errors.New("not implemented") | |||
| 494 | + | } | |||
| 495 | + | ||||
| 492 | func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | 496 | func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | ||
| 493 | return nil, errors.New("not implemented") | 497 | return nil, errors.New("not implemented") | ||
| 494 | } | 498 | } | ||
M internal/app/run.go +11 -1
| @@ -210,9 +210,15 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) { | |||
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | 212 | if cfg.Logging.File.Enabled { | |
| 213 | + | logPath := cfg.Logging.File.Path | |
| 214 | + | if logPath == "" { | |
| 215 | + | // Default to {data_dir}/logs/gordon.log | |
| 216 | + | logPath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log") | |
| 217 | + | } | |
| 218 | + | ||
| 213 | 219 | log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{ | |
| 214 | 220 | Enabled: true, | |
| 215 | - | Path: cfg.Logging.File.Path, | |
| 221 | + | Path: logPath, | |
| 216 | 222 | MaxSize: cfg.Logging.File.MaxSize, | |
| 217 | 223 | MaxBackups: cfg.Logging.File.MaxBackups, | |
| 218 | 224 | MaxAge: cfg.Logging.File.MaxAge, | |
| @@ -317,6 +323,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 317 | 323 | ||
| 318 | 324 | // Create log service for accessing logs via admin API | |
| 319 | 325 | logFilePath := cfg.Logging.File.Path | |
| 326 | + | if logFilePath == "" && cfg.Logging.File.Enabled { | |
| 327 | + | // Default to {data_dir}/logs/gordon.log when file logging enabled but no path specified | |
| 328 | + | logFilePath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log") | |
| 329 | + | } | |
| 320 | 330 | logSvc := logs.NewService(logFilePath, svc.containerSvc, svc.runtime, log) | |
| 321 | 331 | ||
| 322 | 332 | // Create admin handler for admin API | |
| @@ -210,9 +210,15 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) { | |||||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| 212 | if cfg.Logging.File.Enabled { | 212 | if cfg.Logging.File.Enabled { | ||
| 213 | + | logPath := cfg.Logging.File.Path | |||
| 214 | + | if logPath == "" { | |||
| 215 | + | // Default to {data_dir}/logs/gordon.log | |||
| 216 | + | logPath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log") | |||
| 217 | + | } | |||
| 218 | + | ||||
| 213 | log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{ | 219 | log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{ | ||
| 214 | Enabled: true, | 220 | Enabled: true, | ||
| 215 | - | Path: cfg.Logging.File.Path, | 221 | + | Path: logPath, |
| 216 | MaxSize: cfg.Logging.File.MaxSize, | 222 | MaxSize: cfg.Logging.File.MaxSize, | ||
| 217 | MaxBackups: cfg.Logging.File.MaxBackups, | 223 | MaxBackups: cfg.Logging.File.MaxBackups, | ||
| 218 | MaxAge: cfg.Logging.File.MaxAge, | 224 | MaxAge: cfg.Logging.File.MaxAge, | ||
| @@ -317,6 +323,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 317 | 323 | ||||
| 318 | // Create log service for accessing logs via admin API | 324 | // Create log service for accessing logs via admin API | ||
| 319 | logFilePath := cfg.Logging.File.Path | 325 | logFilePath := cfg.Logging.File.Path | ||
| 326 | + | if logFilePath == "" && cfg.Logging.File.Enabled { | |||
| 327 | + | // Default to {data_dir}/logs/gordon.log when file logging enabled but no path specified | |||
| 328 | + | logFilePath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log") | |||
| 329 | + | } | |||
| 320 | logSvc := logs.NewService(logFilePath, svc.containerSvc, svc.runtime, log) | 330 | logSvc := logs.NewService(logFilePath, svc.containerSvc, svc.runtime, log) | ||
| 321 | 331 | ||||
| 322 | // Create admin handler for admin API | 332 | // Create admin handler for admin API | ||
M internal/boundaries/in/auth.go +3 -0
| @@ -34,6 +34,9 @@ type AuthService interface { | |||
| 34 | 34 | // RevokeToken revokes a token by its ID. | |
| 35 | 35 | RevokeToken(ctx context.Context, tokenID string) error | |
| 36 | 36 | ||
| 37 | + | // RevokeAllTokens revokes all stored tokens. | |
| 38 | + | RevokeAllTokens(ctx context.Context) (int, error) | |
| 39 | + | ||
| 37 | 40 | // ListTokens returns all stored tokens. | |
| 38 | 41 | ListTokens(ctx context.Context) ([]domain.Token, error) | |
| 39 | 42 | ||
| @@ -34,6 +34,9 @@ type AuthService interface { | |||||
| 34 | // RevokeToken revokes a token by its ID. | 34 | // RevokeToken revokes a token by its ID. | ||
| 35 | RevokeToken(ctx context.Context, tokenID string) error | 35 | RevokeToken(ctx context.Context, tokenID string) error | ||
| 36 | 36 | ||||
| 37 | + | // RevokeAllTokens revokes all stored tokens. | |||
| 38 | + | RevokeAllTokens(ctx context.Context) (int, error) | |||
| 39 | + | ||||
| 37 | // ListTokens returns all stored tokens. | 40 | // ListTokens returns all stored tokens. | ||
| 38 | ListTokens(ctx context.Context) ([]domain.Token, error) | 41 | ListTokens(ctx context.Context) ([]domain.Token, error) | ||
| 39 | 42 | ||||
M internal/boundaries/in/mocks/mock_auth_service.go +60 -0
| @@ -405,6 +405,66 @@ func (_c *MockAuthService_ListTokens_Call) RunAndReturn(run func(ctx context.Con | |||
| 405 | 405 | return _c | |
| 406 | 406 | } | |
| 407 | 407 | ||
| 408 | + | // RevokeAllTokens provides a mock function for the type MockAuthService | |
| 409 | + | func (_mock *MockAuthService) RevokeAllTokens(ctx context.Context) (int, error) { | |
| 410 | + | ret := _mock.Called(ctx) | |
| 411 | + | ||
| 412 | + | if len(ret) == 0 { | |
| 413 | + | panic("no return value specified for RevokeAllTokens") | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | var r0 int | |
| 417 | + | var r1 error | |
| 418 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (int, error)); ok { | |
| 419 | + | return returnFunc(ctx) | |
| 420 | + | } | |
| 421 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) int); ok { | |
| 422 | + | r0 = returnFunc(ctx) | |
| 423 | + | } else { | |
| 424 | + | r0 = ret.Get(0).(int) | |
| 425 | + | } | |
| 426 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 427 | + | r1 = returnFunc(ctx) | |
| 428 | + | } else { | |
| 429 | + | r1 = ret.Error(1) | |
| 430 | + | } | |
| 431 | + | return r0, r1 | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // MockAuthService_RevokeAllTokens_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeAllTokens' | |
| 435 | + | type MockAuthService_RevokeAllTokens_Call struct { | |
| 436 | + | *mock.Call | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // RevokeAllTokens is a helper method to define mock.On call | |
| 440 | + | // - ctx context.Context | |
| 441 | + | func (_e *MockAuthService_Expecter) RevokeAllTokens(ctx interface{}) *MockAuthService_RevokeAllTokens_Call { | |
| 442 | + | return &MockAuthService_RevokeAllTokens_Call{Call: _e.mock.On("RevokeAllTokens", ctx)} | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (_c *MockAuthService_RevokeAllTokens_Call) Run(run func(ctx context.Context)) *MockAuthService_RevokeAllTokens_Call { | |
| 446 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 447 | + | var arg0 context.Context | |
| 448 | + | if args[0] != nil { | |
| 449 | + | arg0 = args[0].(context.Context) | |
| 450 | + | } | |
| 451 | + | run( | |
| 452 | + | arg0, | |
| 453 | + | ) | |
| 454 | + | }) | |
| 455 | + | return _c | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func (_c *MockAuthService_RevokeAllTokens_Call) Return(n int, err error) *MockAuthService_RevokeAllTokens_Call { | |
| 459 | + | _c.Call.Return(n, err) | |
| 460 | + | return _c | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func (_c *MockAuthService_RevokeAllTokens_Call) RunAndReturn(run func(ctx context.Context) (int, error)) *MockAuthService_RevokeAllTokens_Call { | |
| 464 | + | _c.Call.Return(run) | |
| 465 | + | return _c | |
| 466 | + | } | |
| 467 | + | ||
| 408 | 468 | // RevokeToken provides a mock function for the type MockAuthService | |
| 409 | 469 | func (_mock *MockAuthService) RevokeToken(ctx context.Context, tokenID string) error { | |
| 410 | 470 | ret := _mock.Called(ctx, tokenID) | |
| @@ -405,6 +405,66 @@ func (_c *MockAuthService_ListTokens_Call) RunAndReturn(run func(ctx context.Con | |||||
| 405 | return _c | 405 | return _c | ||
| 406 | } | 406 | } | ||
| 407 | 407 | ||||
| 408 | + | // RevokeAllTokens provides a mock function for the type MockAuthService | |||
| 409 | + | func (_mock *MockAuthService) RevokeAllTokens(ctx context.Context) (int, error) { | |||
| 410 | + | ret := _mock.Called(ctx) | |||
| 411 | + | ||||
| 412 | + | if len(ret) == 0 { | |||
| 413 | + | panic("no return value specified for RevokeAllTokens") | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | var r0 int | |||
| 417 | + | var r1 error | |||
| 418 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (int, error)); ok { | |||
| 419 | + | return returnFunc(ctx) | |||
| 420 | + | } | |||
| 421 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) int); ok { | |||
| 422 | + | r0 = returnFunc(ctx) | |||
| 423 | + | } else { | |||
| 424 | + | r0 = ret.Get(0).(int) | |||
| 425 | + | } | |||
| 426 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 427 | + | r1 = returnFunc(ctx) | |||
| 428 | + | } else { | |||
| 429 | + | r1 = ret.Error(1) | |||
| 430 | + | } | |||
| 431 | + | return r0, r1 | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // MockAuthService_RevokeAllTokens_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RevokeAllTokens' | |||
| 435 | + | type MockAuthService_RevokeAllTokens_Call struct { | |||
| 436 | + | *mock.Call | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | // RevokeAllTokens is a helper method to define mock.On call | |||
| 440 | + | // - ctx context.Context | |||
| 441 | + | func (_e *MockAuthService_Expecter) RevokeAllTokens(ctx interface{}) *MockAuthService_RevokeAllTokens_Call { | |||
| 442 | + | return &MockAuthService_RevokeAllTokens_Call{Call: _e.mock.On("RevokeAllTokens", ctx)} | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func (_c *MockAuthService_RevokeAllTokens_Call) Run(run func(ctx context.Context)) *MockAuthService_RevokeAllTokens_Call { | |||
| 446 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 447 | + | var arg0 context.Context | |||
| 448 | + | if args[0] != nil { | |||
| 449 | + | arg0 = args[0].(context.Context) | |||
| 450 | + | } | |||
| 451 | + | run( | |||
| 452 | + | arg0, | |||
| 453 | + | ) | |||
| 454 | + | }) | |||
| 455 | + | return _c | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func (_c *MockAuthService_RevokeAllTokens_Call) Return(n int, err error) *MockAuthService_RevokeAllTokens_Call { | |||
| 459 | + | _c.Call.Return(n, err) | |||
| 460 | + | return _c | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func (_c *MockAuthService_RevokeAllTokens_Call) RunAndReturn(run func(ctx context.Context) (int, error)) *MockAuthService_RevokeAllTokens_Call { | |||
| 464 | + | _c.Call.Return(run) | |||
| 465 | + | return _c | |||
| 466 | + | } | |||
| 467 | + | ||||
| 408 | // RevokeToken provides a mock function for the type MockAuthService | 468 | // RevokeToken provides a mock function for the type MockAuthService | ||
| 409 | func (_mock *MockAuthService) RevokeToken(ctx context.Context, tokenID string) error { | 469 | func (_mock *MockAuthService) RevokeToken(ctx context.Context, tokenID string) error { | ||
| 410 | ret := _mock.Called(ctx, tokenID) | 470 | ret := _mock.Called(ctx, tokenID) | ||
M internal/usecase/auth/service.go +29 -0
| @@ -386,6 +386,35 @@ func (s *Service) RevokeToken(ctx context.Context, tokenID string) error { | |||
| 386 | 386 | return nil | |
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | + | // RevokeAllTokens revokes all stored tokens and returns the count of revoked tokens. | |
| 390 | + | func (s *Service) RevokeAllTokens(ctx context.Context) (int, error) { | |
| 391 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 392 | + | zerowrap.FieldLayer: "usecase", | |
| 393 | + | zerowrap.FieldUseCase: "RevokeAllTokens", | |
| 394 | + | }) | |
| 395 | + | log := zerowrap.FromCtx(ctx) | |
| 396 | + | ||
| 397 | + | tokens, err := s.tokenStore.ListTokens(ctx) | |
| 398 | + | if err != nil { | |
| 399 | + | return 0, log.WrapErr(err, "failed to list tokens") | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | revoked := 0 | |
| 403 | + | for _, token := range tokens { | |
| 404 | + | if token.Revoked { | |
| 405 | + | continue // Already revoked | |
| 406 | + | } | |
| 407 | + | if err := s.tokenStore.Revoke(ctx, token.ID); err != nil { | |
| 408 | + | log.Warn().Err(err).Str("token_id", token.ID).Msg("failed to revoke token") | |
| 409 | + | continue | |
| 410 | + | } | |
| 411 | + | revoked++ | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | log.Info().Int("count", revoked).Msg("all tokens revoked") | |
| 415 | + | return revoked, nil | |
| 416 | + | } | |
| 417 | + | ||
| 389 | 418 | // ListTokens returns all stored tokens. | |
| 390 | 419 | func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |
| 391 | 420 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -386,6 +386,35 @@ func (s *Service) RevokeToken(ctx context.Context, tokenID string) error { | |||||
| 386 | return nil | 386 | return nil | ||
| 387 | } | 387 | } | ||
| 388 | 388 | ||||
| 389 | + | // RevokeAllTokens revokes all stored tokens and returns the count of revoked tokens. | |||
| 390 | + | func (s *Service) RevokeAllTokens(ctx context.Context) (int, error) { | |||
| 391 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 392 | + | zerowrap.FieldLayer: "usecase", | |||
| 393 | + | zerowrap.FieldUseCase: "RevokeAllTokens", | |||
| 394 | + | }) | |||
| 395 | + | log := zerowrap.FromCtx(ctx) | |||
| 396 | + | ||||
| 397 | + | tokens, err := s.tokenStore.ListTokens(ctx) | |||
| 398 | + | if err != nil { | |||
| 399 | + | return 0, log.WrapErr(err, "failed to list tokens") | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | revoked := 0 | |||
| 403 | + | for _, token := range tokens { | |||
| 404 | + | if token.Revoked { | |||
| 405 | + | continue // Already revoked | |||
| 406 | + | } | |||
| 407 | + | if err := s.tokenStore.Revoke(ctx, token.ID); err != nil { | |||
| 408 | + | log.Warn().Err(err).Str("token_id", token.ID).Msg("failed to revoke token") | |||
| 409 | + | continue | |||
| 410 | + | } | |||
| 411 | + | revoked++ | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | log.Info().Int("count", revoked).Msg("all tokens revoked") | |||
| 415 | + | return revoked, nil | |||
| 416 | + | } | |||
| 417 | + | ||||
| 389 | // ListTokens returns all stored tokens. | 418 | // ListTokens returns all stored tokens. | ||
| 390 | func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | 419 | func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | ||
| 391 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 420 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
M internal/usecase/auth/service_test.go +77 -0
| @@ -541,6 +541,83 @@ func TestService_RevokeToken(t *testing.T) { | |||
| 541 | 541 | assert.NoError(t, err) | |
| 542 | 542 | } | |
| 543 | 543 | ||
| 544 | + | func TestService_RevokeAllTokens(t *testing.T) { | |
| 545 | + | t.Run("revokes all active tokens", func(t *testing.T) { | |
| 546 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 547 | + | ||
| 548 | + | tokens := []domain.Token{ | |
| 549 | + | {ID: "token-1", Subject: "user1", Revoked: false}, | |
| 550 | + | {ID: "token-2", Subject: "user2", Revoked: false}, | |
| 551 | + | {ID: "token-3", Subject: "user3", Revoked: true}, // already revoked | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | tokenStore.EXPECT(). | |
| 555 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |
| 556 | + | Return(tokens, nil) | |
| 557 | + | ||
| 558 | + | // Should only revoke the two non-revoked tokens | |
| 559 | + | tokenStore.EXPECT(). | |
| 560 | + | Revoke(mock.Anything, "token-1"). | |
| 561 | + | Return(nil) | |
| 562 | + | tokenStore.EXPECT(). | |
| 563 | + | Revoke(mock.Anything, "token-2"). | |
| 564 | + | Return(nil) | |
| 565 | + | ||
| 566 | + | svc := NewService(Config{ | |
| 567 | + | Enabled: true, | |
| 568 | + | AuthType: domain.AuthTypeToken, | |
| 569 | + | }, tokenStore, zerowrap.Default()) | |
| 570 | + | ||
| 571 | + | ctx := testContext() | |
| 572 | + | count, err := svc.RevokeAllTokens(ctx) | |
| 573 | + | ||
| 574 | + | require.NoError(t, err) | |
| 575 | + | assert.Equal(t, 2, count) | |
| 576 | + | }) | |
| 577 | + | ||
| 578 | + | t.Run("returns zero when no active tokens", func(t *testing.T) { | |
| 579 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 580 | + | ||
| 581 | + | tokens := []domain.Token{ | |
| 582 | + | {ID: "token-1", Subject: "user1", Revoked: true}, | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | tokenStore.EXPECT(). | |
| 586 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |
| 587 | + | Return(tokens, nil) | |
| 588 | + | ||
| 589 | + | svc := NewService(Config{ | |
| 590 | + | Enabled: true, | |
| 591 | + | AuthType: domain.AuthTypeToken, | |
| 592 | + | }, tokenStore, zerowrap.Default()) | |
| 593 | + | ||
| 594 | + | ctx := testContext() | |
| 595 | + | count, err := svc.RevokeAllTokens(ctx) | |
| 596 | + | ||
| 597 | + | require.NoError(t, err) | |
| 598 | + | assert.Equal(t, 0, count) | |
| 599 | + | }) | |
| 600 | + | ||
| 601 | + | t.Run("returns zero when no tokens exist", func(t *testing.T) { | |
| 602 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 603 | + | ||
| 604 | + | tokenStore.EXPECT(). | |
| 605 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |
| 606 | + | Return([]domain.Token{}, nil) | |
| 607 | + | ||
| 608 | + | svc := NewService(Config{ | |
| 609 | + | Enabled: true, | |
| 610 | + | AuthType: domain.AuthTypeToken, | |
| 611 | + | }, tokenStore, zerowrap.Default()) | |
| 612 | + | ||
| 613 | + | ctx := testContext() | |
| 614 | + | count, err := svc.RevokeAllTokens(ctx) | |
| 615 | + | ||
| 616 | + | require.NoError(t, err) | |
| 617 | + | assert.Equal(t, 0, count) | |
| 618 | + | }) | |
| 619 | + | } | |
| 620 | + | ||
| 544 | 621 | func TestService_ListTokens(t *testing.T) { | |
| 545 | 622 | tokenStore := mocks.NewMockTokenStore(t) | |
| 546 | 623 | ||
| @@ -541,6 +541,83 @@ func TestService_RevokeToken(t *testing.T) { | |||||
| 541 | assert.NoError(t, err) | 541 | assert.NoError(t, err) | ||
| 542 | } | 542 | } | ||
| 543 | 543 | ||||
| 544 | + | func TestService_RevokeAllTokens(t *testing.T) { | |||
| 545 | + | t.Run("revokes all active tokens", func(t *testing.T) { | |||
| 546 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 547 | + | ||||
| 548 | + | tokens := []domain.Token{ | |||
| 549 | + | {ID: "token-1", Subject: "user1", Revoked: false}, | |||
| 550 | + | {ID: "token-2", Subject: "user2", Revoked: false}, | |||
| 551 | + | {ID: "token-3", Subject: "user3", Revoked: true}, // already revoked | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | tokenStore.EXPECT(). | |||
| 555 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |||
| 556 | + | Return(tokens, nil) | |||
| 557 | + | ||||
| 558 | + | // Should only revoke the two non-revoked tokens | |||
| 559 | + | tokenStore.EXPECT(). | |||
| 560 | + | Revoke(mock.Anything, "token-1"). | |||
| 561 | + | Return(nil) | |||
| 562 | + | tokenStore.EXPECT(). | |||
| 563 | + | Revoke(mock.Anything, "token-2"). | |||
| 564 | + | Return(nil) | |||
| 565 | + | ||||
| 566 | + | svc := NewService(Config{ | |||
| 567 | + | Enabled: true, | |||
| 568 | + | AuthType: domain.AuthTypeToken, | |||
| 569 | + | }, tokenStore, zerowrap.Default()) | |||
| 570 | + | ||||
| 571 | + | ctx := testContext() | |||
| 572 | + | count, err := svc.RevokeAllTokens(ctx) | |||
| 573 | + | ||||
| 574 | + | require.NoError(t, err) | |||
| 575 | + | assert.Equal(t, 2, count) | |||
| 576 | + | }) | |||
| 577 | + | ||||
| 578 | + | t.Run("returns zero when no active tokens", func(t *testing.T) { | |||
| 579 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 580 | + | ||||
| 581 | + | tokens := []domain.Token{ | |||
| 582 | + | {ID: "token-1", Subject: "user1", Revoked: true}, | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | tokenStore.EXPECT(). | |||
| 586 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |||
| 587 | + | Return(tokens, nil) | |||
| 588 | + | ||||
| 589 | + | svc := NewService(Config{ | |||
| 590 | + | Enabled: true, | |||
| 591 | + | AuthType: domain.AuthTypeToken, | |||
| 592 | + | }, tokenStore, zerowrap.Default()) | |||
| 593 | + | ||||
| 594 | + | ctx := testContext() | |||
| 595 | + | count, err := svc.RevokeAllTokens(ctx) | |||
| 596 | + | ||||
| 597 | + | require.NoError(t, err) | |||
| 598 | + | assert.Equal(t, 0, count) | |||
| 599 | + | }) | |||
| 600 | + | ||||
| 601 | + | t.Run("returns zero when no tokens exist", func(t *testing.T) { | |||
| 602 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 603 | + | ||||
| 604 | + | tokenStore.EXPECT(). | |||
| 605 | + | ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })). | |||
| 606 | + | Return([]domain.Token{}, nil) | |||
| 607 | + | ||||
| 608 | + | svc := NewService(Config{ | |||
| 609 | + | Enabled: true, | |||
| 610 | + | AuthType: domain.AuthTypeToken, | |||
| 611 | + | }, tokenStore, zerowrap.Default()) | |||
| 612 | + | ||||
| 613 | + | ctx := testContext() | |||
| 614 | + | count, err := svc.RevokeAllTokens(ctx) | |||
| 615 | + | ||||
| 616 | + | require.NoError(t, err) | |||
| 617 | + | assert.Equal(t, 0, count) | |||
| 618 | + | }) | |||
| 619 | + | } | |||
| 620 | + | ||||
| 544 | func TestService_ListTokens(t *testing.T) { | 621 | func TestService_ListTokens(t *testing.T) { | ||
| 545 | tokenStore := mocks.NewMockTokenStore(t) | 622 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 546 | 623 | ||||
M wiki/guides/remote-cli.md +45 -0
| @@ -210,6 +210,33 @@ These commands work with remote targeting: | |||
| 210 | 210 | | `gordon secrets set <domain> KEY=value` | Set secrets | | |
| 211 | 211 | | `gordon secrets remove <domain> <key>` | Remove a secret | | |
| 212 | 212 | | `gordon status` | Show server status | | |
| 213 | + | | `gordon reload` | Reload config and start new routes | | |
| 214 | + | | `gordon logs` | View Gordon process logs | | |
| 215 | + | | `gordon logs <domain>` | View container logs | | |
| 216 | + | ||
| 217 | + | ### Remote Logs Requirement | |
| 218 | + | ||
| 219 | + | The `gordon logs` command requires **file logging to be enabled** on the remote server. Without it, you'll get a "failed to get logs" error. | |
| 220 | + | ||
| 221 | + | On the remote Gordon server, add to `config.toml`: | |
| 222 | + | ||
| 223 | + | ```toml | |
| 224 | + | [logging.file] | |
| 225 | + | enabled = true | |
| 226 | + | # path is optional - defaults to {data_dir}/logs/gordon.log | |
| 227 | + | # path = "/var/log/gordon/gordon.log" | |
| 228 | + | ``` | |
| 229 | + | ||
| 230 | + | Then restart Gordon. The log directory will be created automatically in your data directory (e.g., `~/.gordon/logs/`). | |
| 231 | + | ||
| 232 | + | If you specify a custom path, ensure the directory exists and is writable: | |
| 233 | + | ||
| 234 | + | ```bash | |
| 235 | + | sudo mkdir -p /var/log/gordon | |
| 236 | + | sudo chown gordon:gordon /var/log/gordon # adjust user as needed | |
| 237 | + | ``` | |
| 238 | + | ||
| 239 | + | > **Note:** When running as a systemd service, Gordon logs to journalctl by default. However, the remote admin API cannot read from journalctl—it reads from the configured log file. Both can be used simultaneously. | |
| 213 | 240 | ||
| 214 | 241 | ## Token Security | |
| 215 | 242 | ||
| @@ -385,6 +412,24 @@ Or use explicit flags: | |||
| 385 | 412 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |
| 386 | 413 | ``` | |
| 387 | 414 | ||
| 415 | + | ### "failed to get logs" Error | |
| 416 | + | ||
| 417 | + | The remote Gordon server doesn't have file logging enabled. | |
| 418 | + | ||
| 419 | + | ```bash | |
| 420 | + | # Error: failed to get process logs: 500 Internal Server Error: failed to get logs | |
| 421 | + | ``` | |
| 422 | + | ||
| 423 | + | On the remote server, enable file logging in `config.toml`: | |
| 424 | + | ||
| 425 | + | ```toml | |
| 426 | + | [logging.file] | |
| 427 | + | enabled = true | |
| 428 | + | path = "/var/log/gordon/gordon.log" | |
| 429 | + | ``` | |
| 430 | + | ||
| 431 | + | See [Remote Logs Requirement](#remote-logs-requirement) for details. | |
| 432 | + | ||
| 388 | 433 | ## Related | |
| 389 | 434 | ||
| 390 | 435 | - [CLI Commands](/docs/cli/commands.md) | |
| @@ -210,6 +210,33 @@ These commands work with remote targeting: | |||||
| 210 | | `gordon secrets set <domain> KEY=value` | Set secrets | | 210 | | `gordon secrets set <domain> KEY=value` | Set secrets | | ||
| 211 | | `gordon secrets remove <domain> <key>` | Remove a secret | | 211 | | `gordon secrets remove <domain> <key>` | Remove a secret | | ||
| 212 | | `gordon status` | Show server status | | 212 | | `gordon status` | Show server status | | ||
| 213 | + | | `gordon reload` | Reload config and start new routes | | |||
| 214 | + | | `gordon logs` | View Gordon process logs | | |||
| 215 | + | | `gordon logs <domain>` | View container logs | | |||
| 216 | + | ||||
| 217 | + | ### Remote Logs Requirement | |||
| 218 | + | ||||
| 219 | + | The `gordon logs` command requires **file logging to be enabled** on the remote server. Without it, you'll get a "failed to get logs" error. | |||
| 220 | + | ||||
| 221 | + | On the remote Gordon server, add to `config.toml`: | |||
| 222 | + | ||||
| 223 | + | ```toml | |||
| 224 | + | [logging.file] | |||
| 225 | + | enabled = true | |||
| 226 | + | # path is optional - defaults to {data_dir}/logs/gordon.log | |||
| 227 | + | # path = "/var/log/gordon/gordon.log" | |||
| 228 | + | ``` | |||
| 229 | + | ||||
| 230 | + | Then restart Gordon. The log directory will be created automatically in your data directory (e.g., `~/.gordon/logs/`). | |||
| 231 | + | ||||
| 232 | + | If you specify a custom path, ensure the directory exists and is writable: | |||
| 233 | + | ||||
| 234 | + | ```bash | |||
| 235 | + | sudo mkdir -p /var/log/gordon | |||
| 236 | + | sudo chown gordon:gordon /var/log/gordon # adjust user as needed | |||
| 237 | + | ``` | |||
| 238 | + | ||||
| 239 | + | > **Note:** When running as a systemd service, Gordon logs to journalctl by default. However, the remote admin API cannot read from journalctl—it reads from the configured log file. Both can be used simultaneously. | |||
| 213 | 240 | ||||
| 214 | ## Token Security | 241 | ## Token Security | ||
| 215 | 242 | ||||
| @@ -385,6 +412,24 @@ Or use explicit flags: | |||||
| 385 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | 412 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | ||
| 386 | ``` | 413 | ``` | ||
| 387 | 414 | ||||
| 415 | + | ### "failed to get logs" Error | |||
| 416 | + | ||||
| 417 | + | The remote Gordon server doesn't have file logging enabled. | |||
| 418 | + | ||||
| 419 | + | ```bash | |||
| 420 | + | # Error: failed to get process logs: 500 Internal Server Error: failed to get logs | |||
| 421 | + | ``` | |||
| 422 | + | ||||
| 423 | + | On the remote server, enable file logging in `config.toml`: | |||
| 424 | + | ||||
| 425 | + | ```toml | |||
| 426 | + | [logging.file] | |||
| 427 | + | enabled = true | |||
| 428 | + | path = "/var/log/gordon/gordon.log" | |||
| 429 | + | ``` | |||
| 430 | + | ||||
| 431 | + | See [Remote Logs Requirement](#remote-logs-requirement) for details. | |||
| 432 | + | ||||
| 388 | ## Related | 433 | ## Related | ||
| 389 | 434 | ||||
| 390 | - [CLI Commands](/docs/cli/commands.md) | 435 | - [CLI Commands](/docs/cli/commands.md) | ||