|
Download docs/HA.md from Dibachain/DibaGuard: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/Dibachain/DibaGuard/resolve/main/docs/HA.md
- Command line
-
hf download hf://Dibachain/DibaGuard/docs/HA.md
-
curl -L -o HA.md https://huggingface.co/Dibachain/DibaGuard/resolve/main/docs/HA.md
21.6 kB
| # DibaGuard β high availability (roadmap item 12) | |
| > **Open-release copy** ([DibaAi/DibaGuard-Open](https://huggingface.co/DibaAi/DibaGuard-Open)). This document was written for the private build of DibaGuard. Numbers that involve the HTTP request model (`http_v2`) were measured with that model, which is **not** part of the open release; the open release ships the licence-clean network models described in `CLEAN_MODELS.md`. Licence statements inside this document are superseded by `DATA_LICENSES.md`, `CLEAN_MODELS.md` and the repository's `LICENSE`, `LICENSE-WEIGHTS` and `NOTICE` files. Sections of the original that were written in Persian were removed from this copy; nothing else was edited. | |
| What survives a restart, what moves with a host, and how several replicas behind one load balancer stay one guard. | |
| Everything is in `dibaguard/state.py`; tests in `tests/test_state.py`. | |
| ## 1. The pieces | |
| | piece | what it does | | |
| |---|---| | |
| | `snapshot(layer, dir)` / `restore(layer, dir)` | one versioned, integrity-checked `.npz` holding everything that matters operationally (below); atomic write (temp file + fsync + rename), keeps the newest K per replica; restore takes the newest *valid* one (a corrupted newest file falls back to the previous) | | |
| | `Snapshotter` | periodic snapshots from the sidecar (`maybe()` every `every_s`), `restore_on_start()` before serving | | |
| | `ReplicaSync` + `SharedState` | active-active: the shared enforcement view, operator lifts / allowlist, evidence federation; `FileSharedState` (a directory on shared storage, no locks) or `RedisSharedState` (optional, `pip install redis`) | | |
| | `affinity(ip, replicas)` | the replica that owns a source: rendezvous hash of its /24 (/64 for IPv6) | | |
| | `Housekeeper` / `from_env` | one background thread in the sidecar: sync, snapshots, optional `tick` | | |
| ### What a snapshot holds | |
| | part | content | depends on the model files? | | |
| |---|---|---| | |
| | `guard` | conformal calibration scores, rolling references (tier 1 / seq / HTTP, Mondrian service x age cells, per age), anomaly references, the immutable anchor, the re-calibration buffer (up to `recal_max_rows`), source and destination evidence (fast + slow CUSUM), entity risk, tier-0 stream counters per source / destination (with their remembered host:port keys), GRU states of warm sources, per-host HTTP site profiles + references, drift martingale, site pressure, global failure / scan rates, the alarm log | yes | | |
| | `layer` | per-host HTTP bootstrap and re-profile buffers, the payload-provenance ring (web request -> DB query), playbook counts, posture findings | yes (with `guard`) | | |
| | `db` | known application clients and query shapes per database, per-client volume references, the DbNet reference, decayed rows / bytes / auth-failure counters, operator-approved shapes, DB alarm log, MySQL thread map | yes (DbNet reference) | | |
| | `enforcement` | block / challenge / rate-limit decisions with **absolute** expiry times, recent audit | no | | |
| | `feedback` | operator verdicts, allowlist entries with their TTL | no | | |
| | `rules` | the operator's rules (deduplicated against rules already loaded from the policy) | no | | |
| | `allowlist` | the runtime allowlist (merged); the whole runtime policy with `policy_mode="replace"` | no | | |
| Any other non-scalar attribute of the `Guard`, the `Layer` and the `DbGuard` β state added after this module was | |
| written, e.g. the campaign trackers, the drift-guard events, the reference-hygiene counters, the layer's request-rate / | |
| user-agent / SQL-context tables β is captured by a generic encoder: dicts (order kept), sets, tuples, deques, arrays, | |
| per-key numeric tables, and instances of an explicit allowlist of DibaGuard classes (rebuilt with `__new__`, no | |
| constructor, no import by name). Scalars are treated as configuration and are not restored. Anything the encoder | |
| cannot represent is listed in `stats["not_snapshotted"]` of the snapshot result, and the round-trip test fails while | |
| that list is not empty. | |
| Bounded when huge: per-source tables keep every source in force, then up to `max_sources` (default 250 000) | |
| others β at most half the riskiest (decayed risk, fast evidence, slow evidence, warm GRU sources), the rest the most | |
| recently seen; `max_destinations` (100 000) likewise; logs keep their tail; `buffer_rows=N` trims the | |
| re-calibration buffer (`0` drops it: the next epoch then re-calibrates from the anchor + new traffic only). | |
| ### File format (schema 1) | |
| One `.npz`, always loaded with `allow_pickle=False` (a pickled member is refused, never executed β tested): | |
| * `manifest` β uint8 JSON: magic, schema, created, replica, host, **model fingerprint** (sha256 per component: | |
| tier-1 trees, sequence net, HTTP model incl. its shipped site profile, DbNet; and an id over all), **code-layout id** | |
| (feature names, horizons, memory, age edges, anomaly / destination feature indices), parts, stats, and the | |
| **digest**: sha256 β or HMAC-SHA256 when `DIBAGUARD_SNAPSHOT_KEY` is set β over the manifest itself and every | |
| other member (name, dtype, shape, bytes). | |
| * `state` β uint8 JSON: the structure; numeric tables are referenced by member name. | |
| * `keys` β uint8 JSON: one shared key table; every address / host / (host, port) is stored once and referenced by | |
| int64 index. | |
| * `a0 β¦ aN` β numeric arrays only (float64 / float32 / int64 / int8). | |
| Restore refuses: a bad digest (corrupted or tampered, including a tampered manifest), an unknown schema, a signed | |
| snapshot without the key, an unsigned one when a key is configured, a member with an object dtype, a zip that | |
| expands past 8 GB. A snapshot of **other model files or another feature layout** raises `FingerprintMismatch`; | |
| `restore(..., force=True)` then brings back only `enforcement, feedback, rules, allowlist`. | |
| `Snapshotter.restore_on_start()` does that by itself (`on_mismatch="independent"`), so blocks and operator | |
| decisions survive a model upgrade while every reference is re-learned. Restore decodes everything before it | |
| assigns anything: a failing restore leaves the layer as it was. | |
| ## 2. Deployment patterns | |
| ### 2.1 One node that restarts | |
| ```sh | |
| DIBAGUARD_SNAPSHOT_DIR=/var/lib/dibaguard/snap DIBAGUARD_SNAPSHOT_EVERY_S=60 \ | |
| python -m dibaguard.llm http --models models --policy policy.json # (needs the hook in section 6) | |
| ``` | |
| On start: newest valid snapshot of this replica, expired decisions dropped, blocks put back into the host firewall | |
| for their *remaining* time. On a clean stop `Housekeeper.stop()` writes a final snapshot (RPO 0 for a clean stop). | |
| ### 2.2 Active-passive with snapshot shipping | |
| ``` | |
| VIP / DNS (keepalived, cloud LB health check) | |
| | : | |
| [active: proxy + sidecar] [passive: proxy + sidecar, not serving] | |
| | snapshot every 30-60 s | |
| +--> /var/lib/dibaguard/snap/ --rsync/scp every 30 s--> passive:/var/lib/dibaguard/snap/ | |
| (or both mount one replicated volume) | |
| ``` | |
| * Ship only complete files: snapshots are renamed into place, so `rsync --include 'dibaguard-snap-*.npz' | |
| --exclude '.*'` never copies a half-written one; the receiver verifies the digest anyway | |
| (`state.verify(path)`). | |
| * Promotion: start the passive's sidecar; `restore_on_start()` takes its own snapshots first and, when it has none, | |
| the active's (`replica` filter `None`): a moved / promoted host restores the newest snapshot of any replica. | |
| * Same model files on both nodes, or the promoted node restores only the model-independent parts. | |
| * Fencing is the VIP manager's job (keepalived / the cloud LB); DibaGuard does not elect leaders. | |
| * Set `DIBAGUARD_SNAPSHOT_KEY` on both nodes when the shipping path is not trusted. | |
| ### 2.3 Active-active behind HAProxy / nginx with source affinity | |
| Each replica is a fast path (`dibaguard-proxy` or `dibaguard-authz`) plus its own Python sidecar. The load balancer | |
| sends one source to one replica (its evidence accumulates there); the shared view makes every replica enforce what | |
| any replica decided, so a source that moves (failover, scale-out) is still blocked everywhere. | |
| ``` | |
| HAProxy / nginx (hash of the client /24 -> replica) | |
| / | \ | |
| [lb-a: proxy+sidecar] [lb-b: proxy+sidecar] [lb-c: proxy+sidecar] | |
| \_______________ shared view ________________/ | |
| /mnt/dibaguard (NFS/SMB/replicated volume) or Redis | |
| ``` | |
| HAProxy (2.6+, `balance hash`; older: `balance source` = per address, not per /24): | |
| ``` | |
| backend dibaguard | |
| mode tcp # or http | |
| balance hash src,ipmask(24,64) # one /24 (IPv6 /64) -> one replica | |
| hash-type consistent # adding / removing a replica moves ~1/n of the sources | |
| server lb-a 10.0.0.11:8080 check send-proxy-v2 | |
| server lb-b 10.0.0.12:8080 check send-proxy-v2 | |
| server lb-c 10.0.0.13:8080 check send-proxy-v2 | |
| # replicas: dibaguard-proxy -proxy-protocol -trusted <haproxy CIDR> -challenge-secret <the same on every replica> | |
| ``` | |
| nginx (L7; `hash ... consistent` is ketama): | |
| ``` | |
| map $remote_addr $dg_net { # IPv4 /24; IPv6 hashes per address unless you add a /64 map | |
| ~^(?<n>\d+\.\d+\.\d+)\.\d+$ $n; | |
| default $remote_addr; | |
| } | |
| upstream dibaguard { | |
| hash $dg_net consistent; | |
| server 10.0.0.11:8080; server 10.0.0.12:8080; server 10.0.0.13:8080; | |
| } | |
| # proxy_set_header X-Forwarded-For $remote_addr; replicas: -trusted <nginx CIDR> | |
| ``` | |
| On every replica: | |
| ```sh | |
| DIBAGUARD_REPLICA=lb-a DIBAGUARD_SHARED_DIR=/mnt/dibaguard DIBAGUARD_SYNC_EVERY_S=5 \ | |
| DIBAGUARD_SNAPSHOT_DIR=/var/lib/dibaguard/snap/lb-a DIBAGUARD_SNAPSHOT_EVERY_S=300 \ | |
| python -m dibaguard.llm http --models models --policy policy.json | |
| # or DIBAGUARD_REDIS_URL=redis://redis:6379/0 instead of DIBAGUARD_SHARED_DIR | |
| ``` | |
| Rules for active-active: | |
| * **Unique replica ids.** `FileSharedState` refuses an id whose heartbeat is fresh from another host. | |
| * **Same policy, rules and model files** everywhere (configuration management); rules and policy are not synced. | |
| * **The real client address** must reach the replica (PROXY protocol or a trusted `X-Forwarded-For`); hashing the | |
| LB's own address would put every source on one replica. | |
| * **Shared `-challenge-secret`**, so a challenged client that moves keeps a valid cookie. | |
| * **One HAProxy ACL syncer per HAProxy instance.** `dibaguard-authz -haproxy-socket` owns its ACL / map and deletes | |
| every entry it did not push; with the shared view every replica's block set converges, but several syncers on one | |
| HAProxy would fight during the sync period. Run the syncer from one replica (any: they all hold the cluster set). | |
| * **Clocks within ~1 s (NTP)**: expiries are absolute and a lift cancels decisions published before it. | |
| * `affinity()` is for routing DibaGuard does itself (a NetFlow / IPFIX fan-out to collector replicas, a log | |
| shipper, a summary forwarder). Its hash differs from HAProxy's / nginx's: within one routing layer use one of | |
| them consistently. Both are consistent hashes; `tests/test_state.py` checks that `affinity()` is stable, | |
| independent of list order, balanced (each of 4 replicas 25 % +- 2 %), moves only the removed replica's sources, | |
| moves ~1/5 to a fifth replica, and honours weights. | |
| How the shared view behaves (`state.merge`): each replica publishes only what changed β new / escalated / extended | |
| decisions with their absolute expiry, operator lifts (an entry gone before its expiry, or a `reject` / | |
| `benign_source` verdict, which also resets that source's risk and evidence on every replica), allowlist entries. | |
| For one source the strongest live action wins with the latest expiry of all live decisions (what one Enforcer would | |
| have done had it seen every verdict); a lift cancels every decision published at or before it, on every replica, | |
| and a later decision stands. Imported blocks are applied to the local host firewall too, so the proxy's fast path | |
| (polling `guard_fast_state`) and HAProxy's ACL syncer (`dibaguard-authz -haproxy-socket`) enforce the cluster's | |
| decisions. A replica that restarts empty, or joins later, converges on the view at its first sync. | |
| Evidence federation: every replica publishes its top-K (1000) sources by decayed fast / slow evidence (those above | |
| 10 % of the threshold). A source with evidence on two or more replicas is judged on the **sum** against the engine's | |
| own conformal threshold (`alpha/4`, the same as the evidence tier); a hit is logged as a `federated` alarm and goes | |
| through the policy like any verdict (a named family from the replicas' alarms -> its action; none -> `attack_generic` | |
| -> monitor by default). A source seen on one replica only is left to that replica's engine. | |
| `FileSharedState` layout (no locks; every replica appends / replaces only its own files): | |
| ``` | |
| <root>/enforce/<replica>.jsonl header {"gen"} + append-only records; compaction rewrites it (new gen, atomic rename) | |
| <root>/evidence/<replica>.json latest evidence summary (atomic replace) | |
| <root>/replicas/<replica>.json heartbeat (host, pid, time) | |
| ``` | |
| Readers keep a byte offset per journal and read only appended complete lines; a new generation means re-read. | |
| ## 3. Measured RPO / RTO | |
| Machine: Intel Core 12th gen (Family 6 Model 151), 8 logical CPUs, Windows 11, local NVMe; Python 3.13.3, numpy | |
| 2.2.5. Real engine and Layer, test-fixture models (tier 1: 60 trees depth 4; GRU d=16, 2 layers; HTTP 4096 buckets). | |
| Traffic: a site calibrated on 60 000 flows from 3 000 clients, then new sources with 4 flows each. Reproduce with | |
| `PYTHONPATH=. python scripts/ha_bench.py` (ranges below = two runs). | |
| | tracked sources | re-calibration buffer rows | warm GRU sources | snapshot size | engine lock held | compress + hash + write | restore (load, verify, decode, apply) | | |
| |---:|---:|---:|---:|---:|---:|---:| | |
| | 10 000 | 88 000 | ~1 000 | 8.2 MB (5.7 MB without buffer) | 0.05-0.09 s | 1.0-1.1 s | 0.20-0.24 s | | |
| | 50 000 | 248 000 | ~6 800 | 19.5 MB (13.3 MB) | 0.29-0.38 s | 2.7-3.0 s | 0.63-0.76 s | | |
| | 100 000 | 400 000 (cap) | ~14 000 | 30.7 MB (21.1 MB) | 0.59-0.73 s | 4.4-4.7 s | 1.13-1.34 s | | |
| About 320 bytes per tracked source with the buffer at its cap, ~220 without. Only the "lock held" column stalls | |
| scoring (live structures are copied; key encoding, compression, hashing and the write run after the lock). The GRU | |
| part grows with the real model: `layers x d x 4` bytes per warm source. | |
| Replica sync (`FileSharedState`, local disk, two replicas): | |
| | decisions per replica | evidence sources per replica | first sync (imports all) | steady sync | journal | evidence summary | | |
| |---:|---:|---:|---:|---:|---:| | |
| | 1 000 | 10 000 | 0.03-0.05 s | 0.02 s | 98 kB | 39 kB | | |
| | 10 000 | 100 000 | 0.16-0.40 s | 0.12-0.14 s | 0.99 MB | 40 kB | | |
| (Sync holds the engine lock only while reading / changing the layer; journal and evidence I/O run outside it.) | |
| What that means: | |
| * **RPO, one node / active-passive:** the snapshot interval + ~5 s (write time at 100k sources). With 60 s | |
| snapshots, at most ~65 s of evidence, risk and reference updates are lost on a crash; a clean stop loses nothing. | |
| Enforcement decisions in force at the last snapshot come back with their original expiry. | |
| * **RPO, active-active:** enforcement decisions, lifts and allowlist entries: one sync period (5 s default) β they | |
| live in the shared view, not in one process. Per-source evidence of the crashed replica: its snapshot interval | |
| (federation summaries of the other replicas still hold its top sources only for as long as they decay). | |
| * **RTO:** process start + model load + restore (~1.1-1.3 s per 100k sources) + one firewall command per restored | |
| block. In active-active the LB health check removes the dead replica (its sources re-hash to the others, which | |
| already enforce its decisions); the restarted replica converges at its first sync. | |
| ## 4. Failure modes | |
| | failure | what happens | | |
| |---|---| | |
| | sidecar crash / kill -9 | restart restores the newest valid snapshot; decisions expired meanwhile are dropped, the rest are re-applied to the firewall for their remaining time | | |
| | crash while writing a snapshot | the temp file (`.dibaguard-snap-*.tmp`) is never renamed; it is ignored, deleted after an hour; the previous snapshot stays valid | | |
| | corrupted / truncated / tampered snapshot | digest (or zip CRC) mismatch -> `SnapshotError`; a directory restore falls back to the previous snapshot (`rep["skipped"]` says why) | | |
| | model upgrade | fingerprint mismatch: `restore()` refuses, `restore_on_start()` restores enforcement / feedback / rules / allowlist and the engine re-calibrates from live traffic | | |
| | code upgrade that changes the feature layout | layout-id mismatch: handled like a model change | | |
| | host loss (active-passive) | promote the passive: it restores the last shipped snapshot (RPO = ship interval + snapshot interval) | | |
| | replica loss (active-active) | LB re-hashes its sources; the others already enforce its decisions; their evidence restarts on the new owner, federation sums what the dead replica published until it decays | | |
| | shared storage slow / unavailable | sync I/O runs outside the engine lock; a failed sync is reported by the Housekeeper and retried; each replica keeps enforcing its own decisions (degrades to independent replicas) | | |
| | network partition | each side keeps its own decisions and converges when the partition heals; a lift made on one side reaches the other then (no quorum, no strong consistency) | | |
| | clock skew | expiries are absolute: a replica whose clock is ahead lifts imported decisions early; a lift with a skewed time may cancel (or miss) a decision published within the skew; keep NTP | | |
| | two hosts with one replica id | refused at start while the other's heartbeat is fresh (< 60 s) | | |
| | LB scale-out / rehash | ~1/n of the sources move; their evidence restarts on the new owner, and is judged on its total through federation for as long as the old owner's summary holds it | | |
| | Windows rename while a reader holds a file | retried; a compaction that still fails is skipped until the next round | | |
| | redis down (RedisSharedState) | the sync raises, the Housekeeper records it and retries; replicas run independently meanwhile | | |
| ## 5. What is NOT covered | |
| * **No consensus.** The shared view is eventually consistent (one sync period); there is no leader election, quorum | |
| or fencing. Active-passive failover needs an external VIP manager. | |
| * **No confidentiality at rest.** HMAC gives integrity, not secrecy. A snapshot holds client addresses, request | |
| texts (HTTP buffers, provenance ring), database query shapes and the alarm log: treat the snapshot directory and | |
| the shared directory as sensitive (file permissions, encrypted volume). | |
| * **Not synced between replicas:** rules, the policy, per-host HTTP profiles and references, the alarm log and | |
| review queue (each replica reviews its own alarms; only the verdicts' lifts / allowlist propagate), the DB tier. | |
| Rules and policy come from configuration management. | |
| * **Federation covers source evidence only** (fast and slow CUSUM). Stream counters, GRU states, entity risk and the | |
| destination-flood tier stay per replica; a summed evidence is an approximation (each replica floors its own CUSUM | |
| at zero) and only the top-K sources per replica are exchanged. | |
| * **Bounded snapshots** drop cold sources beyond `max_sources`; they start fresh after a restore. | |
| * **Zero RPO** only for a clean stop; a crash loses what happened since the last snapshot (enforcement excepted in | |
| active-active). | |
| * **Tested on a local filesystem only** (this round); NFS / SMB semantics (close-to-open consistency, rename | |
| atomicity) are relied upon but not exercised. The Redis backend is tested against an in-memory fake with the same | |
| calls, not a server. | |
| * The Go fast path still polls only its local sidecar; it learns other replicas' decisions through that sidecar | |
| (one poll interval later). | |
| * nftables: when an action's TTL is *extended*, the kernel set element keeps its original timeout (pre-existing | |
| Enforcer behaviour); restored and imported blocks are added with their remaining time. | |
| ## 6. Wiring (hook in the sidecar) | |
| `state.py` needs no change to existing modules. To turn it on in the HTTP sidecar, `dibaguard/llm.py` needs: | |
| ```python | |
| # in main(), after the Dispatcher with a Layer exists and the server (make_server) is built: | |
| from dibaguard import state | |
| srv, cons = make_server(disp, a.host, a.port) | |
| hk = state.from_env(disp.layer, cons.lock) if disp.layer is not None else None # restore on start + background thread | |
| try: | |
| srv.serve_forever() | |
| finally: | |
| if hk is not None: | |
| hk.stop() # final snapshot | |
| ``` | |
| `from_env` reads `DIBAGUARD_SNAPSHOT_DIR`, `DIBAGUARD_SNAPSHOT_EVERY_S`, `DIBAGUARD_SNAPSHOT_KEEP`, | |
| `DIBAGUARD_SNAPSHOT_KEY`, `DIBAGUARD_RESTORE`, `DIBAGUARD_SHARED_DIR` or `DIBAGUARD_REDIS_URL`, `DIBAGUARD_REPLICA`, | |
| `DIBAGUARD_SYNC_EVERY_S`, `DIBAGUARD_TICK`; with none set it returns `None` and nothing changes. | |
| `state.py` reads these existing private attributes, so a rename there must be mirrored in `state.py` (the | |
| round-trip test's exact-state check fails if one is missed): `Guard._buffer`, `Guard._anchor`, `Guard._log()`, | |
| `Layer._http_boot_by / _http_recent_by / _http_last_by / _http_last / _http_ring`, `Enforcer._record()`, | |
| `Policy._allow`, `entity._Rec`, `database._MYSQL_THREADS`. A new piece of engine / layer state must be added to | |
| `_guard_out` / `_guard_in` (or `_layer_*`, `_db_*`). | |