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}