返回列表

博客

一个依赖:仅用 ws 包构建生产级服务器

我们的 Node.js 服务器只有一个运行时依赖。一个。这里解释我们为什么做出这个选择,以及它对可靠性、安全性和维护意味着什么。

发布于 标签: engineering / architecture / node.js / security
博客

继续查看该栏目下的产品与工程更新。

浏览此分类

打开 Tactic Remote 服务器的 package.json。在 dependencies 下,你只会看到一个条目:

{
  "dependencies": {
    "ws": "^8.18.0"
  }
}

就这些。一个包。ws WebSocket 库。其他一切——HTTP 服务器、认证、速率限制、终端管理、文件浏览、输入校验、进程执行——都基于 Node.js 内置模块。

这不是偶然的极简主义。这是一个经过深思熟虑的工程决策,在可靠性、安全性以及使用我们软件的用户体验方面都有实际的影响。

为什么这很重要

这个服务器运行在你的 Mac 上,管理你的 Claude Code 会话。它可以访问你的终端、文件系统和正在运行的进程。你添加的每一个依赖都是:

  1. 一个攻击面。 每个 npm 包都是以你的权限运行的代码,由你不认识的人编写,带有你未审计过的传递性依赖。
  2. 一个维护负担。 依赖需要更新。更新可能引入破坏性变更或漏洞。依赖越多,你花在 npm audit fix 上的时间就越多,而不是在构建功能。
  3. 一个可靠性风险。 当依赖出问题(leftpad、colors、node-ipc),你的服务器就会出问题。你用户的开发工作流就会中断。

对于一个介于人类和 AI 之间、在用户机器上编写代码的服务器来说,依赖被攻破的代价是严重的。我们决定外部依赖的正确数量应该尽可能接近零。

不用依赖我们构建了什么

HTTP 服务器

Node.js 自带 http.createServer()。它不是 Express,但我们只需要提供两个 HTTP 端点:

// POST /hook — receives Claude Code lifecycle events
// GET /status — health check with last event info

两个路由。没有中间件链,没有请求体解析器,没有路由库。请求处理器是一个简单的函数,用 === 检查 URL 路径,用 req.on('data') 读取请求体,然后调用对应的处理函数。

这是"可扩展"的吗?不是。需要吗?也不需要。这个服务器只处理来自一台 iPhone 的一个连接。用路由库纯粹是工程表演。

认证

认证系统支持两种请求头格式来验证 API Key:

// HTTP endpoints
const apiKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '')
 
// WebSocket connections
const apiKey =
  req.headers['authorization']?.replace('Bearer ', '') || req.headers['sec-websocket-protocol']

Sec-WebSocket-Protocol 回退的存在是因为 iOS URLSessionWebSocketTask 无法在升级请求中设置自定义头。这是一个有文档记录的 iOS 限制——你可以设置协议头,但不能设置自定义头。与其引入一个绕过此限制的 WebSocket 库,我们直接将协议头用作认证通道。

认证在连接时检查一次。如果失败,WebSocket 以代码 1008(策略违规)关闭。没有会话令牌,没有刷新流程,没有 JWT 库。一个静态 API Key,用 === 比较。

速率限制

一个内存中的滑动窗口,40 行代码:

class RateLimiter {
  constructor(maxAttempts = 30, windowMs = 60000) {
    this.attempts = new Map() // IP -> { count, resetTime }
    this.maxAttempts = maxAttempts
    this.windowMs = windowMs
 
    // Cleanup stale entries every 5 minutes
    setInterval(() => this.cleanup(), 300000)
  }
 
  isRateLimited(ip) {
    const now = Date.now()
    const record = this.attempts.get(ip)
 
    if (!record || now > record.resetTime) {
      this.attempts.set(ip, { count: 1, resetTime: now + this.windowMs })
      return false
    }
 
    record.count++
    return record.count > this.maxAttempts
  }
}

每个 IP 每分钟 30 次失败认证尝试。超过后,服务器返回 429 Too Many Requests 并带有 retryAfter 字段。窗口过期后,计数器重置。

基于 Redis 的速率限制器会更复杂精细。但它也会增加一个 Redis 依赖。对于只处理一个客户端的服务器来说,一个内存中的 Map 加上定期清理就足够了。

输入校验

每个用户输入在接触系统之前都会被校验。以下是完整的校验面:

输入校验原因
会话名/^[a-zA-Z0-9_-]{1,64}$/可能包含 shell 元字符的 tmux 会话名
Claude 命令/^claude(\s+--?[a-zA-Z0-9-]+)*$/只允许 claude 加可选标志——无管道、重定向、链式命令
Tmux 按键名18 个键的显式白名单防止任意按键注入
文件路径path.resolve() + startsWith()防止目录遍历
单个字符/^[a-zA-Z0-9!@#$%^&*()...]$/只允许可打印 ASCII 字符用于输入

没有校验库。正则表达式和字符串比较,内联且可读。

进程执行

这是安全关键部分。服务器代你执行 tmux 命令。如果这一层有漏洞,攻击者就能在你的机器上运行任意命令。

我们的方案:child_process.spawn() 配合 shell: false,封装在一个所有外部命令都必须经过的单一函数中:

function execSafe(cmd, args = [], opts = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      shell: false, // No shell interpolation. Period.
      ...opts,
    })
 
    let stdout = ''
    let stderr = ''
 
    child.stdout?.on('data', (data) => {
      stdout += data
    })
    child.stderr?.on('data', (data) => {
      stderr += data
    })
 
    child.on('close', (code) => {
      if (code === 0) resolve({ stdout, stderr, code })
      else reject(new Error(`Exit code ${code}: ${stderr}`))
    })
  })
}

shell: false 意味着参数直接传递给进程,不经过 /bin/sh。没有字符串插值,没有模板字面量展开,没有 shell 注入的机会。如果你将 ["send-keys", "-t", sessionName, "-l", content] 作为 args 数组传递,那就是 tmux 接收到的 argv。分号、管道、反引号、$()——它们都没有特殊含义。

每个 tmux 操作都是对这个函数的薄封装:

async function tmuxSendKeys(sessionName, keys) {
  return execSafe('tmux', ['send-keys', '-t', sessionName, ...keys])
}
 
async function tmuxCapture(sessionName, lines = 500) {
  const result = await execSafe('tmux', [
    'capture-pane',
    '-t',
    sessionName,
    '-p',
    '-S',
    `-${lines}`,
  ])
  return result.stdout
}
 
async function tmuxNewSession(name, dir) {
  await execSafe('tmux', ['new-session', '-d', '-s', name, '-c', dir])
  await execSafe('tmux', ['set-option', '-t', name, 'history-limit', '50000'])
}

没有 tmux 封装库。没有终端会话的 ORM。只有 spawn 配合显式的参数数组。

路径遍历保护

文件浏览限制在主目录树内。敏感目录始终被阻止:

const SENSITIVE_DIRS = [
  '.ssh',
  '.gnupg',
  '.aws',
  '.kube',
  '.config/gcloud',
  'Library/Keychains',
  'Library/Cookies',
  'Library/Application Support/1Password',
]
 
function containsSensitiveDir(targetPath) {
  const segments = targetPath.split(path.sep)
  return SENSITIVE_DIRS.some((sensitive) => {
    const sensitiveSegments = sensitive.split(path.sep)
    return segments.some((_, i) => sensitiveSegments.every((seg, j) => segments[i + j] === seg))
  })
}

逐段匹配。不是对路径字符串使用 includes()——那样会对名为 my-.ssh-backup 的目录产生误报。检查会寻找与敏感目录模式精确匹配的连续路径段。

所有路径在检查之前都通过 path.resolve() 解析为绝对路径。这可以消除 .. 遍历、符号链接攻击和相对路径技巧。

WebSocket 协议

ws 包为我们提供了 WebSocket 服务器。在此基础上,我们构建了一个双协议系统,同时处理我们最初的扁平消息格式和较新的结构化格式。

为什么有两种协议

我们最初发布的是扁平协议:

{ "type": "send_to_claude", "content": "fix the bug", "session": "webapp" }

它能工作,但没有请求关联。如果客户端连续发送两个请求,响应会作为独立的消息到达,客户端无法判断哪个响应对应哪个请求。

新协议添加了请求 ID:

{"type": "req", "id": "abc-123", "method": "send_to_claude", "params": {"content": "fix the bug"}}
{"type": "res", "id": "abc-123", "ok": true, "payload": {"status": "sent"}}

我们不能通过移除扁平格式来破坏现有客户端。所以服务器同时处理两种格式:

function handleMessage(ws, data) {
    const msg = JSON.parse(data);
 
    if (msg.type === 'req') {
        // New protocol: extract method and params, call handler,
        // respond with matching id
        const result = await handlers[msg.method](msg.params);
        sendToClient(ws, { type: 'res', id: msg.id, ok: true, payload: result });
    } else {
        // Legacy protocol: type IS the method
        const result = await handlers[msg.type](msg);
        sendToClient(ws, { type: 'status', ...result });
    }
}

iOS 应用的 WebSocketClient 在客户端做了同样的事情——先尝试将收到的消息解码为新的 Frame 协议,如果失败则回退到旧版 ServerMessage。

心跳

移动端 WebSocket 连接是脆弱的。手机会在 WiFi 和蜂窝网络之间切换,进入睡眠状态,进入信号死区。我们需要快速检测死连接,但不能杀掉只是暂时中断的连接。

setInterval(() => {
  this.wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      ws.missedHeartbeats = (ws.missedHeartbeats || 0) + 1
      if (ws.missedHeartbeats >= 2) {
        return ws.terminate()
      }
    } else {
      ws.missedHeartbeats = 0
    }
    ws.isAlive = false
    ws.ping()
  })
}, 45000)

45 秒间隔,2 次未响应容忍。一个客户端最多可以在 90 秒内无响应才会被终止。这是为移动端调优的——典型的 WebSocket 心跳间隔是 15-30 秒,但移动客户端在切换基站或重新关联 WiFi 时经常经历 30-60 秒的网络切换。

我们同时发送 WebSocket 层级的 ping 帧(ws 库自动处理)和应用层级的 {"type": "ping"} 消息。应用层级的 ping 是为那些无法直接访问底层 WebSocket 帧的客户端准备的(这是 iOS URLSessionWebSocketTask 的另一个限制)。

我们没有的东西

坦诚地说说这个架构缺少什么:

没有集群。 服务器是单进程、单机器的。它无法水平扩展。但也不需要——它只服务一台 Mac 上的一个用户。

没有持久化。 如果服务器重启,速率限制器状态和客户端注册表会丢失。tmux 会话能幸存(它们是独立进程),但服务器的内存状态不会。对我们的场景来说没问题——客户端会在几秒内重新连接并重建状态。

没有优雅关闭编排。 服务器停止时,WebSocket 连接是直接终止的,而不是排空的。同样,对于单用户服务器来说没问题——客户端会处理重连。

没有请求日志或 APM。 我们输出到标准输出。如果你需要更多,可以管道到文件。没有结构化日志库,没有 OpenTelemetry 集成,没有日志聚合。

这些都是对多租户云服务有意义的功能。对于运行在你桌上、服务你手机的服务器来说,它们没有意义。

维护现实

自上线以来,我们的依赖维护工作是:

  • 当有安全补丁时更新 ws
  • 没了

没有来自传递性依赖的 npm audit 警告。没有我们不直接使用的库的 Dependabot PR。没有中间件框架大版本升级带来的破坏性变更。

当 ws 库有漏洞时(自我们开始以来发生过两次),我们更新一个包。变更很小,风险可控,我们可以在五分钟内审计完差异。

对比一下典型的 Express + passport + helmet + compression + cors + body-parser 技术栈。每一个都带来自己的依赖树。一次 npm audit 可能标记数十个传递性漏洞,其中大部分是理论上的,但每个都需要调查。

这种方式什么时候不适用

需要说清楚:这不是一个通用建议。单依赖服务器适用于以下场景:

  • 你服务的是单个用户或少量已知客户端
  • 你的 HTTP 表面很小(1-5 个路由)
  • 你不需要数据库访问、模板引擎或复杂路由
  • 安全性更适合通过简单性而非层叠库来实现
  • 你的团队能够熟练阅读 Node.js 内置 API

如果你在构建一个有用户账户、OAuth、数据库模型和 REST API 的 SaaS,请使用框架。Express 或 Fastify 配合合适的 ORM 会为你节省数月的工作。"单依赖"方案适用于依赖成本超过实现成本的系统。

对于 Tactic Remote 的服务器——一个单用户、单用途、在 tmux 和 iPhone 之间的 WebSocket 中继——一个依赖就是正确的数量。


了解这个服务器如何融入整体架构,参阅从手机远程运行 Claude Code。完整的 WebSocket 协议参考,参阅快速入门指南。

立即体验 Tactic Remote

用手机远程控制编程 Agent

连接 Mac、Windows 或 Linux 电脑上的 Claude Code、Codex 等 Agent,在 iPhone 或 iPad 上查看进展并发送下一条要求。