diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9851647..e894248 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -488,7 +488,7 @@ func (s *doctorSession) dnsProbe() (probe, bool) { if err != nil { c.Status = docker.StatusWarn c.Detail = err.Error() - c.Remediation = "run `sudo devstack dns setup`" + c.Remediation = fmt.Sprintf("run `%s`", sudoSelfCmd("dns setup")) return plain(c), true } if len(missing) == 0 { @@ -498,10 +498,23 @@ func (s *doctorSession) dnsProbe() (probe, bool) { } c.Status = docker.StatusWarn c.Detail = fmt.Sprintf("%d of %d *.localhost host(s) missing", len(missing), len(hosts)) - c.Remediation = "run `sudo devstack dns setup` (a marker-fenced /etc/hosts write needs root)" + c.Remediation = fmt.Sprintf("run `%s` (a marker-fenced /etc/hosts write needs root)", sudoSelfCmd("dns setup")) return plain(c), true } +// sudoSelfCmd renders a copy-pasteable "sudo " remediation. +// The ABSOLUTE path is load-bearing: sudo resets PATH to its secure_path (which +// on most systems excludes ~/.local/bin), so a bare `sudo devstack …` fails with +// "command not found" for a user-local install. os.Executable() resolves the +// real running binary; it falls back to the bare name only if that lookup fails. +func sudoSelfCmd(sub string) string { + exe, err := os.Executable() + if err != nil || exe == "" { + exe = "devstack" + } + return fmt.Sprintf("sudo %s %s", exe, sub) +} + // trustProbe reports local-CA readiness. Diagnose-only: driving mkcert/NSS // mutates OS/browser trust stores, which is `trust install`, not `--fix`. func (s *doctorSession) trustProbe(ctx context.Context) probe { diff --git a/internal/cli/doctor_sudo_test.go b/internal/cli/doctor_sudo_test.go new file mode 100644 index 0000000..c1b45eb --- /dev/null +++ b/internal/cli/doctor_sudo_test.go @@ -0,0 +1,24 @@ +package cli + +import ( + "strings" + "testing" +) + +// sudoSelfCmd must render an ABSOLUTE binary path. sudo resets PATH to its +// secure_path, which typically excludes ~/.local/bin, so `sudo devstack …` would +// fail with "command not found" for a user-local install. The absolute path from +// os.Executable() is what makes the remediation actually runnable. +func TestSudoSelfCmdUsesAbsolutePath(t *testing.T) { + got := sudoSelfCmd("dns setup") + if !strings.HasPrefix(got, "sudo ") { + t.Fatalf("want a sudo prefix, got %q", got) + } + if !strings.HasSuffix(got, " dns setup") { + t.Fatalf("want the subcommand suffix, got %q", got) + } + fields := strings.Fields(got) + if len(fields) < 2 || !strings.HasPrefix(fields[1], "/") { + t.Errorf("binary path should be absolute (sudo can't use secure_path for ~/.local/bin), got %q", got) + } +} diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 2d87ba5..ca55aba 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -155,7 +155,7 @@ func runUninstall(ctx context.Context, env uninstallEnv) UninstallResult { // 4. remove the marker-fenced /etc/hosts entries. if removed, err := dns.Remove(env.HostsPath); err != nil { - res.Warnings = append(res.Warnings, fmt.Sprintf("/etc/hosts cleanup: %v (try `sudo devstack uninstall`)", err)) + res.Warnings = append(res.Warnings, fmt.Sprintf("/etc/hosts cleanup: %v (try `%s`)", err, sudoSelfCmd("uninstall"))) } else { res.HostsCleared = removed } diff --git a/internal/trust/trust.go b/internal/trust/trust.go index 58d5fe3..846d0c1 100644 --- a/internal/trust/trust.go +++ b/internal/trust/trust.go @@ -114,7 +114,11 @@ func (t *Trust) Status(ctx context.Context) Status { switch { case !s.CAInstalled: - s.Remediation = "run `sudo devstack trust install` to create + trust the local CA" + // NOT `sudo devstack …`: running the whole CLI as root puts mkcert's CAROOT + // in root's home (so later cert generation, run as you, can't find the CA), + // and a user-local install isn't on sudo's secure_path anyway. Run it as + // your user — mkcert self-elevates (its own sudo) only for the system store. + s.Remediation = "run `devstack trust install` (as your user, not sudo) 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: @@ -138,6 +142,11 @@ func (execRunner) Output(ctx context.Context, name string, args ...string) ([]by } func (execRunner) Run(ctx context.Context, name string, args ...string) error { cmd := exec.CommandContext(ctx, name, args...) + // Wire stdin, not just stdout/stderr: `mkcert -install` self-elevates with its + // own `sudo` for the system trust store, and that sudo must be able to prompt + // for a password on the terminal. Without stdin the child reads /dev/null and + // the system-store step fails silently. + cmd.Stdin = os.Stdin cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr return cmd.Run() } diff --git a/internal/trust/trust_test.go b/internal/trust/trust_test.go index 4e54a8a..2951c97 100644 --- a/internal/trust/trust_test.go +++ b/internal/trust/trust_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" ) @@ -77,6 +78,15 @@ func TestStatusCANotInstalled(t *testing.T) { if s.OK() { t.Error("CA not installed → not OK") } + // The remediation must NOT tell the user to `sudo devstack trust install`: + // running the whole CLI as root misplaces mkcert's CAROOT and isn't on sudo's + // secure_path. It runs as the user; mkcert self-elevates for the system store. + if strings.Contains(s.Remediation, "sudo devstack") { + t.Errorf("remediation should not instruct `sudo devstack …`, got %q", s.Remediation) + } + if !strings.Contains(s.Remediation, "trust install") { + t.Errorf("remediation should point at `devstack trust install`, got %q", s.Remediation) + } } func TestStatusFullyReady(t *testing.T) {