Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newDownCmd(g),
newStatusCmd(g),
newDnsCmd(g),
newTrustCmd(g),
newDoctorCmd(g),
newConfigCmd(g),
newGenerateCmd(g),
Expand Down
5 changes: 0 additions & 5 deletions internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
88 changes: 88 additions & 0 deletions internal/cli/trust.go
Original file line number Diff line number Diff line change
@@ -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"
}
31 changes: 31 additions & 0 deletions internal/cli/trust_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
144 changes: 144 additions & 0 deletions internal/trust/trust.go
Original file line number Diff line number Diff line change
@@ -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 <CAROOT>/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) }
Loading
Loading