Skip to content

Commit ba3cb54

Browse files
feat(trust): N2 — mkcert local-CA wrapper + trust CLI (spec 05) (#21)
internal/trust shells out to mkcert (NOT smallstep/truststore) to install/remove the local root CA and diagnose readiness, behind an injectable Runner so it is fully unit-testable without mkcert present: - Available / CARoot / Install (`mkcert -install`) / Uninstall (`mkcert -uninstall`) / Status. Status probes mkcert-on-PATH, the CAROOT rootCA.pem, certutil (Firefox/NSS), and WSL2, emitting the exact one-line remediation for whatever is missing (clean-Ubuntu certutil hint, WSL2 Windows-store import). - CLI `trust install|uninstall|status` replaces the stub. install/uninstall need sudo (mkcert writes system/NSS stores); status is read-only. Per locked decision #3: logic built + fake-runner tested, sudo flagged, Status is the self-verify probe (also feeds the doctor matrix in X6). Unit tests (fake runner + temp CAROOT): available detection, status across missing-mkcert / CA-not-installed / fully-ready / missing-certutil, install + uninstall call-through, missing-mkcert error, install error propagation. CLI: registration + read-only status. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8628134 commit ba3cb54

6 files changed

Lines changed: 410 additions & 5 deletions

File tree

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func NewRootCmd(opts Options) *cobra.Command {
7070
newDownCmd(g),
7171
newStatusCmd(g),
7272
newDnsCmd(g),
73+
newTrustCmd(g),
7374
newDoctorCmd(g),
7475
newConfigCmd(g),
7576
newGenerateCmd(g),

internal/cli/stubs.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3636
stub("login", "Authenticate a secrets provider", "M4"),
3737
stub("keygen", "Generate an age/SOPS key", "M4"),
3838
),
39-
stub("trust", "Local CA trust (install/uninstall/status)", "M5",
40-
stub("install", "Install the local root CA into trust stores", "M5"),
41-
stub("uninstall", "Remove the local root CA from trust stores", "M5"),
42-
stub("status", "Show local CA trust status", "M5"),
43-
),
4439
stub("tunnel", "Optional public tunnel via cloudflared", "M5",
4540
stub("login", "Authenticate cloudflared", "M5"),
4641
stub("create", "Create a named tunnel", "M5"),

internal/cli/trust.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/open-source-cloud/devstack/internal/trust"
9+
)
10+
11+
// newTrustCmd wires `trust install|uninstall|status` — the local CA via mkcert
12+
// (spec 05). install/uninstall need privileges (sudo); status is read-only and
13+
// prints the exact remediation for whatever is missing.
14+
func newTrustCmd(g *GlobalOpts) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "trust",
17+
Short: "Manage the local HTTPS CA (mkcert) for *.localhost",
18+
}
19+
cmd.AddCommand(
20+
newTrustStatusCmd(g),
21+
newTrustInstallCmd(g, true),
22+
newTrustInstallCmd(g, false),
23+
)
24+
return cmd
25+
}
26+
27+
func newTrustStatusCmd(g *GlobalOpts) *cobra.Command {
28+
return &cobra.Command{
29+
Use: "status",
30+
Short: "Diagnose local-CA trust readiness",
31+
Args: cobra.NoArgs,
32+
RunE: func(cmd *cobra.Command, _ []string) error {
33+
s := trust.New().Status(cmd.Context())
34+
if g.JSON {
35+
return writeJSON(cmd, s)
36+
}
37+
w := cmd.OutOrStdout()
38+
fmt.Fprintf(w, "mkcert: %s\n", okmark(s.MkcertFound))
39+
fmt.Fprintf(w, "CA: %s\n", okmark(s.CAInstalled))
40+
fmt.Fprintf(w, "Firefox: %s (certutil)\n", okmark(s.FirefoxTrust))
41+
if s.CARoot != "" {
42+
fmt.Fprintf(w, "CAROOT: %s\n", s.CARoot)
43+
}
44+
if s.Remediation != "" {
45+
fmt.Fprintf(w, "\n→ %s\n", s.Remediation)
46+
} else {
47+
fmt.Fprintln(w, "\nlocal HTTPS trust is ready")
48+
}
49+
return nil
50+
},
51+
}
52+
}
53+
54+
// newTrustInstallCmd builds either `install` (install=true) or `uninstall`.
55+
func newTrustInstallCmd(g *GlobalOpts, install bool) *cobra.Command {
56+
use, short := "uninstall", "Remove the local root CA from trust stores (needs sudo)"
57+
if install {
58+
use, short = "install", "Create + trust the local root CA in system/NSS stores (needs sudo)"
59+
}
60+
return &cobra.Command{
61+
Use: use,
62+
Short: short,
63+
Args: cobra.NoArgs,
64+
RunE: func(cmd *cobra.Command, _ []string) error {
65+
tr := trust.New()
66+
var err error
67+
if install {
68+
err = tr.Install(cmd.Context())
69+
} else {
70+
err = tr.Uninstall(cmd.Context())
71+
}
72+
if err != nil {
73+
return err
74+
}
75+
if !g.Quiet {
76+
fmt.Fprintf(cmd.OutOrStdout(), "trust %s: ok\n", use)
77+
}
78+
return nil
79+
},
80+
}
81+
}
82+
83+
func okmark(ok bool) string {
84+
if ok {
85+
return "ok"
86+
}
87+
return "MISSING"
88+
}

internal/cli/trust_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestTrustRegistered(t *testing.T) {
9+
root := NewRootCmd(Options{})
10+
for _, sub := range []string{"install", "uninstall", "status"} {
11+
c, _, err := root.Find([]string{"trust", sub})
12+
if err != nil || c.Name() != sub || c.RunE == nil {
13+
t.Errorf("trust %s not registered as a real command: %v", sub, err)
14+
}
15+
}
16+
}
17+
18+
func TestTrustStatusRuns(t *testing.T) {
19+
var out strings.Builder
20+
root := NewRootCmd(Options{})
21+
root.SetArgs([]string{"trust", "status"})
22+
root.SetOut(&out)
23+
root.SetErr(&out)
24+
// status is read-only and must not error even when mkcert is absent.
25+
if err := root.Execute(); err != nil {
26+
t.Fatalf("trust status: %v\n%s", err, out.String())
27+
}
28+
if !strings.Contains(out.String(), "mkcert:") {
29+
t.Errorf("trust status output missing the mkcert line:\n%s", out.String())
30+
}
31+
}

internal/trust/trust.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Package trust manages the local CA used for HTTPS at *.localhost (spec 05). It
2+
// shells out to the maintained `mkcert` binary (NOT smallstep/truststore) to
3+
// install/remove the root CA into the host + Firefox/NSS stores, and diagnoses
4+
// the platform tools mkcert needs at runtime so `trust status` can print exact
5+
// remediations.
6+
//
7+
// Installing a CA is sudo-/privilege-gated; per locked decision #3 the logic is
8+
// built + tested with a fake runner, the human/sudo step is flagged, and a
9+
// doctor-style Status probe self-verifies. The mkcert process is run through an
10+
// injectable Runner so the package is fully unit-testable without mkcert present.
11+
package trust
12+
13+
import (
14+
"context"
15+
"fmt"
16+
"os"
17+
"os/exec"
18+
"path/filepath"
19+
"strings"
20+
21+
"github.com/open-source-cloud/devstack/internal/xdg"
22+
)
23+
24+
// Runner runs the external mkcert binary. Injectable for tests.
25+
type Runner interface {
26+
Output(ctx context.Context, name string, args ...string) ([]byte, error)
27+
Run(ctx context.Context, name string, args ...string) error
28+
LookPath(file string) (string, error)
29+
}
30+
31+
// Trust wraps mkcert. The zero value uses the real OS exec runner.
32+
type Trust struct {
33+
Runner Runner
34+
}
35+
36+
// New returns a Trust backed by the real exec runner.
37+
func New() *Trust { return &Trust{Runner: execRunner{}} }
38+
39+
func (t *Trust) runner() Runner {
40+
if t.Runner != nil {
41+
return t.Runner
42+
}
43+
return execRunner{}
44+
}
45+
46+
// Available reports whether the mkcert binary is on PATH.
47+
func (t *Trust) Available() bool {
48+
_, err := t.runner().LookPath("mkcert")
49+
return err == nil
50+
}
51+
52+
// CARoot returns mkcert's CAROOT directory (where rootCA.pem lives).
53+
func (t *Trust) CARoot(ctx context.Context) (string, error) {
54+
out, err := t.runner().Output(ctx, "mkcert", "-CAROOT")
55+
if err != nil {
56+
return "", fmt.Errorf("mkcert -CAROOT: %w", err)
57+
}
58+
return strings.TrimSpace(string(out)), nil
59+
}
60+
61+
// Install installs the local root CA into the system + NSS trust stores
62+
// (`mkcert -install`). Requires privileges; a failure carries mkcert's output.
63+
func (t *Trust) Install(ctx context.Context) error {
64+
if !t.Available() {
65+
return errMkcertMissing()
66+
}
67+
if err := t.runner().Run(ctx, "mkcert", "-install"); err != nil {
68+
return fmt.Errorf("mkcert -install (try sudo; ensure libnss3-tools/certutil on Linux): %w", err)
69+
}
70+
return nil
71+
}
72+
73+
// Uninstall removes the local root CA from the trust stores (`mkcert -uninstall`).
74+
func (t *Trust) Uninstall(ctx context.Context) error {
75+
if !t.Available() {
76+
return errMkcertMissing()
77+
}
78+
if err := t.runner().Run(ctx, "mkcert", "-uninstall"); err != nil {
79+
return fmt.Errorf("mkcert -uninstall: %w", err)
80+
}
81+
return nil
82+
}
83+
84+
// Status is a diagnostic snapshot of local-CA readiness (the `trust status` view
85+
// + a doctor probe). Each field has a one-line remediation when not OK.
86+
type Status struct {
87+
MkcertFound bool `json:"mkcertFound"`
88+
CARoot string `json:"caRoot,omitempty"`
89+
CAInstalled bool `json:"caInstalled"` // rootCA.pem exists in CAROOT
90+
FirefoxTrust bool `json:"firefoxTrust"` // certutil present (NSS / Firefox)
91+
WSL bool `json:"wsl"`
92+
Remediation string `json:"remediation,omitempty"`
93+
}
94+
95+
// Status probes the environment. It never mutates anything (no sudo needed).
96+
func (t *Trust) Status(ctx context.Context) Status {
97+
s := Status{WSL: xdg.IsWSL2()}
98+
s.MkcertFound = t.Available()
99+
if !s.MkcertFound {
100+
s.Remediation = "install mkcert (https://github.com/FiloSottile/mkcert) then run `devstack trust install`"
101+
return s
102+
}
103+
if root, err := t.CARoot(ctx); err == nil {
104+
s.CARoot = root
105+
if root != "" {
106+
if _, err := os.Stat(filepath.Join(root, "rootCA.pem")); err == nil {
107+
s.CAInstalled = true
108+
}
109+
}
110+
}
111+
// certutil backs Firefox/NSS trust; absent on a clean Ubuntu/WSL2.
112+
_, certutilErr := t.runner().LookPath("certutil")
113+
s.FirefoxTrust = certutilErr == nil
114+
115+
switch {
116+
case !s.CAInstalled:
117+
s.Remediation = "run `sudo devstack trust install` to create + trust the local CA"
118+
case !s.FirefoxTrust:
119+
s.Remediation = "install certutil for Firefox/NSS trust: `apt install libnss3-tools` (Debian/Ubuntu)"
120+
case s.WSL:
121+
s.Remediation = "WSL2: also import the CA into the Windows store so browsers-on-Windows trust it (certutil.exe -addstore -user Root <CAROOT>/rootCA.pem)"
122+
}
123+
return s
124+
}
125+
126+
// OK reports whether local HTTPS trust is fully ready.
127+
func (s Status) OK() bool { return s.MkcertFound && s.CAInstalled && s.FirefoxTrust }
128+
129+
func errMkcertMissing() error {
130+
return fmt.Errorf("mkcert not found on PATH — install it (https://github.com/FiloSottile/mkcert)")
131+
}
132+
133+
// execRunner is the production Runner.
134+
type execRunner struct{}
135+
136+
func (execRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
137+
return exec.CommandContext(ctx, name, args...).Output()
138+
}
139+
func (execRunner) Run(ctx context.Context, name string, args ...string) error {
140+
cmd := exec.CommandContext(ctx, name, args...)
141+
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
142+
return cmd.Run()
143+
}
144+
func (execRunner) LookPath(file string) (string, error) { return exec.LookPath(file) }

0 commit comments

Comments
 (0)