v2.5.0 b9b6391

auth.go RawHistoryFolder

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}

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