Unix Domain Socket 使用指南

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

  1. 安全 --- 通过文件权限(0o600)控制谁能连接,不需要担心端口被其他用户扫描或占用
  2. 快速 --- 数据不经过 TCP/IP 协议栈,内核直接在两个进程的缓冲区之间拷贝,延迟更低
  3. 不需要端口 --- 不会和其他服务的端口冲突,也不需要防火墙配置

权限控制对比

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 文件本身不承载任何数据。

区分客户端和服务端靠的是各自的连接对象:

客户端 服务端
连接对象 clientcreateConnection 返回) conncreateServer 回调参数)
发送数据 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 损坏
              → 后续消息无法加密/解密
相关推荐
跑跑快跑1 小时前
PNPM线上部署打包失败
前端
李剑一1 小时前
一行代码复刻微信红包打开效果,近乎100%还原 | 附源码
前端
invicinble1 小时前
前端框架使用vue-cli( 第三层:插件配置层)
前端·vue.js·前端框架
Mr数据杨1 小时前
【Codex】用APP绑定教程模块规范移动端接入指引
java·前端·javascript·django·codex·项目开发
熊出没1 小时前
02——从 Prompt 到 Workflow
java·前端·prompt
超级无敌谢大脚1 小时前
【无标题】
开发语言·前端·javascript
GISer_Jing2 小时前
全栈实战:分支管理到CI/CD全流程
运维·前端·ci/cd·github·devops
隔窗听雨眠2 小时前
Chrome 安全机制深度解析
前端·chrome·安全
史迪仔01122 小时前
[QML] Qt6/Qt5四大渐变效果实战指南
开发语言·前端·c++·qt