问题形态
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-winio 从 go.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.go、ipc_windows.go、ipc_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 抖动失败。