v2.4.2 7da87b2

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 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}

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