This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/daemon/launchd.go
Mathieu Virbel 473f1620d5 feat: adopt kardianos/service for daemon lifecycle management
Replace manual signal handling in runDaemon() with kardianos/service
for cross-platform service lifecycle (Start/Stop/Run). Add daemon
start/stop/restart subcommands using service.Control(), and improve
status detection with s.Status() plus socket-check fallback.

Custom macOS install logic (dscl, sudoers, pf, plist generation)
is unchanged — only the runtime lifecycle is delegated to the library.
2026-03-04 14:48:01 -06:00

549 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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 { //nolint:gosec // sudoers files require 0440 per sudo(8)
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 { //nolint:gosec // sudoers files require 0440 per sudo(8)
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...)
}
}