Unix Domain Socket 使用指南
1. 是什么
Unix Domain Socket(UDS)是一种本机进程间通信(IPC)机制。它使用文件系统中的一个文件作为"地址",让同一台机器上的两个进程通过它互相发送数据。
与普通文件的区别
bash
文件类型 ls 显示 能 cat 吗 存储内容
─────────────────────────────────────────────
普通文件 -rw-r--r-- 能 实际数据(文本、二进制等)
目录 drwxr-xr-x 不能 目录条目列表
符号链接 lrwxr-xr-x 能(读目标) 指向的文件路径
Socket srwx------ 不能 无内容,只是一个"门牌号"
Socket 文件的关键特征:
- 文件大小永远是 0 --- 不存储任何数据,数据在内核内存中传递
- 不能
cat或vim打开 --- 不是用来存数据的 - 唯一作用是提供一个"地址" --- 进程通过
connect()系统调用连接到这个路径
可以用 stat 验证:
bash
stat ~/.clawdis/ipc/relay.sock
# ... Size: 0 Blocks: 0 IO Block: 1024 socket
# 不占磁盘空间 ↑ ↑ 类型是 socket
2. 与 TCP Socket 的区别
| TCP Socket | Unix Domain Socket | |
|---|---|---|
| 地址 | IP + 端口(如 127.0.0.1:3000) |
文件路径(如 /tmp/relay.sock) |
| 通信范围 | 可跨机器通信 | 仅限本机进程 |
| 协议栈 | 经过完整 TCP/IP 网络协议栈 | 绕过网络协议栈,直接在内核中拷贝数据 |
| 性能 | 较慢 | 更快(无网络开销) |
| 安全性 | 防火墙/端口控制 | 文件系统权限控制 |
为什么选 UDS 而不是 TCP
- 安全 --- 通过文件权限(
0o600)控制谁能连接,不需要担心端口被其他用户扫描或占用 - 快速 --- 数据不经过 TCP/IP 协议栈,内核直接在两个进程的缓冲区之间拷贝,延迟更低
- 不需要端口 --- 不会和其他服务的端口冲突,也不需要防火墙配置
权限控制对比
bash
# TCP 方式:relay 监听一个端口
relay 监听 127.0.0.1:9876
# 任何本机用户都能连接
另一个用户 → curl 127.0.0.1:9876 → 能连上!
# Unix Socket + 0o600 权限:
另一个用户 → net.createConnection("~/.clawdis/ipc/relay.sock")
→ EACCES: permission denied(内核直接拒绝)
3. 通信模型
scss
客户端 (CLI) 服务端 (Relay)
│ │
│ net.createConnection(sockPath) │ net.createServer()
│ ↓ │ ↓
│ client ───────────────────────conn ────────── server
│ │ │
│ client.write() ──→ 内核缓冲区 ──→ conn.on("data")
│ │
│ client.on("data") ←── 内核缓冲区 ←── conn.write()
│ │ │
└────────┘ └────────┘
核心要点:数据流在内核内存中传递,socket 文件本身不承载任何数据。
区分客户端和服务端靠的是各自的连接对象:
| 客户端 | 服务端 | |
|---|---|---|
| 连接对象 | client(createConnection 返回) |
conn(createServer 回调参数) |
| 发送数据 | client.write(...) |
conn.write(...) |
| 接收数据 | client.on("data", ...) |
conn.on("data", ...) |
4. Node.js API 用法
服务端
typescript
import net from "node:net";
import fs from "node:fs";
const SOCKET_PATH = "/tmp/relay.sock";
// 1. 清理残留的 socket 文件(防止 EADDRINUSE)
try {
fs.unlinkSync(SOCKET_PATH);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
// 2. 创建服务端
const server = net.createServer((conn) => {
conn.on("data", (data) => {
// 处理请求...
conn.write("响应数据\n");
});
});
// 3. 监听 socket 文件路径
server.listen(SOCKET_PATH, () => {
// 设置权限为仅当前用户可读写(安全加固)
fs.chmodSync(SOCKET_PATH, 0o600);
});
客户端
typescript
const client = net.createConnection(SOCKET_PATH);
client.on("connect", () => {
client.write("请求数据\n");
});
client.on("data", (data) => {
// 处理响应...
client.end();
});
listen() 的两种用法
| 参数 | 类型 | 用途 |
|---|---|---|
listen(3000, cb) |
端口号 | TCP 网络监听 |
listen("/path/to.sock", cb) |
文件路径 | Unix Domain Socket 本地监听 |
传入文件路径时,Node.js 自动创建 Unix Domain Socket 并绑定到该路径。
5. 数据流与消息边界
on("data", ...) 拿到的是内核 socket 缓冲区中当前可读的所有数据。它不保证消息边界,可能出现三种情况:
情况一:数据分片到达
json
发送方写入: {"type":"send","message":"hello"}\n
第一次 data 事件: '{"type":"send","t'
第二次 data 事件: 'o","message":"hello"}\n'
情况二:一次拿到多条消息
json
一次 data 事件拿到:
'{"type":"send","message":"hi"}\n{"type":"send","message":"yo"}\n'
情况三:恰好一条完整消息
json
一次 data 事件拿到: '{"type":"send","message":"hi"}\n'
解决方案:NDJSON 协议 + 缓冲区
使用 newline-delimited JSON(NDJSON)协议,每条消息以 \n 结尾,配合缓冲区拼接:
typescript
let buffer = "";
conn.on("data", (data) => {
buffer += data.toString();
// 按换行符分割
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // 最后一段可能不完整,保留在缓冲区
// 处理所有完整的行
for (const line of lines) {
if (!line.trim()) continue;
const request = JSON.parse(line);
// 处理请求...
}
});
6. 安全检查
清理残留 socket 文件
服务端启动前必须清理上次可能残留的 socket 文件,否则 listen() 会报 EADDRINUSE:
typescript
try {
fs.unlinkSync(SOCKET_PATH);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
// ENOENT = 文件不存在,首次运行,正常跳过
}
文件权限
typescript
// Socket 文件权限:仅当前用户可读写
fs.chmodSync(SOCKET_PATH, 0o600);
// Socket 目录权限:仅当前用户可访问
fs.chmodSync(SOCKET_DIR, 0o700);
防符号链接攻击
typescript
const stat = fs.lstatSync(socketPath);
if (stat.isSymbolicLink()) {
throw new Error(`Refusing IPC socket symlink: ${socketPath}`);
}
防权限提升
typescript
// process.getuid() 只在 POSIX 系统(Linux/macOS)上可用
// Windows 上不存在,所以需要先检查 typeof
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
throw new Error(`IPC socket owned by different user: ${socketPath}`);
}
7. 本项目中的实际应用
为什么需要 IPC
直接创建多个 WhatsApp/Signal 连接会导致 session ratchet 损坏(密钥状态不一致),因此只允许 relay 持有一个活跃连接,其他进程通过 IPC 委托 relay 发送。
通信流程
objectivec
1. CLI 进程检测 relay.sock 是否存在
fs.accessSync("~/.clawdis/ipc/relay.sock") → 存在,relay 在运行
2. CLI 进程连接到 socket
net.createConnection("~/.clawdis/ipc/relay.sock")
内核在两个进程之间建立双向通道
3. CLI 发送一行 JSON
→ {"type":"send","to":"+8613800138000","message":"hello"}\n
4. Relay 收到请求,调用 WhatsApp SDK 发送消息
5. Relay 回写一行 JSON
→ {"success":true,"messageId":"abc123"}\n
6. CLI 收到响应,打印结果,关闭连接并退出
不使用 IPC 的后果
markdown
clawdis send → 创建新的 WhatsApp 连接 → 发消息 → 关闭连接
↑
relay 已经持有一个连接
WhatsApp 只允许一个活跃连接
新连接导致 relay 被踢
两个连接互相抢夺 → session ratchet 损坏
→ 后续消息无法加密/解密