v2.31.0-dev6 8056b57

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

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