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}