Skip to content

Commit c822b5e

Browse files
hhfrancoisclaude
andcommitted
feat(mac): setuid-root helper — run plug <cmd> without sudo
macOS has no file capabilities, so install the launcher as a setuid-root helper (chown root:wheel + chmod u+s, one sudo at install — the counterpart of Linux setcap). plug starts euid 0 to hold the utun + DNS, then drops the child back to the invoking user via syscall.Credential (uid/gid + groups): unlike a Linux capability, a setuid euid is inherited across exec, so the child would otherwise run as root. `sudo plug` drops via SUDO_UID; a genuine root login runs the child as root. self-update re-applies the setuid bit. Also fixes two setuid side-effects found validating the daemon e2e: - daemon log O_APPEND -> O_TRUNC (stale pre-fix lines read as live failures) - ~/.plug/versions written root-owned by euid 0 -> chownToUser (self-healing) Proven e2e on macOS: `plug selftest` and `plug curl portal:9191` without sudo (child dropped to the user, daemon graft, teardown restores DNS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f3e9234 commit c822b5e

7 files changed

Lines changed: 234 additions & 4 deletions

File tree

RELEASE_NOTES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## NEXT RELEASE
44

5+
- **macOS: `plug <command>` now runs without sudo (setuid-root helper).** The
6+
install posts the launcher as a setuid-root helper (`chown root:wheel` +
7+
`chmod u+s`, one sudo at install — the macOS counterpart of the Linux `setcap`),
8+
so day-to-day `plug <command>` needs no sudo, matching Linux. plug starts with
9+
euid 0 to hold the utun + DNS, then **drops your command back to your own user**
10+
before running it — unlike a Linux capability (dropped for free across exec), a
11+
setuid euid is inherited, so the child is spawned under your uid/gid and
12+
supplementary groups. `sudo plug` still works (it drops via `SUDO_UID`); a
13+
genuine root login runs the child as root, unchanged. `self-update` re-applies
14+
the setuid bit so an update doesn't silently disable the helper.
15+
516
---
617

718
## 1.1.0

agent/serve-binary

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,10 @@ case "$os-$arch" in
7676
esac
7777
chmod +x "$dest/plug"
7878
echo "plug: installed to $dest/plug"
79-
# Grant the capabilities plug needs (TUN device + routes, per-launch mount ns,
80-
# bind :53) ONCE, so day-to-day `plug <command>` runs with no sudo. sudo prompts
81-
# on /dev/tty even through `| sh`. macOS has no file capabilities — use sudo there.
79+
# Grant the privilege plug needs (TUN device + routes, per-launch mount ns, bind
80+
# :53) ONCE, so day-to-day `plug <command>` runs with no sudo. sudo prompts on
81+
# /dev/tty even through `| sh`. Linux = file capabilities; macOS has none, so it's
82+
# a setuid-root helper that drops your command back to you (see privdrop_unix.go).
8283
if [ "$os" = linux ]; then
8384
if command -v setcap >/dev/null 2>&1; then
8485
echo "plug: granting network capabilities (one sudo now → future runs need none)…"
@@ -91,6 +92,14 @@ if [ "$os" = linux ]; then
9192
else
9293
echo "plug: 'setcap' missing (install libcap2-bin) — meanwhile run 'sudo plug <command>'" >&2
9394
fi
95+
elif [ "$os" = darwin ]; then
96+
echo "plug: installing the privileged helper (one sudo now → future runs need none)…"
97+
if sudo chown root:wheel "$dest/plug" && sudo chmod u+s "$dest/plug"; then
98+
echo "plug: ready — 'plug <command>' runs without sudo"
99+
else
100+
echo "plug: could not install the setuid helper — meanwhile run 'sudo plug <command>', or fix with:" >&2
101+
echo " sudo chown root:wheel '$dest/plug' && sudo chmod u+s '$dest/plug'" >&2
102+
fi
94103
fi
95104
case ":$PATH:" in *":$dest:"*) : ;; *) echo "plug: add $dest to your PATH" ;; esac
96105
if [ -n "$_profile" ]; then

cli/daemon_darwin.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ func startDaemonDetached(cfg config) error {
125125
}
126126
_ = os.MkdirAll("/var/run/plug", 0o755)
127127
logPath := filepath.Join("/var/run/plug", tun.ClusterHash(cfg.host+":"+cfg.port)+".log")
128-
logf, _ := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
128+
// TRUNC, not APPEND: one daemon owns one log for its lifetime. Appending across
129+
// daemon lifetimes accumulates dead lines from earlier (e.g. pre-fix) attempts,
130+
// which read as live failures and send you chasing ghosts when diagnosing.
131+
logf, _ := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
129132

130133
r, w, err := os.Pipe()
131134
if err != nil {

cli/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,12 @@ func versionsDir() string {
269269
func ensureVersion(v string, cfg config) (string, error) {
270270
dir := filepath.Join(versionsDir(), v)
271271
bin := filepath.Join(dir, "plug")
272+
// Hand the cache back to the user: the setuid helper writes it as euid 0, so
273+
// without this it lands root-owned (can't be listed/cleaned without sudo). Also
274+
// self-heals a cache an earlier privileged run already left root-owned.
275+
own := func() { chownToUser(versionsDir()); chownToUser(dir); chownToUser(bin) }
272276
if fi, err := os.Stat(bin); err == nil && fi.Size() > 1<<20 {
277+
own()
273278
return bin, nil
274279
}
275280
data, err := getDownload(cfg, fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH), "v"+v)
@@ -298,6 +303,7 @@ func ensureVersion(v string, cfg config) (string, error) {
298303
if err := os.Rename(tmp.Name(), bin); err != nil {
299304
return "", err
300305
}
306+
own()
301307
return bin, nil
302308
}
303309

@@ -359,6 +365,9 @@ func selfUpdate(args []string) {
359365
if err := os.Rename(tmp.Name(), self); err != nil {
360366
fatal("cannot replace %s: %v", self, err)
361367
}
368+
// A rename installs a fresh inode that lost the macOS setuid bit — re-apply it
369+
// so the helper keeps working without a sudo (no-op off macOS / when not root).
370+
preserveHelperPrivilege(self)
362371
info("launcher updated %s → v%s", version, remote)
363372
}
364373

@@ -767,6 +776,10 @@ func runChildEnv(cmdArgs []string, env []string) int {
767776
if env != nil {
768777
child.Env = env
769778
}
779+
// When plug is the macOS setuid-root helper, this process runs with euid 0 so
780+
// it can hold the utun + DNS — but YOUR command must not. Drop the child back to
781+
// the human user (no-op on the Linux caps path). See privdrop_unix.go.
782+
applyPrivDrop(child)
770783

771784
sigs := make(chan os.Signal, 1)
772785
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

cli/privdrop_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//go:build !windows
2+
3+
package main
4+
5+
import "testing"
6+
7+
func TestResolveDropTarget(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
euid, ruid, rgid int
11+
sudoUID, sudoGID string
12+
wantUID, wantGID int
13+
wantOK bool
14+
}{
15+
{
16+
name: "linux caps: unprivileged, nothing to drop",
17+
euid: 501, ruid: 501, rgid: 20,
18+
wantOK: false,
19+
},
20+
{
21+
name: "macOS setuid-root: drop to the real user",
22+
euid: 0, ruid: 501, rgid: 20,
23+
wantUID: 501, wantGID: 20, wantOK: true,
24+
},
25+
{
26+
name: "sudo plug: drop to the invoker sudo recorded",
27+
euid: 0, ruid: 0, rgid: 0, sudoUID: "501", sudoGID: "20",
28+
wantUID: 501, wantGID: 20, wantOK: true,
29+
},
30+
{
31+
name: "genuine root, no sudo: don't guess a user",
32+
euid: 0, ruid: 0, rgid: 0,
33+
wantOK: false,
34+
},
35+
{
36+
name: "root euid, root ruid, blank sudo env: stay root",
37+
euid: 0, ruid: 0, rgid: 0, sudoUID: "", sudoGID: "",
38+
wantOK: false,
39+
},
40+
}
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
uid, gid, ok := resolveDropTarget(tt.euid, tt.ruid, tt.rgid, tt.sudoUID, tt.sudoGID)
44+
if ok != tt.wantOK {
45+
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
46+
}
47+
if !ok {
48+
return
49+
}
50+
if uid != tt.wantUID || gid != tt.wantGID {
51+
t.Fatalf("got uid=%d gid=%d, want uid=%d gid=%d", uid, gid, tt.wantUID, tt.wantGID)
52+
}
53+
})
54+
}
55+
}

cli/privdrop_unix.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//go:build !windows
2+
3+
package main
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"os/user"
9+
"runtime"
10+
"strconv"
11+
"syscall"
12+
)
13+
14+
// privDrop is the macOS half of the helper: it drops the child command back to the
15+
// human user so `plug <cmd>` never runs your command as root.
16+
//
17+
// On macOS plug is installed as a setuid-root helper (`chown root` + `chmod u+s`,
18+
// posted once at install). Launching it gives the process euid 0 — enough to create
19+
// the utun and repoint the system resolver — while the REAL uid stays the user's.
20+
// Unlike Linux file capabilities, which are dropped for free because they don't
21+
// survive an exec, a setuid-root euid IS inherited across exec: without an explicit
22+
// drop, the child (`npm run …`, your app) would run as root. That would wreck file
23+
// ownership in your working tree and is exactly the kind of surprise we refuse to
24+
// ship. So the child is spawned under the user's own credentials instead.
25+
26+
// applyPrivDrop makes cmd spawn as the human user when plug is running privileged.
27+
// No-op when there is nothing to drop (see resolveDropTarget), so it's inert on the
28+
// Linux capabilities path and on a genuine root login.
29+
func applyPrivDrop(cmd *exec.Cmd) {
30+
uid, gid, ok := resolveDropTarget(os.Geteuid(), os.Getuid(), os.Getgid(),
31+
os.Getenv("SUDO_UID"), os.Getenv("SUDO_GID"))
32+
if !ok {
33+
return
34+
}
35+
if cmd.SysProcAttr == nil {
36+
cmd.SysProcAttr = &syscall.SysProcAttr{}
37+
}
38+
cred := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
39+
// Carry the user's supplementary groups (staff, admin, _developer, …). Without
40+
// this the child would keep root's groups, which can break group-owned access.
41+
if groups := supplementaryGroups(uid); groups != nil {
42+
cred.Groups = groups
43+
}
44+
cmd.SysProcAttr.Credential = cred
45+
}
46+
47+
// resolveDropTarget decides which uid/gid the child must drop to, from the process
48+
// ids and sudo's record of the invoker. ok is false when there is nothing to drop:
49+
// - euid != 0 → unprivileged (Linux caps path); leave the child untouched.
50+
// - real uid is 0 → launched as genuine root with no SUDO_UID; don't guess a
51+
// user to become — run the child as root, as the user explicitly asked.
52+
//
53+
// The two privileged-but-droppable cases both yield ok == true:
54+
// - setuid-root (euid 0, real uid = the user) → drop to the real uid (macOS).
55+
// - `sudo plug` (euid 0, real uid 0) → drop to SUDO_UID/SUDO_GID.
56+
func resolveDropTarget(euid, ruid, rgid int, sudoUID, sudoGID string) (uid, gid int, ok bool) {
57+
if euid != 0 {
58+
return 0, 0, false
59+
}
60+
uid, gid = ruid, rgid
61+
if uid == 0 {
62+
uid = atoiOr(sudoUID, 0)
63+
gid = atoiOr(sudoGID, 0)
64+
}
65+
if uid == 0 {
66+
return 0, 0, false
67+
}
68+
return uid, gid, true
69+
}
70+
71+
// supplementaryGroups returns uid's group memberships as a syscall.Credential
72+
// group list, or nil if they can't be resolved (the caller then leaves Groups nil,
73+
// which clears root's groups rather than leaking them).
74+
func supplementaryGroups(uid int) []uint32 {
75+
u, err := user.LookupId(strconv.Itoa(uid))
76+
if err != nil {
77+
return nil
78+
}
79+
gids, err := u.GroupIds()
80+
if err != nil {
81+
return nil
82+
}
83+
var out []uint32
84+
for _, g := range gids {
85+
if n, err := strconv.Atoi(g); err == nil {
86+
out = append(out, uint32(n))
87+
}
88+
}
89+
return out
90+
}
91+
92+
// chownToUser gives path back to the human user when plug runs as the macOS
93+
// setuid helper (euid 0). Files plug writes under the user's ~/.plug — the version
94+
// cache, a pinned known_hosts — would otherwise land root-owned, so the user could
95+
// neither clean nor update them without sudo. No-op when unprivileged (see
96+
// resolveDropTarget), so the Linux caps path is untouched.
97+
func chownToUser(path string) {
98+
uid, gid, ok := resolveDropTarget(os.Geteuid(), os.Getuid(), os.Getgid(),
99+
os.Getenv("SUDO_UID"), os.Getenv("SUDO_GID"))
100+
if !ok {
101+
return
102+
}
103+
_ = os.Chown(path, uid, gid)
104+
}
105+
106+
// preserveHelperPrivilege re-applies the macOS setuid-root bit to a freshly written
107+
// launcher, so `plug self-update` doesn't silently disable the helper — a rename
108+
// installs a new inode that has lost the bit. Only on macOS, and only when we're
109+
// already root (the setuid helper is), so it can run without a sudo prompt and a
110+
// capability-based Linux install is never turned into a setuid one.
111+
func preserveHelperPrivilege(path string) {
112+
if runtime.GOOS != "darwin" || os.Geteuid() != 0 {
113+
return
114+
}
115+
_ = os.Chown(path, 0, 0)
116+
_ = os.Chmod(path, 0o755|os.ModeSetuid)
117+
}
118+
119+
func atoiOr(s string, def int) int {
120+
if n, err := strconv.Atoi(s); err == nil {
121+
return n
122+
}
123+
return def
124+
}

cli/privdrop_windows.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build windows
2+
3+
package main
4+
5+
import "os/exec"
6+
7+
// Windows has no setuid: plug elevates per launch (UAC / a SYSTEM service), it
8+
// never runs the child from an inherited root euid, so there is nothing to drop
9+
// and no helper bit to preserve.
10+
11+
func applyPrivDrop(*exec.Cmd) {}
12+
13+
func preserveHelperPrivilege(string) {}
14+
15+
func chownToUser(string) {}

0 commit comments

Comments
 (0)