v2.2.3 133ff88

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

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