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