feat: switch macOS daemon from user-based to group-based pf routing

Sandboxed commands previously ran as `sudo -u _greywall`, breaking user
identity (home dir, SSH keys, git config). Now uses `sudo -u #<uid> -g
_greywall` so the process keeps the real user's identity while pf
matches
on EGID for traffic routing.

Key changes:
- pf rules use `group <GID>` instead of `user _greywall`
- GID resolved dynamically at daemon startup (not hardcoded, since macOS
  system groups like com.apple.access_ssh may claim preferred IDs)
- Sudoers rule installed at /etc/sudoers.d/greywall (validated with
visudo)
- Invoking user added to _greywall group via dscl (not dseditgroup,
which
  clobbers group attributes)
- tun2socks device discovery scans both stdout and stderr (fixes 10s
  timeout caused by STACK message going to stdout)
- Always-on daemon logging for session create/destroy events
This commit is contained in:
2026-02-25 19:20:01 -06:00
parent 4ea4592d75
commit cfe29d2c0b
15 changed files with 3866 additions and 18 deletions

554
internal/daemon/launchd.go Normal file
View File

@@ -0,0 +1,554 @@
//go:build darwin
package daemon
import (
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const (
LaunchDaemonLabel = "co.greyhaven.greywall"
LaunchDaemonPlistPath = "/Library/LaunchDaemons/co.greyhaven.greywall.plist"
InstallBinaryPath = "/usr/local/bin/greywall"
InstallLibDir = "/usr/local/lib/greywall"
SandboxUserName = "_greywall"
SandboxUserUID = "399" // System user range on macOS
SandboxGroupName = "_greywall" // Group used for pf routing (same name as user)
SudoersFilePath = "/etc/sudoers.d/greywall"
DefaultSocketPath = "/var/run/greywall.sock"
)
// Install performs the full LaunchDaemon installation flow:
// 1. Verify running as root
// 2. Create system user _greywall
// 3. Create /usr/local/lib/greywall/ directory and copy tun2socks
// 4. Copy the current binary to /usr/local/bin/greywall
// 5. Generate and write the LaunchDaemon plist
// 6. Set proper permissions, load the daemon, and verify it starts
func Install(currentBinaryPath, tun2socksPath string, debug bool) error {
if os.Getuid() != 0 {
return fmt.Errorf("daemon install must be run as root (use sudo)")
}
// Step 1: Create system user and group.
if err := createSandboxUser(debug); err != nil {
return fmt.Errorf("failed to create sandbox user: %w", err)
}
// Step 1b: Install sudoers rule for group-based sandbox-exec.
if err := installSudoersRule(debug); err != nil {
return fmt.Errorf("failed to install sudoers rule: %w", err)
}
// Step 1c: Add invoking user to _greywall group.
addInvokingUserToGroup(debug)
// Step 2: Create lib directory and copy tun2socks.
logDebug(debug, "Creating directory %s", InstallLibDir)
if err := os.MkdirAll(InstallLibDir, 0o755); err != nil { //nolint:gosec // system lib directory needs 0755 for daemon access
return fmt.Errorf("failed to create %s: %w", InstallLibDir, err)
}
tun2socksDst := filepath.Join(InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
logDebug(debug, "Copying tun2socks to %s", tun2socksDst)
if err := copyFile(tun2socksPath, tun2socksDst, 0o755); err != nil {
return fmt.Errorf("failed to install tun2socks: %w", err)
}
// Step 3: Copy binary to install path.
if err := os.MkdirAll(filepath.Dir(InstallBinaryPath), 0o755); err != nil { //nolint:gosec // /usr/local/bin needs 0755
return fmt.Errorf("failed to create %s: %w", filepath.Dir(InstallBinaryPath), err)
}
logDebug(debug, "Copying binary from %s to %s", currentBinaryPath, InstallBinaryPath)
if err := copyFile(currentBinaryPath, InstallBinaryPath, 0o755); err != nil {
return fmt.Errorf("failed to install binary: %w", err)
}
// Step 4: Generate and write plist.
plist := generatePlist()
logDebug(debug, "Writing plist to %s", LaunchDaemonPlistPath)
if err := os.WriteFile(LaunchDaemonPlistPath, []byte(plist), 0o644); err != nil { //nolint:gosec // LaunchDaemon plist requires 0644 per macOS convention
return fmt.Errorf("failed to write plist: %w", err)
}
// Step 5: Set ownership to root:wheel.
logDebug(debug, "Setting ownership on %s to root:wheel", LaunchDaemonPlistPath)
if err := runCmd(debug, "chown", "root:wheel", LaunchDaemonPlistPath); err != nil {
return fmt.Errorf("failed to set plist ownership: %w", err)
}
// Step 6: Load the daemon.
logDebug(debug, "Loading LaunchDaemon")
if err := runCmd(debug, "launchctl", "load", LaunchDaemonPlistPath); err != nil {
return fmt.Errorf("failed to load daemon: %w", err)
}
// Step 7: Verify the daemon actually started.
running := false
for range 10 {
time.Sleep(500 * time.Millisecond)
if IsRunning() {
running = true
break
}
}
Logf("Daemon installed successfully.")
Logf(" Plist: %s", LaunchDaemonPlistPath)
Logf(" Binary: %s", InstallBinaryPath)
Logf(" Tun2socks: %s", tun2socksDst)
actualUID := readDsclAttr(SandboxUserName, "UniqueID", true)
actualGID := readDsclAttr(SandboxGroupName, "PrimaryGroupID", false)
Logf(" User: %s (UID %s)", SandboxUserName, actualUID)
Logf(" Group: %s (GID %s, pf routing)", SandboxGroupName, actualGID)
Logf(" Sudoers: %s", SudoersFilePath)
Logf(" Log: /var/log/greywall.log")
if !running {
Logf(" Status: NOT RUNNING (check /var/log/greywall.log)")
return fmt.Errorf("daemon was loaded but failed to start; check /var/log/greywall.log")
}
Logf(" Status: running")
return nil
}
// Uninstall performs the full LaunchDaemon uninstallation flow. It attempts
// every cleanup step even if individual steps fail, collecting errors along
// the way.
func Uninstall(debug bool) error {
if os.Getuid() != 0 {
return fmt.Errorf("daemon uninstall must be run as root (use sudo)")
}
var errs []string
// Step 1: Unload daemon (best effort).
logDebug(debug, "Unloading LaunchDaemon")
if err := runCmd(debug, "launchctl", "unload", LaunchDaemonPlistPath); err != nil {
errs = append(errs, fmt.Sprintf("unload daemon: %v", err))
}
// Step 2: Remove plist file.
logDebug(debug, "Removing plist %s", LaunchDaemonPlistPath)
if err := os.Remove(LaunchDaemonPlistPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Sprintf("remove plist: %v", err))
}
// Step 3: Remove lib directory.
logDebug(debug, "Removing directory %s", InstallLibDir)
if err := os.RemoveAll(InstallLibDir); err != nil {
errs = append(errs, fmt.Sprintf("remove lib dir: %v", err))
}
// Step 4: Remove installed binary, but only if it differs from the
// currently running executable.
currentExe, exeErr := os.Executable()
if exeErr != nil {
currentExe = ""
}
resolvedCurrent, _ := filepath.EvalSymlinks(currentExe)
resolvedInstall, _ := filepath.EvalSymlinks(InstallBinaryPath)
if resolvedCurrent != resolvedInstall {
logDebug(debug, "Removing binary %s", InstallBinaryPath)
if err := os.Remove(InstallBinaryPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Sprintf("remove binary: %v", err))
}
} else {
logDebug(debug, "Skipping binary removal (currently running from %s)", InstallBinaryPath)
}
// Step 5: Remove system user and group.
if err := removeSandboxUser(debug); err != nil {
errs = append(errs, fmt.Sprintf("remove sandbox user: %v", err))
}
// Step 6: Remove socket file if it exists.
logDebug(debug, "Removing socket %s", DefaultSocketPath)
if err := os.Remove(DefaultSocketPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Sprintf("remove socket: %v", err))
}
// Step 6b: Remove sudoers file.
logDebug(debug, "Removing sudoers file %s", SudoersFilePath)
if err := os.Remove(SudoersFilePath); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Sprintf("remove sudoers file: %v", err))
}
// Step 7: Remove pf anchor lines from /etc/pf.conf.
if err := removeAnchorFromPFConf(debug); err != nil {
errs = append(errs, fmt.Sprintf("remove pf anchor: %v", err))
}
if len(errs) > 0 {
Logf("Uninstall completed with warnings:")
for _, e := range errs {
Logf(" - %s", e)
}
return nil // partial cleanup is not a fatal error
}
Logf("Daemon uninstalled successfully.")
return nil
}
// generatePlist returns the LaunchDaemon plist XML content.
func generatePlist() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>` + LaunchDaemonLabel + `</string>
<key>ProgramArguments</key>
<array>
<string>` + InstallBinaryPath + `</string>
<string>daemon</string>
<string>run</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key>
<string>/var/log/greywall.log</string>
<key>StandardErrorPath</key>
<string>/var/log/greywall.log</string>
</dict>
</plist>
`
}
// IsInstalled returns true if the LaunchDaemon plist file exists.
func IsInstalled() bool {
_, err := os.Stat(LaunchDaemonPlistPath)
return err == nil
}
// IsRunning returns true if the daemon is currently running. It first tries
// connecting to the Unix socket (works without root), then falls back to
// launchctl print which can inspect the system domain without root.
func IsRunning() bool {
// Primary check: try to connect to the daemon socket. This proves the
// daemon is actually running and accepting connections.
conn, err := net.DialTimeout("unix", DefaultSocketPath, 2*time.Second)
if err == nil {
_ = conn.Close()
return true
}
// Fallback: launchctl print system/<label> works without root on modern
// macOS (unlike launchctl list which only shows the caller's domain).
//nolint:gosec // LaunchDaemonLabel is a constant
out, err := exec.Command("launchctl", "print", "system/"+LaunchDaemonLabel).CombinedOutput()
if err != nil {
return false
}
return strings.Contains(string(out), "state = running")
}
// createSandboxUser creates the _greywall system user and group on macOS
// using dscl (Directory Service command line utility).
//
// If the user/group already exist with valid IDs, they are reused. Otherwise
// a free UID/GID is found dynamically (the hardcoded SandboxUserUID is only
// a preferred default — macOS system groups like com.apple.access_ssh may
// already claim it).
func createSandboxUser(debug bool) error {
userPath := "/Users/" + SandboxUserName
groupPath := "/Groups/" + SandboxUserName
// Check if user already exists with a valid UniqueID.
existingUID := readDsclAttr(SandboxUserName, "UniqueID", true)
existingGID := readDsclAttr(SandboxGroupName, "PrimaryGroupID", false)
if existingUID != "" && existingGID != "" {
logDebug(debug, "System user %s (UID %s) and group (GID %s) already exist",
SandboxUserName, existingUID, existingGID)
return nil
}
// Find a free ID. Try the preferred default first, then scan.
id := SandboxUserUID
if !isIDFree(id, debug) {
var err error
id, err = findFreeSystemID(debug)
if err != nil {
return fmt.Errorf("failed to find free UID/GID: %w", err)
}
logDebug(debug, "Preferred ID %s is taken, using %s instead", SandboxUserUID, id)
}
// Create the group record FIRST (so the GID exists before the user references it).
logDebug(debug, "Ensuring system group %s (GID %s)", SandboxGroupName, id)
if existingGID == "" {
groupCmds := [][]string{
{"dscl", ".", "-create", groupPath},
{"dscl", ".", "-create", groupPath, "PrimaryGroupID", id},
{"dscl", ".", "-create", groupPath, "RealName", "Greywall Sandbox"},
}
for _, args := range groupCmds {
if err := runDsclCreate(debug, args); err != nil {
return err
}
}
// Verify the GID was actually set (runDsclCreate may have skipped it).
actualGID := readDsclAttr(SandboxGroupName, "PrimaryGroupID", false)
if actualGID == "" {
return fmt.Errorf("failed to set PrimaryGroupID on group %s (GID %s may be taken)", SandboxGroupName, id)
}
}
// Create the user record.
logDebug(debug, "Ensuring system user %s (UID %s)", SandboxUserName, id)
if existingUID == "" {
userCmds := [][]string{
{"dscl", ".", "-create", userPath},
{"dscl", ".", "-create", userPath, "UniqueID", id},
{"dscl", ".", "-create", userPath, "PrimaryGroupID", id},
{"dscl", ".", "-create", userPath, "UserShell", "/usr/bin/false"},
{"dscl", ".", "-create", userPath, "RealName", "Greywall Sandbox"},
{"dscl", ".", "-create", userPath, "NFSHomeDirectory", "/var/empty"},
}
for _, args := range userCmds {
if err := runDsclCreate(debug, args); err != nil {
return err
}
}
}
logDebug(debug, "System user and group %s ready (ID %s)", SandboxUserName, id)
return nil
}
// readDsclAttr reads a single attribute from a user or group record.
// Returns empty string if the record or attribute does not exist.
func readDsclAttr(name, attr string, isUser bool) string {
recordType := "/Groups/"
if isUser {
recordType = "/Users/"
}
//nolint:gosec // name and attr are controlled constants
out, err := exec.Command("dscl", ".", "-read", recordType+name, attr).Output()
if err != nil {
return ""
}
// Output format: "AttrName: value"
parts := strings.SplitN(strings.TrimSpace(string(out)), ": ", 2)
if len(parts) != 2 {
return ""
}
return strings.TrimSpace(parts[1])
}
// isIDFree checks whether a given numeric ID is available as both a UID and GID.
func isIDFree(id string, debug bool) bool {
// Check if any user has this UniqueID.
//nolint:gosec // id is a controlled numeric string
out, err := exec.Command("dscl", ".", "-search", "/Users", "UniqueID", id).Output()
if err == nil && strings.TrimSpace(string(out)) != "" {
logDebug(debug, "ID %s is taken by a user", id)
return false
}
// Check if any group has this PrimaryGroupID.
//nolint:gosec // id is a controlled numeric string
out, err = exec.Command("dscl", ".", "-search", "/Groups", "PrimaryGroupID", id).Output()
if err == nil && strings.TrimSpace(string(out)) != "" {
logDebug(debug, "ID %s is taken by a group", id)
return false
}
return true
}
// findFreeSystemID scans the macOS system ID range (350499) for a UID/GID
// pair that is not in use by any existing user or group.
func findFreeSystemID(debug bool) (string, error) {
for i := 350; i < 500; i++ {
id := strconv.Itoa(i)
if isIDFree(id, debug) {
return id, nil
}
}
return "", fmt.Errorf("no free system UID/GID found in range 350-499")
}
// runDsclCreate runs a dscl -create command, silently ignoring
// eDSRecordAlreadyExists errors (idempotent for repeated installs).
func runDsclCreate(debug bool, args []string) error {
err := runCmd(debug, args[0], args[1:]...)
if err != nil && strings.Contains(err.Error(), "eDSRecordAlreadyExists") {
logDebug(debug, "Already exists, skipping: %s", strings.Join(args, " "))
return nil
}
if err != nil {
return fmt.Errorf("dscl command failed (%s): %w", strings.Join(args, " "), err)
}
return nil
}
// removeSandboxUser removes the _greywall system user and group.
func removeSandboxUser(debug bool) error {
var errs []string
userPath := "/Users/" + SandboxUserName
groupPath := "/Groups/" + SandboxUserName
if userExists(SandboxUserName) {
logDebug(debug, "Removing system user %s", SandboxUserName)
if err := runCmd(debug, "dscl", ".", "-delete", userPath); err != nil {
errs = append(errs, fmt.Sprintf("delete user: %v", err))
}
}
// Check if group exists before trying to remove.
logDebug(debug, "Removing system group %s", SandboxUserName)
if err := runCmd(debug, "dscl", ".", "-delete", groupPath); err != nil {
// Group may not exist; only record error if it's not a "not found" case.
errStr := err.Error()
if !strings.Contains(errStr, "not found") && !strings.Contains(errStr, "does not exist") {
errs = append(errs, fmt.Sprintf("delete group: %v", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("sandbox user removal issues: %s", strings.Join(errs, "; "))
}
return nil
}
// userExists checks if a user exists on macOS by querying the directory service.
func userExists(username string) bool {
//nolint:gosec // username is a controlled constant
err := exec.Command("dscl", ".", "-read", "/Users/"+username).Run()
return err == nil
}
// installSudoersRule writes a sudoers rule that allows members of the
// _greywall group to run sandbox-exec as any user with group _greywall,
// without a password. The rule is validated with visudo -cf before install.
func installSudoersRule(debug bool) error {
rule := fmt.Sprintf("%%%s ALL = (ALL:%s) NOPASSWD: /usr/bin/sandbox-exec *\n",
SandboxGroupName, SandboxGroupName)
logDebug(debug, "Writing sudoers rule to %s", SudoersFilePath)
// Ensure /etc/sudoers.d exists.
if err := os.MkdirAll(filepath.Dir(SudoersFilePath), 0o755); err != nil { //nolint:gosec // /etc/sudoers.d must be 0755
return fmt.Errorf("failed to create sudoers directory: %w", err)
}
// Write to a temp file first, then validate with visudo.
tmpFile := SudoersFilePath + ".tmp"
if err := os.WriteFile(tmpFile, []byte(rule), 0o440); err != nil {
return fmt.Errorf("failed to write sudoers temp file: %w", err)
}
// Validate syntax before installing.
//nolint:gosec // tmpFile is a controlled path
if err := runCmd(debug, "visudo", "-cf", tmpFile); err != nil {
_ = os.Remove(tmpFile)
return fmt.Errorf("sudoers validation failed: %w", err)
}
// Move validated file into place.
if err := os.Rename(tmpFile, SudoersFilePath); err != nil {
_ = os.Remove(tmpFile)
return fmt.Errorf("failed to install sudoers file: %w", err)
}
// Ensure correct ownership (root:wheel) and permissions (0440).
if err := runCmd(debug, "chown", "root:wheel", SudoersFilePath); err != nil {
return fmt.Errorf("failed to set sudoers ownership: %w", err)
}
if err := os.Chmod(SudoersFilePath, 0o440); err != nil {
return fmt.Errorf("failed to set sudoers permissions: %w", err)
}
logDebug(debug, "Sudoers rule installed: %s", SudoersFilePath)
return nil
}
// addInvokingUserToGroup adds the real invoking user (detected via SUDO_USER)
// to the _greywall group so they can use sudo -g _greywall. This is non-fatal;
// if it fails, a manual instruction is printed.
//
// We use dscl -append (not dseditgroup) because dseditgroup can reset group
// attributes like PrimaryGroupID on freshly created groups.
func addInvokingUserToGroup(debug bool) {
realUser := os.Getenv("SUDO_USER")
if realUser == "" || realUser == "root" {
Logf("Note: Could not detect invoking user (SUDO_USER not set).")
Logf(" You may need to manually add your user to the %s group:", SandboxGroupName)
Logf(" sudo dscl . -append /Groups/%s GroupMembership YOUR_USERNAME", SandboxGroupName)
return
}
groupPath := "/Groups/" + SandboxGroupName
logDebug(debug, "Adding user %s to group %s", realUser, SandboxGroupName)
//nolint:gosec // realUser comes from SUDO_USER env var set by sudo
err := runCmd(debug, "dscl", ".", "-append", groupPath, "GroupMembership", realUser)
if err != nil {
Logf("Warning: failed to add %s to group %s: %v", realUser, SandboxGroupName, err)
Logf(" You may need to run manually:")
Logf(" sudo dscl . -append %s GroupMembership %s", groupPath, realUser)
} else {
Logf(" User %s added to group %s", realUser, SandboxGroupName)
}
}
// copyFile copies a file from src to dst with the given permissions.
func copyFile(src, dst string, perm os.FileMode) error {
srcFile, err := os.Open(src) //nolint:gosec // src is from os.Executable or user flag
if err != nil {
return fmt.Errorf("open source %s: %w", src, err)
}
defer srcFile.Close() //nolint:errcheck // read-only file; close error is not actionable
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) //nolint:gosec // dst is a controlled install path constant
if err != nil {
return fmt.Errorf("create destination %s: %w", dst, err)
}
defer dstFile.Close() //nolint:errcheck // best-effort close; errors from Chmod/Copy are checked
if _, err := io.Copy(dstFile, srcFile); err != nil {
return fmt.Errorf("copy data: %w", err)
}
if err := dstFile.Chmod(perm); err != nil {
return fmt.Errorf("set permissions on %s: %w", dst, err)
}
return nil
}
// runCmd executes a command and returns an error if it fails. When debug is
// true, the command is logged before execution.
func runCmd(debug bool, name string, args ...string) error {
logDebug(debug, "exec: %s %s", name, strings.Join(args, " "))
//nolint:gosec // arguments are constructed from internal constants
cmd := exec.Command(name, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s failed: %w (output: %s)", name, err, strings.TrimSpace(string(output)))
}
return nil
}
// logDebug writes a timestamped debug message to stderr.
func logDebug(debug bool, format string, args ...interface{}) {
if debug {
Logf(format, args...)
}
}
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...)
}