1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/spf13/cobra"
13 "github.com/spf13/viper"
14 "golang.org/x/term"
15
16 "gordon/internal/adapters/out/secrets"
17 "gordon/internal/adapters/out/tokenstore"
18 "gordon/internal/app"
19 "gordon/internal/domain"
20 "gordon/internal/usecase/auth"
21 "gordon/pkg/duration"
22)
23
24// newAuthCmd creates the auth command group.
25func newAuthCmd() *cobra.Command {
26 cmd := &cobra.Command{
27 Use: "auth",
28 Short: "Manage registry authentication",
29 Long: `Commands for managing registry authentication tokens and passwords.`,
30 }
31
32 cmd.AddCommand(newAuthTokenCmd())
33 cmd.AddCommand(newAuthPasswordCmd())
34 cmd.AddCommand(newAuthInternalCmd())
35
36 return cmd
37}
38
39// newAuthTokenCmd creates the token subcommand group.
40func newAuthTokenCmd() *cobra.Command {
41 cmd := &cobra.Command{
42 Use: "token",
43 Short: "Manage authentication tokens",
44 Long: `Commands for generating, listing, and revoking authentication tokens.`,
45 }
46
47 cmd.AddCommand(newTokenGenerateCmd())
48 cmd.AddCommand(newTokenListCmd())
49 cmd.AddCommand(newTokenRevokeCmd())
50
51 return cmd
52}
53
54// newTokenGenerateCmd creates the token generate command.
55func newTokenGenerateCmd() *cobra.Command {
56 var (
57 subject string
58 scopes string
59 expiry string
60 configPath string
61 )
62
63 cmd := &cobra.Command{
64 Use: "generate",
65 Short: "Generate a new authentication token",
66 Long: `Generate a new JWT authentication token for registry and/or admin access.
67
68The token will be stored in the configured secrets backend and can be used
69for docker login as: docker login -u <subject> -p <token>
70
71SCOPES:
72
73Registry scopes (for Docker push/pull):
74 push Push images to registry
75 pull Pull images from registry
76
77Admin 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
88Combined examples:
89 --scopes "push,pull" Registry only (default)
90 --scopes "admin:*:*" Admin CLI only
91 --scopes "push,pull,admin:*:*" Registry + full admin
92
93EXPIRY:
94
95Supports 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
98
99Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
100Use --expiry=0 for a token that never expires (useful for CI).`,
101 RunE: func(cmd *cobra.Command, args []string) error {
102 return runTokenGenerate(subject, scopes, expiry, configPath)
103 },
104 }
105
106 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
107 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
108 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
109 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
110
111 _ = cmd.MarkFlagRequired("subject")
112
113 return cmd
114}
115
116// newTokenListCmd creates the token list command.
117func newTokenListCmd() *cobra.Command {
118 var configPath string
119
120 cmd := &cobra.Command{
121 Use: "list",
122 Short: "List all authentication tokens",
123 Long: `List all stored authentication tokens with their subjects and expiry information.`,
124 RunE: func(cmd *cobra.Command, args []string) error {
125 return runTokenList(configPath)
126 },
127 }
128
129 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
130
131 return cmd
132}
133
134// newTokenRevokeCmd creates the token revoke command.
135func newTokenRevokeCmd() *cobra.Command {
136 var (
137 configPath string
138 all bool
139 )
140
141 cmd := &cobra.Command{
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
146Examples:
147 gordon auth token revoke abc123-def456 Revoke specific token
148 gordon auth token revoke --all Revoke all tokens`,
149 Args: cobra.MaximumNArgs(1),
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 }
157 return runTokenRevoke(args[0], configPath)
158 },
159 }
160
161 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
162 cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
163
164 return cmd
165}
166
167// newAuthPasswordCmd creates the password subcommand group.
168func newAuthPasswordCmd() *cobra.Command {
169 cmd := &cobra.Command{
170 Use: "password",
171 Short: "Password utilities",
172 Long: `Utilities for managing password authentication.`,
173 }
174
175 cmd.AddCommand(newPasswordHashCmd())
176
177 return cmd
178}
179
180// newAuthInternalCmd creates the internal credentials command.
181func newAuthInternalCmd() *cobra.Command {
182 return &cobra.Command{
183 Use: "internal",
184 Short: "Show internal registry credentials for manual recovery",
185 Long: `Display the auto-generated internal registry credentials.
186
187These credentials are used by Gordon for pulling images from the local registry
188(localhost:5000) during internal deploys. You can use them for manual recovery:
189
190 docker login localhost:5000 -u gordon-internal -p <password>
191
192Note: Credentials are regenerated each time Gordon starts, and are only
193available while Gordon is running.`,
194 RunE: func(cmd *cobra.Command, args []string) error {
195 return runShowInternalAuth()
196 },
197 }
198}
199
200// runShowInternalAuth displays the internal registry credentials.
201func runShowInternalAuth() error {
202 creds, err := app.GetInternalCredentials()
203 if err != nil {
204 return err
205 }
206
207 fmt.Println("Internal Registry Credentials")
208 fmt.Println("==============================")
209 fmt.Printf("Username: %s\n", creds.Username)
210 fmt.Printf("Password: %s\n", creds.Password)
211 fmt.Println()
212 fmt.Println("Usage:")
213 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
214
215 return nil
216}
217
218// newPasswordHashCmd creates the password hash command.
219func newPasswordHashCmd() *cobra.Command {
220 return &cobra.Command{
221 Use: "hash",
222 Short: "Generate a bcrypt hash for a password",
223 Long: `Interactively prompt for a password and output its bcrypt hash.
224
225The hash can be stored in your secrets backend and referenced in the config:
226
227 [registry_auth]
228 type = "password"
229 password_hash = "gordon/registry/password_hash"`,
230 RunE: func(cmd *cobra.Command, args []string) error {
231 return runPasswordHash()
232 },
233 }
234}
235
236// runTokenGenerate generates a new authentication token.
237func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
238 cfg, err := loadAuthConfig(configPath)
239 if err != nil {
240 return err
241 }
242
243 // Parse expiry (supports human-friendly units: d, w, M, y)
244 var expiry time.Duration
245 if expiryStr != "0" {
246 expiry, err = duration.Parse(expiryStr)
247 if err != nil {
248 return fmt.Errorf("invalid expiry duration: %w", err)
249 }
250 }
251
252 // Parse scopes
253 scopes := strings.Split(scopesStr, ",")
254 for i := range scopes {
255 scopes[i] = strings.TrimSpace(scopes[i])
256 }
257
258 // Create auth service
259 log := zerowrap.New(zerowrap.Config{Level: "warn"})
260 authSvc, err := createAuthServiceForCLI(cfg, log)
261 if err != nil {
262 return err
263 }
264
265 // Generate token
266 ctx := context.Background()
267 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
268 if err != nil {
269 return fmt.Errorf("failed to generate token: %w", err)
270 }
271
272 fmt.Println("Token generated successfully!")
273 fmt.Printf("Subject: %s\n", subject)
274 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
275 if expiry == 0 {
276 fmt.Println("Expiry: never")
277 } else {
278 fmt.Printf("Expiry: %s\n", expiry)
279 }
280 fmt.Println()
281 fmt.Println("Token (use as password with docker login):")
282 fmt.Println(token)
283 fmt.Println()
284 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
285
286 return nil
287}
288
289// runTokenList lists all stored tokens.
290func runTokenList(configPath string) error {
291 cfg, err := loadAuthConfig(configPath)
292 if err != nil {
293 return err
294 }
295
296 log := zerowrap.New(zerowrap.Config{Level: "warn"})
297 authSvc, err := createAuthServiceForCLI(cfg, log)
298 if err != nil {
299 return err
300 }
301
302 ctx := context.Background()
303 tokens, err := authSvc.ListTokens(ctx)
304 if err != nil {
305 return fmt.Errorf("failed to list tokens: %w", err)
306 }
307
308 if len(tokens) == 0 {
309 fmt.Println("No tokens found.")
310 return nil
311 }
312
313 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
314 fmt.Println(strings.Repeat("-", 90))
315
316 for _, t := range tokens {
317 expiry := "never"
318 if !t.ExpiresAt.IsZero() {
319 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
320 }
321 revoked := "no"
322 if t.Revoked {
323 revoked = "yes"
324 }
325 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
326 }
327
328 return nil
329}
330
331// runTokenRevoke revokes a token by ID.
332func runTokenRevoke(tokenID, configPath string) error {
333 cfg, err := loadAuthConfig(configPath)
334 if err != nil {
335 return err
336 }
337
338 log := zerowrap.New(zerowrap.Config{Level: "warn"})
339 authSvc, err := createAuthServiceForCLI(cfg, log)
340 if err != nil {
341 return err
342 }
343
344 ctx := context.Background()
345 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
346 return fmt.Errorf("failed to revoke token: %w", err)
347 }
348
349 fmt.Printf("Token %s has been revoked.\n", tokenID)
350 return nil
351}
352
353// runTokenRevokeAll revokes all tokens.
354func 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
380// runPasswordHash generates a bcrypt hash for a password.
381func runPasswordHash() error {
382 fmt.Print("Enter password: ")
383
384 // Read password without echo (use os.Stdin.Fd() for better compatibility)
385 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
386 if err != nil {
387 // Fallback for non-terminal input
388 reader := bufio.NewReader(os.Stdin)
389 password, err := reader.ReadString('\n')
390 if err != nil {
391 return fmt.Errorf("failed to read password: %w", err)
392 }
393 passwordBytes = []byte(strings.TrimSpace(password))
394 }
395 fmt.Println()
396
397 if len(passwordBytes) == 0 {
398 return fmt.Errorf("password cannot be empty")
399 }
400
401 // Create a temporary auth service to generate the hash
402 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
403 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
404 if err != nil {
405 return fmt.Errorf("failed to generate hash: %w", err)
406 }
407
408 fmt.Println()
409 fmt.Println("Bcrypt hash (store in your secrets backend):")
410 fmt.Println(hash)
411 fmt.Println()
412 fmt.Println("Then reference the path in your config:")
413 fmt.Println(" [registry_auth]")
414 fmt.Println(" type = \"password\"")
415 fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
416
417 return nil
418}
419
420// cliConfig holds the configuration needed for CLI commands.
421type cliConfig struct {
422 Backend domain.SecretsBackend
423 DataDir string
424 TokenSecret []byte
425}
426
427// loadAuthConfig loads the configuration needed for auth CLI commands.
428func loadAuthConfig(configPath string) (*cliConfig, error) {
429 v := viper.New()
430
431 // Set defaults
432 v.SetDefault("server.data_dir", app.DefaultDataDir())
433 v.SetDefault("secrets.backend", "unsafe")
434
435 app.ConfigureViper(v, configPath)
436
437 if err := v.ReadInConfig(); err != nil {
438 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
439 return nil, fmt.Errorf("failed to read config file: %w", err)
440 }
441 // Config file not found is OK for CLI commands
442 }
443
444 cfg := &cliConfig{
445 DataDir: v.GetString("server.data_dir"),
446 }
447
448 // Determine backend
449 switch v.GetString("secrets.backend") {
450 case "pass":
451 cfg.Backend = domain.SecretsBackendPass
452 case "sops":
453 cfg.Backend = domain.SecretsBackendSops
454 default:
455 cfg.Backend = domain.SecretsBackendUnsafe
456 }
457
458 // Load token secret from secrets backend
459 tokenSecretPath := v.GetString("registry_auth.token_secret")
460 if tokenSecretPath != "" {
461 // Load actual secret from the configured backend
462 ctx := context.Background()
463 log := zerowrap.New(zerowrap.Config{Level: "warn"})
464
465 switch cfg.Backend {
466 case domain.SecretsBackendPass:
467 provider := secrets.NewPassProvider(log)
468 secret, err := provider.GetSecret(ctx, tokenSecretPath)
469 if err != nil {
470 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
471 }
472 cfg.TokenSecret = []byte(secret)
473 default:
474 // For unsafe backend, use the path as the secret (backwards compatible)
475 cfg.TokenSecret = []byte(tokenSecretPath)
476 }
477 } else {
478 // Generate a default secret for CLI token generation
479 cfg.TokenSecret = []byte("gordon-cli-default-secret")
480 }
481
482 return cfg, nil
483}
484
485// createAuthServiceForCLI creates an auth service for CLI commands.
486func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
487 // Create token store
488 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
489 if err != nil {
490 return nil, fmt.Errorf("failed to create token store: %w", err)
491 }
492
493 // Create auth service config
494 authConfig := auth.Config{
495 Enabled: true,
496 AuthType: domain.AuthTypeToken,
497 TokenSecret: cfg.TokenSecret,
498 }
499
500 return auth.NewService(authConfig, store, log), nil
501}