返回列表

部落格

一個依賴:僅用 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 上查看進度並傳送下一個請求。