技术3/26/2026·7 min

把 IPC 从 Unix socket / 命名管道换成 TCP loopback + token 鉴权

pod daemon 与 manager 之间在 macOS/Linux 走 Unix socket、在 Windows 走命名管道。两套传输、两条测试路径、还有 104 字节路径上限。我们把它们合并到 TCP loopback,并加上每会话 token 鉴权。

问题形态

runner 上每个 agent pod 以一个 detached 的 daemon 进程运行。一个短生命周期的 manager 拉起它,再通过本地 IPC 通道连上去转发终端 I/O。在 #197 之前,这个通道按平台分裂:macOS / Linux 用 Unix domain socket,Windows 用命名管道。

// ipc_unix.go   //go:build !windows
func Listen(path string) (net.Listener, error) {
	_ = os.Remove(path) // 清理崩溃 daemon 残留的 socket 文件
	return net.Listen("unix", path)
}

// ipc_windows.go   //go:build windows
func Listen(path string) (net.Listener, error) {
	return winio.ListenPipe(path, nil) // github.com/Microsoft/go-winio
}

两套 Listen/Dial 藏在 build tag 后面,一个只有 Windows 才拉的三方依赖 go-winio,以及独立的 ipc_windows_test.go。Unix 这边还背着磁盘文件的生命周期:启动时删残留 socket、CleanupSession 里再删一次、daemon 失败路径上还要删一次。

为什么是路径先把我们坑了

macOS 的 Unix socket 路径上限是 104 字节(含结尾的 NUL)。socket 路径最初由 pod 的 sandbox 目录派生,而 sandbox 路径挂在 workspace root 下面。workspace 路径一长就溢出上限,net.Listen("unix", path) 在 daemon 还没 accept 任何连接前就失败。

先前的修复(#147)用一个短而可预测的目录绕开这点:

// GetSocketDir 返回 IPC socket 的目录。
// 派生自 TempBaseDir,保证路径短而可预测,
// 不越过 Unix socket 路径上限(macOS 104 字节)。
func (c *Config) GetSocketDir() string {
	return filepath.Join(TempBaseDir(), "sockets")
}

这让路径变短,但结构性成本还在:上限、文件生命周期、平台分裂都没消失。#197 做的是去掉成因,而不是把它圈起来。

TCP loopback,端口由内核分配

替换后是一个文件,没有 build tag:

// ipc.go —— 18 行,取代 ipc_unix.go + ipc_windows.go
func Listen() (net.Listener, error) {
	return net.Listen("tcp", "127.0.0.1:0")
}

func Dial(addr string) (net.Conn, error) {
	return net.Dial("tcp", addr)
}

127.0.0.1:0 向内核要一个空闲的临时端口。没有路径,就没有 104 字节天花板,也没有 socket 文件可泄漏。同一份代码在 darwin / linux / windows 都能编译运行,go-winiogo.mod 里移除。

这把地址发现的方向反了过来。原先 manager 预先算好 socket 路径递给 daemon;现在端口只有 Listen() 之后才知道,于是改成 daemon 先 bind、再把结果写回自己的 state 文件:

listener, err := Listen() // 内核分配端口
// ...
state.IPCAddr = listener.Addr().String() // 例如 "127.0.0.1:54213"
state.DaemonPID = os.Getpid()
SaveState(state)

manager 不再连一个已知路径,而是轮询 pod_daemon.json 直到 IPCAddr 被填上;同时对 daemon PID 做 fail-fast 检查,避免一个已死的子进程白白耗满整个超时:

const maxAttempts = 50
const retryDelay = 100 * time.Millisecond // 5 秒上限
// ...
state, err := LoadState(sandboxPath)
if err == nil && state.IPCAddr != "" {
	dpty, err := connectDaemon(connectOpts{Addr: state.IPCAddr, AuthToken: authToken})
	// ...
}
if pid > 0 && process.IsAlive(pid) != nil {
	return nil, nil, fmt.Errorf("daemon (pid %d) exited before IPC ready", pid)
}

loopback 让出了什么,token 怎么补上

Unix socket 自带文件系统权限;换成 loopback 后,任何本地进程只要猜到或扫到端口,就能 connect()127.0.0.1:<port>。也就是说,过去由文件系统白送的访问控制,现在必须在应用层显式做出来。

manager 每个会话用 crypto/rand 生成 32 字节 token,hex 编码后存进同一个 state 文件:

const authTokenBytes = 32

func generateAuthToken() (string, error) {
	b := make([]byte, authTokenBytes)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("generate auth token: %w", err)
	}
	return hex.EncodeToString(b), nil
}

attach 握手把它带上。协议版本从 1 升到 2,MsgAttach{version uint8} 扩成 {version uint8}{auth_token bytes}:

// 客户端:[version][token]
attachPayload := make([]byte, 1+len(tokenBytes))
attachPayload[0] = protocolVersion // 2
copy(attachPayload[1:], tokenBytes)
WriteMessage(conn, MsgAttach, attachPayload)

daemon 用常量时间比较来校验,使被拒的 token 不泄漏时序信号:

receivedToken := payload[1:] // version 之后的字节
if len(receivedToken) != len(expectedToken) ||
	subtle.ConstantTimeCompare(receivedToken, expectedToken) != 1 {
	log.Warn("client auth failed: invalid token")
	conn.Close()
	return
}

token 只存在于 pod_daemon.json,该文件位于 per-pod sandbox、由用户私有权限保护——于是文件系统权限仍然守着这个密钥,而socket 本身不再依赖它。manager 在 daemon 写回地址后还会重读 token,不一致就当作可能的篡改拒绝。

把连接参数收进 struct

旧的 connectDaemon(ipcPath string) 变成 connectDaemon(opts connectOpts)。地址和 token 都是字符串,位置参数只要一处写反,就会把地址当 token 传进去:

type connectOpts struct {
	Addr      string // "127.0.0.1:12345"
	AuthToken string // hex 编码
}

改动很小,但这类「编译干净、运行时才炸」的混淆,值得在设计上提前消掉。

结果与约束

整个 diff 28 个文件,+429 / −505——IPC 代码变少了,同时加上了鉴权。一套传输取代两套;ipc_unix.goipc_windows.goipc_windows_test.go 都没了,连同 go-winio 依赖和 GetSocketDir/EnsureSocketDir 这组接口。单测、集成测试、linux/windows/darwin 交叉编译,以及三平台端到端 pod 创建均已验证。

由此沉淀出两条约束:

  • loopback 不是访问边界。 bind 127.0.0.1 把流量挡在网络之外,但任何本地进程都能够到这个端口。一旦放弃文件系统的隐式 ACL,就欠下一个显式的——这里是每会话随机 token 加常量时间校验。token 自身的保密仍靠文件权限;loopback 只是挪动了边界,没有取消对边界的需要。
  • 内核分配端口会反转发现方向。 绑定地址在 Listen() 之后才存在,所以生产方必须把它发布出去、消费方必须轮询,并配合存活检查 fail-fast。这把一个预先共享的路径,换成了 state 文件上一段「先写后读」的小协议。

一条来自 CI 的运维记录:负载下 TCP 的 accept/read 时序比本地 socket 更松。daemon 测试里的读 deadline 从 2s 提到 10s,以免缓慢的共享 runner 抖动失败。