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.
Zwei Anliegen
Abschnitt betitelt „Zwei Anliegen“| Anliegen | Wie 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.
TLS aktivieren
Abschnitt betitelt „TLS aktivieren“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 voncasigniert ist.requestClientCert— ob der Listener von dem, der sich verbindet, ein Zertifikat verlangt. Du setzt das normalerweise nicht: es isttrue, sobaldcagesetzt 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.
Das Zertifikat entscheidet auch, welcher Knoten
Abschnitt betitelt „Das Zertifikat entscheidet auch, welcher Knoten“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 —
certohnekey,keyohnecert, oder eintls-Objekt, das keines von beidem trägt (einecaallein 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: trueohneca— es gäbe nichts, wogegen Peer-Zertifikate validiert werden könnten.- Ein mTLS-Listener auf Deno —
Deno.listenTlsnimmt 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.
Zertifikatsmanagement
Abschnitt betitelt „Zertifikatsmanagement“Drei Ansätze:
Self-signed für Entwicklung
Abschnitt betitelt „Self-signed für Entwicklung“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.
Private CA für Produktion
Abschnitt betitelt „Private CA für Produktion“# 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 365Jeder Node bekommt sein eigenes Cert; alle vertrauen der CA. Certs zu rotieren ist pro Node und erfordert kein Anfassen der CA.
cert-manager / Vault für K8s
Abschnitt betitelt „cert-manager / Vault für K8s“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/v1kind: Certificatemetadata: name: actor-ts-clusterspec: secretName: actor-ts-cluster-tls issuerRef: name: actor-ts-ca kind: ClusterIssuer commonName: actor-ts dnsNames: - actor-ts-cluster.svc duration: 8760h renewBefore: 720hDer Pod mountet das Secret als Files; der Actor liest sie.
Cluster-Port mit Firewall absichern
Abschnitt betitelt „Cluster-Port mit Firewall absichern“Cluster-Port (2552) — nur intern:- Pods können auf 2552 mit Pods reden- nicht via Service / Ingress exponiert- LoadBalancer sieht ihn nieSelbst mit TLS + Auth: exponiere den Cluster-Port eng. Eine NetworkPolicy in K8s:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: actor-ts-cluster-internal-onlyspec: podSelector: matchLabels: app: actor-ts ingress: - from: - podSelector: matchLabels: app: actor-ts ports: - protocol: TCP port: 2552Nur app=actor-ts-Pods können sich gegenseitig auf Port 2552
erreichen.
Bedrohungsmodell
Abschnitt betitelt „Bedrohungsmodell“| Bedrohung | Gegenmaßnahme |
|---|---|
| Netzwerk-Lauschangriff | TLS |
| Man-in-the-Middle | TLS + Zertifikatsprüfung |
| Unautorisierter Cluster-Join | mTLS (CA-signierte Peer-Certs) |
| Insider mit gestohlenem Cert | Cert-Rotation + Revocation |
| Fehlerhafter oder feindlicher Wire-Frame | Shape-Prüfung an der Decode-Grenze |
| Frame in tausende Chunks zerlegt, um die Decode-Kosten zu vervielfachen | Linearer Decode-Puffer — jedes Byte wird einmal kopiert, beim Eintreffen |
| Socket öffnet, schickt einen halben Frame und verstummt | Stall-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üllen | Handshake-Deadline auf jedem angenommenen Socket, damit kein Slot unbefristet gehalten wird |
| Peer lässt die Registries eines Knotens unbegrenzt wachsen | Obergrenzen pro Registry in PubSub und Receptionist |
| Adresse beansprucht, bevor der zugehörige Knoten existiert | Enge Versions-Skew-Grenze auf jeder gegossipten Member-Version |
| Vom Draht mitgeschnittener Gossip-Frame wird später erneut gesendet | Sequence 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 um | mTLS, dazu pinnedAddresses am Seed-Provider |
| Kompromittierter Pod im Cluster | Application-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.
Woher die Seeds kommen
Abschnitt betitelt „Woher die Seeds kommen“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.
Was der Wire-Rand abweist
Abschnitt betitelt „Was der Wire-Rand abweist“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-
kindsein.null, ein nackter String und eine Zahl werden abgewiesen — vorher war alleinnullein Acht-Byte-Remote-Prozesskill, und zwar ohne abgeschlossenen Handshake. - Knotenadressen brauchen ein nicht-leeres
systemNameundhostplus einen positiven ganzzahligenport. 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
statuseines gegossipten Members muss einer der sieben legalen Werte sein. Ein unbekannter erreichte ein exhaustivesmatch, 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.
Was das Decodieren eines Frames kostet
Abschnitt betitelt „Was das Decodieren eines Frames kostet“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
hellogeschickt 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 dashelloda 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:
| Registry | Begrenzt durch | Default |
|---|---|---|
| Lebende Cluster-Mitglieder | cluster.max-members | 1000 |
removed-Tombstones | cluster.max-tombstones | 10000 |
| PubSub-Subscriber pro Topic | cluster.pub-sub.max-subscribers-per-topic | 10000 |
| PubSub-Topics | cluster.pub-sub.max-topics | 10000 |
| PubSub-Remote-Ansprüche pro Topic | cluster.pub-sub.max-remote-nodes-per-topic | 1000 |
| Receptionist-Subscriber pro Key | cluster.receptionist.max-subscribers-per-key | 1000 |
| Receptionist-Subscriptions insgesamt | cluster.receptionist.max-subscriptions-total | 10000 |
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.
Wer was sagen darf
Abschnitt betitelt „Wer was sagen darf“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-upnachup— die muss von außen kommen, weil sie die Entscheidung des Leaders ist, und sie anzunehmen ist harmlos, denn ein joinender Knoten will ohnehinupwerden. 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.
Per-Deployment-Rezept
Abschnitt betitelt „Per-Deployment-Rezept“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.
Wohin als nächstes
Abschnitt betitelt „Wohin als nächstes“- Operations-Überblick — Produktions-Readiness-Checkliste.
- TLS everywhere — TLS für HTTP + Broker + Journals.
- Master-Key-Rotation — Rotation von Data-at-rest-Verschlüsselungsschlüsseln.
- Transports — das zugrundeliegende Transport-Interface.
- Konfiguration — die HOCON-Keys für TLS-Einstellungen.
