mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 20:49:56 +00:00
432 lines
13 KiB
Go
432 lines
13 KiB
Go
//go:build darwin && !ios
|
|
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/sys/unix"
|
|
|
|
"github.com/netbirdio/netbird/client/configs"
|
|
)
|
|
|
|
// darwinAgentManager spawns a per-user VNC agent on demand and keeps it
|
|
// alive across multiple client connections within the same console-user
|
|
// session. A new agent is spawned the first time a client connects, or
|
|
// whenever the console user changes underneath us.
|
|
//
|
|
// Lifecycle is lazy by design: a daemon that never receives a VNC
|
|
// connection never spawns anything. The trade-off versus an eager spawn
|
|
// (the Windows model) is that the first VNC client pays the launchctl
|
|
// asuser + listen-readiness wait, ~hundreds of milliseconds in practice.
|
|
// That cost only repeats on user switch.
|
|
type darwinAgentManager struct {
|
|
mu sync.Mutex
|
|
authToken string
|
|
socketPath string
|
|
uid uint32
|
|
running bool
|
|
}
|
|
|
|
func newDarwinAgentManager(ctx context.Context) *darwinAgentManager {
|
|
m := &darwinAgentManager{}
|
|
go m.watchConsoleUser(ctx)
|
|
return m
|
|
}
|
|
|
|
// agentSocketName is the file name inside the per-uid socket directory
|
|
// the agent binds. The directory itself is created and chowned by the
|
|
// daemon (see prepareAgentSocketDir) so a non-root local user cannot
|
|
// pre-create or symlink the path before the agent listens.
|
|
const agentSocketName = "agent.sock"
|
|
|
|
// watchConsoleUser kills the cached agent whenever the console user
|
|
// changes (logout, fast user switch, login window). Without it the daemon
|
|
// keeps proxying to an agent whose TCC grant and WindowServer access
|
|
// belong to a user who is no longer at the screen, so the new user only
|
|
// ever sees the locked-screen wallpaper. Killing the agent breaks the
|
|
// loopback TCP that the daemon proxies into, the client disconnects, and
|
|
// the next reconnect runs ensure() against the new console uid.
|
|
func (m *darwinAgentManager) watchConsoleUser(ctx context.Context) {
|
|
t := time.NewTicker(2 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
uid, err := consoleUserID()
|
|
m.mu.Lock()
|
|
if !m.running {
|
|
m.mu.Unlock()
|
|
continue
|
|
}
|
|
if err != nil || uid != m.uid {
|
|
prev := m.uid
|
|
m.killLocked()
|
|
m.mu.Unlock()
|
|
if err != nil {
|
|
log.Infof("console user gone (was uid=%d): %v; agent stopped", prev, err)
|
|
} else {
|
|
log.Infof("console user changed %d -> %d; agent stopped, will respawn on next connect", prev, uid)
|
|
}
|
|
continue
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve spawns or respawns the per-user agent process as needed and
|
|
// returns its Unix-socket path, shared token, and the uid the agent was
|
|
// spawned under (so the daemon can validate peer credentials before
|
|
// dispatching the token). Each call is serialized so concurrent VNC
|
|
// clients share the same agent.
|
|
func (m *darwinAgentManager) Resolve(ctx context.Context) (string, string, uint32, error) {
|
|
consoleUID, err := consoleUserID()
|
|
if err != nil {
|
|
return "", "", 0, fmt.Errorf("no console user: %w", err)
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.running && m.uid == consoleUID && vncAgentRunning() {
|
|
return m.socketPath, m.authToken, m.uid, nil
|
|
}
|
|
m.killLocked()
|
|
// Reap stray agents so the new token is the only accepted one.
|
|
killAllVNCAgents()
|
|
|
|
socketDir, err := prepareAgentSocketDir(consoleUID)
|
|
if err != nil {
|
|
return "", "", 0, fmt.Errorf("prepare agent socket dir: %w", err)
|
|
}
|
|
socketPath := socketDir + "/" + agentSocketName
|
|
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
log.Debugf("clear stale agent socket %s: %v", socketPath, err)
|
|
}
|
|
|
|
token, err := generateAuthToken()
|
|
if err != nil {
|
|
return "", "", 0, fmt.Errorf("generate agent auth token: %w", err)
|
|
}
|
|
if err := spawnAgentForUser(consoleUID, socketPath, token); err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
if err := waitForAgent(ctx, socketPath, 5*time.Second); err != nil {
|
|
killAllVNCAgents()
|
|
return "", "", 0, fmt.Errorf("agent did not start listening: %w", err)
|
|
}
|
|
m.authToken = token
|
|
m.socketPath = socketPath
|
|
m.uid = consoleUID
|
|
m.running = true
|
|
log.Infof("spawned VNC agent for console uid=%d on %s", consoleUID, socketPath)
|
|
return socketPath, token, consoleUID, nil
|
|
}
|
|
|
|
// prepareAgentSocketDir creates a per-uid subdirectory under the netbird
|
|
// runtime directory where the agent will bind its Unix socket. The leaf is
|
|
// owned by uid with mode 0700, so only the target user and root can write
|
|
// there. The parent is created root-owned with mode 0755 if missing.
|
|
// Symlinks at the per-uid level are refused (replaced with a fresh
|
|
// directory) so a low-priv user cannot redirect the chown that follows.
|
|
func prepareAgentSocketDir(uid uint32) (string, error) {
|
|
parent := configs.RuntimeDir
|
|
if err := ensureAgentSocketParent(parent); err != nil {
|
|
return "", err
|
|
}
|
|
subdir := fmt.Sprintf("%s/vnc-%d", parent, uid)
|
|
if err := purgeStaleAgentSubdir(subdir, uid); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Mkdir(subdir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return "", fmt.Errorf("mkdir %s: %w", subdir, err)
|
|
}
|
|
if err := os.Chmod(subdir, 0o700); err != nil {
|
|
return "", fmt.Errorf("chmod %s: %w", subdir, err)
|
|
}
|
|
if err := os.Chown(subdir, int(uid), -1); err != nil {
|
|
return "", fmt.Errorf("chown %s -> uid %d: %w", subdir, uid, err)
|
|
}
|
|
return subdir, nil
|
|
}
|
|
|
|
// ensureAgentSocketParent verifies the runtime parent dir exists, is not a
|
|
// symlink, and is owned by root.
|
|
func ensureAgentSocketParent(parent string) error {
|
|
if parent == "" {
|
|
return fmt.Errorf("no runtime directory configured for this platform")
|
|
}
|
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", parent, err)
|
|
}
|
|
info, err := os.Lstat(parent)
|
|
if err != nil {
|
|
return fmt.Errorf("lstat %s: %w", parent, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("%s is a symlink", parent)
|
|
}
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid != 0 {
|
|
return fmt.Errorf("%s not owned by root (uid=%d)", parent, st.Uid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// purgeStaleAgentSubdir removes a leftover subdir unless it is a real dir
|
|
// owned by uid with mode 0700. Lstat (not Stat) so a symlink is detected.
|
|
func purgeStaleAgentSubdir(subdir string, uid uint32) error {
|
|
info, err := os.Lstat(subdir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if agentSubdirOK(info, uid) {
|
|
return nil
|
|
}
|
|
if err := os.RemoveAll(subdir); err != nil {
|
|
return fmt.Errorf("remove stale %s: %w", subdir, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func agentSubdirOK(info os.FileInfo, uid uint32) bool {
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return false
|
|
}
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return st.Uid == uid && info.Mode().Perm() == 0o700
|
|
}
|
|
|
|
// stop terminates the spawned agent, if any. Intended for daemon shutdown.
|
|
func (m *darwinAgentManager) stop() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.killLocked()
|
|
}
|
|
|
|
func (m *darwinAgentManager) killLocked() {
|
|
if !m.running {
|
|
return
|
|
}
|
|
killAllVNCAgents()
|
|
if m.socketPath != "" {
|
|
if err := os.Remove(m.socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
log.Debugf("remove agent socket %s: %v", m.socketPath, err)
|
|
}
|
|
}
|
|
m.running = false
|
|
m.authToken = ""
|
|
m.socketPath = ""
|
|
m.uid = 0
|
|
}
|
|
|
|
// consoleUserID returns the uid of the user currently sitting at the
|
|
// console (the one whose Aqua session is active). Returns
|
|
// errNoConsoleUser when nobody is logged in: at the login window
|
|
// /dev/console is owned by root.
|
|
func consoleUserID() (uint32, error) {
|
|
info, err := os.Stat("/dev/console")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("stat /dev/console: %w", err)
|
|
}
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0, fmt.Errorf("/dev/console stat has unexpected type")
|
|
}
|
|
if st.Uid == 0 {
|
|
return 0, errNoConsoleUser
|
|
}
|
|
return st.Uid, nil
|
|
}
|
|
|
|
// spawnAgentForUser uses launchctl asuser to start a netbird vnc-agent
|
|
// process inside the target user's launchd bootstrap namespace. That is
|
|
// the only spawn mode on macOS that gives the child access to the user's
|
|
// WindowServer. The agent's stderr is relogged into the daemon log so
|
|
// startup failures are not silently lost when the readiness check times
|
|
// out.
|
|
func spawnAgentForUser(uid uint32, socketPath, token string) error {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve own executable: %w", err)
|
|
}
|
|
cmd := exec.Command(
|
|
"/bin/launchctl", "asuser", strconv.FormatUint(uint64(uid), 10),
|
|
exe, vncAgentSubcommand,
|
|
"--socket", socketPath,
|
|
// Drop privs inside the agent: launchctl asuser preserves the
|
|
// daemon's uid (root), so without this the capture/input/
|
|
// encoder paths would run as root for the lifetime of the
|
|
// session. validateAgentPeer on the daemon side also relies on
|
|
// the agent's effective uid matching consoleUID.
|
|
"--target-uid", strconv.FormatUint(uint64(uid), 10),
|
|
)
|
|
cmd.Env = append(os.Environ(), agentTokenEnvVar+"="+token)
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("agent stderr pipe: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("launchctl asuser: %w", err)
|
|
}
|
|
go func() {
|
|
defer stderr.Close()
|
|
relogAgentStream(stderr)
|
|
}()
|
|
go func() { _ = cmd.Wait() }()
|
|
return nil
|
|
}
|
|
|
|
// waitForAgent dials the agent's Unix socket until it answers. Used to
|
|
// gate proxy attempts until the spawned process has finished its Start.
|
|
func waitForAgent(ctx context.Context, socketPath string, wait time.Duration) error {
|
|
var d net.Dialer
|
|
deadline := time.Now().Add(wait)
|
|
for time.Now().Before(deadline) {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
dialCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
c, err := d.DialContext(dialCtx, "unix", socketPath)
|
|
cancel()
|
|
if err == nil {
|
|
_ = c.Close()
|
|
return nil
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("timeout dialing %s", socketPath)
|
|
}
|
|
|
|
// vncAgentRunning reports whether any vnc-agent process exists on the
|
|
// system. There is at most one agent per machine, so any match is "the"
|
|
// agent.
|
|
func vncAgentRunning() bool {
|
|
pids, err := vncAgentPIDs()
|
|
if err != nil {
|
|
log.Debugf("scan for vnc-agent: %v", err)
|
|
return false
|
|
}
|
|
return len(pids) > 0
|
|
}
|
|
|
|
// killAllVNCAgents sends SIGTERM to every process whose argv contains
|
|
// "vnc-agent", waits briefly for them to exit, and escalates to SIGKILL
|
|
// for any that remain. We enumerate kern.proc.all rather than
|
|
// kern.proc.uid because launchctl asuser preserves the caller's uid
|
|
// (root) on the spawned child, so a uid-scoped filter would never match.
|
|
func killAllVNCAgents() {
|
|
pids, err := vncAgentPIDs()
|
|
if err != nil {
|
|
log.Debugf("scan for vnc-agent: %v", err)
|
|
return
|
|
}
|
|
for _, pid := range pids {
|
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
|
}
|
|
if len(pids) == 0 {
|
|
return
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
remaining, _ := vncAgentPIDs()
|
|
if len(remaining) == 0 {
|
|
return
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
leftover, _ := vncAgentPIDs()
|
|
for _, pid := range leftover {
|
|
_ = syscall.Kill(pid, syscall.SIGKILL)
|
|
}
|
|
}
|
|
|
|
// vncAgentPIDs returns the pids of vnc-agent subprocesses spawned from
|
|
// this binary. Matches exactly on argv[0] == our own executable path
|
|
// AND argv[1] == "vnc-agent" so unrelated processes that happen to have
|
|
// the same name elsewhere in argv are not targeted. Skips pid 0 and 1
|
|
// defensively.
|
|
func vncAgentPIDs() ([]int, error) {
|
|
procs, err := unix.SysctlKinfoProcSlice("kern.proc.all")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sysctl kern.proc.all: %w", err)
|
|
}
|
|
ownExe, err := os.Executable()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve own executable: %w", err)
|
|
}
|
|
var out []int
|
|
for i := range procs {
|
|
pid := int(procs[i].Proc.P_pid)
|
|
if pid <= 1 {
|
|
continue
|
|
}
|
|
argv, err := procArgv(pid)
|
|
if err != nil || !argvIsVNCAgent(argv, ownExe) {
|
|
continue
|
|
}
|
|
out = append(out, pid)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// procArgv reads the kernel's stored argv for pid via the kern.procargs2
|
|
// sysctl. Format: 4-byte argc, then argv[0..argc) each NUL-terminated,
|
|
// then envp, then padding. We only need argv so we stop after argc.
|
|
func procArgv(pid int) ([]string, error) {
|
|
raw, err := unix.SysctlRaw("kern.procargs2", pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw) < 4 {
|
|
return nil, fmt.Errorf("procargs2 truncated")
|
|
}
|
|
argc := int(raw[0]) | int(raw[1])<<8 | int(raw[2])<<16 | int(raw[3])<<24
|
|
body := raw[4:]
|
|
// Skip the executable path (NUL-terminated) and any zero padding that
|
|
// follows before argv[0].
|
|
end := bytes.IndexByte(body, 0)
|
|
if end < 0 {
|
|
return nil, fmt.Errorf("procargs2 path unterminated")
|
|
}
|
|
body = body[end+1:]
|
|
for len(body) > 0 && body[0] == 0 {
|
|
body = body[1:]
|
|
}
|
|
args := make([]string, 0, argc)
|
|
for i := 0; i < argc; i++ {
|
|
end := bytes.IndexByte(body, 0)
|
|
if end < 0 {
|
|
break
|
|
}
|
|
args = append(args, string(body[:end]))
|
|
body = body[end+1:]
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
// argvIsVNCAgent reports whether argv belongs to a vnc-agent subprocess
|
|
// spawned from our binary. Requires argv[0] to match ownExe exactly and
|
|
// argv[1] to be the vnc-agent subcommand. Matches the spawn shape in
|
|
// spawnAgentForUser and rejects anything else.
|
|
func argvIsVNCAgent(argv []string, ownExe string) bool {
|
|
if len(argv) < 2 || ownExe == "" {
|
|
return false
|
|
}
|
|
return argv[0] == ownExe && argv[1] == vncAgentSubcommand
|
|
}
|