v2.31.0-dev3 cbca441

ca.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"crypto/x509"
6	"encoding/pem"
7	"fmt"
8	"io"
9	"os"
10	"path/filepath"
11	"time"
12
13	"github.com/spf13/cobra"
14
15	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16)
17
18func newCACmd() *cobra.Command {
19	cmd := &cobra.Command{
20		Use:   "ca",
21		Short: "Manage Gordon's internal Certificate Authority",
22	}
23
24	cmd.AddCommand(newCAExportCmd())
25	cmd.AddCommand(newCAInstallCmd())
26	cmd.AddCommand(newCAInfoCmd())
27
28	return cmd
29}
30
31func newCAExportCmd() *cobra.Command {
32	var outPath string
33
34	cmd := &cobra.Command{
35		Use:   "export",
36		Short: "Export the root CA certificate",
37		Long:  "Export Gordon's root CA certificate in PEM format for manual trust installation.",
38		RunE: func(cmd *cobra.Command, _ []string) error {
39			dataDir, err := resolveCADataDir()
40			if err != nil {
41				return err
42			}
43			return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath)
44		},
45	}
46
47	cmd.Flags().StringVar(&outPath, "out", "", "Write certificate to file instead of stdout")
48
49	return cmd
50}
51
52func newCAInstallCmd() *cobra.Command {
53	var (
54		uninstall bool
55		jsonOut   bool
56	)
57
58	cmd := &cobra.Command{
59		Use:   "install",
60		Short: "Install/uninstall the root CA in the system trust store",
61		Long:  "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.",
62		RunE: func(cmd *cobra.Command, _ []string) error {
63			dataDir, err := resolveCADataDir()
64			if err != nil {
65				return err
66			}
67			return runCAInstall(cmd.Context(), cmd.OutOrStdout(), dataDir, uninstall, jsonOut)
68		},
69	}
70
71	cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")
72	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
73
74	return cmd
75}
76
77func newCAInfoCmd() *cobra.Command {
78	var jsonOut bool
79
80	cmd := &cobra.Command{
81		Use:   "info",
82		Short: "Show CA status information",
83		RunE: func(cmd *cobra.Command, _ []string) error {
84			dataDir, err := resolveCADataDir()
85			if err != nil {
86				return err
87			}
88			return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut)
89		},
90	}
91
92	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
93
94	return cmd
95}
96
97func resolveCADataDir() (string, error) {
98	local, err := GetLocalServices(configPath)
99	if err != nil {
100		return "", err
101	}
102	if local.GetTLSPort() == 0 {
103		return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable")
104	}
105	return local.GetDataDir(), nil
106}
107
108// loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer).
109// This is intentional: CLI commands like 'ca export' and 'ca info' are standalone
110// admin utilities that run outside the server lifecycle and don't need business logic.
111func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) {
112	ca, err := pkiadapter.NewCA(dataDir, cliLogger())
113	if err != nil {
114		return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)
115	}
116	return ca, nil
117}
118
119func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error {
120	ca, err := loadCAFromDataDir(dataDir)
121	if err != nil {
122		return err
123	}
124
125	rootPEM := ca.RootCertificate()
126
127	if outPath != "" {
128		if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil {
129			return fmt.Errorf("create directory for %s: %w", outPath, err)
130		}
131		if err := os.WriteFile(outPath, rootPEM, 0600); err != nil {
132			return fmt.Errorf("write certificate to %s: %w", outPath, err)
133		}
134		return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
135	}
136
137	_, err = out.Write(rootPEM)
138	return err
139}
140
141func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, jsonOut bool) error {
142	ca, err := loadCAFromDataDir(dataDir)
143	if err != nil {
144		return err
145	}
146
147	rootPEM := ca.RootCertificate()
148	block, _ := pem.Decode(rootPEM)
149	if block == nil {
150		return fmt.Errorf("failed to decode root CA PEM")
151	}
152	cert, err := x509.ParseCertificate(block.Bytes)
153	if err != nil {
154		return fmt.Errorf("failed to parse root CA: %w", err)
155	}
156
157	if uninstall {
158		if err := pkiadapter.UninstallRoot(cert); err != nil {
159			return fmt.Errorf("failed to uninstall root CA: %w", err)
160		}
161		if jsonOut {
162			return writeJSON(out, map[string]string{"status": "uninstalled"})
163		}
164		return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
165	}
166
167	if err := pkiadapter.InstallRoot(cert); err != nil {
168		return fmt.Errorf("failed to install root CA: %w", err)
169	}
170	if jsonOut {
171		return writeJSON(out, map[string]string{"status": "installed"})
172	}
173	return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
174}
175
176func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error {
177	ca, err := loadCAFromDataDir(dataDir)
178	if err != nil {
179		return err
180	}
181
182	interExpiry := ca.IntermediateExpiresAt()
183	remaining := time.Until(interExpiry).Truncate(time.Minute)
184
185	if jsonOut {
186		return writeJSON(out, map[string]any{
187			"root_cn":             ca.RootCommonName(),
188			"fingerprint":         "SHA256:" + ca.RootFingerprint(),
189			"intermediate_expiry": interExpiry.Format(time.RFC3339),
190			"intermediate_ttl":    remaining.String(),
191		})
192	}
193
194	if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {
195		return err
196	}
197	if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil {
198		return err
199	}
200	return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining)))
201}

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