v3.3.0 b164fe9

ca.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"crypto/x509"
6	"encoding/pem"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"path/filepath"
12	"strings"
13	"time"
14
15	"github.com/spf13/cobra"
16
17	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
18	boundaryout "github.com/bnema/gordon/internal/boundaries/out"
19)
20
21func newCACmd() *cobra.Command {
22	cmd := &cobra.Command{
23		Use:   "ca",
24		Short: "Manage Gordon's internal Certificate Authority",
25	}
26
27	cmd.AddCommand(newCAExportCmd())
28	cmd.AddCommand(newCAInstallCmd())
29	cmd.AddCommand(newCAInfoCmd())
30
31	return cmd
32}
33
34// caResolveRemote allows tests to override remote control-plane resolution.
35// Tests that swap it must not run in parallel. It returns a nil ControlPlane when no remote is targeted.
36var caResolveRemote = func() (ControlPlane, error) {
37	client, isRemote, err := GetRemoteClient()
38	if err != nil || !isRemote {
39		return nil, err
40	}
41	return client, nil
42}
43
44// caDetails is the CA data the ca commands operate on, whether it was read
45// from the local data directory or fetched from a remote Gordon.
46type caDetails struct {
47	rootPEM            []byte
48	rootCN             string
49	fingerprint        string
50	intermediateExpiry time.Time
51	// remote is true when the CA was fetched over the network.
52	remote bool
53}
54
55func newCAExportCmd() *cobra.Command {
56	var outPath string
57
58	cmd := &cobra.Command{
59		Use:   "export",
60		Short: "Export the root CA certificate",
61		Long:  "Export Gordon's root CA certificate in PEM format for manual trust installation. When a remote is targeted (--remote, GORDON_REMOTE, or the active remote), the certificate is fetched from that Gordon.",
62		RunE: func(cmd *cobra.Command, _ []string) error {
63			details, err := loadCADetails(cmd.Context())
64			if err != nil {
65				return err
66			}
67			if details.remote {
68				// The checks prove a well-formed CA, not its origin: over an
69				// insecure or plain-http remote the response can be swapped.
70				if err := cliWriteLine(cmd.ErrOrStderr(), cliRenderWarning(fmt.Sprintf(
71					"Before trusting this CA, check that fingerprint %s matches `gordon ca info` on the Gordon host", details.fingerprint))); err != nil {
72					return err
73				}
74			}
75			return runCAExport(cmd.OutOrStdout(), details, outPath)
76		},
77	}
78
79	cmd.Flags().StringVar(&outPath, "out", "", "Write certificate to file instead of stdout")
80
81	return cmd
82}
83
84func newCAInstallCmd() *cobra.Command {
85	var (
86		uninstall bool
87		jsonOut   bool
88	)
89
90	cmd := &cobra.Command{
91		Use:   "install",
92		Short: "Install/uninstall the root CA in the system trust store",
93		Long:  "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root on the Gordon host.",
94		RunE: func(cmd *cobra.Command, _ []string) error {
95			if err := rejectRemoteForCAInstall(); err != nil {
96				return err
97			}
98			dataDir, err := resolveCADataDir(false)
99			if err != nil {
100				return err
101			}
102			return runCAInstall(cmd.Context(), cmd.OutOrStdout(), dataDir, uninstall, jsonOut)
103		},
104	}
105
106	cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")
107	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
108
109	return cmd
110}
111
112// rejectRemoteForCAInstall refuses to run install/uninstall while a remote is
113// targeted: trusting a network-fetched root is unsafe (insecure or plain-http
114// remotes), sudo drops the user's remotes config, and uninstall would depend
115// on the remote still being reachable.
116func rejectRemoteForCAInstall() error {
117	cp, err := caResolveRemote()
118	if err != nil {
119		return err
120	}
121	if cp != nil {
122		return errCAInstallRemote
123	}
124	return nil
125}
126
127var errCAInstallRemote = errors.New("ca install only works on the Gordon host; from another machine run " +
128	"`gordon ca export --remote <name> --out gordon-ca.pem` and install that file with your OS trust tools")
129
130func newCAInfoCmd() *cobra.Command {
131	var jsonOut bool
132
133	cmd := &cobra.Command{
134		Use:   "info",
135		Short: "Show CA status information",
136		RunE: func(cmd *cobra.Command, _ []string) error {
137			details, err := loadCADetails(cmd.Context())
138			if err != nil {
139				return err
140			}
141			return runCAInfo(cmd.OutOrStdout(), details, jsonOut)
142		},
143	}
144
145	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
146
147	return cmd
148}
149
150// loadCADetails reads the CA from the remote Gordon when --remote targets one,
151// otherwise from the local server config and data directory.
152func loadCADetails(ctx context.Context) (*caDetails, error) {
153	cp, err := caResolveRemote()
154	if err != nil {
155		return nil, err
156	}
157	if cp != nil {
158		return fetchRemoteCADetails(ctx, cp)
159	}
160
161	dataDir, err := resolveCADataDir(true)
162	if err != nil {
163		return nil, err
164	}
165	return loadLocalCADetails(dataDir)
166}
167
168// fetchRemoteCADetails fetches the CA from the remote and verifies it locally:
169// the PEM must hold exactly one self-signed CA certificate, and the CN and
170// fingerprint are computed from it rather than trusted from the response.
171func fetchRemoteCADetails(ctx context.Context, cp ControlPlane) (*caDetails, error) {
172	resp, err := cp.GetCA(ctx)
173	if err != nil {
174		return nil, fmt.Errorf("failed to get CA from remote: %w", err)
175	}
176	if resp == nil || resp.RootPEM == "" {
177		return nil, fmt.Errorf("failed to get CA from remote: empty response")
178	}
179	cert, err := parseRemoteRootCA([]byte(resp.RootPEM))
180	if err != nil {
181		return nil, fmt.Errorf("remote returned an invalid root CA: %w", err)
182	}
183	fingerprint := pkiadapter.CertFingerprint(cert)
184	if resp.Fingerprint != "" && !strings.EqualFold(resp.Fingerprint, fingerprint) {
185		return nil, fmt.Errorf("remote returned an invalid root CA: reported fingerprint %s does not match certificate fingerprint %s",
186			resp.Fingerprint, fingerprint)
187	}
188	return &caDetails{
189		rootPEM:            []byte(resp.RootPEM),
190		rootCN:             cert.Subject.CommonName,
191		fingerprint:        fingerprint,
192		intermediateExpiry: resp.IntermediateExpiry,
193		remote:             true,
194	}, nil
195}
196
197func parseRemoteRootCA(rootPEM []byte) (*x509.Certificate, error) {
198	block, rest := pem.Decode(rootPEM)
199	if block == nil || block.Type != "CERTIFICATE" {
200		return nil, errors.New("no CERTIFICATE PEM block found")
201	}
202	if extra, _ := pem.Decode(rest); extra != nil {
203		return nil, errors.New("expected exactly one certificate, found more")
204	}
205	cert, err := x509.ParseCertificate(block.Bytes)
206	if err != nil {
207		return nil, fmt.Errorf("parse certificate: %w", err)
208	}
209	if !cert.BasicConstraintsValid || !cert.IsCA {
210		return nil, errors.New("certificate is not a CA")
211	}
212	if err := cert.CheckSignatureFrom(cert); err != nil {
213		return nil, fmt.Errorf("certificate is not validly self-signed: %w", err)
214	}
215	return cert, nil
216}
217
218// resolveCADataDir returns the local data directory. remoteHint adds the
219// --remote suggestion for commands that support remotes.
220func resolveCADataDir(remoteHint bool) (string, error) {
221	local, err := GetLocalServices(cliConfigPath)
222	if err != nil {
223		return "", err
224	}
225	if !local.HasInternalTLS() {
226		return "", errLocalInternalTLSDisabled(local.GetConfigFile(), remoteHint)
227	}
228	return local.GetDataDir(), nil
229}
230
231func errLocalInternalTLSDisabled(configFile string, remoteHint bool) error {
232	source := "no local server config file was found (searched /etc/gordon, ~/.config/gordon and the current directory)"
233	if configFile != "" {
234		source = fmt.Sprintf("local server config %s was read", configFile)
235	}
236	hint := ""
237	if remoteHint {
238		hint = "; to use the CA of a remote Gordon, pass --remote <name|url>"
239	}
240	return fmt.Errorf("internal TLS is disabled: %s and it has no TLS-capable entrypoint (smart_tcp or tls_mux)%s", source, hint)
241}
242
243// loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer).
244// This is intentional: CLI commands like 'ca export' and 'ca info' are standalone
245// admin utilities that run outside the server lifecycle and don't need business logic.
246func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) {
247	ca, err := pkiadapter.NewCA(dataDir, cliLogger())
248	if err != nil {
249		return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)
250	}
251	return ca, nil
252}
253
254func loadLocalCADetails(dataDir string) (*caDetails, error) {
255	ca, err := loadCAFromDataDir(dataDir)
256	if err != nil {
257		return nil, err
258	}
259	return &caDetails{
260		rootPEM:            ca.RootCertificate(),
261		rootCN:             ca.RootCommonName(),
262		fingerprint:        ca.RootFingerprint(),
263		intermediateExpiry: ca.IntermediateExpiresAt(),
264	}, nil
265}
266
267func runCAExport(out io.Writer, details *caDetails, outPath string) error {
268	if outPath != "" {
269		if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil {
270			return fmt.Errorf("create directory for %s: %w", outPath, err)
271		}
272		if err := os.WriteFile(outPath, details.rootPEM, 0600); err != nil {
273			return fmt.Errorf("write certificate to %s: %w", outPath, err)
274		}
275		return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
276	}
277
278	_, err := out.Write(details.rootPEM)
279	return err
280}
281
282func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, jsonOut bool) error {
283	ca, err := loadCAFromDataDir(dataDir)
284	if err != nil {
285		return err
286	}
287
288	rootPEM := ca.RootCertificate()
289	block, _ := pem.Decode(rootPEM)
290	if block == nil {
291		return fmt.Errorf("failed to decode root CA PEM")
292	}
293	cert, err := x509.ParseCertificate(block.Bytes)
294	if err != nil {
295		return fmt.Errorf("failed to parse root CA: %w", err)
296	}
297
298	if uninstall {
299		if err := ca.UninstallRoot(cert); err != nil {
300			return fmt.Errorf("failed to uninstall root CA: %w", err)
301		}
302		if jsonOut {
303			return writeJSON(out, map[string]string{"status": "uninstalled"})
304		}
305		return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
306	}
307
308	if err := ca.InstallRoot(cert); err != nil {
309		return fmt.Errorf("failed to install root CA: %w", err)
310	}
311	if jsonOut {
312		return writeJSON(out, map[string]string{"status": "installed"})
313	}
314	return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
315}
316
317func runCAInfo(out io.Writer, details *caDetails, jsonOut bool) error {
318	remaining := time.Until(details.intermediateExpiry).Truncate(time.Minute)
319
320	if jsonOut {
321		return writeJSON(out, map[string]any{
322			"root_cn":             details.rootCN,
323			"fingerprint":         "SHA256:" + details.fingerprint,
324			"intermediate_expiry": details.intermediateExpiry.Format(time.RFC3339),
325			"intermediate_ttl":    remaining.String(),
326		})
327	}
328
329	if err := cliWriteLine(out, cliRenderMeta("Root CA:", details.rootCN)); err != nil {
330		return err
331	}
332	if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+details.fingerprint)); err != nil {
333		return err
334	}
335	return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining)))
336}

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