Zum Inhalt springen
Deutsch

Cluster-Sicherheit

Der Cluster-Transport ist standardmäßig Plain-TCP ohne Authentifizierung — schnell, einfach, okay für ein privates Netzwerk. Nicht okay für irgendeinen Cluster, der eine unsichere Grenze überschreitet (öffentliches Internet, Multi-Tenant-Kubernetes, Cross-Region-Links ohne VPN).

Diese Seite behandelt das Produktions-Sicherheits-Setup für den Cluster-Transport.

AnliegenWie adressieren
Lauschangriff — Peer-to-Peer-Traffic lesbar für jeden auf der Leitung.TLS auf dem Cluster-Transport.
Unautorisierte Joins — ein bösartiger Node verbindet sich + wird Cluster-Mitglied.Mutual TLS (mTLS) — Peer-Zertifikatsprüfung.

Aktiviere TLS mit gegenseitiger Authentifizierung (mTLS) für jeden nach außen gerichteten Cluster — das adressiert beide.

import { TcpTransport, NodeAddress, Cluster, ClusterOptions } from 'actor-ts/cluster';
import fs from 'node:fs';
const transport = new TcpTransport(
NodeAddress.parse('my-app@10.0.0.5:2552'),
system.log,
{
cert: fs.readFileSync('./tls/cluster.crt'),
key: fs.readFileSync('./tls/cluster.key'),
ca: fs.readFileSync('./tls/ca.crt'),
rejectUnauthorized: true, // Peer-Certs verifizieren
},
);
const clusterOptions = ClusterOptions.create()
.withHost('10.0.0.5')
.withPort(2552)
.withSeeds([...])
.withTransport(transport);
await Cluster.join(system, clusterOptions);

Die TLS-Einstellungen:

  • cert + key — Zertifikat + Private Key dieses Nodes. Beide tragen das Material selbst, keinen Pfad darauf: nichts im Transport liest von der Platte, und auch keine der drei Runtimes akzeptiert in diesen Feldern einen Dateinamen. Lies es selbst ein, so wie es das Beispiel oben tut. Auf einem Listener sind beide Hälften nur gemeinsam gültig — siehe die Ablehnungen weiter unten.
  • ca — vertrauenswürdiges CA-Bundle. Nutze, um die Zertifikate der Peers zu verifizieren.
  • rejectUnauthorized: true — Handshakes scheitern lassen, wenn das Cert des Peers nicht von ca signiert ist.
  • requestClientCert — ob der Listener von dem, der sich verbindet, ein Zertifikat verlangt. Du setzt das normalerweise nicht: es ist true, sobald ca gesetzt ist, denn ein Trust-Bundle auf einem Cluster-Listener hat keinen anderen Zweck.

Mit einer geteilten ca ist der Cluster mutually authenticated — jede Verbindung verlangt ein Peer-Cert, das von der vertrauenswürdigen CA signiert ist, in beide Richtungen.

mTLS beantwortet „darf dieser Peer in den Cluster”. Für sich genommen hat es nie beantwortet „ist dieser Peer der Knoten, für den er sich ausgibt”: das hello-Frame trägt eine Adresse und kein Credential, ein CA-signierter Knoten konnte sich also unter der Adresse eines anderen Mitglieds ankündigen. Das ist deshalb relevant, weil die Gossip-Autoritätsregeln weiter unten allesamt am Peer der Verbindung hängen — sie sind genau so stark wie die Identität darunter.

Legt ein Peer also ein Zertifikat vor, muss die im hello beanspruchte Adresse eine sein, für die das Zertifikat bürgt. Ein Anspruch wird akzeptiert, wenn CN oder ein SAN eines von beidem abdeckt:

  • den Host der Adresse — der Normalfall, in dem das Zertifikat jedes Knotens seinen eigenen Hostnamen oder seine IP trägt, oder
  • das vollständige systemName@host — für Deployments, die eine Identität pro Knoten ausstellen und die engere Bindung wollen.

Wildcards gelten für den Host, nur im äußersten linken Label — genau wie bei der TLS-Hostnamensprüfung.

Für einen Cluster ohne lesbares Peer-Zertifikat ändert sich nichts: einfaches TCP, einseitiges TLS und ein Deno-Listener (der gar keins melden kann) verhalten sich exakt wie vorher. Die Prüfung stärkt mTLS-Deployments, statt einen Schalter hinzuzufügen, den man vergessen kann.

Drei Konfigurationen werden beim Bind abgelehnt, statt in einem schwächeren Zustand zu starten, als sie sich lesen:

  • Ein unvollständiges Server-Credential — cert ohne key, key ohne cert, oder ein tls-Objekt, das keines von beidem trägt (eine ca allein sagt, welchen Peers beim Wählen zu trauen ist; dem Listener gibt sie nichts zum Vorzeigen). Leer zählt als nicht gesetzt — genau so sieht eine nicht gesetzte Umgebungsvariable oder ein falsch gemountetes Secret aus, wenn es hier ankommt.
  • requestClientCert: true ohne ca — es gäbe nichts, wogegen Peer-Zertifikate validiert werden könnten.
  • Ein mTLS-Listener auf Deno — Deno.listenTls nimmt nur Zertifikat und Key entgegen und kann ein Client-Zertifikat weder anfordern noch prüfen; der Listener würde also niemanden authentifizieren.

Einem mTLS-Cluster beitreten kann ein Deno-Knoten sehr wohl: er präsentiert beim Wählen sein eigenes cert/key, und der Listener (auf Node.js oder Bun) authentifiziert ihn wie jeden anderen Peer. Nur das Hosten des Listeners fällt aus — halte in einem gemischten Deployment die Seed-Knoten auf Node.js oder Bun.

Ein Deno-Unterschied, den man kennen sollte: rejectUnauthorized hat dort kein Äquivalent und wird nicht abgebildet. Deno prüft die Kette immer; einen self-signed Peer erreicht man also, indem man dessen signierende CA in ca mitgibt — die Form, die hier ohnehin empfohlen wird.

Drei Ansätze:

Terminal-Fenster
openssl req -x509 -newkey rsa:4096 -keyout cluster.key -out cluster.crt -days 365 -nodes -subj "/CN=actor-ts"

Nutze dasselbe Cert + denselben Key auf jedem Node. Okay für Dev / Staging. Nicht in Produktion einsetzen.

Terminal-Fenster
# CA einmalig erstellen:
openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 3650 -nodes -subj "/CN=actor-ts-ca"
# Per-Node-Certs, signiert von der CA:
openssl req -newkey rsa:4096 -keyout node-1.key -out node-1.csr -nodes -subj "/CN=node-1"
openssl x509 -req -in node-1.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out node-1.crt -days 365

Jeder Node bekommt sein eigenes Cert; alle vertrauen der CA. Certs zu rotieren ist pro Node und erfordert kein Anfassen der CA.

Für K8s-Deployments nutze cert-manager mit einer internen CA oder HashiCorp Vault. Certs werden als Volume-Secrets gemountet; Rotation übernimmt der Cert-Manager.

# Beispiel cert-manager-Certificate-Spec:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: actor-ts-cluster
spec:
secretName: actor-ts-cluster-tls
issuerRef:
name: actor-ts-ca
kind: ClusterIssuer
commonName: actor-ts
dnsNames:
- actor-ts-cluster.svc
duration: 8760h
renewBefore: 720h

Der Pod mountet das Secret als Files; der Actor liest sie.

Cluster-Port (2552) — nur intern:
- Pods können auf 2552 mit Pods reden
- nicht via Service / Ingress exponiert
- LoadBalancer sieht ihn nie

Selbst mit TLS + Auth: exponiere den Cluster-Port eng. Eine NetworkPolicy in K8s:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: actor-ts-cluster-internal-only
spec:
podSelector:
matchLabels:
app: actor-ts
ingress:
- from:
- podSelector:
matchLabels:
app: actor-ts
ports:
- protocol: TCP
port: 2552

Nur app=actor-ts-Pods können sich gegenseitig auf Port 2552 erreichen.

BedrohungGegenmaßnahme
Netzwerk-LauschangriffTLS
Man-in-the-MiddleTLS + Zertifikatsprüfung
Unautorisierter Cluster-JoinmTLS (CA-signierte Peer-Certs)
Insider mit gestohlenem CertCert-Rotation + Revocation
Fehlerhafter oder feindlicher Wire-FrameShape-Prüfung an der Decode-Grenze
Frame in tausende Chunks zerlegt, um die Decode-Kosten zu vervielfachenLinearer Decode-Puffer — jedes Byte wird einmal kopiert, beim Eintreffen
Socket öffnet, schickt einen halben Frame und verstummtStall-Deadline auf einem unvollständigen Frame, dazu eine Obergrenze für eingehende Verbindungen
Massenhaft geöffnete Sockets, die gar nichts senden, um die Obergrenze zu füllenHandshake-Deadline auf jedem angenommenen Socket, damit kein Slot unbefristet gehalten wird
Peer lässt die Registries eines Knotens unbegrenzt wachsenObergrenzen pro Registry in PubSub und Receptionist
Adresse beansprucht, bevor der zugehörige Knoten existiertEnge Versions-Skew-Grenze auf jeder gegossipten Member-Version
Vom Draht mitgeschnittener Gossip-Frame wird später erneut gesendetSequence pro Sender — ein Frame muss den zuletzt von diesem Sender akzeptierten überbieten (teilweise: erst wenn einer akzeptiert wurde)
Gefälschte Discovery-Antwort lenkt den Bootstrap ummTLS, dazu pinnedAddresses am Seed-Provider
Kompromittierter Pod im ClusterApplication-Level-Auth (hier nicht abgedeckt)

Der Cluster-Transport handhabt Transport-Level-Sicherheit. Application-Level-Anliegen (Auth zwischen spezifischen Actors, Per-Tenant-Isolation) bleiben dein Job — der Cluster-Transport ist in sich selbst vertrauenswürdig.

Ein Seed-Provider fragt eine Gegenstelle außerhalb — DNS, die K8s-API —, mit welchen Adressen zu reden ist, und diese Antwort trifft ein, bevor irgendein Zertifikat geprüft wurde. Sie kann einem Angreifer nicht den Join verschaffen: die Adresse, die ein Peer beansprucht, muss von seinem Zertifikat gedeckt sein, ein gefälschter Seed erzeugt also eine Verbindung, die am Handshake stirbt.

Diese Garantie ist konfigurationsabhängig, und genau das gehört klar gesagt. Ein Node verlangt nur dann ein Peer-Zertifikat, wenn sein Listener so konfiguriert wurde, eines anzufordern; wo TLS aus ist oder wo ein halb konfigurierter Listener nie danach fragt, gibt es nichts, wogegen die beanspruchte Adresse geprüft werden könnte — und eine vergiftete Discovery-Antwort wird befolgt, wie sie kommt.

Pinne also, was der Resolver sagen darf:

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('actor-ts.example.com')
.withSystemName('my-app')
.withPort(2552)
.withPinnedAddresses(['10.0.0.0/8'])
.withLog((message) => logger.warn(message));

Adressen außerhalb der Liste werden nie zu Seeds. Details — einschließlich des SRV-Vorbehalts (Ziele sind dort Hostnames, Pins also Suffixe, und der spätere A-Lookup bleibt ungepinnt) — stehen auf den Seiten zum DNS Seed Provider und zum Kubernetes-API Seed Provider.

Das ist Defense-in-Depth: es senkt, was eine DNS- oder Endpoints-Kompromittierung einbringt, und es ist die einzige Kontrolle auf der Discovery-Ebene, die übrig bleibt, wenn mTLS nicht eingerichtet ist.

Frames kommen als JSON an und wurden früher auf den Protokolltyp gecastet statt gegen ihn geprüft — ein Peer konnte einem Knoten also einen Wert reichen, den der Code anschließend las, als stimmte der Typ. Jeder Frame wird jetzt geprüft, bevor ihn irgendein Handler sieht:

  • Der Frame muss ein Objekt mit einem String-kind sein. null, ein nackter String und eine Zahl werden abgewiesen — vorher war allein null ein Acht-Byte-Remote-Prozesskill, und zwar ohne abgeschlossenen Handshake.
  • Knotenadressen brauchen ein nicht-leeres systemName und host plus einen positiven ganzzahligen port. Der lohnende Fall: ein Port, der als String "2552" ankommt. Er sieht in jeder Logzeile identisch aus und keyt jede Map gleich, vergleicht sich aber nie gleich — ein Knoten, der seine eigene Adresse in dieser Form mergte, erkannte sich selbst nicht mehr wieder.
  • Der status eines gegossipten Members muss einer der sieben legalen Werte sein. Ein unbekannter erreichte ein exhaustives match, das warf — nachdem der Member schon gespeichert war. Der Knoten stürzte also ab und gossipte den vergifteten Eintrag an seine Peers weiter.
  • Ein Gossip-Batch wird als Ganzes geprüft. Ein fehlerhafter Member weist den Frame ab, damit sich ein schlechter Eintrag nicht hinter einem guten einschleichen kann.

Bewusst zwei Stufen: ein Frame, der die Prüfung nicht besteht, wird verworfen und die Verbindung bleibt stehen — ein einzelner kaputter Frame soll einen gesunden Peer nicht seine Leitung kosten. Ein Handler, der wirft, kappt die Verbindung: das ist der Fall, den niemand versteht, und er darf nicht in den Socket-Callback des Runtimes entkommen.

Frame-Kinds, die Extensions registrieren (Sharding, PubSub, Receptionist, DistributedData, DevTools), passieren diese Schicht und prüfen ihr eigenes Payload selbst. Replicated Event Sourcing ebenso, dessen Event-Envelopes über PubSub laufen statt über einen eigenen Frame-Kind — siehe was eine Replica ihren Peers glaubt.

Validierung ist nur die Hälfte. Ein Frame kann völlig wohlgeformt sein und trotzdem für einen Node sprechen, der ihn nicht gesendet hat, und diese Fehlerklasse wird in dieser Codebase überall gleich geschlossen: die Identität kommt aus der Verbindung, nicht aus dem Payload. Ein leave darf nur den Node abmelden, der es gesendet hat; Gossip ersetzt den Beitrag des sendenden Peers und den sonst niemandes; eine Sharding- oder Singleton-Direktive wird nur befolgt, wenn sie vom Envelope-Handler des jeweiligen Pfads eingepackt ankam — der einzigen Stelle, an der die authentifizierte Adresse existiert; und die replica eines replizierten Events wird gegen den Node gehalten, der es publiziert hat. Ein Frame, der einen Actor stattdessen über die generische Pfadauflösung erreicht — die nichts entfernt, aber auch keinen Sender mitführt —, gilt als nicht authentifiziert und nicht als vertrauenswürdig.

Der Zustand eines CRDTs ist die eine Stelle, an die diese Regel nicht reicht. DistributedData prüft ein dekodiertes Payload auf Form und Plausibilität: ein Counter-Slot muss eine nichtnegative Ganzzahl höchstens MAX_COUNTER_SLOT sein (≈ 2,2e12 — der größte Wert pro Slot, bei dem value() eines vollen Counters noch eine exakte Ganzzahl ist), ein Register-Timestamp darf höchstens fünf Minuten vor der lokalen Zeit liegen, eine Collection ist auf 4 096 Einträge gedeckelt, und ein __proto__-Key wird abgewiesen. Was es nicht prüft, ist, ob der sendende Peer überhaupt etwas in dem Slot zu suchen hatte, den er beschrieben hat. Grow-only-Counter mergen per Maximum, also kann jeder Peer, der gossipen darf, jeden Replika-Slot innerhalb dieser Schranken anheben — der besitzende Node bekommt ihn nicht wieder herunter, der Wert wandert in den durablen Record, und delete(key) ist Best-Effort, weil ein Peer, der den Key noch hält, ihn zurückgossipt. Ein DistributedData-Counter ist damit genau so vertrauenswürdig wie die Menge der Peers, die du auf die Leitung lässt — was das mTLS-Setup oben für einen Quota- oder Billing-Counter zur Voraussetzung macht statt zur Härtungsmaßnahme.

Jede Abweisung wird auf WARN mit Peer und beanstandetem Feld geloggt, damit ein verworfener Frame diagnostizierbar bleibt — eine Versionsabweichung und ein feindlicher Peer sehen im Log unterschiedlich aus.

Die Prüfung läuft auf einem ganzen Frame, und ein ganzer Frame muss aus so vielen TCP-Chunks zusammengesetzt werden, wie der Sender ihn zerlegt hat. Der Peer wählt diese Zerlegung, und das war früher die teure Hälfte: der Decoder baute seinen Akkumulator bei jedem Chunk neu auf, sodass das Zusammensetzen eines Frames quadratisch in der Chunk-Anzahl kostete. Ein Frame knapp unter der 16-MiB-Grenze, geliefert in TCP-großen ~1400-Byte-Writes, sind rund 12 000 Chunks und ≈ 100 GB Speicherkopieren — etwa 6000-fache Verstärkung auf Bytes, die der Angreifer nie senden musste, und erreichbar vor dem hello-Gate, also ganz ohne Mitgliedschaft. Der Decoder hängt jetzt an einen Puffer an, den er durch Verdoppeln wachsen lässt: jedes Byte wird beim Eintreffen einmal kopiert, die Verstärkung ist 1×.

Drei Grenzen decken ab, was ein unauthentifizierter Socket während der Verbindung noch halten darf:

  • Ein unvollständiger Frame darf 30 Sekunden liegen, ohne dass ein weiteres Byte eintrifft, dann wird die Verbindung geschlossen. Das ist eine Stall-Grenze, kein Budget für den Frame: die Deadline wird bei jedem Chunk neu gestellt, ein Peer mit einem großen Frame auf einer überlasteten Leitung wird also nie für Langsamkeit bestraft — nur fürs Verstummen.
  • Der Handshake selbst hat 5 Sekunden, gerechnet ab dem Annehmen des Sockets; ein angenommener Socket, der bis dahin kein hello geschickt hat, wird geschlossen. Genau das deckt ein unvollständiger Frame nicht ab: ein Socket, der gar keine Bytes sendet, steckt in keinem Frame fest, also wird an ihm auch nichts verfolgt. Es sind dieselben 5 Sekunden, die sich die wählende Seite selbst gibt, und deren Uhr läuft früher los — vor dem TCP-Connect und dem TLS-Handshake — ein Peer, der es noch versucht, hat also immer schon vorher aufgegeben. Sobald das hello da ist, ist die Deadline weg: ein etablierter Peer zwischen zwei Gossip-Runden ist von Natur aus untätig und wird dafür nie geschlossen.
  • Eingehende Verbindungen sind auf 1024 begrenzt. Ein voll vermaschter Cluster braucht eine pro Peer, die Grenze liegt also weit über jeder realistischen Topologie; was sie nimmt, ist die Möglichkeit, die Kosten pro Verbindung durch das Öffnen von Sockets in einer Schleife zu vervielfachen. Die neueste Verbindung wird abgewiesen, statt eine etablierte zu verdrängen — Verdrängung ließe einen Angreifer echte Peers vom Knoten stoßen.

Die zweite und die dritte gehören zusammen. Eine Obergrenze ist nur so viel wert wie der Durchsatz ihrer Slots, und ohne Handshake-Deadline wird die Obergrenze selbst zum Angriff: 1024 Verbindungen, die nichts senden, halten jeden Slot für die Lebensdauer des Prozesses, und jeder Peer und jeder ClusterClient danach wird abgewiesen. Auf Bun ist das sogar unter mTLS erreichbar, denn der open-Callback eines Sockets feuert bevor der TLS-Handshake fertig ist — der Slot wäre belegt, während es noch gar kein Zertifikat zu prüfen gibt.

Die erste und die dritte begrenzen den eingehenden Decode-Speicher überhaupt erst: als Produkt der beiden, wobei der Term pro Verbindung schon durch actor-ts.remote.max-frame-bytes gedeckelt ist. Diesen Key zu senken ist auch der Hebel für ein Deployment, das nie große Envelopes schickt — die residenten Kosten pro Verbindung folgen ihm direkt.

Was ein wohlgeformter Frame trotzdem nicht wachsen lassen kann

Abschnitt betitelt „Was ein wohlgeformter Frame trotzdem nicht wachsen lassen kann“

Die Shape-Prüfung sagt, dass ein Frame lesbar ist — nicht, dass es umsonst ist, auf ihn zu reagieren. PubSub-Gossip ist das saubere Beispiel: ein Peer, der 100 000 Topics nennt, für die er Subscriber beansprucht, schickt einen völlig legalen Frame, und der Empfänger legte dafür früher 100 000 Map-Einträge an — kein lokales Subscribe, kein fehlerhaftes Feld, keine Logzeile. Der Receptionist hatte auf seinem eigenen Gossip-Pfad dieselbe Form.

Die Mitgliedschaft selbst hatte dieselbe Form — und sie ist die eine Registry, in die niemand erst hineinoptieren muss: jeder geclusterte Knoten hält eine Mitgliederkarte, und Gossip ist das, was sie füllt. Die Regeln weiter unten entscheiden, ob ein Anspruch glaubhaft ist; keine von ihnen begrenzte, wie viele glaubhafte Ansprüche ein Peer erheben darf. Ein Sender, der seine eigene Adresse ankündigt, wird bewusst durchgewunken — das zu verweigern hieße, dass kein Knoten je joinen könnte — also legte eine frisch benannte Adresse pro Frame einen Eintrag pro Namen an.

Diese Registries sind jetzt begrenzt, und die Grenzen gelten für den Gossip-Pfad genauso wie für lokale Aufrufe:

RegistryBegrenzt durchDefault
Lebende Cluster-Mitgliedercluster.max-members1000
removed-Tombstonescluster.max-tombstones10000
PubSub-Subscriber pro Topiccluster.pub-sub.max-subscribers-per-topic10000
PubSub-Topicscluster.pub-sub.max-topics10000
PubSub-Remote-Ansprüche pro Topiccluster.pub-sub.max-remote-nodes-per-topic1000
Receptionist-Subscriber pro Keycluster.receptionist.max-subscribers-per-key1000
Receptionist-Subscriptions insgesamtcluster.receptionist.max-subscriptions-total10000

Ein Anspruch über einer Grenze wird verworfen und geloggt, statt auf der Wire abgewiesen zu werden — der Frame ist wohlgeformt, und die Verbindung deswegen zu kappen ließe einen lauten Peer eine gesunde Leitung kosten. Ein lokales Subscribe über einer Grenze wird stattdessen mit SubscribeRejected beantwortet, denn der Aufrufer kann etwas dagegen tun. Beide PubSub-Registries watchen außerdem ihre Subscriber, sodass die andere Hälfte des alten Wachstums — Refs, die stoppten, ohne je zu unsubscriben — zurückgewonnen statt begrenzt wird.

Senke die Defaults für ein Deployment, dessen legitime Zahlen weit darunter liegen: eine Obergrenze begrenzt nur den Schaden, und eine weit über der echten Nutzung begrenzt sehr wenig.

Warum die Mitgliedschaft zwei Grenzen braucht — und warum die zweite die eigentliche ist

Abschnitt betitelt „Warum die Mitgliedschaft zwei Grenzen braucht — und warum die zweite die eigentliche ist“

Mitglieder und Tombstones zu trennen ist keine Ordnungsliebe. Ein Phantom-Mitglied in up / joining / unreachable ist ein Mitglied, das der Failure Detector beobachtet: es wird failure-detector.down-after nach dem letzten Nachschub gedownt und gelöscht — im Default fünf Sekunden. Ein als removed gegossipter Eintrag wird von nichts beobachtet; nur cluster.tombstone.time-to-live holt ihn zurück, einen Tag später. Die Flut, die bleibt, ist also die Tombstone-Flut, und max-tombstones ist die Grenze, die die Arbeit macht. Einen solchen Eintrag abzulehnen kostet auch nichts: ein Tombstone für eine Adresse, zu der dieser Knoten keinen Eintrag hat, unterdrückt nichts, was existiert — während ein abgelehnter lebender Eintrag ein legitimes Mitglied eine Gossip-Runde kostet und mit einem WARN samt Namen der Grenze kommt.

Beide Grenzen werden auf den Topf abgerechnet, in den ein Record wandert, nicht darauf, ob er einen Eintrag neu anlegt. Ein gegossipter Record, der einen Eintrag dort lässt, wo er schon ist — up → unreachable, oder ein neuerer Tombstone über einen älteren —, ist ein kostenloses In-Place-Update. Einer, der zwischen den Töpfen wechselt, wird dem Topf angerechnet, den er betritt: ein als up wiedergeborener Tombstone braucht Platz bei den lebenden Mitgliedern, ein als removed gegossiptes lebendes Mitglied braucht Platz bei den Tombstones. Nur das Anlegen zu berechnen ließ die beiden Grenzen kostenlos Spielraum untereinander tauschen — eine Wiedergeburt räumte den Tombstone-Topf, ohne einen Map-Slot herzugeben, sodass die nächste Tombstone-Flut ebenfalls durchging; im Wechsel wuchs die Map unbegrenzt, obwohl beide Grenzen bei jedem einzelnen Schritt eingehalten wurden. Eine abgelehnte Umwandlung lässt das Mitglied lebendig, wo der Failure Detector es auf dem langsameren Weg einsammelt.

Tombstones, die der Knoten selbst erzeugt — das leave eines Peers, eine Downing-Entscheidung, ein down() des Operators — wandeln einen Eintrag um, den er ohnehin schon hält, und unterliegen der Grenze nie. Die eigene Buchführung zu begrenzen würde die Unterdrückung wegnehmen, die verhindert, dass altes Gossip eine geräumte Adresse wiederbelebt — ein Liveness-Bug im Kostüm eines Security-Fixes.

Die Decke, die man kennen sollte, ist nicht der Heap. Gossip trägt die vollständige Mitgliederliste, also überschreitet der eigene Frame eines Knotens bei rund 110 000 Einträgen remote.max-frame-bytes, und jeder Peer beendet die Verbindung schon am Längen-Präfix — der Knoten wirft sich selbst aus dem Cluster, während er noch läuft, lange bevor irgendwo der Speicher ausgeht.

Die Shape-Prüfung zu bestehen macht einen Frame noch nicht glaubwürdig. Gossip-Merges entschied früher allein die Versionsgröße, und Versionen werden aus Date.now() gesät — ein Angreifer konnte sich also immer eine Gewinnerzahl aussuchen und jeden Member-Status umschreiben, auch den des empfangenden Knotens selbst. Vor dem Merge stehen jetzt zwei Regeln:

  • Niemand stuft uns herab. Eine Aussage über die eigene Adresse dieses Knotens wird abgewiesen. Die einzige Ausnahme ist die Beförderung von joining/weakly-up nach up — die muss von außen kommen, weil sie die Entscheidung des Leaders ist, und sie anzunehmen ist harmlos, denn ein joinender Knoten will ohnehin up werden. Alles andere über den eigenen Record entscheiden wir selbst.
  • Aussagen über Dritte brauchen einen Absender mit Standing. Etwas über einen anderen Knoten zu behaupten setzt voraus, dass der Peer der Verbindung ein Member ist, den dieser Knoten bereits als aktiv führt. Seinen eigenen Record darf ein Absender immer ankündigen — so funktioniert der Join.

Beide Regeln hängen am Peer der Verbindung, nicht am from-Feld des Payloads: genau dieses Feld kontrolliert ein Angreifer vollständig. Dieselbe Überlegung deckt drei Nachbarn mit ab. Ein leave wird nur vom scheidenden Knoten selbst akzeptiert. Ein Heartbeat frischt den Failure Detector für den sendenden Peer auf statt für die Adresse, die er nennt — das ließ vorher einen Peer einen toten Knoten dauerhaft gesund erscheinen und brachte den Empfänger dazu, einen angreiferbestimmten Host anzuwählen. Und ein ClusterClient-ask wird über die Verbindung beantwortet, auf der das Envelope ankam: auf der Payload zu routen ließ jede verbundene Gegenstelle die Adresse wählen, an die dieser Knoten antwortete und zu der er eine Verbindung aufbaute.

Das Client-Envelope trägt überhaupt kein Absenderfeld mehr — nach der obigen Regel hatte es genau einen richtigen Wert, und zwar den, den der Empfänger ohnehin schon hat. Ein Envelope, das weiterhin eine andere Adresse als seine eigene Verbindung nennt, wird auf cluster_envelope_from_mismatch_total{frame} gezählt und sonst ignoriert; siehe ClusterClient.

Unerreichbarkeit ist bewusst nicht von diesen Regeln erfasst: „Ich erreiche C nicht” ist naturgemäß eine Drittbeobachtung, und alle Knoten müssen auf dieselbe Sicht konvergieren, bevor ein Downing-Provider entscheidet. Solche Aussagen abzuweisen ließe jedem Knoten nur sein eigenes Erreichbarkeitsbild.

Eine Sharding-Direktive braucht den Koordinator dahinter

Abschnitt betitelt „Eine Sharding-Direktive braucht den Koordinator dahinter“

Dieselbe Überlegung reicht eine Schicht höher, in die Extensions, deren Frames die Wire-Kante passieren und ihre Payload selbst validieren. Eine ShardRegion behandelte jede Nachricht, deren kind mit sharding. begann, als Framework-Direktive — inklusive der fünf, die ausschließlich der Koordinator aussprechen darf. HandOff stoppt jede Entity unter einem Shard; ShardHome verschiebt den Besitz; RememberedEntities legt Entities vorab an; ShardMapUpdate veröffentlicht eine Allokationskarte an jeden lokalen Subscriber, DevTools-Panel und Anwendungs-Listener inklusive. Ein kleiner Frame von jedem, der hello abgeschlossen hatte, löste das aus — beliebig oft.

Die Region konnte gar nicht prüfen: Sharding registrierte keinen pfadgebundenen Envelope-Handler, ein eingehender Frame erreichte den Actor also über die generische Pfadauflösung, und die stellt ohne jeden Absender zu. Die Region beansprucht jetzt ihren eigenen Pfad im Envelope-Router, der dem Handler den Peer der Verbindung übergibt, und verpackt den Frame in eine Klasseninstanz, bevor sie ihn in die eigene Mailbox legt — eine Form, die ein JSON-Wire-Body nicht erzeugen kann, sodass die Direktiven-Arme einen geroutet zugestellten Frame von einem gefälschten unterscheiden können. Ein getaggtes { kind }-Objekt hätte nicht gereicht: Das lässt sich aus einer Payload wortgleich nachbilden.

Beide Hälften tragen. Ohne die Wrapper-Prüfung verfehlt ein nicht-kanonisch adressierter Frame — ein Slash am Ende, ein doppelter Trenner — den exakten String-Lookup des Handlers, löst sich über den Actor-Baum trotzdem auf dieselbe Region auf und kommt unverpackt an. Und ohne die Herkunftsprüfung ist ein authentifizierter Peer schlicht jedes Cluster-Mitglied, sodass jedes Mitglied jeder Region Direktiven erteilen könnte.

Verworfene Frames werden gedroppt und auf WARN geloggt. Der lokale Zweig des Koordinators baut denselben Wrapper, ein Ein-Node-Cluster rebalanciert also genau wie zuvor.

Eine Region kann nur für ihren eigenen Node sprechen

Abschnitt betitelt „Eine Region kann nur für ihren eigenen Node sprechen“

Dieselbe Lücke lief auch in die andere Richtung, und dort war sie teurer. Jede Nachricht, die ein ShardCoordinator annimmt, ist eine Aussage über den eigenen Node des Absenders: welche Shards seine Region hostet, dass seine Region weg ist, dass ein Hand-off fertig ist, wohin ein Shard-Home oder eine Statistik-Antwort geht. Der Koordinator las das alles aus der Payload. Sein einziges Gate war isLeader() — plus, mit Lease, das gehaltene Lease — und das beantwortet „bin ich der autoritative Koordinator?”, nie „darf dieser Absender für jene Region sprechen?”.

Ein einziges wohlgeformtes sharding.Register mit der Adresse eines anderen riss damit jeden Shard eines Typs an sich, und ein sharding.RegionTerminated warf dessen Region hinaus. Die Räumung ist die schlimmere Hälfte: Sie verschickt kein HandOff, das Opfer behält seine Shard-Actors und seine Entities also im Betrieb. Ergebnis sind zwei Besitzer für einen lebenden Shard — dieselbe Entity-Id zweimal instanziiert und, bei einer persistenten Entity, zwei Schreiber auf einer persistenceId.

Die Herkunftsprüfung der Region oben mildert das nicht, denn der Angreifer schickt gar kein ShardHome. Er vergiftet die Allokationskarte des Koordinators mit einem gefälschten Frame, und der echte Koordinator verschickt die Umleitung dann selbst — vom Node des Leaders, in einem Wrapper, den jede Region zu Recht annimmt. Eine Fälschung, jeder weitere Hop echt.

Der Koordinator beansprucht jetzt seinen eigenen well-known Pfad im Envelope-Router und wendet dieselben zwei Bedingungen an: Der Frame kam im Wrapper an, und die Adresse in seiner Payload ist die des Peers. Beide Richtungen der Register-Schleife sind damit attribuiert — was auch korrigiert hat, wie eine Region den Koordinator adressiert: über den Systemnamen des Leaders, nicht über den eigenen, denn ein Actor-Pfad trägt das System, zu dem er gehört.

hostedShards ist obendrein begrenzt und gedeckelt. Es war die einzige Eingabe des Koordinators, deren Größe der Aufrufer bestimmte — ein Register schrieb einen Allokationseintrag pro Array-Element, ohne Bereichsprüfung und ohne Längenlimit, in Zustand, der an jede Region gesendet und in den Coordinator-State-Store persistiert wird; das Wachstum überlebte also Neustarts. Einträge außerhalb 0 .. numShards - 1 werden jetzt verworfen, und die akzeptierte Menge kann numShards nicht überschreiten.

Eine Version kann keine Adresse im Voraus beanspruchen

Abschnitt betitelt „Eine Version kann keine Adresse im Voraus beanspruchen“

Die Version ist eine logische Uhr, gesät aus Date.now() — und „höchste Version gewinnt” entscheidet damit auch, was beim ersten Auftauchen einer Adresse überhaupt passiert. Genau das machte eine Adresse beanspruchbar, bevor der Knoten existiert, dem sie gehört. Ein Fremder kündigt sich unter der Adresse an, die der nächste Pod bekommen wird — den eigenen Record anzukündigen ist die eine Aussage, die die Regeln oben nie abweisen —, datiert sie dicht an die 24-h-Skew-Grenze und hängt beliebige Rollen daran. Die Beförderungsschleife des Leaders hebt den Record in die aktive Menge, und der Knoten, dem die Adresse wirklich gehört, verliert danach jeden Merge: er sät seine Version aus der eigenen Uhr, und die ist niedriger. Aus Rollen werden Routing, Sharding-Platzierung, Singleton-Hosting und Downing-Quoren berechnet — das Phantom ist also keine kosmetische Zeile in einer Member-Liste.

Eine gegossipte Member-Version unterliegt deshalb einem engen Uhrabweichungs-Budget — standardmäßig 5 Minuten:

const clusterOptions = ClusterOptions.create()
.withHost('10.0.0.5')
.withPort(2552)
.withMaxVersionSkewMs(30 * 60 * 1000);

Das Budget gilt für jeden Merge, nicht nur für den Record, der eine Adresse einführt. Ursprünglich war es die engere Regel — enge Grenze bei der Erstsichtung, großzügige 24 h bei jedem Update —, und diese Aufteilung ließ sich umgehen, indem man die Adresse zuerst einführte: zwei Records für dieselbe Adresse in einem Frame, oder ein Frame ganz ohne Member-Records, der den Empfänger trotzdem die Absenderadresse eintragen lässt. Jede Regel, die einen Record das weitere Budget verdienen lässt, scheitert einen Schritt später genauso — ohne Peer-Zertifikate kann der Angreifer jeden Schritt des Verdienens selbst erzeugen.

Erhöhe den Wert für ein Deployment, dessen Uhren bekanntermaßen lose laufen; 24 * 60 * 60 * 1000 stellt das alte Verhalten mit einer einzigen Grenze wieder her. Eine Abweisung ist kein Ausschluss — ein Knoten, der sich selbst ankündigt, wird trotzdem erfasst, mit Version 1 und ohne Rollen —, aber sie ist dauerhaft: Ein Knoten, dessen Uhr weiter vorgeht als das Budget, bleibt ohne Rollen in der Member-Liste, bis seine Uhr zurückkommt. Das war immer schon das Urteil dieser Grenze über einen solchen Knoten; geändert hat sich, dass es jetzt bestehen bleibt, statt vom zweiten Gossip-Frame des Knotens aufgehoben zu werden.

Abgewiesene Records werden einmal pro Frame gemeldet, nicht einmal pro Record — eine WARN-Zeile mit Peer und Anzahl, dazu der Zähler cluster_gossip_records_refused_total{reason}, dessen Label reason auf version-skew, map-cap, timestamp-skew, replayed-frame und self-claim geschlossen ist. Pro Record zu loggen würde einem Peer Log-Verstärkung schenken statt des Wachstums, das er gerade verloren hat.

self-claim ist die Regel von oben, aus Sicht des Zählers: ein Peer behauptet einen Status für den empfangenden Knoten, der weder der bereits gehaltene ist noch die Beförderung nach up durch den Leader. Dass ein Peer den Status zurückspiegelt, den der Knoten tatsächlich hält, ist der gewöhnliche Inhalt jeder Gossip-Runde — der eigene Record kommt in jedem Frame zurück —, und genau der wird abgewiesen, ohne gezählt oder geloggt zu werden. In einem gesunden Cluster steht der Zähler damit weiterhin auf null.

Ein mitgeschnittener Frame lässt sich nicht bei einem Empfänger abspielen, der von seinem Absender gehört hat

Abschnitt betitelt „Ein mitgeschnittener Frame lässt sich nicht bei einem Empfänger abspielen, der von seinem Absender gehört hat“

Ein Gossip-Frame ist eine Momentaufnahme der Member-Map, und die Version eines Members bewegt sich nur, wenn sich sein Status bewegt — ein vom Draht mitgeschnittener Frame bleibt also unbegrenzt gültig. Gegen einen konvergierten Empfänger kostet das nichts: jeder Record darin verliert den Vergleich „höhere Version gewinnt” gegen den Record, der schon da ist. Zum Exploit wird es bei einem Eintrag, den der Empfänger gelöscht hat. Der Down-Pfad des Failure Detectors löscht rundheraus, statt einen Tombstone zu hinterlassen, damit eine Partition nach dem Heilen den Peer wiederfinden kann — und ein abgelaufener Tombstone wird aus demselben Grund entfernt. So oder so bleibt nichts zum Vergleichen übrig, und der Zweig, der eine Erstsichtung ablegt, hat überhaupt keine untere Versionsgrenze.

Das Wiedereinspielen des eigenen Records eines heruntergefahrenen Members brachte ihn also zurück: gleiche Adresse, gleiche Version, up, mit den Rollen, die er hatte. Shard-Platzierung und Singleton-Hosting richten sich nach den Rollen, der Cluster schickt also Arbeit an einen Knoten, den er verstoßen hatte.

Jeder Gossip-Frame trägt deshalb eine Sequence, die sein Autor stempelt — beim Start aus dessen Wanduhr gesetzt und pro Frame um eins erhöht — und ein Empfänger merkt sich die höchste, die er von jedem Verbindungs-Peer akzeptiert hat. Ein Frame, der diese Marke nicht überbietet, wird als Ganzes verworfen, bevor ein einziger Record darin angesehen wird. Der Vergleich selbst braucht keinen Stellknopf: verglichen wird ein Peer mit sich selbst, damit kommt kein Uhren-Skew-Budget ins Spiel.

Die Marke wird an genau einer Stelle geschrieben — im Gossip-Pfad, während er einen Frame annimmt — sie existiert also für einen Peer, von dem dieser Knoten einen Frame angenommen hat, und für keine andere Adresse. Das ist die Grenze, und das dritte Restrisiko unten ist das, was außerhalb davon liegt.

Eine Sequence muss außerdem plausibel sein — eine endliche Zahl, die nicht weiter vor der Uhr des Empfängers liegt als maxVersionSkewMs, dasselbe Budget, das eine gegossipte Version bekommt, denn die Zahl wird aus der Wanduhr des Autors gestempelt. Ein Frame außerhalb dieses Budgets wird abgewiesen, genau wie eine Wiederholung.

Angefangen hat das als die schwächere Regel: der Frame wurde gemerged, und nur das Übernehmen seiner Zahl als Marke wurde verweigert — mit dem Argument, ein Frame mit Number.MAX_SAFE_INTEGER könne kein Mitschnitt eines echten Frames sein. Gefälscht ist bei diesem Angriff aber nur die Sequence. Das members-Array ist weiterhin der Mitschnitt, und nichts auf diesem Draht bindet beides aneinander — ein mitgeschnittener Frame mit einem umgeschriebenen Feld wurde also gemerged, ließ die Marke unangetastet und wurde deshalb bei jeder Zustellung erneut gemerged, unbegrenzt, gegen einen Empfänger, der eine Marke für einen noch lebenden Absender hielt. Ihn abzuweisen kostet nichts: die Marke bleibt dort, wo der letzte plausible Frame sie hinterlassen hat, der nächste Frame des echten Knotens überbietet sie also weiterhin und landet. Den echten Knoten für immer verstummen zu lassen — der Exploit, den die Versionsgrenze oben verhindert — ist damit von beiden Seiten geschlossen statt von einer.

Eine Adresse trägt zusätzlich eine Inkarnation: welcher Prozess unter diesem system@host:port antwortet, einmal pro Cluster.join geprägt. Sie wird bewusst mitgeführt und noch nicht ausgewertet. Das Feld ist auf dem Draht optional, damit ein Knoten jeder der beiden Versionen einen der anderen versteht — und ein optionales Feld umgeht man durch Weglassen, eine auf eine Abweichung gestützte Ablehnung wäre also eine, aus der ein Angreifer aussteigt und in die ein legitimer Peer der Vorversion hineinläuft. Das Feld verpflichtend zu machen bricht alle acht adresstragenden Frame-Felder auf einmal und wartet auf die Versionierung des Wire-Protokolls. Aus toString, equals und compareTo bleibt es heraus, an der Identität eines Knotens ändert sich also nichts.

Der eine Vergleich, der keine Einigung braucht, wird gezogen: Ein Record, den ein Peer über diesen Knoten schickt, behält die eigene Inkarnation dieses Knotens. Die Beförderung durch den Leader ist die einzige Aussage über sich selbst, die ein Knoten annimmt, und sie wird als Ganzes gemerged, Adresse inklusive — ohne diese Ersetzung wäre der Identifikator des lokalen Records also das, was der letzte befördernde Peer gerade behauptet hat.

Drei Dinge schließt das nicht. Erstens einen Peer, der sich Standing erworben hat und einen frischen Frame komponiert, der eine gelöschte Adresse mit ihrer alten Version nennt: Das ist eine Fälschung, kein Replay, und um sie abzuweisen, müsste die Inkarnation verpflichtend sein statt nur mitgeführt — also genau der Wire-Bruch von oben.

Zweitens lässt eine fehlende Marke alles zu, und es gibt drei Wege, keine zu haben — nur einer davon ist die Evakuierung des Absenders selbst. Ein gelöschtes Member verliert seine Marke, aus dem Grund von oben — und eine gewöhnliche Partition trifft Absender und Subjekt gemeinsam, dieser Weg kommt also gebrauchsfertig. Ein frischer oder neu gestarteter Prozess hat von Anfang an keine. Und ein Member, von dem man über Dritte erfahren hat, hatte noch nie eine: Gossip ist epidemisch, ein Knoten legt C also auf Bs Wort hin als up ab und hat trotzdem noch nie einen Frame von C gesehen. Im letzten Fall ist der Absender die ganze Zeit volles Member und nirgends wurde etwas evakuiert — deshalb lässt sich dieser Schutz nicht so beschreiben, dass er gilt, „solange sein Absender Member ist”. Genau das stand vorher in diesem Abschnitt sowie in den Changelog- und Roadmap-Einträgen, und es war falsch.

Einen Frame von einem Peer ohne Marke abzuweisen ist nicht die fehlende Prüfung: Der erste Frame jedes Peers ist einer, ein Empfänger, der sie abweist, würde also nie erfahren, dass ein Cluster existiert. Was ein Mitschnitt aus einer leeren Marke zieht, ist ein Bootstrap über zwei Frames — der erste kauft Standing und setzt die Marke, aus seiner eigenen mitgeschnittenen Zahl, und der zweite überbietet sie und spricht für das Subjekt. Gegen ein über Dritte erfahrenes Member geht es noch kürzer, weil das Standing schon da ist und ein Frame genügt.

Drittens, und das ist der Grund, warum die beiden oben offen bleiben: Nichts, was sich auf den eigenen Zähler des Absenders stützt, kann einen Mitschnitt von einem lebenden Frame trennen — beide wurden von demselben Zähler gestempelt. Was sie trennt, ist, welcher Prozess sie abgeschickt hat, und die einzige für den Empfänger prüfbare Aussage darüber ist die Inkarnation. Die muss verpflichtend sein, bevor eine Ablehnung darauf ruhen kann, und das wartet auf die Versionierung des Wire-Protokolls.

Verpflichtend würde sie einen Mitschnitt einer früheren Inkarnation rundheraus abweisen — das ist der gewöhnliche Restart-Fall und der Großteil der Exposition. Zwei Kanten blieben auch dann, und sie sind es wert, genannt zu werden, damit niemand mehr erwartet: Ein Knoten, der heruntergefahren wurde, während er noch läuft, antwortet unter derselben Inkarnation, die der Mitschnitt trägt; und ein frisch gestarteter Empfänger hat keine frühere Inkarnation des Subjekts, gegen die er eine Erstsichtung vergleichen könnte, ein Record über ein Member, von dem er noch nie gehört hat, bleibt also zulässig. In einem korrekt konfigurierten mTLS-Cluster ist keine der beiden Kanten von außen erreichbar — hello ist an das Peer-Zertifikat gebunden — die Exposition sind also Plaintext- und Server-only-TLS-Deployments plus ein kompromittierter Peer.

Alle drei Restrisiken werden separat verfolgt.

production.ts
import { TcpTransport, Cluster, ClusterOptions } from 'actor-ts/cluster';
const tlsOptionsType = {
cert: fs.readFileSync(process.env.TLS_CERT_PATH!),
key: fs.readFileSync(process.env.TLS_KEY_PATH!),
ca: fs.readFileSync(process.env.TLS_CA_PATH!),
rejectUnauthorized: true,
};
const transport = new TcpTransport(self, log, tlsOptionsType);
const clusterOptions = ClusterOptions.create()
.withHost(host)
.withPort(port)
.withSeeds(seeds)
.withTransport(transport);
await Cluster.join(system, clusterOptions);

Env-Vars tragen Pfade; cert-manager / Vault mounten die Files. Code bleibt umgebungs-generisch.