- Add daemon CLI subcommand (install/uninstall/status/run) - Download tun2socks for darwin platforms in Makefile - Export ExtractTun2Socks and add darwin embed support - Use group-based pf filtering instead of user-based for transparent proxying - Install sudoers rule for passwordless sandbox-exec with _greywall group - Add nolint directives for gosec false positives on sudoers 0440 perms - Fix lint issues: lowercase errors, fmt.Fprintf, nolint comments Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
555 lines
20 KiB
Go
555 lines
20 KiB
Go
//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 (350–499) 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...)
|
||
}
|
||
}
|
||
|
||
// 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...)
|
||
}
|