detached 25f0ae5

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
6363 cmd := &cobra.Command{
6464 Use: "generate",
6565 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.
6767
6868 The token will be stored in the configured secrets backend and can be used
6969 for docker login as: docker login -u <subject> -p <token>
7070
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
7698
7799 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
78100 Use --expiry=0 for a token that never expires (useful for CI).`,
@@ -111,19 +133,33 @@ func newTokenListCmd() *cobra.Command {
111133
112134 // newTokenRevokeCmd creates the token revoke command.
113135 func newTokenRevokeCmd() *cobra.Command {
114- var configPath string
136+ var (
137+ configPath string
138+ all bool
139+ )
115140
116141 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),
121150 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+ }
122157 return runTokenRevoke(args[0], configPath)
123158 },
124159 }
125160
126161 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
162+ cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
127163
128164 return cmd
129165 }
@@ -314,6 +350,33 @@ func runTokenRevoke(tokenID, configPath string) error {
314350 return nil
315351 }
316352
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+
317380 // runPasswordHash generates a bcrypt hash for a password.
318381 func runPasswordHash() error {
319382 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 used68 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 hours72+
73- - w (weeks): 1w = 7 days73+Registry scopes (for Docker push/pull):
74- - M (months): 1M = 30 days74+ push Push images to registry
75- - y (years): 1y = 365 days75+ 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, 2w3d99 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 string136+ 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 cmd164 return cmd
129 }165 }
@@ -314,6 +350,33 @@ func runTokenRevoke(tokenID, configPath string) error {
314 return nil350 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
22
33 import (
44 "fmt"
5+ "strings"
56
67 "gordon/internal/adapters/in/cli/remote"
78 "gordon/internal/adapters/in/cli/ui/components"
@@ -107,7 +108,7 @@ Examples:
107108 Args: cobra.ExactArgs(2),
108109 RunE: func(cmd *cobra.Command, args []string) error {
109110 name := args[0]
110- url := args[1]
111+ url := normalizeURL(args[1])
111112
112113 // If token-env is provided, use that instead of token
113114 finalToken := token
@@ -244,3 +245,11 @@ Examples:
244245 },
245246 }
246247 }
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 token113 // If token-env is provided, use that instead of token
113 finalToken := token114 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
8989 Use this command after editing config.toml to add new routes, or after pushing
9090 images to the registry when the route was not yet configured.`,
9191 RunE: func(cmd *cobra.Command, args []string) error {
92+ client, isRemote := GetRemoteClient()
93+ if isRemote {
94+ return runReloadRemote(cmd.Context(), client)
95+ }
9296 return runReload()
9397 },
9498 }
@@ -112,6 +116,15 @@ func runReload() error {
112116 return app.SendReloadSignal()
113117 }
114118
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+
115128 // newLogsCmd creates the logs command.
116129 func newLogsCmd() *cobra.Command {
117130 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 pushing89 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 bool130 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 {
489489 return errors.New("not implemented")
490490 }
491491
492+func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
493+ return 0, errors.New("not implemented")
494+}
495+
492496 func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
493497 return nil, errors.New("not implemented")
494498 }
@@ -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) {
210210 }
211211
212212 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+
213219 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
214220 Enabled: true,
215- Path: cfg.Logging.File.Path,
221+ Path: logPath,
216222 MaxSize: cfg.Logging.File.MaxSize,
217223 MaxBackups: cfg.Logging.File.MaxBackups,
218224 MaxAge: cfg.Logging.File.MaxAge,
@@ -317,6 +323,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
317323
318324 // Create log service for accessing logs via admin API
319325 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+ }
320330 logSvc := logs.NewService(logFilePath, svc.containerSvc, svc.runtime, log)
321331
322332 // 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 API324 // Create log service for accessing logs via admin API
319 logFilePath := cfg.Logging.File.Path325 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 API332 // Create admin handler for admin API
M internal/boundaries/in/auth.go +3 -0
@@ -34,6 +34,9 @@ type AuthService interface {
3434 // RevokeToken revokes a token by its ID.
3535 RevokeToken(ctx context.Context, tokenID string) error
3636
37+ // RevokeAllTokens revokes all stored tokens.
38+ RevokeAllTokens(ctx context.Context) (int, error)
39+
3740 // ListTokens returns all stored tokens.
3841 ListTokens(ctx context.Context) ([]domain.Token, error)
3942
@@ -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) error35 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
405405 return _c
406406 }
407407
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+
408468 // RevokeToken provides a mock function for the type MockAuthService
409469 func (_mock *MockAuthService) RevokeToken(ctx context.Context, tokenID string) error {
410470 ret := _mock.Called(ctx, tokenID)
@@ -405,6 +405,66 @@ func (_c *MockAuthService_ListTokens_Call) RunAndReturn(run func(ctx context.Con
405 return _c405 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 MockAuthService468 // 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 {
386386 return nil
387387 }
388388
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+
389418 // ListTokens returns all stored tokens.
390419 func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
391420 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -386,6 +386,35 @@ func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
386 return nil386 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) {
541541 assert.NoError(t, err)
542542 }
543543
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+
544621 func TestService_ListTokens(t *testing.T) {
545622 tokenStore := mocks.NewMockTokenStore(t)
546623
@@ -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:
210210 | `gordon secrets set <domain> KEY=value` | Set secrets |
211211 | `gordon secrets remove <domain> <key>` | Remove a secret |
212212 | `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.
213240
214241 ## Token Security
215242
@@ -385,6 +412,24 @@ Or use explicit flags:
385412 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
386413 ```
387414
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+
388433 ## Related
389434
390435 - [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 Security241 ## Token Security
215 242
@@ -385,6 +412,24 @@ Or use explicit flags:
385 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN412 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 ## Related433 ## Related
389 434
390 - [CLI Commands](/docs/cli/commands.md)435 - [CLI Commands](/docs/cli/commands.md)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close