//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 ` Label ` + LaunchDaemonLabel + ` ProgramArguments ` + InstallBinaryPath + ` daemon run RunAtLoad KeepAlive StandardOutPath /var/log/greywall.log StandardErrorPath /var/log/greywall.log ` } // 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/