Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type Config struct {
BeaconAddr string
ListenAddr string // UDP listen address for tunnel traffic
SocketPath string // Unix socket path for IPC
IPCWhitelist []string // process names (comm) trusted to bypass per-client dial quota (PILOT-346)
Encrypt bool // enable tunnel-layer encryption (X25519 + AES-256-GCM)
RegistryTLS bool // use TLS for registry connection
RegistryFingerprint string // hex SHA-256 fingerprint for TLS cert pinning
Expand Down
45 changes: 38 additions & 7 deletions pkg/daemon/ipc.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ type ipcConn struct {
closeOnce sync.Once
writeDone chan struct{}

// peerPID is the PID of the connected process (Linux SO_PEERCRED,
// 0 on Darwin/other). Used for IPC whitelist matching (PILOT-346).
peerPID int32
whitelisted bool // bypasses per-client dial quota (PILOT-346)

// dialCancels holds cancel funcs for in-flight DialConnection calls
// this client started. On Close() we fire them all so the daemon's
// dial loops bail out immediately instead of grinding to their full
Expand Down Expand Up @@ -229,14 +234,17 @@ const MaxConnsPerIPCClient = 4096

// newIPCConn wraps a net.Conn and starts the per-conn writer goroutine.
// All callers must use this constructor (not &ipcConn{...}) so the writer
// is properly initialized.
func newIPCConn(c net.Conn) *ipcConn {
// is properly initialized. peerPID is the PID of the connected process
// (0 on non-Linux); whitelisted bypasses the per-client dial quota.
func newIPCConn(c net.Conn, peerPID int32, whitelisted bool) *ipcConn {
ic := &ipcConn{
Conn: c,
sendCh: make(chan []byte, ipcSendBuffer),
done: make(chan struct{}),
writeDone: make(chan struct{}),
dialCancels: make(map[uint64]context.CancelFunc),
peerPID: peerPID,
whitelisted: whitelisted,
}
go ic.writeLoop()
return ic
Expand Down Expand Up @@ -511,15 +519,34 @@ func (s *IPCServer) acceptLoop() {
// PILOT-246: Reject connections from other UIDs — only same-UID
// processes may issue IPC commands. Without this, any local
// process can connect and control the daemon.
if err := checkPeerUID(conn); err != nil {
peerPID, err := checkPeerUID(conn)
if err != nil {
slog.Warn("IPC rejected cross-UID connection", "err", err)
conn.Close()
continue
}

// PILOT-346: Check if the connecting process is in the IPC
// whitelist. Whitelisted clients bypass the per-client dial
// connection quota (MaxConnsPerIPCClient).
var whitelisted bool
if peerPID > 0 && len(s.daemon.config.IPCWhitelist) > 0 {
name := resolveProcessName(peerPID)
if name != "" {
for _, w := range s.daemon.config.IPCWhitelist {
if name == w {
whitelisted = true
slog.Info("IPC whitelisted client connected", "pid", peerPID, "name", name)
break
}
}
}
}

s.mu.Lock()
full := len(s.clients) >= MaxIPCClients
if !full {
ic := newIPCConn(conn)
ic := newIPCConn(conn, peerPID, whitelisted)
s.clients[ic] = true
s.mu.Unlock()
go s.handleClient(ic)
Expand Down Expand Up @@ -757,9 +784,13 @@ func (s *IPCServer) handleDial(conn *ipcConn, reqID uint64, payload []byte) {
// P2-002: reject dial if this client already owns MaxConnsPerIPCClient
// connections. Avoids the single-client DoS where one buggy driver
// exhausts the global connection table.
if n := conn.connCount(); n >= MaxConnsPerIPCClient {
s.sendError(conn, reqID, fmt.Sprintf("dial: per-client connection quota (%d) reached", MaxConnsPerIPCClient))
return
// PILOT-346: whitelisted clients (trusted integrations) bypass the
// per-client quota limit.
if !conn.whitelisted {
if n := conn.connCount(); n >= MaxConnsPerIPCClient {
s.sendError(conn, reqID, fmt.Sprintf("dial: per-client connection quota (%d) reached", MaxConnsPerIPCClient))
return
}
}

dstAddr := protocol.UnmarshalAddr(payload[0:protocol.AddrSize])
Expand Down
21 changes: 13 additions & 8 deletions pkg/daemon/ipc_peercred_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,36 @@ import (
"golang.org/x/sys/unix"
)

// resolveProcessName is a no-op on Darwin — Xucred doesn't expose PID.
// IPC whitelist is Linux-only (PILOT-346).
func resolveProcessName(pid int32) string { return "" }

// checkPeerUID — Darwin variant. Uses LOCAL_PEERCRED + GetsockoptXucred,
// the BSD equivalent of Linux SO_PEERCRED, to retrieve the effective UID
// of the connected peer.
func checkPeerUID(conn net.Conn) error {
// of the connected peer. Returns 0 for PID — Darwin Xucred does not
// expose the peer PID, so IPC whitelist is Linux-only (PILOT-346).
func checkPeerUID(conn net.Conn) (int32, error) {
unixConn, ok := conn.(*net.UnixConn)
if !ok {
return fmt.Errorf("IPC: not a unix socket")
return 0, fmt.Errorf("IPC: not a unix socket")
}
rawConn, err := unixConn.SyscallConn()
if err != nil {
return fmt.Errorf("IPC: SyscallConn: %w", err)
return 0, fmt.Errorf("IPC: SyscallConn: %w", err)
}
var xucred *unix.Xucred
var getErr error
ctrlErr := rawConn.Control(func(fd uintptr) {
xucred, getErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
})
if ctrlErr != nil {
return fmt.Errorf("IPC: Control: %w", ctrlErr)
return 0, fmt.Errorf("IPC: Control: %w", ctrlErr)
}
if getErr != nil {
return fmt.Errorf("IPC: LOCAL_PEERCRED: %w", getErr)
return 0, fmt.Errorf("IPC: LOCAL_PEERCRED: %w", getErr)
}
if xucred.Uid != uint32(os.Getuid()) {
return fmt.Errorf("IPC: peer UID %d != daemon UID %d", xucred.Uid, os.Getuid())
return 0, fmt.Errorf("IPC: peer UID %d != daemon UID %d", xucred.Uid, os.Getuid())
}
return nil
return 0, nil
}
33 changes: 23 additions & 10 deletions pkg/daemon/ipc_peercred_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,51 @@ import (
"fmt"
"net"
"os"
"strings"

"golang.org/x/sys/unix"
)

// checkPeerUID verifies that a Unix-domain socket connection comes from
// the same Unix UID as the daemon. Linux variant: SO_PEERCRED + Ucred.
//
// Returns nil if the peer UID matches the daemon's UID, or an error
// if the socket is not Unix-domain, the syscall failed, or the peer
// UID differs. This is the primary IPC access control for PILOT-246.
func checkPeerUID(conn net.Conn) error {
// Returns the peer PID (for whitelist checks) and nil if the peer UID
// matches the daemon's UID, or an error if the socket is not Unix-domain,
// the syscall failed, or the peer UID differs. This is the primary IPC
// access control for PILOT-246.
func checkPeerUID(conn net.Conn) (int32, error) {
unixConn, ok := conn.(*net.UnixConn)
if !ok {
return fmt.Errorf("IPC: not a unix socket")
return 0, fmt.Errorf("IPC: not a unix socket")
}
rawConn, err := unixConn.SyscallConn()
if err != nil {
return fmt.Errorf("IPC: SyscallConn: %w", err)
return 0, fmt.Errorf("IPC: SyscallConn: %w", err)
}
var ucred *unix.Ucred
var getErr error
ctrlErr := rawConn.Control(func(fd uintptr) {
ucred, getErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
})
if ctrlErr != nil {
return fmt.Errorf("IPC: Control: %w", ctrlErr)
return 0, fmt.Errorf("IPC: Control: %w", ctrlErr)
}
if getErr != nil {
return fmt.Errorf("IPC: SO_PEERCRED: %w", getErr)
return 0, fmt.Errorf("IPC: SO_PEERCRED: %w", getErr)
}
if ucred.Uid != uint32(os.Getuid()) {
return fmt.Errorf("IPC: peer UID %d != daemon UID %d", ucred.Uid, os.Getuid())
return 0, fmt.Errorf("IPC: peer UID %d != daemon UID %d", ucred.Uid, os.Getuid())
}
return nil
return int32(ucred.Pid), nil
}

// resolveProcessName reads /proc/<pid>/comm and returns the process name
// (trimmed). Returns empty string on any error (process gone, permission
// denied, etc.). Used for IPC whitelist matching (PILOT-346).
func resolveProcessName(pid int32) string {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
12 changes: 8 additions & 4 deletions pkg/daemon/ipc_peercred_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import (
"net"
)

// resolveProcessName is a no-op on unsupported platforms.
// IPC whitelist is Linux-only (PILOT-346).
func resolveProcessName(pid int32) string { return "" }

// checkPeerUID — fallback for non-Linux, non-Darwin builds. Pilot does
// not officially support these platforms; the IPC peer-UID check is a
// no-op so the build keeps compiling.
func checkPeerUID(conn net.Conn) error {
// no-op so the build keeps compiling. Returns 0 for PID.
func checkPeerUID(conn net.Conn) (int32, error) {
if _, ok := conn.(*net.UnixConn); !ok {
return fmt.Errorf("IPC: not a unix socket")
return 0, fmt.Errorf("IPC: not a unix socket")
}
return nil
return 0, nil
}
8 changes: 4 additions & 4 deletions pkg/daemon/zz_ipc_async_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func pairedConn(t *testing.T) (server, client net.Conn) {
func TestIPCConnAsyncWriteSerializesConcurrent(t *testing.T) {
t.Parallel()
server, client := pairedConn(t)
conn := newIPCConn(server)
conn := newIPCConn(server, 0, false)
defer conn.Close()

const writers = 16
Expand Down Expand Up @@ -106,7 +106,7 @@ func TestIPCConnAsyncWriteSerializesConcurrent(t *testing.T) {
func TestIPCConnAsyncWriteRejectsAfterClose(t *testing.T) {
t.Parallel()
server, _ := pairedConn(t)
conn := newIPCConn(server)
conn := newIPCConn(server, 0, false)
conn.Close()

err := conn.ipcWrite([]byte("late"))
Expand All @@ -130,7 +130,7 @@ func TestIPCConnAsyncWriteRejectsAfterClose(t *testing.T) {
func TestIPCConnAsyncWriteBlocksUntilClose(t *testing.T) {
t.Parallel()
server, client := pairedConn(t)
conn := newIPCConn(server)
conn := newIPCConn(server, 0, false)
defer client.Close() // intentionally do NOT read — block forever

// Fill the buffer + writer's in-flight slot. ipcSendBuffer + 1
Expand Down Expand Up @@ -191,7 +191,7 @@ func TestIPCConnAsyncWriteBlocksUntilClose(t *testing.T) {
func TestIPCConnCloseDrainsBufferedMessages(t *testing.T) {
t.Parallel()
server, client := pairedConn(t)
conn := newIPCConn(server)
conn := newIPCConn(server, 0, false)

const N = 20
for i := 0; i < N; i++ {
Expand Down
2 changes: 1 addition & 1 deletion pkg/daemon/zz_ipc_conncount_stale_bug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestIPCConnCountIncludesClosedConns(t *testing.T) {
clientConn, serverConn := net.Pipe()
t.Cleanup(func() { clientConn.Close(); serverConn.Close() })

ic := newIPCConn(serverConn)
ic := newIPCConn(serverConn, 0, false)
t.Cleanup(func() { ic.Close() })

pm := NewPortManager()
Expand Down
2 changes: 1 addition & 1 deletion pkg/daemon/zz_ipc_dialcancel_leak_bug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestIPCDialCancelsLeakOnCompletedDials(t *testing.T) {
clientConn, serverConn := net.Pipe()
t.Cleanup(func() { clientConn.Close(); serverConn.Close() })

ic := newIPCConn(serverConn)
ic := newIPCConn(serverConn, 0, false)
t.Cleanup(func() { ic.Close() })

const N = 1000
Expand Down
4 changes: 2 additions & 2 deletions pkg/daemon/zz_ipc_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
func newPipePair(t *testing.T) (*ipcConn, net.Conn) {
t.Helper()
client, server := net.Pipe()
ic := newIPCConn(server)
ic := newIPCConn(server, 0, false)
t.Cleanup(func() {
ic.Close()
client.Close()
Expand Down Expand Up @@ -222,7 +222,7 @@ func TestIPCServerCloseClosesClients(t *testing.T) {

client, server := net.Pipe()
t.Cleanup(func() { client.Close() })
ic := newIPCConn(server)
ic := newIPCConn(server, 0, false)
s.clients[ic] = true

if err := s.Close(); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions pkg/daemon/zz_ipc_socket_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ func TestCheckPeerUIDRejectsNonUnixSocket(t *testing.T) {
defer server.Close()
defer client.Close()

if err := checkPeerUID(server); err == nil {
if _, err := checkPeerUID(server); err == nil {
t.Fatal("checkPeerUID should reject non-Unix conn")
}
}
Expand All @@ -440,7 +440,7 @@ func TestCheckPeerUIDAcceptsSameUIDUnixSocket(t *testing.T) {
}
defer conn.Close()

if err := checkPeerUID(conn); err != nil {
if _, err := checkPeerUID(conn); err != nil {
t.Fatalf("checkPeerUID should accept same-UID connection: %v", err)
}
}
2 changes: 1 addition & 1 deletion pkg/daemon/zz_ipc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
func newIPCTestConn(t *testing.T) (*ipcConn, net.Conn) {
t.Helper()
server, client := net.Pipe()
ic := newIPCConn(server)
ic := newIPCConn(server, 0, false)
t.Cleanup(func() {
_ = ic.Close() // signals writer goroutine to drain + exit
_ = client.Close()
Expand Down
4 changes: 2 additions & 2 deletions pkg/daemon/zz_ipc_write_deadline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestWriteLoopExitsOnWriteDeadline(t *testing.T) {
}
server := res.conn

ic := newIPCConn(server)
ic := newIPCConn(server, 0, false)
defer func() {
ic.Close()
client.Close()
Expand Down Expand Up @@ -114,7 +114,7 @@ func TestHealthHandlerInlineDispatch(t *testing.T) {
defer server.Close()
defer client.Close()

ic := newIPCConn(server)
ic := newIPCConn(server, 0, false)
defer ic.Close()

// Minimal daemon — handleHealth only needs Info().
Expand Down
Loading