detached 848492c

feat(cli): add gordon ca export/install/info commands

commit
848492c299c8e2ef994c0eb5b6fb7bcea1fd6106
author
brice · · 2026-03-29 16:48 UTC
parent
923d96f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +156 -0
A internal/adapters/in/cli/ca.go +152 -0
@@ -0,0 +1,152 @@
1+package cli
2+
3+import (
4+ "crypto/x509"
5+ "encoding/pem"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "time"
11+
12+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
13+ "github.com/bnema/gordon/internal/app"
14+ "github.com/bnema/zerowrap"
15+ "github.com/spf13/cobra"
16+)
17+
18+func 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+
31+func 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+ return runCAExport(cmd.OutOrStdout(), outPath)
40+ },
41+ }
42+
43+ cmd.Flags().StringVar(&outPath, "out", "", "Write certificate to file instead of stdout")
44+
45+ return cmd
46+}
47+
48+func newCAInstallCmd() *cobra.Command {
49+ var uninstall bool
50+
51+ cmd := &cobra.Command{
52+ Use: "install",
53+ Short: "Install/uninstall the root CA in the system trust store",
54+ Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.",
55+ RunE: func(cmd *cobra.Command, _ []string) error {
56+ return runCAInstall(cmd.OutOrStdout(), uninstall)
57+ },
58+ }
59+
60+ cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")
61+
62+ return cmd
63+}
64+
65+func newCAInfoCmd() *cobra.Command {
66+ return &cobra.Command{
67+ Use: "info",
68+ Short: "Show CA status information",
69+ RunE: func(cmd *cobra.Command, _ []string) error {
70+ return runCAInfo(cmd.OutOrStdout())
71+ },
72+ }
73+}
74+
75+func loadCAFromDataDir() (*pkiadapter.CA, error) {
76+ dataDir := app.DefaultDataDir()
77+ log := zerowrap.Default()
78+ ca, err := pkiadapter.NewCA(dataDir, log)
79+ if err != nil {
80+ return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)
81+ }
82+ return ca, nil
83+}
84+
85+func runCAExport(out io.Writer, outPath string) error {
86+ ca, err := loadCAFromDataDir()
87+ if err != nil {
88+ return err
89+ }
90+
91+ rootPEM := ca.RootCertificate()
92+
93+ if outPath != "" {
94+ if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
95+ return err
96+ }
97+ if err := os.WriteFile(outPath, rootPEM, 0644); err != nil {
98+ return err
99+ }
100+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
101+ }
102+
103+ _, err = out.Write(rootPEM)
104+ return err
105+}
106+
107+func runCAInstall(out io.Writer, uninstall bool) error {
108+ ca, err := loadCAFromDataDir()
109+ if err != nil {
110+ return err
111+ }
112+
113+ rootPEM := ca.RootCertificate()
114+ block, _ := pem.Decode(rootPEM)
115+ if block == nil {
116+ return fmt.Errorf("failed to decode root CA PEM")
117+ }
118+ cert, err := x509.ParseCertificate(block.Bytes)
119+ if err != nil {
120+ return fmt.Errorf("failed to parse root CA: %w", err)
121+ }
122+
123+ if uninstall {
124+ if err := pkiadapter.UninstallRoot(cert); err != nil {
125+ return fmt.Errorf("failed to uninstall root CA: %w", err)
126+ }
127+ return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
128+ }
129+
130+ if err := pkiadapter.InstallRoot(cert); err != nil {
131+ return fmt.Errorf("failed to install root CA: %w", err)
132+ }
133+ return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
134+}
135+
136+func runCAInfo(out io.Writer) error {
137+ ca, err := loadCAFromDataDir()
138+ if err != nil {
139+ return err
140+ }
141+
142+ interExpiry := ca.IntermediateExpiresAt()
143+ remaining := time.Until(interExpiry).Truncate(time.Minute)
144+
145+ if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {
146+ return err
147+ }
148+ if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil {
149+ return err
150+ }
151+ return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining)))
152+}
@@ -0,0 +1,152 @@
1+package cli
2+
3+import (
4+ "crypto/x509"
5+ "encoding/pem"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "time"
11+
12+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
13+ "github.com/bnema/gordon/internal/app"
14+ "github.com/bnema/zerowrap"
15+ "github.com/spf13/cobra"
16+)
17+
18+func 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+
31+func 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+ return runCAExport(cmd.OutOrStdout(), outPath)
40+ },
41+ }
42+
43+ cmd.Flags().StringVar(&outPath, "out", "", "Write certificate to file instead of stdout")
44+
45+ return cmd
46+}
47+
48+func newCAInstallCmd() *cobra.Command {
49+ var uninstall bool
50+
51+ cmd := &cobra.Command{
52+ Use: "install",
53+ Short: "Install/uninstall the root CA in the system trust store",
54+ Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.",
55+ RunE: func(cmd *cobra.Command, _ []string) error {
56+ return runCAInstall(cmd.OutOrStdout(), uninstall)
57+ },
58+ }
59+
60+ cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")
61+
62+ return cmd
63+}
64+
65+func newCAInfoCmd() *cobra.Command {
66+ return &cobra.Command{
67+ Use: "info",
68+ Short: "Show CA status information",
69+ RunE: func(cmd *cobra.Command, _ []string) error {
70+ return runCAInfo(cmd.OutOrStdout())
71+ },
72+ }
73+}
74+
75+func loadCAFromDataDir() (*pkiadapter.CA, error) {
76+ dataDir := app.DefaultDataDir()
77+ log := zerowrap.Default()
78+ ca, err := pkiadapter.NewCA(dataDir, log)
79+ if err != nil {
80+ return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)
81+ }
82+ return ca, nil
83+}
84+
85+func runCAExport(out io.Writer, outPath string) error {
86+ ca, err := loadCAFromDataDir()
87+ if err != nil {
88+ return err
89+ }
90+
91+ rootPEM := ca.RootCertificate()
92+
93+ if outPath != "" {
94+ if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
95+ return err
96+ }
97+ if err := os.WriteFile(outPath, rootPEM, 0644); err != nil {
98+ return err
99+ }
100+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
101+ }
102+
103+ _, err = out.Write(rootPEM)
104+ return err
105+}
106+
107+func runCAInstall(out io.Writer, uninstall bool) error {
108+ ca, err := loadCAFromDataDir()
109+ if err != nil {
110+ return err
111+ }
112+
113+ rootPEM := ca.RootCertificate()
114+ block, _ := pem.Decode(rootPEM)
115+ if block == nil {
116+ return fmt.Errorf("failed to decode root CA PEM")
117+ }
118+ cert, err := x509.ParseCertificate(block.Bytes)
119+ if err != nil {
120+ return fmt.Errorf("failed to parse root CA: %w", err)
121+ }
122+
123+ if uninstall {
124+ if err := pkiadapter.UninstallRoot(cert); err != nil {
125+ return fmt.Errorf("failed to uninstall root CA: %w", err)
126+ }
127+ return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
128+ }
129+
130+ if err := pkiadapter.InstallRoot(cert); err != nil {
131+ return fmt.Errorf("failed to install root CA: %w", err)
132+ }
133+ return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
134+}
135+
136+func runCAInfo(out io.Writer) error {
137+ ca, err := loadCAFromDataDir()
138+ if err != nil {
139+ return err
140+ }
141+
142+ interExpiry := ca.IntermediateExpiresAt()
143+ remaining := time.Until(interExpiry).Truncate(time.Minute)
144+
145+ if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {
146+ return err
147+ }
148+ if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil {
149+ return err
150+ }
151+ return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining)))
152+}
M internal/adapters/in/cli/root.go +4 -0
@@ -76,6 +76,10 @@ Commands are organized by where they run:
7676 authCmd.GroupID = groupServer
7777 rootCmd.AddCommand(authCmd)
7878
79+ caCmd := newCACmd()
80+ caCmd.GroupID = groupServer
81+ rootCmd.AddCommand(caCmd)
82+
7983 // Management commands (work locally or via --remote)
8084 routesCmd := newRoutesCmd()
8185 routesCmd.GroupID = groupManage
@@ -76,6 +76,10 @@ Commands are organized by where they run:
76 authCmd.GroupID = groupServer76 authCmd.GroupID = groupServer
77 rootCmd.AddCommand(authCmd)77 rootCmd.AddCommand(authCmd)
78 78
79+ caCmd := newCACmd()
80+ caCmd.GroupID = groupServer
81+ rootCmd.AddCommand(caCmd)
82+
79 // Management commands (work locally or via --remote)83 // Management commands (work locally or via --remote)
80 routesCmd := newRoutesCmd()84 routesCmd := newRoutesCmd()
81 routesCmd.GroupID = groupManage85 routesCmd.GroupID = groupManage

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 8

Tags 106

↑↓ moveenter openesc close