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
17 changes: 15 additions & 2 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 <abs-binary> <sub>" 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 {
Expand Down
24 changes: 24 additions & 0 deletions internal/cli/doctor_sudo_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/cli/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
11 changes: 10 additions & 1 deletion internal/trust/trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
}
Expand Down
10 changes: 10 additions & 0 deletions internal/trust/trust_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -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) {
Expand Down
Loading