diff --git a/internal/cli/root.go b/internal/cli/root.go index 56140d5..b21fa35 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -70,6 +70,7 @@ func NewRootCmd(opts Options) *cobra.Command { newDownCmd(g), newStatusCmd(g), newDnsCmd(g), + newTrustCmd(g), newDoctorCmd(g), newConfigCmd(g), newGenerateCmd(g), diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index e3bbf62..07b87fe 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -36,11 +36,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) { stub("login", "Authenticate a secrets provider", "M4"), stub("keygen", "Generate an age/SOPS key", "M4"), ), - stub("trust", "Local CA trust (install/uninstall/status)", "M5", - stub("install", "Install the local root CA into trust stores", "M5"), - stub("uninstall", "Remove the local root CA from trust stores", "M5"), - stub("status", "Show local CA trust status", "M5"), - ), stub("tunnel", "Optional public tunnel via cloudflared", "M5", stub("login", "Authenticate cloudflared", "M5"), stub("create", "Create a named tunnel", "M5"), diff --git a/internal/cli/trust.go b/internal/cli/trust.go new file mode 100644 index 0000000..87d5dda --- /dev/null +++ b/internal/cli/trust.go @@ -0,0 +1,88 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/trust" +) + +// newTrustCmd wires `trust install|uninstall|status` — the local CA via mkcert +// (spec 05). install/uninstall need privileges (sudo); status is read-only and +// prints the exact remediation for whatever is missing. +func newTrustCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "trust", + Short: "Manage the local HTTPS CA (mkcert) for *.localhost", + } + cmd.AddCommand( + newTrustStatusCmd(g), + newTrustInstallCmd(g, true), + newTrustInstallCmd(g, false), + ) + return cmd +} + +func newTrustStatusCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Diagnose local-CA trust readiness", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s := trust.New().Status(cmd.Context()) + if g.JSON { + return writeJSON(cmd, s) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "mkcert: %s\n", okmark(s.MkcertFound)) + fmt.Fprintf(w, "CA: %s\n", okmark(s.CAInstalled)) + fmt.Fprintf(w, "Firefox: %s (certutil)\n", okmark(s.FirefoxTrust)) + if s.CARoot != "" { + fmt.Fprintf(w, "CAROOT: %s\n", s.CARoot) + } + if s.Remediation != "" { + fmt.Fprintf(w, "\n→ %s\n", s.Remediation) + } else { + fmt.Fprintln(w, "\nlocal HTTPS trust is ready") + } + return nil + }, + } +} + +// newTrustInstallCmd builds either `install` (install=true) or `uninstall`. +func newTrustInstallCmd(g *GlobalOpts, install bool) *cobra.Command { + use, short := "uninstall", "Remove the local root CA from trust stores (needs sudo)" + if install { + use, short = "install", "Create + trust the local root CA in system/NSS stores (needs sudo)" + } + return &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + tr := trust.New() + var err error + if install { + err = tr.Install(cmd.Context()) + } else { + err = tr.Uninstall(cmd.Context()) + } + if err != nil { + return err + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "trust %s: ok\n", use) + } + return nil + }, + } +} + +func okmark(ok bool) string { + if ok { + return "ok" + } + return "MISSING" +} diff --git a/internal/cli/trust_test.go b/internal/cli/trust_test.go new file mode 100644 index 0000000..bc5b7fc --- /dev/null +++ b/internal/cli/trust_test.go @@ -0,0 +1,31 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestTrustRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, sub := range []string{"install", "uninstall", "status"} { + c, _, err := root.Find([]string{"trust", sub}) + if err != nil || c.Name() != sub || c.RunE == nil { + t.Errorf("trust %s not registered as a real command: %v", sub, err) + } + } +} + +func TestTrustStatusRuns(t *testing.T) { + var out strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"trust", "status"}) + root.SetOut(&out) + root.SetErr(&out) + // status is read-only and must not error even when mkcert is absent. + if err := root.Execute(); err != nil { + t.Fatalf("trust status: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "mkcert:") { + t.Errorf("trust status output missing the mkcert line:\n%s", out.String()) + } +} diff --git a/internal/trust/trust.go b/internal/trust/trust.go new file mode 100644 index 0000000..58d5fe3 --- /dev/null +++ b/internal/trust/trust.go @@ -0,0 +1,144 @@ +// Package trust manages the local CA used for HTTPS at *.localhost (spec 05). It +// shells out to the maintained `mkcert` binary (NOT smallstep/truststore) to +// install/remove the root CA into the host + Firefox/NSS stores, and diagnoses +// the platform tools mkcert needs at runtime so `trust status` can print exact +// remediations. +// +// Installing a CA is sudo-/privilege-gated; per locked decision #3 the logic is +// built + tested with a fake runner, the human/sudo step is flagged, and a +// doctor-style Status probe self-verifies. The mkcert process is run through an +// injectable Runner so the package is fully unit-testable without mkcert present. +package trust + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// Runner runs the external mkcert binary. Injectable for tests. +type Runner interface { + Output(ctx context.Context, name string, args ...string) ([]byte, error) + Run(ctx context.Context, name string, args ...string) error + LookPath(file string) (string, error) +} + +// Trust wraps mkcert. The zero value uses the real OS exec runner. +type Trust struct { + Runner Runner +} + +// New returns a Trust backed by the real exec runner. +func New() *Trust { return &Trust{Runner: execRunner{}} } + +func (t *Trust) runner() Runner { + if t.Runner != nil { + return t.Runner + } + return execRunner{} +} + +// Available reports whether the mkcert binary is on PATH. +func (t *Trust) Available() bool { + _, err := t.runner().LookPath("mkcert") + return err == nil +} + +// CARoot returns mkcert's CAROOT directory (where rootCA.pem lives). +func (t *Trust) CARoot(ctx context.Context) (string, error) { + out, err := t.runner().Output(ctx, "mkcert", "-CAROOT") + if err != nil { + return "", fmt.Errorf("mkcert -CAROOT: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// Install installs the local root CA into the system + NSS trust stores +// (`mkcert -install`). Requires privileges; a failure carries mkcert's output. +func (t *Trust) Install(ctx context.Context) error { + if !t.Available() { + return errMkcertMissing() + } + if err := t.runner().Run(ctx, "mkcert", "-install"); err != nil { + return fmt.Errorf("mkcert -install (try sudo; ensure libnss3-tools/certutil on Linux): %w", err) + } + return nil +} + +// Uninstall removes the local root CA from the trust stores (`mkcert -uninstall`). +func (t *Trust) Uninstall(ctx context.Context) error { + if !t.Available() { + return errMkcertMissing() + } + if err := t.runner().Run(ctx, "mkcert", "-uninstall"); err != nil { + return fmt.Errorf("mkcert -uninstall: %w", err) + } + return nil +} + +// Status is a diagnostic snapshot of local-CA readiness (the `trust status` view +// + a doctor probe). Each field has a one-line remediation when not OK. +type Status struct { + MkcertFound bool `json:"mkcertFound"` + CARoot string `json:"caRoot,omitempty"` + CAInstalled bool `json:"caInstalled"` // rootCA.pem exists in CAROOT + FirefoxTrust bool `json:"firefoxTrust"` // certutil present (NSS / Firefox) + WSL bool `json:"wsl"` + Remediation string `json:"remediation,omitempty"` +} + +// Status probes the environment. It never mutates anything (no sudo needed). +func (t *Trust) Status(ctx context.Context) Status { + s := Status{WSL: xdg.IsWSL2()} + s.MkcertFound = t.Available() + if !s.MkcertFound { + s.Remediation = "install mkcert (https://github.com/FiloSottile/mkcert) then run `devstack trust install`" + return s + } + if root, err := t.CARoot(ctx); err == nil { + s.CARoot = root + if root != "" { + if _, err := os.Stat(filepath.Join(root, "rootCA.pem")); err == nil { + s.CAInstalled = true + } + } + } + // certutil backs Firefox/NSS trust; absent on a clean Ubuntu/WSL2. + _, certutilErr := t.runner().LookPath("certutil") + s.FirefoxTrust = certutilErr == nil + + switch { + case !s.CAInstalled: + s.Remediation = "run `sudo devstack trust install` to create + trust the local CA" + case !s.FirefoxTrust: + s.Remediation = "install certutil for Firefox/NSS trust: `apt install libnss3-tools` (Debian/Ubuntu)" + case s.WSL: + s.Remediation = "WSL2: also import the CA into the Windows store so browsers-on-Windows trust it (certutil.exe -addstore -user Root /rootCA.pem)" + } + return s +} + +// OK reports whether local HTTPS trust is fully ready. +func (s Status) OK() bool { return s.MkcertFound && s.CAInstalled && s.FirefoxTrust } + +func errMkcertMissing() error { + return fmt.Errorf("mkcert not found on PATH — install it (https://github.com/FiloSottile/mkcert)") +} + +// execRunner is the production Runner. +type execRunner struct{} + +func (execRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} +func (execRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} +func (execRunner) LookPath(file string) (string, error) { return exec.LookPath(file) } diff --git a/internal/trust/trust_test.go b/internal/trust/trust_test.go new file mode 100644 index 0000000..4e54a8a --- /dev/null +++ b/internal/trust/trust_test.go @@ -0,0 +1,146 @@ +package trust + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +type fakeRunner struct { + caroot string + have map[string]bool // binaries "on PATH" + installErr error + calls []string +} + +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, name+" "+join(args)) + if name == "mkcert" && len(args) == 1 && args[0] == "-CAROOT" { + return []byte(f.caroot + "\n"), nil + } + return nil, errors.New("unexpected Output call") +} +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.calls = append(f.calls, name+" "+join(args)) + if name == "mkcert" && len(args) == 1 && args[0] == "-install" { + return f.installErr + } + return nil +} +func (f *fakeRunner) LookPath(file string) (string, error) { + if f.have[file] { + return "/usr/bin/" + file, nil + } + return "", errors.New("not found") +} + +func join(a []string) string { + out := "" + for i, s := range a { + if i > 0 { + out += " " + } + out += s + } + return out +} + +func TestAvailable(t *testing.T) { + if (&Trust{Runner: &fakeRunner{have: map[string]bool{"mkcert": true}}}).Available() != true { + t.Error("mkcert present → Available true") + } + if (&Trust{Runner: &fakeRunner{have: map[string]bool{}}}).Available() != false { + t.Error("mkcert absent → Available false") + } +} + +func TestStatusMkcertMissing(t *testing.T) { + tr := &Trust{Runner: &fakeRunner{have: map[string]bool{}}} + s := tr.Status(context.Background()) + if s.MkcertFound || s.OK() { + t.Error("missing mkcert → not found, not OK") + } + if s.Remediation == "" { + t.Error("missing mkcert → a remediation") + } +} + +func TestStatusCANotInstalled(t *testing.T) { + root := t.TempDir() // CAROOT exists but no rootCA.pem + tr := &Trust{Runner: &fakeRunner{caroot: root, have: map[string]bool{"mkcert": true}}} + s := tr.Status(context.Background()) + if !s.MkcertFound || s.CAInstalled { + t.Errorf("status = %+v, want found + not installed", s) + } + if s.OK() { + t.Error("CA not installed → not OK") + } +} + +func TestStatusFullyReady(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "rootCA.pem"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + tr := &Trust{Runner: &fakeRunner{caroot: root, have: map[string]bool{"mkcert": true, "certutil": true}}} + s := tr.Status(context.Background()) + if !s.CAInstalled || !s.FirefoxTrust { + t.Errorf("status = %+v, want CA installed + firefox trust", s) + } + if !s.OK() { + t.Error("mkcert + CA + certutil → OK") + } +} + +func TestStatusMissingCertutil(t *testing.T) { + root := t.TempDir() + _ = os.WriteFile(filepath.Join(root, "rootCA.pem"), []byte("x"), 0o644) + tr := &Trust{Runner: &fakeRunner{caroot: root, have: map[string]bool{"mkcert": true}}} + s := tr.Status(context.Background()) + if s.FirefoxTrust { + t.Error("no certutil → FirefoxTrust false") + } + if s.OK() { + t.Error("no certutil → not OK") + } +} + +func TestInstallUninstall(t *testing.T) { + fr := &fakeRunner{have: map[string]bool{"mkcert": true}} + tr := &Trust{Runner: fr} + if err := tr.Install(context.Background()); err != nil { + t.Fatal(err) + } + if err := tr.Uninstall(context.Background()); err != nil { + t.Fatal(err) + } + sawInstall, sawUninstall := false, false + for _, c := range fr.calls { + if c == "mkcert -install" { + sawInstall = true + } + if c == "mkcert -uninstall" { + sawUninstall = true + } + } + if !sawInstall || !sawUninstall { + t.Errorf("calls = %v, want install + uninstall", fr.calls) + } +} + +func TestInstallMissingMkcert(t *testing.T) { + tr := &Trust{Runner: &fakeRunner{have: map[string]bool{}}} + if err := tr.Install(context.Background()); err == nil { + t.Error("install without mkcert should error") + } +} + +func TestInstallPropagatesError(t *testing.T) { + boom := errors.New("permission denied") + tr := &Trust{Runner: &fakeRunner{have: map[string]bool{"mkcert": true}, installErr: boom}} + if err := tr.Install(context.Background()); err == nil { + t.Error("install failure should surface") + } +}