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 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
71Expiry 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
76
77Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
78Use --expiry=0 for a token that never expires (useful for CI).`,
79 RunE: func(cmd *cobra.Command, args []string) error {
80 return runTokenGenerate(subject, scopes, expiry, configPath)
81 },
82 }
83
84 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
85 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
86 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
87 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
88
89 _ = cmd.MarkFlagRequired("subject")
90
91 return cmd
92}
93
94// newTokenListCmd creates the token list command.
95func newTokenListCmd() *cobra.Command {
96 var configPath string
97
98 cmd := &cobra.Command{
99 Use: "list",
100 Short: "List all authentication tokens",
101 Long: `List all stored authentication tokens with their subjects and expiry information.`,
102 RunE: func(cmd *cobra.Command, args []string) error {
103 return runTokenList(configPath)
104 },
105 }
106
107 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
108
109 return cmd
110}
111
112// newTokenRevokeCmd creates the token revoke command.
113func newTokenRevokeCmd() *cobra.Command {
114 var configPath string
115
116 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),
121 RunE: func(cmd *cobra.Command, args []string) error {
122 return runTokenRevoke(args[0], configPath)
123 },
124 }
125
126 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
127
128 return cmd
129}
130
131// newAuthPasswordCmd creates the password subcommand group.
132func newAuthPasswordCmd() *cobra.Command {
133 cmd := &cobra.Command{
134 Use: "password",
135 Short: "Password utilities",
136 Long: `Utilities for managing password authentication.`,
137 }
138
139 cmd.AddCommand(newPasswordHashCmd())
140
141 return cmd
142}
143
144// newAuthInternalCmd creates the internal credentials command.
145func newAuthInternalCmd() *cobra.Command {
146 return &cobra.Command{
147 Use: "internal",
148 Short: "Show internal registry credentials for manual recovery",
149 Long: `Display the auto-generated internal registry credentials.
150
151These credentials are used by Gordon for pulling images from the local registry
152(localhost:5000) during internal deploys. You can use them for manual recovery:
153
154 docker login localhost:5000 -u gordon-internal -p <password>
155
156Note: Credentials are regenerated each time Gordon starts, and are only
157available while Gordon is running.`,
158 RunE: func(cmd *cobra.Command, args []string) error {
159 return runShowInternalAuth()
160 },
161 }
162}
163
164// runShowInternalAuth displays the internal registry credentials.
165func runShowInternalAuth() error {
166 creds, err := app.GetInternalCredentials()
167 if err != nil {
168 return err
169 }
170
171 fmt.Println("Internal Registry Credentials")
172 fmt.Println("==============================")
173 fmt.Printf("Username: %s\n", creds.Username)
174 fmt.Printf("Password: %s\n", creds.Password)
175 fmt.Println()
176 fmt.Println("Usage:")
177 fmt.Printf(" docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
178
179 return nil
180}
181
182// newPasswordHashCmd creates the password hash command.
183func newPasswordHashCmd() *cobra.Command {
184 return &cobra.Command{
185 Use: "hash",
186 Short: "Generate a bcrypt hash for a password",
187 Long: `Interactively prompt for a password and output its bcrypt hash.
188
189The hash can be stored in your secrets backend and referenced in the config:
190
191 [registry_auth]
192 type = "password"
193 password_hash = "gordon/registry/password_hash"`,
194 RunE: func(cmd *cobra.Command, args []string) error {
195 return runPasswordHash()
196 },
197 }
198}
199
200// runTokenGenerate generates a new authentication token.
201func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
202 cfg, err := loadAuthConfig(configPath)
203 if err != nil {
204 return err
205 }
206
207 // Parse expiry (supports human-friendly units: d, w, M, y)
208 var expiry time.Duration
209 if expiryStr != "0" {
210 expiry, err = duration.Parse(expiryStr)
211 if err != nil {
212 return fmt.Errorf("invalid expiry duration: %w", err)
213 }
214 }
215
216 // Parse scopes
217 scopes := strings.Split(scopesStr, ",")
218 for i := range scopes {
219 scopes[i] = strings.TrimSpace(scopes[i])
220 }
221
222 // Create auth service
223 log := zerowrap.New(zerowrap.Config{Level: "warn"})
224 authSvc, err := createAuthServiceForCLI(cfg, log)
225 if err != nil {
226 return err
227 }
228
229 // Generate token
230 ctx := context.Background()
231 token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
232 if err != nil {
233 return fmt.Errorf("failed to generate token: %w", err)
234 }
235
236 fmt.Println("Token generated successfully!")
237 fmt.Printf("Subject: %s\n", subject)
238 fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
239 if expiry == 0 {
240 fmt.Println("Expiry: never")
241 } else {
242 fmt.Printf("Expiry: %s\n", expiry)
243 }
244 fmt.Println()
245 fmt.Println("Token (use as password with docker login):")
246 fmt.Println(token)
247 fmt.Println()
248 fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
249
250 return nil
251}
252
253// runTokenList lists all stored tokens.
254func runTokenList(configPath string) error {
255 cfg, err := loadAuthConfig(configPath)
256 if err != nil {
257 return err
258 }
259
260 log := zerowrap.New(zerowrap.Config{Level: "warn"})
261 authSvc, err := createAuthServiceForCLI(cfg, log)
262 if err != nil {
263 return err
264 }
265
266 ctx := context.Background()
267 tokens, err := authSvc.ListTokens(ctx)
268 if err != nil {
269 return fmt.Errorf("failed to list tokens: %w", err)
270 }
271
272 if len(tokens) == 0 {
273 fmt.Println("No tokens found.")
274 return nil
275 }
276
277 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
278 fmt.Println(strings.Repeat("-", 90))
279
280 for _, t := range tokens {
281 expiry := "never"
282 if !t.ExpiresAt.IsZero() {
283 expiry = t.ExpiresAt.Format("2006-01-02 15:04")
284 }
285 revoked := "no"
286 if t.Revoked {
287 revoked = "yes"
288 }
289 fmt.Printf("%-36s %-20s %-20s %-10s\n", t.ID, t.Subject, expiry, revoked)
290 }
291
292 return nil
293}
294
295// runTokenRevoke revokes a token by ID.
296func runTokenRevoke(tokenID, configPath string) error {
297 cfg, err := loadAuthConfig(configPath)
298 if err != nil {
299 return err
300 }
301
302 log := zerowrap.New(zerowrap.Config{Level: "warn"})
303 authSvc, err := createAuthServiceForCLI(cfg, log)
304 if err != nil {
305 return err
306 }
307
308 ctx := context.Background()
309 if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
310 return fmt.Errorf("failed to revoke token: %w", err)
311 }
312
313 fmt.Printf("Token %s has been revoked.\n", tokenID)
314 return nil
315}
316
317// runPasswordHash generates a bcrypt hash for a password.
318func runPasswordHash() error {
319 fmt.Print("Enter password: ")
320
321 // Read password without echo (use os.Stdin.Fd() for better compatibility)
322 passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
323 if err != nil {
324 // Fallback for non-terminal input
325 reader := bufio.NewReader(os.Stdin)
326 password, err := reader.ReadString('\n')
327 if err != nil {
328 return fmt.Errorf("failed to read password: %w", err)
329 }
330 passwordBytes = []byte(strings.TrimSpace(password))
331 }
332 fmt.Println()
333
334 if len(passwordBytes) == 0 {
335 return fmt.Errorf("password cannot be empty")
336 }
337
338 // Create a temporary auth service to generate the hash
339 authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
340 hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
341 if err != nil {
342 return fmt.Errorf("failed to generate hash: %w", err)
343 }
344
345 fmt.Println()
346 fmt.Println("Bcrypt hash (store in your secrets backend):")
347 fmt.Println(hash)
348 fmt.Println()
349 fmt.Println("Then reference the path in your config:")
350 fmt.Println(" [registry_auth]")
351 fmt.Println(" type = \"password\"")
352 fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
353
354 return nil
355}
356
357// cliConfig holds the configuration needed for CLI commands.
358type cliConfig struct {
359 Backend domain.SecretsBackend
360 DataDir string
361 TokenSecret []byte
362}
363
364// loadAuthConfig loads the configuration needed for auth CLI commands.
365func loadAuthConfig(configPath string) (*cliConfig, error) {
366 v := viper.New()
367
368 // Set defaults
369 v.SetDefault("server.data_dir", app.DefaultDataDir())
370 v.SetDefault("secrets.backend", "unsafe")
371
372 app.ConfigureViper(v, configPath)
373
374 if err := v.ReadInConfig(); err != nil {
375 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
376 return nil, fmt.Errorf("failed to read config file: %w", err)
377 }
378 // Config file not found is OK for CLI commands
379 }
380
381 cfg := &cliConfig{
382 DataDir: v.GetString("server.data_dir"),
383 }
384
385 // Determine backend
386 switch v.GetString("secrets.backend") {
387 case "pass":
388 cfg.Backend = domain.SecretsBackendPass
389 case "sops":
390 cfg.Backend = domain.SecretsBackendSops
391 default:
392 cfg.Backend = domain.SecretsBackendUnsafe
393 }
394
395 // Load token secret from secrets backend
396 tokenSecretPath := v.GetString("registry_auth.token_secret")
397 if tokenSecretPath != "" {
398 // Load actual secret from the configured backend
399 ctx := context.Background()
400 log := zerowrap.New(zerowrap.Config{Level: "warn"})
401
402 switch cfg.Backend {
403 case domain.SecretsBackendPass:
404 provider := secrets.NewPassProvider(log)
405 secret, err := provider.GetSecret(ctx, tokenSecretPath)
406 if err != nil {
407 return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
408 }
409 cfg.TokenSecret = []byte(secret)
410 default:
411 // For unsafe backend, use the path as the secret (backwards compatible)
412 cfg.TokenSecret = []byte(tokenSecretPath)
413 }
414 } else {
415 // Generate a default secret for CLI token generation
416 cfg.TokenSecret = []byte("gordon-cli-default-secret")
417 }
418
419 return cfg, nil
420}
421
422// createAuthServiceForCLI creates an auth service for CLI commands.
423func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
424 // Create token store
425 store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
426 if err != nil {
427 return nil, fmt.Errorf("failed to create token store: %w", err)
428 }
429
430 // Create auth service config
431 authConfig := auth.Config{
432 Enabled: true,
433 AuthType: domain.AuthTypeToken,
434 TokenSecret: cfg.TokenSecret,
435 }
436
437 return auth.NewService(authConfig, store, log), nil
438}