Zurück zur Übersicht

Blog

Eine Abhängigkeit: Einen Produktionsserver nur mit dem ws-Paket bauen

Unser Node.js-Server hat eine Laufzeitabhängigkeit. Eine. Hier erfahren Sie, warum wir diese Entscheidung getroffen haben und was sie für Zuverlässigkeit, Sicherheit und Wartung bedeutet.

Veröffentlicht Tags: Engineering / Architektur / Node.js / Sicherheit
Blog

Weitere Produkt- und Engineering-Updates aus dieser Kategorie.

Kategorie öffnen

Öffnen Sie die package.json des TacticRemote-Servers. Unter dependencies finden Sie genau einen Eintrag:

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

Das ist alles. Ein Paket. Die ws WebSocket-Bibliothek. Alles andere — HTTP-Server, Authentifizierung, Rate Limiting, Terminal-Verwaltung, Datei-Browsing, Input-Validierung, Prozessausführung — ist mit Node.js-Built-ins gebaut.

Das ist kein zufälliger Minimalismus. Es ist eine bewusste Engineering-Entscheidung mit konkreten Konsequenzen für Zuverlässigkeit, Sicherheit und die Menschen, die unsere Software nutzen.

Warum das wichtig ist

Der Server läuft auf Ihrem Mac und verwaltet Ihre Claude Code-Sessions. Er hat Zugriff auf Ihr Terminal, Ihr Dateisystem und Ihre laufenden Prozesse. Jede Abhängigkeit, die Sie einem solchen System hinzufügen, ist:

  1. Eine Angriffsfläche. Jedes npm-Paket ist Code, der mit Ihren Berechtigungen läuft, von jemandem geschrieben, den Sie nicht kennen, mit transitiven Abhängigkeiten, die Sie nicht geprüft haben.
  2. Eine Wartungslast. Abhängigkeiten brauchen Updates. Updates können Breaking Changes oder Sicherheitslücken einführen. Je mehr Sie haben, desto öfter machen Sie npm audit fix statt Features zu bauen.
  3. Ein Zuverlässigkeitsrisiko. Wenn eine Abhängigkeit bricht (leftpad, colors, node-ipc), bricht Ihr Server. Die Entwicklungsworkflows Ihrer Nutzer stoppen.

Für einen Server, der zwischen einem Menschen und einer KI steht, die Code auf deren Rechner schreibt, sind die Kosten einer kompromittierten Abhängigkeit gravierend. Wir entschieden, dass die richtige Anzahl externer Abhängigkeiten so nah an Null wie möglich sein sollte.

Was wir ohne Abhängigkeiten gebaut haben

HTTP-Server

Node.js liefert http.createServer(). Es ist nicht Express, aber wir bedienen genau zwei HTTP-Endpunkte:

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

Zwei Routen. Keine Middleware-Ketten, keine Body-Parser, keine Router-Bibliotheken. Der Request-Handler ist eine einzelne Funktion, die den URL-Pfad mit === prüft, den Request-Body mit req.on('data') liest und den passenden Handler aufruft.

Ist das „webscale"? Nein. Muss es das sein? Auch nein. Dieser Server verarbeitet eine Verbindung von einem iPhone. Eine Routing-Bibliothek wäre Engineering-Theater.

Authentifizierung

Das Auth-System unterstützt zwei Header-Formate zur API-Schlüssel-Verifizierung:

// 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']

Der Sec-WebSocket-Protocol-Fallback existiert, weil iOS' URLSessionWebSocketTask beim Upgrade-Request keine beliebigen Header setzen kann. Das ist eine dokumentierte iOS-Einschränkung — Sie können den Protocol-Header setzen, aber keine benutzerdefinierten Header. Statt eine WebSocket-Bibliothek einzubinden, die das umgeht, nutzen wir den Protocol-Header als Auth-Kanal.

Authentifizierung wird einmal beim Verbindungsaufbau geprüft. Bei Fehlschlag wird der WebSocket mit Code 1008 (Policy Violation) geschlossen. Keine Session-Tokens, keine Refresh-Flows, keine JWT-Bibliotheken. Ein statischer API-Schlüssel, verglichen mit ===.

Rate Limiting

Ein In-Memory-Sliding-Window, 40 Zeilen Code:

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
  }
}

30 fehlgeschlagene Authentifizierungsversuche pro Minute pro IP. Bei Überschreitung gibt der Server 429 Too Many Requests mit einem retryAfter-Feld zurück. Wenn das Fenster abläuft, wird der Zähler zurückgesetzt.

Ein Redis-basierter Rate-Limiter wäre ausgefeilter. Er würde auch eine Redis-Abhängigkeit hinzufügen. Für einen Server, der einen Client bedient, ist eine In-Memory-Map mit periodischer Bereinigung angemessen.

Input-Validierung

Jede Benutzereingabe wird validiert, bevor sie das System berührt. Hier die vollständige Validierungsoberfläche:

InputValidierungGrund
Session-Namen/^[a-zA-Z0-9_-]{1,64}$/tmux-Session-Namen könnten Shell-Metazeichen enthalten
Claude-Befehl/^claude(\s+--?[a-zA-Z0-9-]+)*$/Nur claude mit optionalen Flags — keine Pipes, Redirects, Verkettungen
tmux-TastennamenExplizite Allowlist von 18 TastenBeliebige Tasteneingabe-Injection verhindern
Dateipfadepath.resolve() + startsWith()Directory-Traversal verhindern
Einzelzeichen/^[a-zA-Z0-9!@#$%^&*()...]$/Nur druckbares ASCII zum Tippen

Keine Validierungsbibliothek. Reguläre Ausdrücke und Stringvergleich, inline und lesbar.

Prozessausführung

Das ist das sicherheitskritische Stück. Der Server führt tmux-Befehle in Ihrem Namen aus. Wenn diese Schicht eine Schwachstelle hat, kann ein Angreifer beliebige Befehle auf Ihrem Rechner ausführen.

Unsere Antwort: child_process.spawn() mit shell: false, eingehüllt in eine einzige Funktion, durch die jeder externe Befehl geht:

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 bedeutet, dass Argumente direkt an den Prozess übergeben werden, nicht durch /bin/sh. Es gibt keine String-Interpolation, keine Template-Literal-Expansion, keine Möglichkeit für Shell-Injection. Wenn Sie ["send-keys", "-t", sessionName, "-l", content] als Args-Array übergeben, ist das genau, was tmux als argv erhält. Semikolons, Pipes, Backticks, $() — nichts davon hat eine besondere Bedeutung.

Jede tmux-Operation ist ein dünner Wrapper um diese Funktion:

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'])
}

Keine tmux-Wrapper-Bibliothek. Kein ORM für Terminal-Sessions. Einfach spawn mit expliziten Argument-Arrays.

Pfad-Traversal-Schutz

Datei-Browsing beschränkt den Zugriff auf den Home-Verzeichnisbaum. Sensible Verzeichnisse sind immer blockiert:

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))
  })
}

Segment-für-Segment-Matching. Nicht includes() auf dem Pfad-String, was bei einem Verzeichnis namens my-.ssh-backup einen Fehlalarm auslösen würde. Die Prüfung sucht nach aufeinanderfolgenden Pfadsegmenten, die exakt dem sensiblen Verzeichnismuster entsprechen.

Alle Pfade werden über path.resolve() zu absoluten Pfaden aufgelöst, bevor sie geprüft werden. Das neutralisiert ..-Traversal, Symlink-Angriffe und Tricks mit relativen Pfaden.

Das WebSocket-Protokoll

Das ws-Paket gibt uns einen WebSocket-Server. Darauf haben wir ein Dual-Protokoll-System gebaut, das sowohl unser ursprüngliches Flat-Message-Format als auch ein neueres strukturiertes Format gleichzeitig verarbeitet.

Warum zwei Protokolle

Wir haben zuerst das Flat-Protokoll ausgeliefert:

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

Es funktioniert, hat aber keine Request-Korrelation. Wenn der Client zwei Requests schnell hintereinander sendet, kommen Antworten als separate Nachrichten und der Client kann nicht zuordnen, welche Antwort zu welchem Request gehört.

Das neue Protokoll fügt Request-IDs hinzu:

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

Wir konnten bestehende Clients nicht kaputt machen, indem wir das Flat-Format entfernen. Also verarbeitet der Server beides:

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 });
    }
}

Die iOS-App macht das Gleiche auf der Client-Seite — versucht eingehende Nachrichten zuerst als neues Frame-Protokoll zu dekodieren, fällt bei Fehlschlag auf Legacy-ServerMessage zurück.

Heartbeat

Mobile WebSocket-Verbindungen sind fragil. Das Telefon wechselt zwischen WiFi und Mobilfunk, fährt in Aufzüge, geht in den Ruhezustand. Wir müssen tote Verbindungen schnell erkennen, aber nicht Verbindungen beenden, die nur vorübergehend unterbrochen sind.

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-Sekunden-Intervall, 2-Strike-Toleranz. Ein Client kann bis zu 90 Sekunden nicht antworten, bevor er getrennt wird. Das ist auf mobile Geräte abgestimmt — ein typischer WebSocket-Heartbeat ist 15-30 Sekunden, aber mobile Clients durchlaufen häufig 30-60-Sekunden-Netzwerkübergänge beim Wechsel von Masten oder WiFi-Neuverbindung.

Wir senden sowohl einen WebSocket-Level-ping-Frame (den die ws-Bibliothek automatisch handhabt) als auch eine Anwendungsebene-{"type": "ping"}-Nachricht. Der Anwendungsebene-Ping ist für Clients, die nicht direkt auf Low-Level-WebSocket-Frames zugreifen können (eine weitere iOS-URLSessionWebSocketTask-Einschränkung).

Was wir nicht haben

Ehrlich, was dieser Architektur fehlt:

Kein Clustering. Der Server ist Single-Process, Single-Machine. Er kann nicht horizontal skalieren. Er braucht es auch nicht — er bedient einen Benutzer auf einem Mac.

Keine Persistenz. Bei Server-Neustart gehen Rate-Limiter-State und Client-Registry verloren. tmux-Sessions überleben (sie sind unabhängige Prozesse), aber der In-Memory-State des Servers nicht. Für unseren Anwendungsfall ist das in Ordnung — der Client verbindet sich wieder und stellt den State innerhalb von Sekunden her.

Keine orchestrierte Graceful-Shutdown. Wenn der Server stoppt, werden WebSocket-Verbindungen terminiert, nicht drainiert. Auch in Ordnung für einen Einzelbenutzer-Server — der Client handhabt die Wiederverbindung.

Kein Request-Logging oder APM. Wir loggen auf stdout. Wenn Sie mehr brauchen, pipen Sie es in eine Datei. Es gibt keine strukturierte Logging-Bibliothek, keine OpenTelemetry-Integration, keine Log-Aggregation.

Das sind alles Features, die für mandantenfähige Cloud-Services sinnvoll sind. Für einen Server, der auf Ihrem Schreibtisch steht und Ihr Telefon bedient, ergeben sie keinen Sinn.

Die Wartungsrealität

Seit dem Launch bestand unsere Abhängigkeitswartung aus:

  • ws aktualisieren, wenn es einen Security-Patch gibt
  • Das war's

Keine npm audit-Warnungen von transitiven Abhängigkeiten. Keine Dependabot-PRs für Bibliotheken, die wir nicht direkt nutzen. Keine Breaking Changes von Major-Version-Bumps in Middleware-Frameworks.

Wenn die ws-Bibliothek eine Schwachstelle hat (ist zweimal passiert seit unserem Start), aktualisieren wir ein Paket. Die Änderung ist klein, das Risiko ist begrenzt, und wir können den Diff in fünf Minuten prüfen.

Vergleichen Sie das mit einem typischen Express + passport + helmet + compression + cors + body-parser Stack. Jedes davon zieht seinen eigenen Abhängigkeitsbaum mit. Ein einziges npm audit kann Dutzende transitive Schwachstellen aufzeigen, von denen die meisten theoretisch sind, aber alle untersucht werden müssen.

Wann dieser Ansatz nicht funktioniert

Zur Klarstellung: Das ist keine allgemeine Empfehlung. Ein-Abhängigkeit-Server funktionieren, wenn:

  • Sie einen einzelnen Benutzer oder eine kleine, bekannte Gruppe von Clients bedienen
  • Ihre HTTP-Oberfläche winzig ist (1-5 Routen)
  • Sie keinen Datenbankzugriff, kein Templating oder komplexes Routing brauchen
  • Sicherheit besser durch Einfachheit als durch Schichtung von Bibliotheken gewährleistet ist
  • Ihr Team vertraut ist mit Node.js-Built-in-APIs

Wenn Sie einen SaaS mit Benutzerkonten, OAuth, Datenbankmodellen und einer REST-API bauen, verwenden Sie ein Framework. Express oder Fastify mit einem ordentlichen ORM wird Ihnen Monate Arbeit sparen. Der „eine Abhängigkeit"-Ansatz ist für Systeme, bei denen die Abhängigkeitskosten die Implementierungskosten übersteigen.

Für TacticRemotes Server — ein Einzelbenutzer, Einzelzweck WebSocket-Relay zwischen tmux und einem iPhone — ist eine Abhängigkeit die richtige Zahl.


Wie dieser Server in die größere Architektur passt, erfahren Sie in Claude Code per Smartphone fernsteuern. Für die vollständige WebSocket-Protokoll-Referenz siehe die Erste Schritte-Anleitung.

Tactic Remote ausprobieren

Coding-Agenten vom Smartphone aus steuern

Verbinde dich mit Claude Code, Codex und anderen Agenten auf deinem Mac-, Windows- oder Linux-Rechner. Prüfe den Fortschritt und sende die nächste Anweisung vom iPhone oder iPad.