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.HasInternalTLS() {
104 return "", fmt.Errorf("internal TLS is disabled (no TLS-capable entrypoint configured); CA commands are not available — configure a smart_tcp or tls_mux entrypoint")
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}