174 lines
5.5 KiB
Go
174 lines
5.5 KiB
Go
//go:build darwin
|
|
|
|
package main
|
|
|
|
// The macOS half of sysservice.go: a launchd daemon in the system domain.
|
|
//
|
|
// launchctl is run by its absolute path with a fixed argument list. This code
|
|
// runs as root, so a launchctl found through PATH would be whatever the
|
|
// caller's PATH named first.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const launchctlPath = "/bin/launchctl"
|
|
|
|
// systemServiceInstalled reports whether the daemon's job file is in place. A
|
|
// file that cannot be checked is reported as an error, which the caller treats
|
|
// as installed for the guard (adoptSystemServiceState).
|
|
func systemServiceInstalled() (bool, error) {
|
|
_, err := os.Stat(launchdPlistPath)
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// darwinOwnerMode is pathOwnerMode for this machine: Lstat, so a symlink is
|
|
// seen as one rather than followed.
|
|
func darwinOwnerMode(p string) (uint32, fs.FileMode, error) {
|
|
fi, err := os.Lstat(p)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0, 0, fmt.Errorf("no owner information for %s", p)
|
|
}
|
|
return st.Uid, fi.Mode(), nil
|
|
}
|
|
|
|
func installSystemService() error {
|
|
self, err := resolvedSelf()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot find this program's own path: %w", err)
|
|
}
|
|
if err := binaryChainRootOnly(self, darwinOwnerMode); err != nil {
|
|
return fmt.Errorf("refusing to register %s as the system service: %w.\n"+
|
|
" The installer's --networked option puts the program in a folder only root can change", self, err)
|
|
}
|
|
plist, err := launchdPlist(self)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeRootFile(launchdPlistPath, plist, 0o644); err != nil {
|
|
return fmt.Errorf("cannot write %s: %w", launchdPlistPath, err)
|
|
}
|
|
// launchd creates a missing log file readable by everyone. What the bastion
|
|
// reports about this machine (the agents it sees, the hosts it serves) is
|
|
// the administrator's to read, so the file is made first, root-only, and
|
|
// launchd appends to it as it finds it.
|
|
if f, err := os.OpenFile(darwinDaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
|
_ = f.Close()
|
|
}
|
|
_ = os.Chmod(darwinDaemonLog, 0o600)
|
|
// A job already loaded under this label keeps its old definition until it
|
|
// is booted out, so a reinstall boots it out first. Not loaded is fine.
|
|
_ = exec.Command(launchctlPath, "bootout", "system/"+launchdLabel).Run()
|
|
// bootout returns before launchd has finished removing the job, and a
|
|
// bootstrap in that window fails. A few short retries cover it.
|
|
var out []byte
|
|
for i := 0; i < 5; i++ {
|
|
out, err = exec.Command(launchctlPath, "bootstrap", "system", launchdPlistPath).CombinedOutput()
|
|
if err == nil {
|
|
break
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("launchctl bootstrap failed: %v: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
_ = exec.Command(launchctlPath, "enable", "system/"+launchdLabel).Run()
|
|
fmt.Printf("System service installed: launchd daemon %s, running %s as root.\n", launchdLabel, self)
|
|
fmt.Println(" It starts at boot, before anyone signs in, and launchd restarts it whenever it stops.")
|
|
fmt.Printf(" Its log is %s.\n", darwinDaemonLog)
|
|
return nil
|
|
}
|
|
|
|
func uninstallSystemService() error {
|
|
_ = exec.Command(launchctlPath, "bootout", "system/"+launchdLabel).Run()
|
|
if err := os.Remove(launchdPlistPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("cannot remove %s: %w", launchdPlistPath, err)
|
|
}
|
|
fmt.Printf("System service removed: %s is no longer registered with launchd.\n", launchdLabel)
|
|
fmt.Println(" The bastion's state under /var/root is left in place.")
|
|
return nil
|
|
}
|
|
|
|
func printSystemServiceStatus() error {
|
|
installed, err := systemServiceInstalled()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !installed {
|
|
fmt.Println("No system service is installed on this machine.")
|
|
return nil
|
|
}
|
|
out, err := exec.Command(launchctlPath, "print", "system/"+launchdLabel).CombinedOutput()
|
|
if err != nil {
|
|
fmt.Printf("System service %s is installed but not loaded (launchctl: %s).\n", launchdLabel, strings.TrimSpace(string(out)))
|
|
return nil
|
|
}
|
|
state, pid := "unknown", ""
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if v, ok := strings.CutPrefix(line, "state = "); ok && state == "unknown" {
|
|
state = v
|
|
}
|
|
if v, ok := strings.CutPrefix(line, "pid = "); ok && pid == "" {
|
|
pid = v
|
|
}
|
|
}
|
|
if pid != "" {
|
|
fmt.Printf("System service %s: %s (pid %s).\n", launchdLabel, state, pid)
|
|
} else {
|
|
fmt.Printf("System service %s: %s.\n", launchdLabel, state)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeRootFile writes a root-owned file with an exact mode, through a temp
|
|
// file and a rename in the same directory, so launchd never reads half a job.
|
|
func writeRootFile(p string, data []byte, mode os.FileMode) error {
|
|
f, err := os.CreateTemp(filepath.Dir(p), ".privatae-bastion-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := f.Name()
|
|
defer os.Remove(tmp)
|
|
if _, err := f.Write(data); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chown(tmp, 0, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmp, mode); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, p)
|
|
}
|
|
|
|
// systemServiceRunning: the launchd daemon runs `run` at once, whose console
|
|
// answers for it (pair.go pairDaemonRunning).
|
|
func systemServiceRunning() bool { return false }
|