Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(leveva): D-lines (DLINE/UNDLINE) — IP-level operator bans (P11 slice 284)

charybdis-style D-lines, the IP/CIDR sibling of the temporary K-line family
(slices 281–283): `DLINE <duration> <ip/cidr> [:reason]` bans a raw IP or CIDR
block, `UNDLINE <ip/cidr>` lifts it. Built as a near-clone of the K-line plane
keyed on a single IP/CIDR `mask` (matched with the CIDR-aware
`matching::host_component_matches`) instead of `user@host`.

- `dline.rs`: `DlineStore` mirrors `KlineStore` (Vec+Mutex, NOCASE dedup,
`find_active`, same `database {}` SQLite write-through + boot reload as slice
282); `Dline::covers` = `host_component_matches`. Reuses the kline duration
grammar/clock/clamp.
- `command/dline.rs`: `dline`/`undline` mirror `tkline`/`untkline` (461→481 gate,
empty/`*` mask → Incorrect format, no success reply); `reap_matching_ip` ejects
local matching clients by `orighost` (real connect IP), skipping remote +
`kline-exempt`. New `OperPrivilege::Dline` (bit 0x400000).
- `session.rs`: registration gate checked before the K-line gate, matching the
pre-cloak connect IP → 465+ERROR+REJ snomask, never counted/claimed.
- `s2s/dline.rs`+`forward.rs`+`burst.rs`: `ENCAP * DLINE`/`UNDLINE` propagation
(server-prefixed, remaining-seconds, slice-281 KLINE shape) + burst
re-assertion.
- `stats.rs`: `STATS d` → `250 RPL_STATSDLINE` via `dline_report`.
- `server.rs`: `ctx.dlines` opens against the same `database {}` file (persists
across restart); boot test + inverse extended.
- help `DLINE.md`/`UNDLINE.md` + COMMANDS allowlist.

Tests (TDD, inverse invariants, mandated fuzzing): unit+proptest per module;
`tests/golden_dline.rs` (real binary, `dline.kdl` fixture) and
`tests/dline_proptest.rs` (6 properties incl. model-lockstep registration gate
over `Session::feed`). leveva-native, no oracle differential.

`cargo test -p leveva` green (2530 lib); clippy clean; workspace 0 warnings.

+2095 -4
+1 -1
PLAN.md
··· 90 90 | **P8 — Delete the C + retire the oracle harness ✅ DONE** (per-sub-phase: [`PLAN-P8-progress.md`](PLAN-P8-progress.md) + [`docs/progress-log/p8.md`](docs/progress-log/p8.md)) | remaining C (the ~25 variadic sender trampolines + residual data globals) + `ircd-sys` C build + `ircd-golden` (L2) + `ircd-testkit`/`cref_*` archives (L1) + the frozen reference‑C tree | With all C _logic_ already Rust (P7 exit), delete the last C: replace the variadic sender trampolines with a non‑variadic Rust sender API at every call site (faithful `wire!`/`reply_one!`/`format!` first; leveva typed messages adopted alongside the IRC senders), drop the residual data globals via the data‑symbol seam (bindgen `extern` decls resolve to `#[no_mangle]` Rust defs at link), then drop **all** C compilation from `ircd-sys` (no more `cc::Build`), retire the differential harness (`cref_*`/L1 `ircd-testkit`/`ircd-golden` L2 can no longer be built once the oracle is gone), and **migrate the load‑bearing tests into `ircd-common`** as self‑contained Rust tests (own fixtures, no `cref_` oracle). **Progress (P8a–P8r — full entries in the progress logs):** **every variadic sender trampoline is now Rust** (P8a–P8m, incl. the keystones `sendto_one`/`sendto_flag`/`esendto_*`), and the five data‑global `.o`s are dropped outright (`s_auth.o` iauth globals P8n, `send.o` P8o, `s_bsd.o` `local[]`/`highest_fd`/… P8p, `ircd.o` `me`/`client`/timers/… P8q). **P8r dropped the last C‑logic TU `support.o`** — `dgets`/`make_isupport` ported to Rust + the `ipv6string`/`minus_one` data globals defined as `#[no_mangle]` statics (`snprintf_append` is dead in Rust — WHOX is field‑by‑field — so it died with the `.o`; `irc_sprintf` is deleted not ported, in P11). **Every ircd C TU is now Rust:** the link set holds only the generated `version.o` + the L1 harness's `ctruth.o`. **P8s migrated all 115 L1 `cref_` differentials into self‑contained `ircd-common` `insta` snapshot tests** (drive only the Rust port via `link_anchor()`, no oracle; soundness via the capture chain) — the suite is green + 0 warnings. **P8t ported the last *generated* C TU `version.c` to Rust** (`ircd-common/src/version.rs`): the data globals `generation`/`creation`/`pass_version`/`infotext`/`isupport` are now `#[no_mangle]` statics via the data‑symbol seam — `generation`/`creation` sourced from crate metadata at build time (`CARGO_PKG_VERSION` + an `ircd-common/build.rs` UTC build stamp), `pass_version`/`infotext` copied verbatim. `version.c.SH` generation + the `version.o` compile/archive are gone from `ircd-sys/build.rs`; `libircd_c.a` now holds **only** the L1 harness `ctruth.o`. (`creation` is now genuinely build‑time volatile, so the golden canonicalizer masks RPL_CREATED 003, matching the existing 371 Birth‑Date rule.) **P8u (FINAL) retired the oracle.** Ran the differential suite a last time green — L1 `ircd-testkit` all pass; L2 `ircd-golden` 86 pass + only the documented reference‑C‑garbage `s_serv_stats` flake (where Rust is the *correct* side) — then **dropped all C compilation** from `ircd-sys/build.rs`: the `make` object build, the `cref_*`/`ctruth.o`/`res.o` recompiles, the `libircd_c.a` archive, and the whole‑archive link + `-lz`/`-lm`/`-lcrypt` (none needed by Rust — `pow` resolves from glibc 2.29+). `build.rs` now only runs `configure` + bindgen over the C *headers* (the struct view `ircd-common` still uses until P9–P12) and expands the install‑path Makefile vars. `ircd-testkit` (L1) + `ircd-golden` (L2) are `exclude`d from the workspace (mothballed, git‑recoverable); the `ctruth.c`/`layout.rs` drift‑net is deleted. **The workspace is now 100% Rust — zero C TUs compiled.** **P8v (literal end of C) deleted the C *source tree* itself + retired `ircd-sys`:** froze the generated `bindings.rs` into a committed `ircd-common/src/bindings.rs` (a self‑alias `extern crate self as ircd_sys;` keeps every `ircd_sys::bindings::*` path resolving, zero src churn) + the install‑path consts as literals, then deleted `ircd-sys`, the mothballed `ircd-testkit`/`ircd-golden` oracle crates, the `iauth-rs` C‑iauth differential, and **all** C source (`common/ ircd/ iauth/ support/ contrib/ cbuild/ configure .clang-format* clangformat.yml`). `ircd-rs` calls `ircd_common::ircd::c_ircd_main` directly. **Not one line of C remains in the repo.** | **MET. Final differential run green; oracle mothballed; `cargo build --workspace` 0 warnings links pure Rust; `cargo test --workspace` 695 pass / 0 fail (P8v; was 698 before deleting the C‑oracle tests); the `ircd-common` snapshot tests carry the reference‑C correctness forward. C source tree deleted — repo is 100% Rust at the file level.** | 91 91 | **P9 — std‑library cleanup ❌ RETIRED (2026‑06‑08)** | — (was: whole Rust tree) | **Skipped by decision — superseded by the `leveva` greenfield.** P9 assumed an *in‑place* idiomatic transformation of the mechanical port (`MyMalloc`/`MyFree`→`Box`/`Vec`, `dbuf` freelist→owned, raw `libc`→`std`/`nix`, `[c_char;N]`→`[u8;N]`). Because the idiomatic end‑state is now a separate greenfield crate (`leveva`), `ircd-common` is throwaway code (the oracle, deleted at parity), so polishing it earns nothing. Nothing was implemented; the row is kept for provenance. | n/a — retired. | 92 92 | **P10 — `leveva` greenfield foundations 🔶 IN PROGRESS** ([`docs/progress-log/p10.md`](docs/progress-log/p10.md)) | new `leveva` crate (lib) + `leveva-integration` differential tests | The idiomatic‑Rust product, built from scratch (not transformed from the port). **Done so far:** RFC 1459 case‑folding (`casemap`); typed identifier strings (`IrcStr`/`IrcString` + `Nick`/`ChanName`/`Uid`/`Sid`/`Cid`/`ServerName`/`UserName`/`HostName` validating newtypes via `ident_newtype!`); `Message`/`MessageBuilder`; the full `Numeric` reply/error enum (184 codes, discriminant = wire code); a generic safe `patricia` trie; glob `matching`; `mode`; KDL `config` (model/parse/password/privilege); `rustls`/`tokio` `tls` endpoint with hot reload; `ident`. The async boot path (`main.rs`) binds listeners + watches certs but currently drops accepted connections — no protocol layer yet. Idiomatic Rust allowed everywhere; pulls real crates (tokio/rustls/kdl/nom/clap). | `cargo test -p leveva` green + clippy clean per module; `leveva-integration` pins each greenfield reimplementation against `ircd-common` (the oracle) within their shared domain (documented divergences asserted explicitly). | 93 - | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 283 slices** (283 = SQLite persistence for permanent (`+P`) channels — a `+P` channel (slice 244, survives empty) now also survives a **restart**, persisting to the same `database {}` SQLite file that backs chat-history (274) and K-lines/reservations (282): its identity + `created_at` TS + full `ChannelModes` + topic write through to a `channel` table (JSON `modes`/`topic` columns, the serde codec chat-history uses) on every durable mutation, via the single `Channels::reconcile_persist` chokepoint (upsert when `+P`, **delete on `-P`**) hooked into all eight durable-state mutators — `apply_modes`/`apply_modes_oper`/`apply_modes_as_server` (MODE incl. the `+P`/`-P` toggle, key, limit, all `+b/+e/+I/+R` lists), `reset_flag_state`/`wipe_list_masks` (CHANTS-merge), `set_topic`/`set_topic_as_server` (TOPIC), and `create_permanent_from_burst` (an empty `+P` channel learned from a peer burst persists so it outlives *this* server's restart too, mirroring 282's inbound-S2S persist); recreated **empty** at boot (members/INVITE-tokens/join-throttle counters never persist — an empty `+P` channel is exactly what 244/269 make valid), with `created_at` carried so TS6 merge arbitration is stable post-reboot; a `persisted: HashSet` of folded keys makes a `reconcile_persist` on a never-persisted (non-`+P`) channel a pure in-memory miss — **no disk write per MODE on an ordinary channel** — and the two leaf locks (`db`/`persisted`) are never held simultaneously so there is no lock-order hazard with the held `by_name` guard; a `new()`/no-`database{}` table stays in-memory only (every persistence path a no-op); `ChannelModes`/`Topic` gained `#[derive(Serialize, Deserialize)]`; `from_config` opens the table against `cfg.database.path` (log + fall back to in-memory on error, never fatal), exactly the 282 klines/resvs shape; 282 = SQLite persistence for K-lines + reservations — operator-laid `TKLINE` bans and `RESV` reservations (lost on restart before) now persist to the **same `database {}` SQLite file** that backs chat-history (slice 274), so they survive a reboot: each `KlineStore`/`ResvStore` gains an optional write-through connection (`open(path)` creates a `kline`/`resv` table + loads existing rows; `add`/`remove`/`prune` mirror to it; a `new()`/no-`database{}` store stays in-memory only, every persistence path a no-op), the in-memory `Vec` staying the hot-path authority; K-lines drop `expires <= now` rows on load + prune; only **runtime** (`conf == false`) reservations persist (config `resv {}` re-derives from `ircd.kdl`, so `seed_config` bypasses persistence and a `REHASH` never drops a learned reservation); `COLLATE NOCASE` primary keys make a case-differing re-add upsert not duplicate; open/write errors log and fall back to in-memory (never fatal); inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns from the network too (all via `add`); 281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); the UID-based S2S link/burst/netsplit protocol (keepalive, live introduction, NJOIN chunking, TS merge arbitration); `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident); config live-rehash across every block; CertFP; the **IRCv3 cap track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track); elemental channel modes + extbans; KNOCK; command-rate flood protection (fakelag); the **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 93 + | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 284 slices** (284 = D-lines: `DLINE <duration> <ip/cidr> [:reason]` / `UNDLINE <ip/cidr>` — charybdis-style IP-level operator bans, the IP/CIDR sibling of the temporary K-line family (281–283); new `DlineStore` (mirrors `KlineStore`, CIDR-aware `host_component_matches`, same `database {}` SQLite write-through + boot reload), `OperPrivilege::Dline` (bit `0x400000`), registration gate **before** the K-line gate matching the pre-cloak connect IP (`465`+`ERROR`+REJ snomask, exemption reuses `kline-exempt`), local reap by `orighost`, S2S `ENCAP * DLINE`/`UNDLINE` propagation + burst re-assertion (slice-281 KLINE shape), `STATS d` → `250 RPL_STATSDLINE`, `DLINE.md`/`UNDLINE.md` help; leveva-native, no oracle differential; 283 = SQLite persistence for permanent (`+P`) channels — a `+P` channel (slice 244, survives empty) now also survives a **restart**, persisting to the same `database {}` SQLite file that backs chat-history (274) and K-lines/reservations (282): its identity + `created_at` TS + full `ChannelModes` + topic write through to a `channel` table (JSON `modes`/`topic` columns, the serde codec chat-history uses) on every durable mutation, via the single `Channels::reconcile_persist` chokepoint (upsert when `+P`, **delete on `-P`**) hooked into all eight durable-state mutators — `apply_modes`/`apply_modes_oper`/`apply_modes_as_server` (MODE incl. the `+P`/`-P` toggle, key, limit, all `+b/+e/+I/+R` lists), `reset_flag_state`/`wipe_list_masks` (CHANTS-merge), `set_topic`/`set_topic_as_server` (TOPIC), and `create_permanent_from_burst` (an empty `+P` channel learned from a peer burst persists so it outlives *this* server's restart too, mirroring 282's inbound-S2S persist); recreated **empty** at boot (members/INVITE-tokens/join-throttle counters never persist — an empty `+P` channel is exactly what 244/269 make valid), with `created_at` carried so TS6 merge arbitration is stable post-reboot; a `persisted: HashSet` of folded keys makes a `reconcile_persist` on a never-persisted (non-`+P`) channel a pure in-memory miss — **no disk write per MODE on an ordinary channel** — and the two leaf locks (`db`/`persisted`) are never held simultaneously so there is no lock-order hazard with the held `by_name` guard; a `new()`/no-`database{}` table stays in-memory only (every persistence path a no-op); `ChannelModes`/`Topic` gained `#[derive(Serialize, Deserialize)]`; `from_config` opens the table against `cfg.database.path` (log + fall back to in-memory on error, never fatal), exactly the 282 klines/resvs shape; 282 = SQLite persistence for K-lines + reservations — operator-laid `TKLINE` bans and `RESV` reservations (lost on restart before) now persist to the **same `database {}` SQLite file** that backs chat-history (slice 274), so they survive a reboot: each `KlineStore`/`ResvStore` gains an optional write-through connection (`open(path)` creates a `kline`/`resv` table + loads existing rows; `add`/`remove`/`prune` mirror to it; a `new()`/no-`database{}` store stays in-memory only, every persistence path a no-op), the in-memory `Vec` staying the hot-path authority; K-lines drop `expires <= now` rows on load + prune; only **runtime** (`conf == false`) reservations persist (config `resv {}` re-derives from `ircd.kdl`, so `seed_config` bypasses persistence and a `REHASH` never drops a learned reservation); `COLLATE NOCASE` primary keys make a case-differing re-add upsert not duplicate; open/write errors log and fall back to in-memory (never fatal); inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns from the network too (all via `add`); 281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); the UID-based S2S link/burst/netsplit protocol (keepalive, live introduction, NJOIN chunking, TS merge arbitration); `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident); config live-rehash across every block; CertFP; the **IRCv3 cap track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track); elemental channel modes + extbans; KNOCK; command-rate flood protection (fakelag); the **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 94 94 | **P12 — Retire the mechanical port; `leveva` is the product ✅ DONE (2026‑06‑13)** ([`docs/progress-log/p12.md`](docs/progress-log/p12.md)) | deleted `ircd-common`/`ircd-rs` + C‑era `iauth-rs` + `leveva-integration` + the mechanical port's deployment rigging; workspace = `leveva` + `leveva-iauth` | Ran the differential suite a **final time green** (`cargo test -p leveva-integration`, skeleton‑identical to the oracle), then **deleted the mechanical port** (`ircd-common`, `ircd-rs`), its C‑era auth scaffolding (`iauth-rs`, long since subsumed by the native `leveva-iauth`), and the oracle differential crate (`leveva-integration`). The oracle tests retired with their oracle — the load‑bearing behavior was already carried by leveva's self‑contained 212‑file golden + proptest suite (verified: leveva/leveva-iauth have zero Cargo/`use` dep on the deleted crates). Also stripped the mechanical port's **deployment rigging** (`docker/Dockerfile.ircd`, the `ircd` bake target, the entire `docs/k8s/` example network) and updated the README to describe the finished strangler. This is the literal end of the strangler — same lifecycle the C tree had at P8. | **MET. Final differential run green; `ircd-common`/`ircd-rs`/`iauth-rs`/`leveva-integration` deleted; `cargo build --workspace` 0 warnings; `cargo clippy --workspace --tests` clean; `cargo test -p leveva -p leveva-iauth` green — the behavioral guarantees carry forward. The workspace is now `leveva` + `leveva-iauth`.** | 95 95 96 96 ---
+1
docs/claude-memory/README.md
··· 2 2 3 3 - [leveva NO DM history](leveva-no-dm-history.md) — HARD RULE: user-to-user private messages MUST NOT be stored in the database; chathistory is channel-only; DM follow-on is cancelled not deferred 4 4 - [leveva chathistory (slice 274)](leveva-chathistory.md) — IRCv3 draft/chathistory: SQLite (rusqlite bundled) message history, optional `database{}` block, all channels auto-enrolled; store/dynamic-caps/recording-at-delivery-plane/replay-on-join seams; Message gained serde 5 + - [leveva D-lines (DLINE/UNDLINE)](leveva-dline.md) — slice 284: charybdis IP-level operator bans, IP/CIDR sibling of the K-line family; near-clone keyed on `mask`; `OperPrivilege::Dline`; gate before K-line gate; S2S `ENCAP * DLINE`/burst/SQLite persistence; `STATS d`; +ServerContext-field-needs-~62-literals note 5 6 6 7 - [leveva user-mode wiring](leveva-user-mode-wiring.md) — checklist for adding a umode (mode.rs enum/ALL/Display + command::mode self-settable group & umode_diff [PAIRED allowlists; slice-251 +G empty-echo bug] + 2 hardcoded 004 asserts + ~5 snapshots). SLICE 254: SEND_UMODES is now EVERY umode — leveva has NO local-only user modes (nothing to decide, auto-global); standard charybdis umode surface COMPLETE (247 +D/248-249 +R/250 +Q/252 +z/253 +S/254 +l locops+LOCOPS cmd). Enforcement (caller-id/+R accept-list) stays home-server even though bits now propagate 7 8 - [Unknown chanmode letter is '>'](unknown-chanmode-letter-is-gt.md) — in leveva tests use `ChanMode::try_from('>')` for the guaranteed-unknown-mode assertion (charybdis's non-existent example-mode letter); never a real unused letter (a future slice can claim it)
+42
docs/claude-memory/leveva-dline.md
··· 1 + --- 2 + name: leveva-dline 3 + description: "leveva D-lines (DLINE/UNDLINE) — charybdis IP-level operator bans, slice 284; the IP/CIDR sibling of the K-line family" 4 + metadata: 5 + node_type: memory 6 + type: project 7 + originSessionId: 8294a963-9e18-4dc5-8111-0444ecd0b512 8 + --- 9 + 10 + P11 slice 284 (2026-06-19) added **D-lines** to leveva: `DLINE <duration> <ip/cidr> [:reason]` / 11 + `UNDLINE <ip/cidr>`, charybdis-style IP-level operator bans — the IP/CIDR sibling of the temporary 12 + K-line family ([[leveva-config-ban-enforcement]] + the S2S/persistence slices 281–283). Built as a 13 + near-clone of the K-line plane keyed on a single IP/CIDR `mask` instead of `user@host`. 14 + 15 + **Code map** (each file mirrors its K-line counterpart): 16 + - `leveva/src/dline.rs` — `DlineStore` (mirror of `KlineStore`): `Vec<Dline>`+`Mutex`, case-insensitive 17 + `mask` dedup, `find_active(ip, now)`, same optional `database {}` SQLite write-through (`dline` 18 + table, NOCASE PK) + boot reload as slice 282. `Dline::covers(ip)` = `matching::host_component_matches` 19 + (CIDR for `addr/prefix`, glob otherwise). Reuses `kline::parse_duration`/`now_secs`/`TKLINE_MAXTIME`. 20 + - `leveva/src/command/dline.rs` — `dline`/`undline` (mirror `tkline`); `reap_matching_ip` matches each 21 + record's **`orighost`** (real pre-cloak connect IP), skips remote + `kline_exempt`. Quit reason 22 + `"D-line active: <reason>"`; 465 text is `"You are banned from this server: <reason>"` (NO `user@host` 23 + parenthetical, unlike the K-line 465). 24 + - `session.rs` registration gate — checked **before** the K-line gate, matches `self.host` (pre-cloak 25 + connect IP); refuse 465+ERROR+`+s +r` REJ snomask. Exemption reuses the one `kline_exempt` flag 26 + (charybdis combines k/d/x-line exempt). 27 + - `s2s/dline.rs` + `forward.rs` + `burst.rs` — `ENCAP * DLINE <remaining> <mask> :<reason>` / 28 + `ENCAP * UNDLINE <mask>` (server-prefixed, remaining-seconds, leveva-native ENCAP carrier — the 29 + slice-281 KLINE shape); `dline_burst` re-asserts active bans. 30 + - `stats.rs` — `STATS d`/`D` → `250 RPL_STATSDLINE` (`d <mask> <ttl> :<reason>`) via `dline_report`. 31 + - `config/privilege.rs` — `OperPrivilege::Dline` (token `"dline"`, bit `0x400000`, in `ALL` which is now 32 + `[_; 22]`, granted by `"all"`). 33 + - help `DLINE.md`/`UNDLINE.md` + `command::help` COMMANDS allowlist (bijection test enforces both ways). 34 + 35 + **Tests:** unit+proptest in each module (inverse invariants always); `leveva/tests/golden_dline.rs` 36 + (real binary, fixture `dline.kdl` whose operator carries `dline`) + `leveva/tests/dline_proptest.rs` 37 + (6 properties incl. model-lockstep registration gate over `Session::feed`). Gate: `cargo test -p leveva` 38 + 2530 lib green, clippy clean, workspace 0 warnings. **leveva-native, no oracle differential.** 39 + 40 + When adding a new `ServerContext` field, ~62 struct literals need it (`from_config` + 2 in 41 + `command/testutil.rs` + ~60 `leveva/tests/*_proptest.rs`); the test ones all use the 42 + `klines: leveva::kline::KlineStore::new(),` line as an anchor for a perl insert.
+55
docs/progress-log/p11.md
··· 15553 15553 15554 15554 **Gate:** `cargo test -p leveva` green (2487 lib + the new units/fuzz/e2e); `cargo clippy -p leveva 15555 15555 --tests` clean; `cargo build -p leveva` 0 warnings. 15556 + 15557 + - **2026-06-19 — P11 slice 284 (D-lines: DLINE/UNDLINE IP-level operator bans) merged.** 15558 + The IP/CIDR sibling of the temporary K-line family (slices 281–283), continuing the just-closed 15559 + ban/persistence arc. An operator may `DLINE <duration> <ip/cidr> [:reason]` to ban a raw IP or 15560 + CIDR block and `UNDLINE <ip/cidr>` to lift it; structurally a near-clone of the K-line plane keyed 15561 + on a single IP/CIDR `mask` instead of `user@host`, matched with the CIDR-aware 15562 + `matching::host_component_matches`. 15563 + - **Store (`leveva/src/dline.rs`).** `DlineStore` mirrors `KlineStore`: an owned `Vec<Dline>` 15564 + behind a `Mutex`, case-insensitive `mask` dedup/refresh, `find_active(ip, now)` / 15565 + `add`/`remove`/`prune`/`entries`, and the same optional write-through SQLite (`dline` table, 15566 + `COLLATE NOCASE` PK) + boot reload as slice 282 — `open(path)` loads surviving bans, a `new()` 15567 + store is in-memory only (every persistence path a no-op). `Dline::covers(ip)` = 15568 + `host_component_matches(mask, ip)` (CIDR for `addr/prefix`, glob otherwise). Reuses 15569 + `kline::parse_duration`/`now_secs`/`TKLINE_MAXTIME` rather than re-deriving the duration grammar. 15570 + - **Command (`leveva/src/command/dline.rs`).** `dline`/`undline` mirror `tkline`/`untkline`: param 15571 + gate (461) before privilege gate (481, the new `OperPrivilege::Dline`, bit `0x400000`), `wdhms2sec` 15572 + duration clamp, mask validation (empty or bare-`*` → `NOTICE :DLINE: Incorrect format`), no 15573 + in-band success reply. `reap_matching_ip` ejects every **local** matching client with `465` + 15574 + `ERROR :... (D-line active: <reason>)`, matching each record's **`orighost`** (the real, 15575 + cloak-independent connect IP), skipping remote (`is_local_uid`) and `kline-exempt` clients 15576 + (charybdis's combined k/d-line exemption; leveva reuses the one `kline_exempt` flag). 15577 + - **Registration gate (`session.rs`).** A D-line is checked at the registration finalize gate 15578 + **before** the K-line gate (an IP ban is the coarsest, earliest filter), matching `self.host` 15579 + (the raw pre-cloak connect IP); a covered, non-exempt client is refused with `465` + `ERROR` and 15580 + a `+s +r` REJ snomask, never counted/claimed. 15581 + - **S2S (`leveva/src/s2s/dline.rs` + `forward.rs` + `burst.rs`).** `propagate_dline`/`propagate_undline` 15582 + broadcast `:<sid> ENCAP * DLINE <remaining> <mask> :<reason>` / `:<sid> ENCAP * UNDLINE <mask>` 15583 + (server-prefixed, remaining-seconds, leveva-native ENCAP carrier — the slice-281 KLINE shape); 15584 + `apply_encap_dline`/`apply_encap_undline` apply a peer's carrier (store + local reap) without 15585 + relaying (the caller onward-relays every ENCAP); `dline_burst` re-asserts active D-lines with 15586 + their remaining time to a freshly-linked peer (expired bursts nothing). 15587 + - **STATS (`stats.rs`).** New `STATS d`/`D` arm → `250 RPL_STATSDLINE` (`d <mask> <ttl> :<reason>`) 15588 + via the pure `dline_report`, the IP analogue of the `216`/`STATS k` arm. 15589 + - **Persistence (`server.rs`).** `ctx.dlines` opens against the same `database {}` file as 15590 + klines/resvs (log + fall back to in-memory on error), so an operator's D-lines survive a restart; 15591 + the slice-282/283 boot test (and its no-`database{}` inverse) extended to cover a D-line. 15592 + - **Help.** `DLINE.md`/`UNDLINE.md` pages + `DLINE`/`UNDLINE` added to the `command::help` 15593 + COMMANDS allowlist (the bijection test enforces both directions). 15594 + - **Tests (TDD — failing first — inverse invariants + mandated fuzzing).** `dline.rs` units 15595 + (round-trip + CIDR/glob cover + refresh + remove + prune + persist-survives-reopen + 15596 + expired-not-reloaded + in-memory-writes-nothing) + 2 proptests (persisted-set-after-reopen, 15597 + `covers == host_component_matches`); `command/dline.rs` units (461/481/incorrect-format/store/reap 15598 + + exempt/non-matching/remote inverses + propagation carriers + rejected-propagates-nothing); 15599 + `s2s/dline.rs` units + 2 proptests (inbound apply total/consistent vs model, junk-never-panics); 15600 + `s2s/burst.rs` active/expired burst + inverse; `stats.rs` `dline_report` + `STATS d` + inverse; 15601 + `session.rs` gate + inverse; new integration `leveva/tests/golden_dline.rs` (real binary: 481 → 15602 + incorrect-format → non-matching-survives → covering-reap → reconnect-refused, snapshotted) and 15603 + `leveva/tests/dline_proptest.rs` (6 properties incl. the model-lockstep registration gate over 15604 + `Session::feed` and the reap-conservation/exempt fan-out). 15605 + - **Divergences.** No operator feedback (mirrors TKLINE); exemption reuses `kline-exempt`; 15606 + leveva-native (no oracle differential — the C 2.11 ENCAP is an opaque relay). New fixture 15607 + `leveva/tests/fixtures/dline.kdl` (operator carries `dline`). 15608 + - **Plan:** `docs/superpowers/plans/2026-06-19-p11-slice284-dline.md`. 15609 + - **Gate:** `cargo test -p leveva` green (2530 lib + golden_dline + dline_proptest + patched 15610 + binaries); `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings.
+84
docs/superpowers/plans/2026-06-19-p11-slice284-dline.md
··· 1 + # P11 slice 284 — D-lines (DLINE/UNDLINE): IP-level operator bans 2 + 3 + **Date:** 2026-06-19 4 + **Status:** planned → in progress 5 + 6 + ## Scope 7 + 8 + charybdis-style **D-lines** — the IP/CIDR sibling of the temporary K-line family 9 + (slices 281–283). An operator may `DLINE <duration> <ip/cidr> [:reason]` to ban a raw 10 + IP or CIDR block, and `UNDLINE <ip/cidr>` to lift it. A D-line: 11 + 12 + 1. **refuses** a matching connection at the registration gate (before it is counted or 13 + claimed) — `465 ERR_YOUREBANNEDCREEP` + `ERROR` close, like the K-line gate; 14 + 2. **reaps** any currently-connected local client whose *connect IP* matches; 15 + 3. **propagates** network-wide as `ENCAP * DLINE`/`UNDLINE` (slice 281's K-line carrier 16 + shape) and re-asserts to a freshly-linked peer via the burst (slice 281); 17 + 4. **persists** to the same `database {}` SQLite file (slice 282), surviving a restart. 18 + 19 + This is a clean continuation of the just-closed ban/persistence arc: it reuses the 20 + exact KlineStore/`s2s::kline`/burst/persistence machinery, keyed on a single IP/CIDR 21 + `mask` instead of `user@host`, matched with the CIDR-aware 22 + [`matching::host_component_matches`]. 23 + 24 + ### Locked decisions / divergences 25 + 26 + - **Match value = the connect IP.** The gate matches `self.host` (the raw pre-cloak 27 + connect host, which in leveva's no-DNS world is the IP); the reap matches each 28 + record's `orighost` (the real connect IP, cloak-independent). CIDR-aware via 29 + `host_component_matches` (handles `10.0.0.0/24` and globs). 30 + - **Exemption reuses `kline_exempt`.** charybdis's `kline_exempt` auth flag exempts 31 + k/d/x-lines alike; leveva has one exempt flag, so a `kline-exempt` I-line bypasses the 32 + D-line gate and reap too. No new config flag. 33 + - **Mask validation:** empty or bare `*` → `NOTICE :DLINE: Incorrect format` (the bare 34 + `*` footgun is rejected like the K-line `*@*` rejection). Any other non-empty mask is 35 + accepted and matched as CIDR-or-glob. 36 + - **No operator feedback** (mirrors TKLINE slice 281): a successful DLINE returns nothing 37 + in-band; the only wire output is the `465`+`ERROR` to each reaped client. 38 + - **Duration grammar** reused verbatim from `kline::parse_duration` (`wdhms2sec`), clamp 39 + `[0, TKLINE_MAXTIME]`. 40 + - **STATS d** lists active D-lines (`250 RPL_STATSDLINE`), mirroring the STATS k arm. 41 + - **leveva-native**, no oracle differential (the C 2.11 ENCAP is an opaque relay). 42 + 43 + ## Design 44 + 45 + | Piece | Where | Mirrors | 46 + | --- | --- | --- | 47 + | `DlineStore` (mask/reason/expires, CIDR `covers`, SQLite write-through + boot load) | `leveva/src/dline.rs` | `kline::KlineStore` | 48 + | `DLINE`/`UNDLINE` handlers + `reap_matching_ip` | `leveva/src/command/dline.rs` | `command::tkline` | 49 + | `propagate_dline`/`propagate_undline` + `apply_encap_dline`/`apply_encap_undline` | `leveva/src/s2s/dline.rs` | `s2s::kline` | 50 + | `dline_burst` | `leveva/src/s2s/burst.rs` | `kline_burst` | 51 + | registration D-line gate | `leveva/src/session.rs` (before the K-line gate) | the K-line gate | 52 + | `ctx.dlines` field + `from_config` open | `leveva/src/server.rs` | `ctx.klines` | 53 + | `OperPrivilege::Dline` (bit 0x400000) | `leveva/src/config/privilege.rs` | `Tkline`/`Resv` | 54 + | ENCAP `DLINE`/`UNDLINE` dispatch | `leveva/src/s2s/forward.rs` | `KLINE`/`UNKLINE` | 55 + | STATS `d` arm + `dline_report` | `leveva/src/command/stats.rs` | STATS `k` | 56 + | `DLINE.md`/`UNDLINE.md` + COMMANDS | `leveva/help/` + `command/help.rs` | TKLINE pages | 57 + 58 + ## Test plan (TDD — failing first — inverse invariants always) 59 + 60 + 1. **`dline.rs` units:** add/find/expire round-trip (+ inverse: non-matching IP, expired, 61 + case); CIDR cover (`10.0.0.0/24` covers `10.0.0.7`, not `10.0.1.1`); refresh-not- 62 + duplicate; remove lifts (+ inverse: 2nd remove false); prune drops expired; persisted 63 + survives reopen (+ inverse lift); expired not reloaded; in-memory writes nothing. 64 + 2. **`dline.rs` fuzz (proptest):** arbitrary add/remove over a small IP alphabet → reopen 65 + ⇒ reloaded set == live set (the slice-282 persistence invariant). Plus a CIDR-match 66 + model proptest (`covers` == reference CIDR/glob scan, never panics). 67 + 3. **`command/dline.rs` units:** 461-before-privilege; 481 lays nothing; incorrect-format 68 + for empty/`*`/bad-duration; success stores + no reply; reap a matching local conn with 69 + 465+ERROR; does-not-reap exempt / non-matching / remote; UNDLINE lifts; propagation 70 + carriers to peers (+ inverse: a rejected DLINE propagates nothing). 71 + 4. **`s2s/dline.rs` units + proptest:** propagate carriers; inbound apply stores + reaps 72 + local/not-remote/not-exempt; inbound through `forward::inbound_encap` applies + relays; 73 + fuzz: interleaved inbound DLINE/UNDLINE total & consistent vs a model; junk never panics. 74 + 5. **`leveva/tests/golden_dline.rs`:** boot the real binary, oper lays a DLINE on the test 75 + client's IP, a fresh connection from that IP is refused with 465+ERROR (and the inverse: 76 + an un-DLINE'd IP connects fine). 77 + 6. **`leveva/tests/dline_registration_proptest.rs`:** the registration gate refuses iff an 78 + active D-line covers the connect IP and the client is not exempt (model lockstep). 79 + 80 + ## Gate 81 + 82 + `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p 83 + leveva` 0 warnings. Record: append to `docs/progress-log/p11.md`, bump PLAN.md P11 row, 84 + write a memory note.
+25
leveva/help/DLINE.md
··· 1 + DLINE <duration> <ip/cidr> [:<reason>] 2 + 3 + Lays a temporary D-line banning every connection from the given IP address or 4 + CIDR block. The IP-level sibling of TKLINE: where TKLINE bans a user@host glob, 5 + DLINE bans a raw IP and is checked against each client's real connect IP (before 6 + the K-line gate) at registration. Operator-only: without the dline privilege you 7 + get 481 ERR_NOPRIVILEGES, and too few parameters yield 461 ERR_NEEDMOREPARAMS 8 + before the privilege check. 9 + 10 + The duration uses the weeks/days/hours/minutes/seconds grammar (1h, 30m, 2d) 11 + and is clamped to about 3.2 years. The mask is an IP, a CIDR block 12 + (10.0.0.0/24), or a glob; an empty mask, a bare '*', or a bad duration character 13 + returns a NOTICE "DLINE: Incorrect format". 14 + 15 + On success no reply is sent to the issuer. The ban is stored and every matching 16 + connected local client is reaped: it gets 465 ERR_YOUREBANNEDCREEP and an ERROR 17 + closing its link, then QUITs with "D-line active: <reason>". A kline-exempt 18 + connection is spared. The ban propagates network-wide and persists across a 19 + restart when a database block is configured. 20 + 21 + Examples: 22 + DLINE 1h 192.0.2.7 :scanner 23 + DLINE 2d 10.0.0.0/24 :open proxy range 24 + 25 + See also: UNDLINE, TKLINE, KILL.
+17
leveva/help/UNDLINE.md
··· 1 + UNDLINE <ip/cidr> 2 + 3 + Lifts a temporary D-line laid by DLINE, removing the IP/CIDR ban so that address 4 + may connect again. Operator-only: without the dline privilege you get 481 5 + ERR_NOPRIVILEGES. An empty or bare-'*' mask returns a NOTICE "UNDLINE: Incorrect 6 + format". 7 + 8 + The mask must match the D-line exactly (the same IP or CIDR string). Lifting a 9 + ban that is not present is a silent no-op. UNDLINE only lifts the future- 10 + connection gate; a client already reaped by the ban stays disconnected. The lift 11 + propagates network-wide. 12 + 13 + Examples: 14 + UNDLINE 192.0.2.7 15 + UNDLINE 10.0.0.0/24 16 + 17 + See also: DLINE, UNTKLINE.
+347
leveva/src/command/dline.rs
··· 1 + use crate::command::*; 2 + use crate::kline::{now_secs, parse_duration, TKLINE_MAXTIME}; 3 + use crate::matching; 4 + 5 + /// `DLINE <duration> <ip/cidr> [:reason]` — an operator lays a temporary **IP-level** ban. 6 + /// 7 + /// The IP/CIDR sibling of [`TKLINE`](super::tkline::tkline) (P11 slice 284). Where a TKLINE bans a 8 + /// `user@host` glob, a DLINE bans a raw IP or CIDR block, matched against each client's *connect IP* 9 + /// with the CIDR-aware [`matching::host_component_matches`]. 10 + /// 11 + /// - **≥ 2 args** (duration + mask; the reason is optional) → too few → `461 ERR_NEEDMOREPARAMS`, 12 + /// checked first so the param gate precedes the privilege gate (matching the TKLINE ordering). 13 + /// - The [`dline`](OperPrivilege::Dline) privilege gates the command; without it → `481`. 14 + /// - The duration is parsed by the `wdhms2sec` grammar ([`parse_duration`]) and clamped to 15 + /// `[0, TKLINE_MAXTIME]`. An empty mask, a bare `*`, or a duration with a bad character → 16 + /// `NOTICE :DLINE: Incorrect format`. 17 + /// - On success the ban is stored ([`crate::dline::DlineStore`]) and every currently-connected 18 + /// **local** client whose connect IP the mask covers is **reaped** (`465` + `ERROR` close, the 19 + /// same wire shape as the TKLINE reap), then propagated network-wide. 20 + /// 21 + /// **Documented divergences** (identical to the TKLINE family, slice 281): 22 + /// - **No operator feedback** — a successful DLINE returns nothing in-band; the only wire output is 23 + /// the `465`+`ERROR` to each reaped client. 24 + /// - **Exemption reuses `kline-exempt`** — a `kline-exempt` I-line (the C `FLAGS_EXEMPT`, charybdis's 25 + /// combined k/d/x-line exemption) is skipped by the reap and the registration gate alike. 26 + pub(crate) fn dline(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 27 + let params = msg.params(); 28 + let (Some(dur_arg), Some(mask_arg)) = (params.first(), params.get(1)) else { 29 + return vec![need_more_params(ctx, &client.nick, "DLINE")]; 30 + }; 31 + 32 + if !client.has_privilege(OperPrivilege::Dline) { 33 + return vec![no_privileges(ctx, &client.nick)]; 34 + } 35 + 36 + let reason = msg 37 + .trailing() 38 + .or_else(|| params.get(2).map(String::as_str)) 39 + .unwrap_or(""); 40 + 41 + let dur = parse_duration(dur_arg); 42 + let Some(mask) = valid_mask(mask_arg) else { 43 + return vec![incorrect_format(ctx, &client.nick, "DLINE")]; 44 + }; 45 + if !dur.ok { 46 + return vec![incorrect_format(ctx, &client.nick, "DLINE")]; 47 + } 48 + 49 + let secs = if dur.seconds > TKLINE_MAXTIME || dur.seconds < 0 { 50 + TKLINE_MAXTIME 51 + } else { 52 + dur.seconds 53 + }; 54 + let now = now_secs(); 55 + ctx.dlines.add(mask, reason, now + secs); 56 + 57 + reap_matching_ip(ctx, mask, reason); 58 + 59 + crate::s2s::dline::propagate_dline(ctx, mask, secs, reason); 60 + Vec::new() 61 + } 62 + 63 + /// Reap every **local** connected client whose *connect IP* ([`crate::registry::ClientRecord::orighost`]) 64 + /// the CIDR/glob `mask` covers, sending each `:<server> 465 …` + `ERROR :Closing Link …` and ejecting 65 + /// it with the `D-line active: <reason>` quit comment. Skips `kline-exempt` clients and **remote** 66 + /// users ([`ServerContext::is_local_uid`] — a remote user is reaped by *its* own server when the 67 + /// carrier reaches there). Shared by the local [`dline`] command and the inbound 68 + /// [`crate::s2s::dline::apply_encap_dline`]. 69 + /// 70 + /// Matching uses `orighost` (the real, cloak-independent connect IP), not the visible (possibly 71 + /// cloaked) `host`, so a `+x`-cloaked client is still reaped by its real IP. 72 + pub(crate) fn reap_matching_ip(ctx: &ServerContext, mask: &str, reason: &str) { 73 + let quit_reason = format!("D-line active: {reason}"); 74 + for rec in ctx.registry.records() { 75 + if !ctx.is_local_uid(&rec.uid) { 76 + continue; 77 + } 78 + if rec.kline_exempt { 79 + continue; 80 + } 81 + if matching::host_component_matches(mask, &rec.orighost) { 82 + let mut wire = Message::builder(Numeric::ErrYourebannedcreep) 83 + .prefix(&ctx.name) 84 + .param(&rec.nick) 85 + .trailing(format!("You are banned from this server: {reason}")) 86 + .build() 87 + .to_wire(); 88 + wire.extend( 89 + Message::builder("ERROR") 90 + .trailing(format!("Closing Link: {} ({quit_reason})", rec.host)) 91 + .to_wire(), 92 + ); 93 + ctx.registry.eject_uid(&rec.uid, wire, quit_reason.clone()); 94 + } 95 + } 96 + } 97 + 98 + /// `UNDLINE <ip/cidr>` — an operator lifts a temporary D-line. 99 + /// 100 + /// Mirrors [`UNTKLINE`](super::tkline::untkline): a missing/empty mask → `NOTICE :UNDLINE: Incorrect 101 + /// format`, the same [`dline`](OperPrivilege::Dline) privilege gates it, removal is a silent no-op 102 + /// when no ban matched, and the lift propagates network-wide unconditionally (the network may hold 103 + /// the ban on another server). 104 + pub(crate) fn undline(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 105 + if !client.has_privilege(OperPrivilege::Dline) { 106 + return vec![no_privileges(ctx, &client.nick)]; 107 + } 108 + let Some(mask) = msg.params().first().and_then(|m| valid_mask(m)) else { 109 + return vec![incorrect_format(ctx, &client.nick, "UNDLINE")]; 110 + }; 111 + ctx.dlines.remove(mask); 112 + crate::s2s::dline::propagate_undline(ctx, mask); 113 + Vec::new() 114 + } 115 + 116 + /// Validate a D-line mask: non-empty and not the bare `*` everyone-glob (the footgun analogue of 117 + /// the TKLINE `*@*` rejection). Returns the mask unchanged on success, `None` (→ "Incorrect format") 118 + /// otherwise. Any other non-empty mask (an IP, a CIDR, a partial glob) is accepted and matched as 119 + /// CIDR-or-glob by [`matching::host_component_matches`]. 120 + fn valid_mask(mask: &str) -> Option<&str> { 121 + (!mask.is_empty() && mask != "*").then_some(mask) 122 + } 123 + 124 + /// `:<server> NOTICE <nick> :<CMD>: Incorrect format`. 125 + fn incorrect_format(ctx: &ServerContext, nick: &str, cmd: &str) -> Message { 126 + Message::builder("NOTICE") 127 + .prefix(&ctx.name) 128 + .param(nick) 129 + .trailing(format!("{cmd}: Incorrect format")) 130 + .build() 131 + } 132 + 133 + #[cfg(test)] 134 + mod tests { 135 + use super::*; 136 + use crate::command::testutil::*; 137 + use crate::UserMode; 138 + 139 + /// An opered `alice` carrying the `dline` privilege. 140 + fn dline_oper() -> Registered { 141 + let mut c = client(); 142 + c.modes |= UserMode::Oper.bit(); 143 + c.privileges = vec![OperPrivilege::Dline]; 144 + c 145 + } 146 + 147 + fn drive(client: &Registered, ctx: &ServerContext, line: &str) -> Vec<Message> { 148 + dispatch(&mut client.clone(), &Message::parse(line).unwrap(), ctx) 149 + } 150 + 151 + #[test] 152 + fn missing_args_gives_461_before_privilege() { 153 + let ctx = ctx(); 154 + assert_eq!(codes(&drive(&client(), &ctx, "DLINE")), &["461"]); 155 + assert_eq!(codes(&drive(&client(), &ctx, "DLINE 1h")), &["461"]); 156 + assert!(ctx.dlines.is_empty()); 157 + } 158 + 159 + #[test] 160 + fn non_oper_with_enough_args_gets_481_and_lays_nothing() { 161 + let ctx = ctx(); 162 + let r = drive(&client(), &ctx, "DLINE 1h 10.0.0.1 :spam"); 163 + assert_eq!(codes(&r), &["481"]); 164 + assert!(ctx.dlines.is_empty(), "no ban laid by a non-oper"); 165 + } 166 + 167 + #[test] 168 + fn bad_mask_or_duration_gives_incorrect_format_notice() { 169 + let ctx = ctx(); 170 + for line in [ 171 + "DLINE 1h * :r", // bare-* everyone glob 172 + "DLINE 1zzz 10.0.0.1 :r", // bad duration char 173 + ] { 174 + let r = drive(&dline_oper(), &ctx, line); 175 + assert_eq!(r.len(), 1, "{line}"); 176 + assert_eq!(r[0].command(), "NOTICE", "{line}"); 177 + assert_eq!(r[0].trailing(), Some("DLINE: Incorrect format"), "{line}"); 178 + } 179 + assert!(ctx.dlines.is_empty(), "no ban laid on a bad request"); 180 + } 181 + 182 + #[test] 183 + fn successful_dline_stores_the_ban_and_makes_no_reply() { 184 + let ctx = ctx(); 185 + let r = drive(&dline_oper(), &ctx, "DLINE 1h 10.0.0.0/24 :spamming"); 186 + assert!(r.is_empty(), "no in-band reply to the issuer: {r:?}"); 187 + let e = ctx.dlines.entries(); 188 + assert_eq!(e.len(), 1); 189 + assert_eq!(e[0].mask, "10.0.0.0/24"); 190 + assert_eq!(e[0].reason, "spamming"); 191 + } 192 + 193 + #[test] 194 + fn dline_reaps_a_matching_connection_with_465_and_error() { 195 + let ctx = ctx(); 196 + // bob registers from connect-host "h" (the testutil fixture orighost). 197 + let mut bob = claim_observed(&ctx, "bob"); 198 + let r = drive(&dline_oper(), &ctx, "DLINE 1h h :begone"); 199 + assert!(r.is_empty()); 200 + let (wire, reason) = ejected(&mut bob); 201 + assert!(bob.try_recv().is_err(), "exactly one eject frame"); 202 + assert_eq!(reason, "D-line active: begone"); 203 + assert_eq!( 204 + String::from_utf8_lossy(&wire), 205 + ":leveva.test 465 bob :You are banned from this server: begone\r\n\ 206 + ERROR :Closing Link: h (D-line active: begone)\r\n" 207 + ); 208 + } 209 + 210 + #[test] 211 + fn dline_does_not_reap_a_kline_exempt_connection() { 212 + let ctx = ctx(); 213 + let mut bob = claim_observed(&ctx, "bob"); 214 + ctx.registry.set_kline_exempt(&uid_for("bob"), true); 215 + let r = drive(&dline_oper(), &ctx, "DLINE 1h h :begone"); 216 + assert!(r.is_empty()); 217 + assert!(bob.try_recv().is_err(), "an exempt client must not be reaped"); 218 + assert!(ctx.registry.contains("bob")); 219 + assert_eq!(ctx.dlines.len(), 1, "the ban is still stored"); 220 + } 221 + 222 + #[test] 223 + fn dline_does_not_reap_a_non_matching_connection() { 224 + let ctx = ctx(); 225 + let mut bob = claim_observed(&ctx, "bob"); // orighost "h" 226 + let r = drive(&dline_oper(), &ctx, "DLINE 1h 10.0.0.0/24 :nope"); 227 + assert!(r.is_empty()); 228 + assert!(bob.try_recv().is_err()); 229 + assert!(ctx.registry.contains("bob")); 230 + } 231 + 232 + #[test] 233 + fn undline_lifts_an_existing_ban() { 234 + let ctx = ctx(); 235 + let _ = drive(&dline_oper(), &ctx, "DLINE 1h 10.0.0.1 :x"); 236 + assert_eq!(ctx.dlines.len(), 1); 237 + let r = drive(&dline_oper(), &ctx, "UNDLINE 10.0.0.1"); 238 + assert!(r.is_empty()); 239 + assert!(ctx.dlines.is_empty(), "ban lifted"); 240 + } 241 + 242 + #[test] 243 + fn undline_non_oper_gets_481() { 244 + let ctx = ctx(); 245 + let r = drive(&client(), &ctx, "UNDLINE 10.0.0.1"); 246 + assert_eq!(codes(&r), &["481"]); 247 + } 248 + 249 + #[test] 250 + fn undline_bad_mask_gives_incorrect_format() { 251 + let ctx = ctx(); 252 + let r = drive(&dline_oper(), &ctx, "UNDLINE *"); 253 + assert_eq!(r.len(), 1); 254 + assert_eq!(r[0].trailing(), Some("UNDLINE: Incorrect format")); 255 + } 256 + 257 + // --- S2S propagation (P11 slice 284): a successful DLINE/UNDLINE reaches every peer --- 258 + 259 + use crate::ident::{ServerName, Sid}; 260 + use crate::registry::Envelope; 261 + use crate::s2s::network::RemoteServer; 262 + use crate::s2s::PeerLink; 263 + use tokio::sync::mpsc::UnboundedReceiver; 264 + 265 + fn link_peer(ctx: &ServerContext) -> UnboundedReceiver<Envelope> { 266 + let (link, rx) = PeerLink::new( 267 + Sid::try_from("0XYZ").unwrap(), 268 + ServerName::try_from("a.peer.test").unwrap(), 269 + ); 270 + ctx.net.insert_server(RemoteServer { 271 + sid: link.sid.clone(), 272 + name: link.name.clone(), 273 + description: "Peer".into(), 274 + hopcount: 1, 275 + uplink: link.sid.clone(), 276 + parent: ctx.uids.sid().clone(), 277 + }); 278 + ctx.peers 279 + .link(link.sid.clone(), link.mailbox_tx.clone(), link.caps); 280 + rx 281 + } 282 + 283 + fn peer_lines(rx: &mut UnboundedReceiver<Envelope>) -> Vec<String> { 284 + let mut out = Vec::new(); 285 + while let Ok(Envelope::Wire(b)) = rx.try_recv() { 286 + out.push(String::from_utf8(b).unwrap()); 287 + } 288 + out 289 + } 290 + 291 + #[test] 292 + fn a_successful_dline_propagates_the_carrier_to_peers() { 293 + let ctx = ctx(); 294 + let mut rx = link_peer(&ctx); 295 + let _ = drive(&dline_oper(), &ctx, "DLINE 1h 10.0.0.0/24 :spam"); 296 + assert_eq!( 297 + peer_lines(&mut rx), 298 + vec![":0ABC ENCAP * DLINE 3600 10.0.0.0/24 :spam\r\n".to_string()] 299 + ); 300 + } 301 + 302 + #[test] 303 + fn a_successful_undline_propagates_the_lift_to_peers() { 304 + let ctx = ctx(); 305 + let _ = drive(&dline_oper(), &ctx, "DLINE 1h 10.0.0.0/24 :x"); 306 + let mut rx = link_peer(&ctx); 307 + let _ = drive(&dline_oper(), &ctx, "UNDLINE 10.0.0.0/24"); 308 + assert_eq!( 309 + peer_lines(&mut rx), 310 + vec![":0ABC ENCAP * UNDLINE 10.0.0.0/24\r\n".to_string()] 311 + ); 312 + } 313 + 314 + #[test] 315 + fn a_rejected_dline_propagates_nothing() { 316 + let ctx = ctx(); 317 + let mut rx = link_peer(&ctx); 318 + let _ = drive(&client(), &ctx, "DLINE 1h 10.0.0.1 :x"); // 481 319 + let _ = drive(&dline_oper(), &ctx, "DLINE 1h"); // 461 320 + let _ = drive(&dline_oper(), &ctx, "DLINE 1h * :x"); // incorrect format 321 + let _ = drive(&client(), &ctx, "UNDLINE 10.0.0.1"); // 481 322 + assert!( 323 + peer_lines(&mut rx).is_empty(), 324 + "a rejected DLINE/UNDLINE must not propagate" 325 + ); 326 + } 327 + 328 + /// The local reap fires for a local matching client but never for a remote one. 329 + #[test] 330 + fn local_reap_skips_remote_users() { 331 + let ctx = ctx(); 332 + let mut local = claim_observed(&ctx, "bob"); // 0ABC-prefixed uid, orighost "h" 333 + let remote_uid = crate::ident::Uid::try_from("0XYZAAAAA").unwrap(); 334 + let (tx, mut remote_rx) = tokio::sync::mpsc::unbounded_channel(); 335 + ctx.registry 336 + .try_claim(&remote_uid, "rover", "u", "h", "R", tx) 337 + .unwrap(); 338 + 339 + let _ = drive(&dline_oper(), &ctx, "DLINE 1h h :begone"); 340 + let (_w, reason) = ejected(&mut local); 341 + assert_eq!(reason, "D-line active: begone"); 342 + assert!( 343 + remote_rx.try_recv().is_err(), 344 + "a remote user must not be reaped locally" 345 + ); 346 + } 347 + }
+2 -1
leveva/src/command/help.rs
··· 100 100 "INFO", 101 101 "LUSERS", 102 102 "USERS", "SUMMON", "MOTD", "HELP", "LINKS", "MAP", "STATS", "TRACE", "ETRACE", "TKLINE", 103 - "UNTKLINE", "RESV", "UNRESV", "REHASH", "RESTART", "DIE", "SQUIT", "CONNECT", "MKPASSWD", 103 + "UNTKLINE", "DLINE", "UNDLINE", "RESV", "UNRESV", "REHASH", "RESTART", "DIE", "SQUIT", 104 + "CONNECT", "MKPASSWD", 104 105 ]; 105 106 106 107 /// Reference topic pages that are *not* commands — multi-word mode catalogues the
+3
leveva/src/command/mod.rs
··· 172 172 "ETRACE" => etrace::etrace(client, msg, ctx), 173 173 "TKLINE" => tkline::tkline(client, msg, ctx), 174 174 "UNTKLINE" => tkline::untkline(client, msg, ctx), 175 + "DLINE" => dline::dline(client, msg, ctx), 176 + "UNDLINE" => dline::undline(client, msg, ctx), 175 177 "RESV" => resv::resv(client, msg, ctx), 176 178 "UNRESV" => resv::unresv(client, msg, ctx), 177 179 "LINKS" => links::links(nick, msg, ctx), ··· 587 589 pub(crate) mod chghost; 588 590 mod connect; 589 591 mod die; 592 + pub(crate) mod dline; 590 593 mod etrace; 591 594 mod help; 592 595 pub(crate) mod hunt;
+91
leveva/src/command/stats.rs
··· 340 340 out.extend(config_ban_report(server, nick, bans)); 341 341 true 342 342 } 343 + Some('d' | 'D') => { 344 + // 250 RPL_STATSDLINE: active IP-level D-lines from the live `DlineStore` (P11 slice 345 + // 284), one line per not-yet-expired ban. `now` read once so `dline_report` stays 346 + // deterministic. leveva has no separate permanent D-line store, so `d` and `D` render 347 + // the same active set. 348 + let now = now_secs(); 349 + out.extend(dline_report(server, nick, &ctx.dlines.entries(), now)); 350 + true 351 + } 343 352 Some('K') => { 344 353 // Recognized but empty: leveva has no separate permanent-kline (`kconf`) *store* to 345 354 // enumerate — the permanent static config `ban {}` blocks are reported under `k` ··· 425 434 .param(&k.user) 426 435 .param(k.expires - now) 427 436 .trailing(&k.reason) 437 + .build() 438 + }) 439 + .collect() 440 + } 441 + 442 + /// Render the active IP-level D-lines (P11 slice 284) as `250 RPL_STATSDLINE` lines for `STATS d`, 443 + /// the IP analogue of [`kline_report`]. One line per not-yet-expired ban, in store order: 444 + /// 445 + /// ```text 446 + /// :<server> 250 <nick> d <ip/cidr> <ttl> :<reason> 447 + /// ``` 448 + /// 449 + /// where `<ttl>` is the seconds remaining (`expires - now`, never negative — expired entries are 450 + /// filtered, not rendered negative). leveva-native wire form (the C oracle had no D-lines); the 451 + /// `<ttl>` is wall-clock-volatile and the boot-golden canonicalizer masks it. 452 + fn dline_report(server: &str, nick: &str, entries: &[crate::dline::Dline], now: i64) -> Vec<Message> { 453 + entries 454 + .iter() 455 + .filter(|d| now < d.expires) 456 + .map(|d| { 457 + line(server, 250, nick) 458 + .param('d') 459 + .param(&d.mask) 460 + .param(d.expires - now) 461 + .trailing(&d.reason) 428 462 .build() 429 463 }) 430 464 .collect() ··· 1156 1190 let r = run(&ctx, "STATS k"); 1157 1191 assert_eq!(codes(&r), &["219"]); 1158 1192 assert_eq!(r[0].params(), &["alice", "k"]); 1193 + } 1194 + 1195 + // ── slice 284: STATS d — active IP-level D-lines (250 RPL_STATSDLINE) ───────── 1196 + 1197 + use crate::dline::Dline; 1198 + 1199 + #[test] 1200 + fn dline_report_renders_one_250_per_active_ban_in_order() { 1201 + let bans = [ 1202 + Dline { mask: "10.0.0.0/24".into(), reason: "spam".into(), expires: 1_000 }, 1203 + Dline { mask: "192.0.2.7".into(), reason: "scanner".into(), expires: 2_000 }, 1204 + ]; 1205 + let r = dline_report("leveva.test", "alice", &bans, 100); 1206 + assert_eq!(codes(&r), &["250", "250"]); 1207 + // `:srv 250 nick d <mask> <ttl> :<reason>`. 1208 + assert_eq!(r[0].params(), &["alice", "d", "10.0.0.0/24", "900"]); 1209 + assert_eq!(r[0].trailing(), Some("spam")); 1210 + assert_eq!(r[1].params(), &["alice", "d", "192.0.2.7", "1900"]); 1211 + assert_eq!(r[0].prefix(), Some("leveva.test")); 1212 + } 1213 + 1214 + #[test] 1215 + fn dline_report_filters_expired() { 1216 + // Inverse: an entry already expired at `now` is not rendered (never a negative ttl). 1217 + let bans = [ 1218 + Dline { mask: "10.0.0.0/24".into(), reason: "lapsed".into(), expires: 500 }, 1219 + Dline { mask: "192.0.2.7".into(), reason: "active".into(), expires: 900 }, 1220 + ]; 1221 + let r = dline_report("leveva.test", "alice", &bans, 500); 1222 + assert_eq!(codes(&r), &["250"]); 1223 + assert_eq!(r[0].params(), &["alice", "d", "192.0.2.7", "400"]); 1224 + } 1225 + 1226 + #[test] 1227 + fn stats_d_lists_the_store_then_the_d_terminator() { 1228 + let ctx = stats_ctx(); 1229 + ctx.dlines.add("10.0.0.0/24", "spam", i64::MAX); 1230 + let r = run(&ctx, "STATS d"); 1231 + assert_eq!(codes(&r), &["250", "219"]); 1232 + let p = r[0].params(); 1233 + // `:srv 250 nick d <mask> <ttl> :<reason>` — the ttl is wall-clock-volatile. 1234 + assert_eq!(p[1], "d"); 1235 + assert_eq!(p[2], "10.0.0.0/24"); 1236 + assert_eq!(r[0].trailing(), Some("spam")); 1237 + // Terminator echoes the requested letter `d`, not `*`. 1238 + assert_eq!(r[1].params(), &["alice", "d"]); 1239 + } 1240 + 1241 + #[test] 1242 + fn stats_d_after_remove_is_just_the_terminator() { 1243 + // Inverse at the handler layer: lay a ban, remove it → no 250 body, `d` still recognized. 1244 + let ctx = stats_ctx(); 1245 + ctx.dlines.add("10.0.0.0/24", "spam", i64::MAX); 1246 + assert!(ctx.dlines.remove("10.0.0.0/24")); 1247 + let r = run(&ctx, "STATS d"); 1248 + assert_eq!(codes(&r), &["219"]); 1249 + assert_eq!(r[0].params(), &["alice", "d"]); 1159 1250 } 1160 1251 1161 1252 // ── slice 192: STATS k also lists the permanent static config `ban {}` blocks ─
+2
leveva/src/command/testutil.rs
··· 199 199 operators: Vec::new(), 200 200 stats_conf: crate::server::StatsConf::default(), 201 201 klines: crate::kline::KlineStore::new(), 202 + dlines: crate::dline::DlineStore::new(), 202 203 net: crate::s2s::Network::new(), 203 204 peers: crate::s2s::PeerLinks::new(), 204 205 auth: crate::server::AuthConfig::default(), ··· 240 241 operators: Vec::new(), 241 242 stats_conf: crate::server::StatsConf::default(), 242 243 klines: crate::kline::KlineStore::new(), 244 + dlines: crate::dline::DlineStore::new(), 243 245 net: crate::s2s::Network::new(), 244 246 peers: crate::s2s::PeerLinks::new(), 245 247 auth: crate::server::AuthConfig::default(),
+7 -1
leveva/src/config/privilege.rs
··· 61 61 /// `resv` — RESV/UNRESV nick and channel reservation. leveva-native (charybdis's distinct 62 62 /// `resv` flag); the C oracle is gone, so this bit lives past the C `ACL_*` range. 63 63 Resv, 64 + /// `dline` — DLINE/UNDLINE IP-level bans. leveva-native (charybdis's distinct `dline` flag); 65 + /// the C oracle is gone, so this bit lives past the C `ACL_*` range. 66 + Dline, 64 67 } 65 68 66 69 impl OperPrivilege { 67 70 /// Every privilege, in declaration order (the canonical order in which a 68 71 /// parsed [`crate::config::Operator::privileges`] set is stored). 69 - pub const ALL: [OperPrivilege; 21] = [ 72 + pub const ALL: [OperPrivilege; 22] = [ 70 73 OperPrivilege::KillLocal, 71 74 OperPrivilege::KillRemote, 72 75 OperPrivilege::SquitLocal, ··· 88 91 OperPrivilege::Kline, 89 92 OperPrivilege::SidTrace, 90 93 OperPrivilege::Resv, 94 + OperPrivilege::Dline, 91 95 ]; 92 96 93 97 /// The canonical kebab-case token for this privilege, e.g. `"kill-local"`. ··· 114 118 OperPrivilege::Kline => "kline", 115 119 OperPrivilege::SidTrace => "sid-trace", 116 120 OperPrivilege::Resv => "resv", 121 + OperPrivilege::Dline => "dline", 117 122 } 118 123 } 119 124 ··· 142 147 OperPrivilege::SidTrace => 0x100000, 143 148 // leveva-native (past the C `ACL_*` range; the oracle is gone). 144 149 OperPrivilege::Resv => 0x200000, 150 + OperPrivilege::Dline => 0x400000, 145 151 } 146 152 } 147 153
+425
leveva/src/dline.rs
··· 1 + //! Temporary D-lines (DLINE) — the operator-managed **IP-level** ban list. 2 + //! 3 + //! An operator may `DLINE <duration> <ip/cidr> [:reason]` to ban a raw IP address or a 4 + //! CIDR block for a bounded time; `UNDLINE <ip/cidr>` lifts it. A D-line is the IP/CIDR 5 + //! sibling of the `user@host` [`KlineStore`](crate::kline::KlineStore): it **reaps** any 6 + //! currently-connected local client whose *connect IP* matches (see 7 + //! [`crate::command::dline`]) and **refuses** a matching client at registration 8 + //! ([`DlineStore::find_active`] is consulted in [`crate::session::Session::feed`], before 9 + //! the K-line gate). Bans expire on their own — a D-line is inert once the wall clock 10 + //! passes its `expires` instant. 11 + //! 12 + //! **Network scope (P11 slice 284).** A `DLINE`/`UNDLINE` **propagates network-wide** as 13 + //! `ENCAP * DLINE`/`UNDLINE` (carrying the *remaining* seconds) and re-asserts to a 14 + //! freshly-linked peer via the netburst (see [`crate::s2s::dline`]). Every server applies 15 + //! the ban to its own store — so the registration gate fires everywhere — and reaps its 16 + //! **own local** matching clients (a remote user is reaped by *its* server). This mirrors 17 + //! the K-line plane (slice 281) exactly. 18 + //! 19 + //! **Persistence (P11 slice 284).** When opened against a `database {}` SQLite file 20 + //! ([`DlineStore::open`]) the store is write-through: bans survive a restart, exactly as 21 + //! the [`KlineStore`](crate::kline::KlineStore) does (slice 282). A `new()` store is 22 + //! in-memory only. 23 + //! 24 + //! The duration grammar, clamp, and clock are shared with the K-line family — this module 25 + //! reuses [`crate::kline::parse_duration`], [`crate::kline::now_secs`], and 26 + //! [`crate::kline::TKLINE_MAXTIME`] rather than re-deriving them. 27 + //! 28 + //! ## Matching 29 + //! 30 + //! A D-line mask is matched against the client IP with 31 + //! [`crate::matching::host_component_matches`]: a CIDR spec (`addr/prefix`, e.g. 32 + //! `10.0.0.0/24`) takes the CIDR path, anything else (a bare IP, a `*`/`?` glob) the glob 33 + //! path. There is no `user@host` split — a D-line is host-only. 34 + 35 + use std::sync::Mutex; 36 + 37 + use rusqlite::{params, Connection}; 38 + 39 + use crate::matching; 40 + 41 + /// One active IP-level ban: a CIDR/glob `mask`, a reason, and the absolute unix-second 42 + /// instant at which it stops applying. 43 + #[derive(Debug, Clone, PartialEq, Eq)] 44 + pub struct Dline { 45 + /// The IP/CIDR mask (e.g. `10.0.0.0/24`, `192.168.1.7`, or a `*` glob). 46 + pub mask: String, 47 + /// The free-text ban reason, shown to a reaped/refused client in `465`. 48 + pub reason: String, 49 + /// Unix seconds at which the ban expires; inert once `now >= expires`. 50 + pub expires: i64, 51 + } 52 + 53 + impl Dline { 54 + /// Does this D-line cover the connecting `ip`? CIDR-match when the mask is an 55 + /// `addr/prefix` spec, else glob-match (case-insensitive, RFC 1459). 56 + pub fn covers(&self, ip: &str) -> bool { 57 + matching::host_component_matches(&self.mask, ip) 58 + } 59 + } 60 + 61 + /// The DDL for the optional persistence table. `COLLATE NOCASE` makes the `mask` primary key 62 + /// case-insensitive, matching the in-memory store's case-folded dedup, so an `INSERT OR REPLACE` 63 + /// of a case-differing re-`DLINE` refreshes the row rather than stacking a duplicate. 64 + const DLINE_SCHEMA: &str = " 65 + CREATE TABLE IF NOT EXISTS dline ( 66 + mask TEXT NOT NULL COLLATE NOCASE PRIMARY KEY, 67 + reason TEXT NOT NULL, 68 + expires INTEGER NOT NULL 69 + ); 70 + "; 71 + 72 + /// The shared IP-level temporary-ban list. Authority on which IPs are banned, consulted both 73 + /// when a ban is laid (to reap matching connections) and at every registration (to refuse 74 + /// matching clients). The K-line analogue is [`KlineStore`](crate::kline::KlineStore); this 75 + /// store is identical bar the single-`mask` key (no `user@host` split). 76 + /// 77 + /// **Persistence (P11 slice 284).** When opened against a `database {}` file ([`DlineStore::open`]) 78 + /// the store carries a write-through SQLite connection: every [`add`](Self::add)/[`remove`](Self::remove)/ 79 + /// [`prune`](Self::prune) is mirrored to the `dline` table, and [`open`](Self::open) loads the 80 + /// surviving (non-expired) bans back into memory at boot. A store built with [`new`](Self::new)/ 81 + /// [`default`](Self::default) (`db == None`) is in-memory only — every persistence path is a no-op. 82 + #[derive(Default)] 83 + pub struct DlineStore { 84 + inner: Mutex<Vec<Dline>>, 85 + /// `None` for an in-memory-only store; otherwise the write-through SQLite connection to the 86 + /// `database {}` file. The in-memory [`inner`](Self::inner) Vec stays the hot-path authority. 87 + db: Option<Mutex<Connection>>, 88 + } 89 + 90 + impl std::fmt::Debug for DlineStore { 91 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 92 + f.debug_struct("DlineStore") 93 + .field("len", &self.len()) 94 + .field("persistent", &self.db.is_some()) 95 + .finish() 96 + } 97 + } 98 + 99 + impl DlineStore { 100 + /// An empty, in-memory-only store (no persistence). 101 + pub fn new() -> Self { 102 + Self::default() 103 + } 104 + 105 + /// Open (creating if absent) the SQLite database at `path`, initialise the `dline` table, and 106 + /// **load** the surviving (not-yet-expired) bans into memory — so a `DLINE` laid before a 107 + /// restart is still in force after it. `path` may be `:memory:`. Returns the rusqlite error on 108 + /// failure so the caller can log and fall back to an in-memory store. 109 + pub fn open(path: &str) -> rusqlite::Result<DlineStore> { 110 + let conn = Connection::open(path)?; 111 + let _ = conn.pragma_update(None, "journal_mode", "WAL"); 112 + let _ = conn.busy_timeout(std::time::Duration::from_secs(5)); 113 + conn.execute_batch(DLINE_SCHEMA)?; 114 + let store = DlineStore { 115 + inner: Mutex::new(Vec::new()), 116 + db: Some(Mutex::new(conn)), 117 + }; 118 + store.load(); 119 + Ok(store) 120 + } 121 + 122 + /// Load the persisted bans into memory at boot: drop any row that has already expired, then 123 + /// read the rest into the in-memory Vec. A no-op for an in-memory-only store. Errors are logged, 124 + /// never propagated — a corrupt row must not abort boot. 125 + fn load(&self) { 126 + let Some(lock) = &self.db else { return }; 127 + let now = crate::kline::now_secs(); 128 + let loaded: Vec<Dline> = { 129 + let conn = lock.lock().unwrap(); 130 + let _ = conn.execute("DELETE FROM dline WHERE expires <= ?1", params![now]); 131 + let mut stmt = match conn.prepare("SELECT mask, reason, expires FROM dline") { 132 + Ok(s) => s, 133 + Err(e) => { 134 + tracing::warn!(error = %e, "dline: prepare load failed"); 135 + return; 136 + } 137 + }; 138 + let rows = stmt.query_map([], |r| { 139 + Ok(Dline { 140 + mask: r.get(0)?, 141 + reason: r.get(1)?, 142 + expires: r.get(2)?, 143 + }) 144 + }); 145 + match rows { 146 + Ok(it) => it.filter_map(Result::ok).collect(), 147 + Err(e) => { 148 + tracing::warn!(error = %e, "dline: load query failed"); 149 + return; 150 + } 151 + } 152 + }; 153 + *self.inner.lock().unwrap() = loaded; 154 + } 155 + 156 + /// Write-through helper: upsert one ban into the `dline` table. A no-op when not persistent. 157 + fn persist_add(&self, mask: &str, reason: &str, expires: i64) { 158 + let Some(lock) = &self.db else { return }; 159 + let conn = lock.lock().unwrap(); 160 + if let Err(e) = conn.execute( 161 + "INSERT OR REPLACE INTO dline (mask, reason, expires) VALUES (?1, ?2, ?3)", 162 + params![mask, reason, expires], 163 + ) { 164 + tracing::warn!(error = %e, "dline: persist add failed"); 165 + } 166 + } 167 + 168 + /// Write-through helper: delete one ban (by case-insensitive `mask`). No-op when not persistent. 169 + fn persist_remove(&self, mask: &str) { 170 + let Some(lock) = &self.db else { return }; 171 + let conn = lock.lock().unwrap(); 172 + if let Err(e) = conn.execute("DELETE FROM dline WHERE mask = ?1", params![mask]) { 173 + tracing::warn!(error = %e, "dline: persist remove failed"); 174 + } 175 + } 176 + 177 + /// Add a ban, or **refresh** an existing one. The key is the `mask`, matched 178 + /// case-insensitively; a re-`DLINE` of the same mask updates its reason and expiry rather than 179 + /// stacking a duplicate. 180 + pub fn add(&self, mask: &str, reason: &str, expires: i64) { 181 + { 182 + let mut v = self.inner.lock().unwrap(); 183 + if let Some(d) = v.iter_mut().find(|d| d.mask.eq_ignore_ascii_case(mask)) { 184 + d.reason = reason.to_string(); 185 + d.expires = expires; 186 + } else { 187 + v.push(Dline { 188 + mask: mask.to_string(), 189 + reason: reason.to_string(), 190 + expires, 191 + }); 192 + } 193 + } 194 + self.persist_add(mask, reason, expires); 195 + } 196 + 197 + /// Remove the ban whose mask equals `mask` (case-insensitively). Returns whether anything was 198 + /// removed. 199 + pub fn remove(&self, mask: &str) -> bool { 200 + let removed = { 201 + let mut v = self.inner.lock().unwrap(); 202 + let before = v.len(); 203 + v.retain(|d| !d.mask.eq_ignore_ascii_case(mask)); 204 + v.len() != before 205 + }; 206 + self.persist_remove(mask); 207 + removed 208 + } 209 + 210 + /// The first **active** (not-yet-expired at `now`) D-line whose mask covers `ip`, if any. Used 211 + /// both to refuse a registering client and to decide whether a connected client should be reaped. 212 + pub fn find_active(&self, ip: &str, now: i64) -> Option<Dline> { 213 + let v = self.inner.lock().unwrap(); 214 + v.iter().find(|d| now < d.expires && d.covers(ip)).cloned() 215 + } 216 + 217 + /// Drop every D-line that has expired at `now`. Pure housekeeping — 218 + /// [`find_active`](Self::find_active) already ignores expired entries, so this only reclaims 219 + /// memory (and the matching disk rows). 220 + pub fn prune(&self, now: i64) { 221 + self.inner.lock().unwrap().retain(|d| now < d.expires); 222 + if let Some(lock) = &self.db { 223 + let conn = lock.lock().unwrap(); 224 + if let Err(e) = conn.execute("DELETE FROM dline WHERE expires <= ?1", params![now]) { 225 + tracing::warn!(error = %e, "dline: persist prune failed"); 226 + } 227 + } 228 + } 229 + 230 + /// A snapshot of the current bans (for tests / introspection / STATS d). 231 + pub fn entries(&self) -> Vec<Dline> { 232 + self.inner.lock().unwrap().clone() 233 + } 234 + 235 + /// How many bans are stored (expired or not). 236 + pub fn len(&self) -> usize { 237 + self.inner.lock().unwrap().len() 238 + } 239 + 240 + /// Whether the store holds no bans. 241 + pub fn is_empty(&self) -> bool { 242 + self.inner.lock().unwrap().is_empty() 243 + } 244 + } 245 + 246 + #[cfg(test)] 247 + mod tests { 248 + use super::*; 249 + 250 + #[test] 251 + fn add_find_expire_roundtrip() { 252 + let s = DlineStore::new(); 253 + assert!(s.is_empty()); 254 + s.add("10.0.0.0/24", "spam", 100); 255 + assert_eq!(s.len(), 1); 256 + 257 + // Active before expiry; CIDR covers a matching IP. 258 + let hit = s.find_active("10.0.0.7", 50).expect("active"); 259 + assert_eq!(hit.reason, "spam"); 260 + // Inverse 1: an IP outside the block is not covered. 261 + assert!(s.find_active("10.0.1.1", 50).is_none()); 262 + // Inverse 2: at/after expiry the same query misses. 263 + assert!(s.find_active("10.0.0.7", 100).is_none()); 264 + assert!(s.find_active("10.0.0.7", 200).is_none()); 265 + } 266 + 267 + #[test] 268 + fn bare_ip_and_glob_masks_match() { 269 + let s = DlineStore::new(); 270 + s.add("192.168.1.7", "r", 100); 271 + s.add("172.16.*", "r", 100); 272 + assert!(s.find_active("192.168.1.7", 0).is_some()); 273 + assert!(s.find_active("192.168.1.8", 0).is_none()); 274 + assert!(s.find_active("172.16.4.9", 0).is_some()); 275 + assert!(s.find_active("172.17.0.1", 0).is_none()); 276 + } 277 + 278 + #[test] 279 + fn add_same_mask_refreshes_not_duplicates() { 280 + let s = DlineStore::new(); 281 + s.add("10.0.0.0/24", "first", 100); 282 + s.add("10.0.0.0/24", "second", 500); 283 + assert_eq!(s.len(), 1, "refresh, not a duplicate"); 284 + let hit = s.find_active("10.0.0.5", 200).expect("still active"); 285 + assert_eq!(hit.reason, "second"); 286 + assert_eq!(hit.expires, 500); 287 + } 288 + 289 + #[test] 290 + fn remove_lifts_the_ban() { 291 + let s = DlineStore::new(); 292 + s.add("10.0.0.1", "spam", 100); 293 + assert!(s.remove("10.0.0.1")); 294 + assert!(s.is_empty()); 295 + // Inverse: now nobody matches, and a second remove reports nothing removed. 296 + assert!(s.find_active("10.0.0.1", 50).is_none()); 297 + assert!(!s.remove("10.0.0.1")); 298 + } 299 + 300 + #[test] 301 + fn prune_drops_expired_only() { 302 + let s = DlineStore::new(); 303 + s.add("1.1.1.1", "r", 100); 304 + s.add("2.2.2.2", "r", 300); 305 + s.prune(200); 306 + let e = s.entries(); 307 + assert_eq!(e.len(), 1); 308 + assert_eq!(e[0].mask, "2.2.2.2"); 309 + } 310 + 311 + // ── slice 284: SQLite persistence (write-through + boot reload) ──────────────── 312 + 313 + fn unique_db_path(tag: &str) -> std::path::PathBuf { 314 + use std::sync::atomic::{AtomicU64, Ordering}; 315 + static N: AtomicU64 = AtomicU64::new(0); 316 + let n = N.fetch_add(1, Ordering::Relaxed); 317 + std::env::temp_dir().join(format!("leveva-dline-{tag}-{}-{n}.db", std::process::id())) 318 + } 319 + 320 + fn cleanup(p: &std::path::Path) { 321 + let _ = std::fs::remove_file(p); 322 + let _ = std::fs::remove_file(format!("{}-wal", p.display())); 323 + let _ = std::fs::remove_file(format!("{}-shm", p.display())); 324 + } 325 + 326 + #[test] 327 + fn a_persisted_dline_survives_a_reopen() { 328 + let path = unique_db_path("survive"); 329 + cleanup(&path); 330 + let ps = path.to_str().unwrap(); 331 + let now = crate::kline::now_secs(); 332 + { 333 + let s = DlineStore::open(ps).unwrap(); 334 + s.add("10.0.0.0/24", "spam", now + 3600); 335 + s.add("192.168.0.1", "abuse", now + 7200); 336 + } 337 + let s2 = DlineStore::open(ps).unwrap(); 338 + assert_eq!(s2.len(), 2, "both bans reloaded"); 339 + assert_eq!(s2.find_active("10.0.0.9", now).unwrap().reason, "spam"); 340 + // Inverse: lift one, reopen again — only it is gone. 341 + assert!(s2.remove("10.0.0.0/24")); 342 + drop(s2); 343 + let s3 = DlineStore::open(ps).unwrap(); 344 + assert_eq!(s3.len(), 1); 345 + assert!(s3.find_active("10.0.0.9", now).is_none()); 346 + assert!(s3.find_active("192.168.0.1", now).is_some()); 347 + cleanup(&path); 348 + } 349 + 350 + #[test] 351 + fn an_expired_dline_is_not_reloaded() { 352 + let path = unique_db_path("expired"); 353 + cleanup(&path); 354 + let ps = path.to_str().unwrap(); 355 + let now = crate::kline::now_secs(); 356 + { 357 + let s = DlineStore::open(ps).unwrap(); 358 + s.add("10.0.0.1", "r", now + 3600); 359 + s.add("10.0.0.2", "r", now - 10); 360 + } 361 + let s2 = DlineStore::open(ps).unwrap(); 362 + assert_eq!(s2.len(), 1, "the expired ban is not reloaded"); 363 + assert!(s2.find_active("10.0.0.2", now).is_none()); 364 + assert!(s2.find_active("10.0.0.1", now).is_some()); 365 + cleanup(&path); 366 + } 367 + 368 + #[test] 369 + fn an_in_memory_store_writes_nothing() { 370 + let s = DlineStore::new(); 371 + s.add("10.0.0.1", "r", 100); 372 + assert!(s.remove("10.0.0.1")); 373 + s.prune(0); 374 + } 375 + 376 + proptest::proptest! { 377 + #![proptest_config(proptest::test_runner::Config::with_cases(24))] 378 + 379 + /// After an arbitrary add/remove sequence on a file-backed store, **reopening** it yields 380 + /// exactly the live `mask` set that was in memory before the close. 381 + #[test] 382 + fn the_persisted_set_matches_after_reopen( 383 + ops in proptest::collection::vec( 384 + (proptest::bool::ANY, "(1|2|10)\\.(0|1)\\.0\\.[0-9](/24)?"), 385 + 1..14, 386 + ), 387 + ) { 388 + use std::collections::BTreeSet; 389 + let path = unique_db_path("fuzz"); 390 + cleanup(&path); 391 + let ps = path.to_str().unwrap(); 392 + let now = crate::kline::now_secs(); 393 + let in_memory: BTreeSet<String> = { 394 + let s = DlineStore::open(ps).unwrap(); 395 + for (is_add, mask) in &ops { 396 + if *is_add { 397 + s.add(mask, "r", now + 3600); 398 + } else { 399 + s.remove(mask); 400 + } 401 + } 402 + s.entries().iter().map(|d| d.mask.to_ascii_lowercase()).collect() 403 + }; 404 + let reloaded: BTreeSet<String> = DlineStore::open(ps) 405 + .unwrap() 406 + .entries() 407 + .iter() 408 + .map(|d| d.mask.to_ascii_lowercase()) 409 + .collect(); 410 + cleanup(&path); 411 + proptest::prop_assert_eq!(reloaded, in_memory); 412 + } 413 + 414 + /// `Dline::covers` is exactly `host_component_matches(mask, ip)` (CIDR-or-glob) and never 415 + /// panics over arbitrary input. 416 + #[test] 417 + fn covers_equals_host_component_matches( 418 + mask in "[0-9.*/]{0,18}", 419 + ip in "[0-9.]{0,15}", 420 + ) { 421 + let d = Dline { mask: mask.clone(), reason: "r".into(), expires: 1 }; 422 + proptest::prop_assert_eq!(d.covers(&ip), matching::host_component_matches(&mask, &ip)); 423 + } 424 + } 425 + }
+1
leveva/src/lib.rs
··· 61 61 pub mod connlimit; 62 62 pub mod connstats; 63 63 pub mod control; 64 + pub mod dline; 64 65 pub mod elist; 65 66 pub mod extban; 66 67 pub mod flood;
+83
leveva/src/s2s/burst.rs
··· 388 388 // re-asserts with its **remaining** seconds; an already-expired ban bursts nothing. 389 389 out.extend(kline_burst(ctx)); 390 390 391 + // 3.8. Operator-laid temporary D-lines (P11 slice 284) — the IP-level analogue of the K-line 392 + // burst above; each active ban re-asserts with its remaining seconds. 393 + out.extend(dline_burst(ctx)); 394 + 391 395 // 4. End of burst. 392 396 out.extend(Message::builder("EOB").prefix(our_sid).to_wire()); 393 397 out ··· 477 481 .param(&k.user) 478 482 .param(&k.host) 479 483 .trailing(&k.reason) 484 + .to_wire(), 485 + ); 486 + } 487 + out 488 + } 489 + 490 + /// Re-assert every **active** (not-yet-expired) temporary D-line to a freshly-linked peer as 491 + /// `:<our-sid> ENCAP * DLINE <remaining> <ip/cidr> :<reason>` lines — the IP-level analogue of 492 + /// [`kline_burst`], carrying the **remaining** seconds so a long-laid ban re-asserts with the time 493 + /// it has left. The receiver routes each through [`super::forward::inbound_encap`] → 494 + /// [`super::dline::apply_encap_dline`]. An already-expired ban bursts nothing. 495 + fn dline_burst(ctx: &ServerContext) -> Vec<u8> { 496 + let our_sid = ctx.uids.sid(); 497 + let now = crate::kline::now_secs(); 498 + let mut out = Vec::new(); 499 + for d in ctx.dlines.entries() { 500 + let remaining = d.expires - now; 501 + if remaining <= 0 { 502 + continue; 503 + } 504 + out.extend( 505 + Message::builder("ENCAP") 506 + .prefix(our_sid) 507 + .param("*") 508 + .param("DLINE") 509 + .param(remaining) 510 + .param(&d.mask) 511 + .trailing(&d.reason) 480 512 .to_wire(), 481 513 ); 482 514 } ··· 950 982 assert!( 951 983 !lines.iter().any(|l| l.contains(" ENCAP * KLINE ")), 952 984 "no active kline → no KLINE burst line: {lines:?}" 985 + ); 986 + } 987 + 988 + /// The D-line burst (P11 slice 284): an active D-line re-asserts with its remaining time; an 989 + /// expired one bursts nothing, and the DLINE line precedes EOB. 990 + #[test] 991 + fn the_burst_re_asserts_active_dlines_with_remaining_time() { 992 + let ctx = ctx(); 993 + let now = crate::kline::now_secs(); 994 + ctx.dlines.add("10.0.0.0/24", "spam", now + 3600); 995 + ctx.dlines.add("172.16.0.0/24", "stale", now - 10); 996 + 997 + let lines = burst_lines(&ctx); 998 + let dlines: Vec<&String> = lines 999 + .iter() 1000 + .filter(|l| l.contains(" ENCAP * DLINE ")) 1001 + .collect(); 1002 + assert_eq!(dlines.len(), 1, "only the active ban bursts: {lines:?}"); 1003 + let line = dlines[0]; 1004 + assert!( 1005 + line.starts_with(":0ABC ENCAP * DLINE ") && line.ends_with(" 10.0.0.0/24 :spam"), 1006 + "unexpected DLINE burst line: {line}" 1007 + ); 1008 + let remaining: i64 = line 1009 + .split_whitespace() 1010 + .nth(4) 1011 + .and_then(|s| s.parse().ok()) 1012 + .expect("a numeric remaining duration"); 1013 + assert!((3599..=3600).contains(&remaining), "remaining {remaining} not ≈3600"); 1014 + assert!( 1015 + !lines.iter().any(|l| l.contains("172.16.0.0/24")), 1016 + "an expired dline is not bursted: {lines:?}" 1017 + ); 1018 + let eob = lines.iter().position(|l| l == ":0ABC EOB").unwrap(); 1019 + let last_dline = lines 1020 + .iter() 1021 + .rposition(|l| l.contains(" ENCAP * DLINE ")) 1022 + .unwrap(); 1023 + assert!(last_dline < eob, "dlines precede EOB: {lines:?}"); 1024 + } 1025 + 1026 + /// Inverse: a server with no active D-lines bursts no DLINE line at all. 1027 + #[test] 1028 + fn no_active_dline_means_no_dline_burst_line() { 1029 + let ctx = ctx(); 1030 + ctx.dlines 1031 + .add("10.0.0.0/24", "stale", crate::kline::now_secs() - 10); 1032 + let lines = burst_lines(&ctx); 1033 + assert!( 1034 + !lines.iter().any(|l| l.contains(" ENCAP * DLINE ")), 1035 + "no active dline → no DLINE burst line: {lines:?}" 953 1036 ); 954 1037 } 955 1038
+378
leveva/src/s2s/dline.rs
··· 1 + //! **`DLINE` / `UNDLINE` propagation (`ENCAP * DLINE` / `ENCAP * UNDLINE`)** — the network plane 2 + //! for the [`DLINE`](crate::command::dline)/[`UNDLINE`](crate::command::dline::undline) operator 3 + //! commands (P11 slice 284), the IP-level analogue of the K-line propagation (slice 281, 4 + //! [`super::kline`]). 5 + //! 6 + //! A D-line originates at the [`DLINE`](crate::command::dline) command (a local operator) or arrives 7 + //! over a link as `:<sid> ENCAP * DLINE <duration> <ip/cidr> :<reason>` / 8 + //! `:<sid> ENCAP * UNDLINE <ip/cidr>`. Either way every server applies it to its own 9 + //! [`DlineStore`](crate::dline::DlineStore) — so the registration D-line gate fires network-wide — 10 + //! and **reaps its own local matching clients** (a remote user is reaped by *its* server). This is 11 + //! structurally identical to the K-line plane bar the single-`mask` payload. 12 + //! 13 + //! Like [`super::kline`], the carrier rides `ENCAP * DLINE` (a leveva-native verb the IRCnet 2.11 14 + //! oracle never had, tolerated by a capability-unaware peer via `m_nop`), is **server-prefixed** 15 + //! (`:<sid>`), and carries the **remaining seconds** so each server re-clocks `expires = now + 16 + //! duration` against its own clock (no cross-server skew). The [`super::burst::dline_burst`] 17 + //! re-asserts active D-lines with their remaining time. 18 + //! 19 + //! # Divergences (documented) 20 + //! 21 + //! - **Broadcast vs point-to-point** — leveva broadcasts to all peers (the standing [`super::relay`] 22 + //! divergence). 23 + //! - **`UNDLINE` does not un-kill** — it lifts the future-registration gate; an already-reaped 24 + //! client stays gone. Propagates unconditionally, mirroring `UNKLINE`/`UNRESV`. 25 + //! - **leveva-native** — no `leveva-integration` differential. 26 + 27 + use crate::kline::{now_secs, TKLINE_MAXTIME}; 28 + use crate::server::ServerContext; 29 + use crate::Message; 30 + 31 + use super::PeerLink; 32 + 33 + /// Broadcast a locally-laid D-line to every peer as 34 + /// `:<our-sid> ENCAP * DLINE <duration> <ip/cidr> :<reason>`. `duration` is the remaining seconds 35 + /// (the caller's clamped value). A no-op when there are no peers. 36 + pub fn propagate_dline(ctx: &ServerContext, mask: &str, duration: i64, reason: &str) { 37 + if ctx.peers.is_empty() { 38 + return; 39 + } 40 + ctx.peers.broadcast( 41 + None, 42 + Message::builder("ENCAP") 43 + .prefix(ctx.uids.sid().as_str()) 44 + .param("*") 45 + .param("DLINE") 46 + .param(duration) 47 + .param(mask) 48 + .trailing(reason) 49 + .to_wire(), 50 + ); 51 + } 52 + 53 + /// Broadcast a locally-lifted D-line to every peer as `:<our-sid> ENCAP * UNDLINE <ip/cidr>`. A 54 + /// no-op when there are no peers. 55 + pub fn propagate_undline(ctx: &ServerContext, mask: &str) { 56 + if ctx.peers.is_empty() { 57 + return; 58 + } 59 + ctx.peers.broadcast( 60 + None, 61 + Message::builder("ENCAP") 62 + .prefix(ctx.uids.sid().as_str()) 63 + .param("*") 64 + .param("UNDLINE") 65 + .param(mask) 66 + .to_wire(), 67 + ); 68 + } 69 + 70 + /// Inbound `:<sid> ENCAP * DLINE <duration> <ip/cidr> [:<reason>]` — a peer announcing a D-line. 71 + /// Re-clamps the duration to `[0, TKLINE_MAXTIME]`, stores `mask` with an absolute 72 + /// `expires = now + duration` in this server's [`DlineStore`], and reaps every **local** matching 73 + /// client (via [`crate::command::dline::reap_matching_ip`], which skips remote and `kline-exempt` 74 + /// clients). The reason defaults to `""`. A missing duration/mask leaves the store untouched. Does 75 + /// **not** relay — the caller ([`super::forward::inbound_encap`]) onward-relays every `ENCAP`. 76 + pub(crate) fn apply_encap_dline(_link: &PeerLink, msg: &Message, ctx: &ServerContext) { 77 + // Params after the ENCAP target-mask (`*`) and the `DLINE` verb: 78 + // [`*`, `DLINE`, <duration>, <mask>] + optional trailing/middle reason. 79 + let p = msg.params(); 80 + let (Some(dur_s), Some(mask)) = (p.get(2), p.get(3).filter(|s| !s.is_empty())) else { 81 + return; 82 + }; 83 + let secs = dur_s.parse::<i64>().unwrap_or(0).clamp(0, TKLINE_MAXTIME); 84 + let reason = msg 85 + .trailing() 86 + .or_else(|| p.get(4).map(String::as_str)) 87 + .unwrap_or(""); 88 + let now = now_secs(); 89 + ctx.dlines.add(mask, reason, now + secs); 90 + crate::command::dline::reap_matching_ip(ctx, mask, reason); 91 + } 92 + 93 + /// Inbound `:<sid> ENCAP * UNDLINE <ip/cidr>` — a peer lifting a D-line. Removes the matching `mask` 94 + /// from this server's store; a mask not present here is a silent no-op. Does **not** un-kill an 95 + /// already-reaped client, and does **not** relay. 96 + pub(crate) fn apply_encap_undline(_link: &PeerLink, msg: &Message, ctx: &ServerContext) { 97 + let p = msg.params(); 98 + let Some(mask) = p.get(2).filter(|s| !s.is_empty()) else { 99 + return; 100 + }; 101 + ctx.dlines.remove(mask); 102 + } 103 + 104 + #[cfg(test)] 105 + mod tests { 106 + use super::*; 107 + use crate::ident::{ServerName, Sid, Uid}; 108 + use crate::registry::Envelope; 109 + use crate::s2s::network::RemoteServer; 110 + use std::sync::Arc; 111 + use tokio::sync::mpsc::UnboundedReceiver; 112 + 113 + fn ctx() -> Arc<ServerContext> { 114 + let kdl = r#" 115 + server { name "leveva.test"; description "Leveva Test"; sid "0ABC" } 116 + admin { name "A"; email "a@test"; network "Net" } 117 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 118 + "#; 119 + let cfg = crate::config::Config::from_kdl(kdl).expect("valid test config"); 120 + ServerContext::from_config(&cfg, "now".to_string(), None) 121 + } 122 + 123 + fn link_peer( 124 + ctx: &ServerContext, 125 + sid_s: &str, 126 + name_s: &str, 127 + ) -> (PeerLink, UnboundedReceiver<Envelope>) { 128 + let (link, rx) = PeerLink::new( 129 + Sid::try_from(sid_s).unwrap(), 130 + ServerName::try_from(name_s).unwrap(), 131 + ); 132 + ctx.net.insert_server(RemoteServer { 133 + sid: link.sid.clone(), 134 + name: link.name.clone(), 135 + description: "Peer".into(), 136 + hopcount: 1, 137 + uplink: link.sid.clone(), 138 + parent: ctx.uids.sid().clone(), 139 + }); 140 + ctx.peers 141 + .link(link.sid.clone(), link.mailbox_tx.clone(), link.caps); 142 + (link, rx) 143 + } 144 + 145 + fn claim_local( 146 + ctx: &ServerContext, 147 + uid_s: &str, 148 + nick: &str, 149 + host: &str, 150 + ) -> UnboundedReceiver<Envelope> { 151 + let uid = Uid::try_from(uid_s).unwrap(); 152 + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); 153 + ctx.registry 154 + .try_claim(&uid, nick, "u", host, "Real", tx) 155 + .unwrap(); 156 + rx 157 + } 158 + 159 + fn drain(rx: &mut UnboundedReceiver<Envelope>) -> Vec<String> { 160 + let mut out = Vec::new(); 161 + while let Ok(env) = rx.try_recv() { 162 + if let Envelope::Wire(b) = env { 163 + out.push(String::from_utf8(b).unwrap()); 164 + } 165 + } 166 + out 167 + } 168 + 169 + fn ejected(rx: &mut UnboundedReceiver<Envelope>) -> Option<String> { 170 + while let Ok(env) = rx.try_recv() { 171 + if let Envelope::Eject { reason, .. } = env { 172 + return Some(reason); 173 + } 174 + } 175 + None 176 + } 177 + 178 + fn parse(line: &str) -> Message { 179 + Message::parse(line).unwrap() 180 + } 181 + 182 + #[test] 183 + fn propagate_dline_emits_the_carrier() { 184 + let ctx = ctx(); 185 + let (_p, mut rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 186 + propagate_dline(&ctx, "10.0.0.0/24", 3600, "spam"); 187 + assert_eq!( 188 + drain(&mut rx), 189 + vec![":0ABC ENCAP * DLINE 3600 10.0.0.0/24 :spam\r\n".to_string()] 190 + ); 191 + } 192 + 193 + #[test] 194 + fn propagate_undline_emits_the_bare_carrier() { 195 + let ctx = ctx(); 196 + let (_p, mut rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 197 + propagate_undline(&ctx, "10.0.0.0/24"); 198 + assert_eq!( 199 + drain(&mut rx), 200 + vec![":0ABC ENCAP * UNDLINE 10.0.0.0/24\r\n".to_string()] 201 + ); 202 + } 203 + 204 + #[test] 205 + fn propagate_with_no_peers_is_a_noop() { 206 + let ctx = ctx(); 207 + propagate_dline(&ctx, "10.0.0.1", 60, "x"); 208 + propagate_undline(&ctx, "10.0.0.1"); 209 + let (_p, mut rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 210 + assert_eq!(drain(&mut rx), Vec::<String>::new()); 211 + } 212 + 213 + #[test] 214 + fn inbound_dline_stores_an_active_covering_ban() { 215 + let ctx = ctx(); 216 + let (a, _a_rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 217 + apply_encap_dline( 218 + &a, 219 + &parse(":0XYZ ENCAP * DLINE 3600 10.0.0.0/24 :go away"), 220 + &ctx, 221 + ); 222 + let e = ctx.dlines.entries(); 223 + assert_eq!(e.len(), 1); 224 + assert_eq!(e[0].mask, "10.0.0.0/24"); 225 + assert_eq!(e[0].reason, "go away"); 226 + let now = now_secs(); 227 + assert!(ctx.dlines.find_active("10.0.0.9", now).is_some()); 228 + // Inverse: a non-matching IP is not covered. 229 + assert!(ctx.dlines.find_active("10.0.1.9", now).is_none()); 230 + } 231 + 232 + #[test] 233 + fn inbound_dline_reaps_a_local_matching_client_but_not_a_remote_or_exempt_one() { 234 + let ctx = ctx(); 235 + let (a, _a_rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 236 + let mut local = claim_local(&ctx, "0ABCAAAAA", "bob", "1.2.3.4"); 237 + let mut remote = claim_local(&ctx, "0XYZAAAAA", "rover", "1.2.3.4"); 238 + let mut exempt = claim_local(&ctx, "0ABCAAAAB", "carol", "1.2.3.4"); 239 + ctx.registry 240 + .set_kline_exempt(&Uid::try_from("0ABCAAAAB").unwrap(), true); 241 + 242 + apply_encap_dline(&a, &parse(":0XYZ ENCAP * DLINE 3600 1.2.3.0/24 :begone"), &ctx); 243 + 244 + assert_eq!(ejected(&mut local).as_deref(), Some("D-line active: begone")); 245 + assert!(ejected(&mut remote).is_none(), "a remote user is reaped by its own server"); 246 + assert!(ejected(&mut exempt).is_none(), "a kline-exempt local client is spared"); 247 + assert_eq!(ctx.dlines.len(), 1); 248 + } 249 + 250 + #[test] 251 + fn inbound_undline_lifts_a_ban() { 252 + let ctx = ctx(); 253 + let (a, _a_rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 254 + ctx.dlines.add("10.0.0.0/24", "x", now_secs() + 3600); 255 + apply_encap_undline(&a, &parse(":0XYZ ENCAP * UNDLINE 10.0.0.0/24"), &ctx); 256 + assert!(ctx.dlines.is_empty(), "the ban was lifted"); 257 + // Inverse: a second lift (now absent) is a silent no-op, no panic. 258 + apply_encap_undline(&a, &parse(":0XYZ ENCAP * UNDLINE 10.0.0.0/24"), &ctx); 259 + assert!(ctx.dlines.is_empty()); 260 + } 261 + 262 + #[test] 263 + fn inbound_malformed_line_leaves_the_store_untouched() { 264 + let ctx = ctx(); 265 + let (a, _a_rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 266 + apply_encap_dline(&a, &parse(":0XYZ ENCAP * DLINE 3600"), &ctx); // missing mask 267 + apply_encap_undline(&a, &parse(":0XYZ ENCAP * UNDLINE"), &ctx); // missing mask 268 + assert!(ctx.dlines.is_empty()); 269 + } 270 + 271 + #[test] 272 + fn inbound_dline_routed_through_forward_is_applied_and_relayed() { 273 + let ctx = ctx(); 274 + let (mut a, _a_rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 275 + let (_b, mut b_rx) = link_peer(&ctx, "0DEF", "b.peer.test"); 276 + 277 + let out = crate::s2s::forward::inbound_encap( 278 + &mut a, 279 + &parse(":0XYZ ENCAP * DLINE 3600 10.0.0.0/24 :hi"), 280 + &ctx, 281 + ); 282 + assert!(out.is_empty()); 283 + assert!(ctx.dlines.find_active("10.0.0.9", now_secs()).is_some()); 284 + assert_eq!( 285 + drain(&mut b_rx), 286 + vec![":0XYZ ENCAP * DLINE 3600 10.0.0.0/24 :hi\r\n".to_string()] 287 + ); 288 + } 289 + 290 + #[test] 291 + fn inbound_undline_routed_through_forward_lifts_and_relays() { 292 + let ctx = ctx(); 293 + let (mut a, _a_rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 294 + let (_b, mut b_rx) = link_peer(&ctx, "0DEF", "b.peer.test"); 295 + ctx.dlines.add("10.0.0.0/24", "x", now_secs() + 3600); 296 + 297 + let out = crate::s2s::forward::inbound_encap( 298 + &mut a, 299 + &parse(":0XYZ ENCAP * UNDLINE 10.0.0.0/24"), 300 + &ctx, 301 + ); 302 + assert!(out.is_empty()); 303 + assert!(ctx.dlines.is_empty(), "lifted"); 304 + assert_eq!( 305 + drain(&mut b_rx), 306 + vec![":0XYZ ENCAP * UNDLINE 10.0.0.0/24\r\n".to_string()] 307 + ); 308 + } 309 + 310 + use proptest::prelude::*; 311 + 312 + proptest! { 313 + /// An arbitrary interleaving of inbound `ENCAP * DLINE`/`UNDLINE` lines never panics and 314 + /// leaves the store **banned iff the last op on that `mask` was a DLINE** — total and 315 + /// consistent. 316 + #[test] 317 + fn inbound_dline_apply_is_total_and_consistent( 318 + ops in proptest::collection::vec( 319 + (proptest::bool::ANY, "(1|2|10)\\.0\\.0\\.[0-9](/24)?"), 320 + 1..16, 321 + ), 322 + ) { 323 + let ctx = ctx(); 324 + let (link, _rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 325 + use std::collections::BTreeSet; 326 + let mut live: BTreeSet<String> = BTreeSet::new(); 327 + 328 + for (is_dline, mask) in &ops { 329 + if *is_dline { 330 + let line = format!(":0XYZ ENCAP * DLINE 3600 {mask} :r"); 331 + apply_encap_dline(&link, &parse(&line), &ctx); 332 + live.insert(mask.to_ascii_lowercase()); 333 + } else { 334 + let line = format!(":0XYZ ENCAP * UNDLINE {mask}"); 335 + apply_encap_undline(&link, &parse(&line), &ctx); 336 + live.remove(&mask.to_ascii_lowercase()); 337 + } 338 + } 339 + 340 + let stored: BTreeSet<String> = ctx 341 + .dlines 342 + .entries() 343 + .iter() 344 + .map(|d| d.mask.to_ascii_lowercase()) 345 + .collect(); 346 + prop_assert_eq!(stored, live); 347 + } 348 + 349 + /// A junk `ENCAP * DLINE`/`UNDLINE` line never panics; an `UNDLINE` only ever removes the 350 + /// matching `mask`, never an unrelated ban. 351 + #[test] 352 + fn inbound_junk_never_panics_and_undline_is_targeted( 353 + args in proptest::collection::vec("[a-z0-9.*/]{1,6}", 0..5), 354 + ) { 355 + let ctx = ctx(); 356 + let (link, _rx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 357 + ctx.dlines.add("ZZkeep", "x", now_secs() + 3600); 358 + 359 + let joined = args.join(" "); 360 + let dline_line = format!(":0XYZ ENCAP * DLINE {joined}"); 361 + let undline_line = format!(":0XYZ ENCAP * UNDLINE {joined}"); 362 + if let Ok(m) = Message::parse(dline_line.trim_end()) { 363 + apply_encap_dline(&link, &m, &ctx); 364 + } 365 + if let Ok(m) = Message::parse(undline_line.trim_end()) { 366 + apply_encap_undline(&link, &m, &ctx); 367 + } 368 + 369 + // The unrelated ban survives unless its exact mask was UNDLINE'd — i.e. the first junk 370 + // arg is exactly "ZZkeep". 371 + let undline_hit_keep = !args.is_empty() && args[0].eq_ignore_ascii_case("ZZkeep"); 372 + prop_assert_eq!( 373 + ctx.dlines.find_active("ZZkeep", now_secs()).is_some(), 374 + !undline_hit_keep 375 + ); 376 + } 377 + } 378 + }
+5
leveva/src/s2s/forward.rs
··· 119 119 // network-wide, and reap this server's own local matching clients. 120 120 Some("KLINE") => super::kline::apply_encap_kline(link, msg, ctx), 121 121 Some("UNKLINE") => super::kline::apply_encap_unkline(link, msg, ctx), 122 + // The DLINE/UNDLINE propagation relays (P11 slice 284, leveva-native): apply a remote 123 + // IP-level ban (or its lift) to this server's store so the registration D-line gate fires 124 + // network-wide, and reap this server's own local matching clients. 125 + Some("DLINE") => super::dline::apply_encap_dline(link, msg, ctx), 126 + Some("UNDLINE") => super::dline::apply_encap_undline(link, msg, ctx), 122 127 // The SANICK oper-override relay (P11 slice 182): only the target's home server performs 123 128 // the forced rename (via the local NICK machinery, whose `:<uid> NICK <new>` relay then 124 129 // carries it network-wide); everywhere else this is a no-op.
+1
leveva/src/s2s/mod.rs
··· 35 35 pub mod chghost; 36 36 pub mod collision; 37 37 pub mod eob; 38 + pub mod dline; 38 39 pub mod forward; 39 40 pub mod handshake; 40 41 pub mod invite;
+32
leveva/src/server.rs
··· 130 130 /// (which also reaps matching connections), lifted by `UNTKLINE`, and consulted at 131 131 /// every registration to refuse a banned client (`465`). See [`crate::kline`]. 132 132 pub klines: KlineStore, 133 + /// The shared **IP-level** temporary-ban list (P11 slice 284) — authority on which IPs are 134 + /// D-lined. Laid by `DLINE` (which also reaps matching connections by their connect IP), 135 + /// lifted by `UNDLINE`, and consulted at every registration to refuse a banned IP (`465`), 136 + /// before the [`klines`](Self::klines) gate. See [`crate::dline`]. 137 + pub dlines: crate::dline::DlineStore, 133 138 /// The shared **network mirror** — every remote server and remote user learned over 134 139 /// an S2S link. Empty on a single-server boot; populated by the S2S burst and drained 135 140 /// by `SQUIT`. Keyed by [`Sid`](crate::ident::Sid)/[`Uid`](crate::ident::Uid) in ··· 363 368 } 364 369 }, 365 370 }, 371 + // IP-level D-lines (slice 284): same `database {}` persistence shape as the K-lines 372 + // above — survive a restart when configured, else in-memory only; an open failure logs 373 + // and falls back rather than aborting boot. 374 + dlines: match &cfg.database { 375 + None => crate::dline::DlineStore::new(), 376 + Some(db) => match crate::dline::DlineStore::open(&db.path) { 377 + Ok(s) => { 378 + tracing::info!(path = %db.path, "persistent D-lines enabled"); 379 + s 380 + } 381 + Err(e) => { 382 + tracing::error!(path = %db.path, error = %e, 383 + "cannot open D-line database; D-lines in-memory only"); 384 + crate::dline::DlineStore::new() 385 + } 386 + }, 387 + }, 366 388 net: crate::s2s::Network::new(), 367 389 peers: crate::s2s::PeerLinks::new(), 368 390 auth: AuthConfig { ··· 484 506 let ctx = ServerContext::from_config(&cfg, "now".into(), None); 485 507 ctx.klines 486 508 .add("bob", "*.evil.net", "spam", crate::kline::now_secs() + 3600); 509 + ctx.dlines 510 + .add("10.0.0.0/24", "ipspam", crate::kline::now_secs() + 3600); 487 511 ctx.resvs.add("Clone*", "no clones", false); 488 512 let alice = crate::ident::Uid::try_from("0ABCAAAAA").unwrap(); 489 513 ctx.channels.join("#perm", &alice, 1234); ··· 506 530 "the kline reloaded across the reboot" 507 531 ); 508 532 assert!( 533 + ctx2.dlines 534 + .find_active("10.0.0.9", crate::kline::now_secs()) 535 + .is_some(), 536 + "the D-line reloaded across the reboot" 537 + ); 538 + assert!( 509 539 ctx2.resvs.nick_reserved("Clone7").is_some(), 510 540 "the reservation reloaded across the reboot" 511 541 ); ··· 537 567 { 538 568 let ctx = ServerContext::from_config(&cfg, "now".into(), None); 539 569 ctx.klines.add("bob", "h", "x", crate::kline::now_secs() + 3600); 570 + ctx.dlines.add("10.0.0.0/24", "x", crate::kline::now_secs() + 3600); 540 571 ctx.resvs.add("Clone*", "x", false); 541 572 let alice = crate::ident::Uid::try_from("0ABCAAAAA").unwrap(); 542 573 ctx.channels.join("#perm", &alice, 1); ··· 551 582 } 552 583 let ctx2 = ServerContext::from_config(&cfg, "now".into(), None); 553 584 assert!(ctx2.klines.is_empty(), "no persistence without a database block"); 585 + assert!(ctx2.dlines.is_empty(), "no D-line persistence without a database block"); 554 586 // The resv store still seeds any config reservations (none here) but no runtime ones. 555 587 assert!(ctx2.resvs.runtime_entries().is_empty()); 556 588 assert!(
+69 -1
leveva/src/session.rs
··· 1237 1237 return; 1238 1238 } 1239 1239 1240 + let now = crate::clock::to_unix_seconds(crate::clock::unixnano()) as i64; 1241 + 1242 + // D-line gate (P11 slice 284): a client whose *connect IP* matches an active D-line is 1243 + // refused here, before the K-line gate below — an IP ban is the coarsest, earliest filter. 1244 + // `self.host` at this point is the raw pre-cloak connect host (the IP, in leveva's no-DNS 1245 + // world); cloaking happens later, after registration finalizes. The same `kline-exempt` 1246 + // I-line (the C `FLAGS_EXEMPT`, charybdis's combined k/d-line exemption) bypasses it. 1247 + if let Some(d) = (!admission.kline_exempt) 1248 + .then(|| ctx.dlines.find_active(&self.host, now)) 1249 + .flatten() 1250 + { 1251 + out.extend( 1252 + Message::builder(Numeric::ErrYourebannedcreep) 1253 + .prefix(&ctx.name) 1254 + .param(&nick) 1255 + .trailing(format!("You are banned from this server: {}", d.reason)) 1256 + .build() 1257 + .to_wire(), 1258 + ); 1259 + out.extend( 1260 + Message::builder("ERROR") 1261 + .trailing(format!( 1262 + "Closing Link: {} (D-line active: {})", 1263 + self.host, d.reason 1264 + )) 1265 + .to_wire(), 1266 + ); 1267 + // REJ snomask spy (slice 262): an active D-line banned this connection — fan a 1268 + // `+s +r` notice (charybdis `SNO_REJ`), like the K-line gate. 1269 + crate::snotice::server_notice_cat( 1270 + ctx, 1271 + crate::snomask::SnoMask::rej(), 1272 + &crate::snotice::reject_notice(&nick, &user, &self.host, &d.reason), 1273 + ); 1274 + self.closing = true; 1275 + return; 1276 + } 1277 + 1240 1278 // K-line gate: a client whose resolved `user@host` matches an active ban is 1241 1279 // refused here (the oracle's registration-time `find_kill`), before it is ever 1242 1280 // counted or claimed. It receives `465` then an ERROR close. A `kline-exempt` I-line 1243 1281 // (the C `CFLAG_KEXEMPT`) skips this gate entirely. 1244 - let now = crate::clock::to_unix_seconds(crate::clock::unixnano()) as i64; 1245 1282 if let Some(k) = (!admission.kline_exempt) 1246 1283 .then(|| ctx.klines.find_active(&user, &self.host, now)) 1247 1284 .flatten() ··· 2153 2190 let ctx = ctx(); 2154 2191 // A ban for a different host must not touch this client. 2155 2192 ctx.klines.add("*", "10.0.0.0", "elsewhere", i64::MAX); 2193 + let mut s = Session::new("127.0.0.1"); 2194 + let out = text(&s.feed(b"NICK alice\r\nUSER alice 0 * :A\r\n", &ctx)); 2195 + assert!(out.contains(" 001 alice "), "got: {out}"); 2196 + assert!(s.is_registered()); 2197 + assert_eq!(ctx.counters.snapshot().users, 1); 2198 + } 2199 + 2200 + #[test] 2201 + fn a_dlined_client_is_refused_at_registration() { 2202 + let ctx = ctx(); 2203 + // An always-active D-line covering this connection's IP (CIDR-matched). 2204 + ctx.dlines.add("127.0.0.0/24", "go away", i64::MAX); 2205 + let mut s = Session::new("127.0.0.1"); 2206 + let out = text(&s.feed(b"NICK mallory\r\nUSER m 0 * :M\r\n", &ctx)); 2207 + assert!( 2208 + out.contains(" 465 mallory :You are banned from this server: go away"), 2209 + "got: {out}" 2210 + ); 2211 + assert!(out.contains("ERROR :Closing Link: 127.0.0.1 (D-line active: go away)")); 2212 + // Inverse: the client never registered — not counted, not in the registry. 2213 + assert!(!s.is_registered()); 2214 + assert!(s.should_close()); 2215 + assert_eq!(ctx.counters.snapshot().users, 0); 2216 + assert!(!ctx.registry.contains("mallory")); 2217 + } 2218 + 2219 + #[test] 2220 + fn an_unmatched_client_registers_normally_despite_a_dline() { 2221 + let ctx = ctx(); 2222 + // A D-line for a different IP block must not touch this client. 2223 + ctx.dlines.add("10.0.0.0/24", "elsewhere", i64::MAX); 2156 2224 let mut s = Session::new("127.0.0.1"); 2157 2225 let out = text(&s.feed(b"NICK alice\r\nUSER alice 0 * :A\r\n", &ctx)); 2158 2226 assert!(out.contains(" 001 alice "), "got: {out}");
+1
leveva/tests/account_tag_proptest.rs
··· 43 43 operators: Vec::new(), 44 44 stats_conf: leveva::server::StatsConf::default(), 45 45 klines: leveva::kline::KlineStore::new(), 46 + dlines: leveva::dline::DlineStore::new(), 46 47 net: leveva::s2s::Network::new(), 47 48 peers: leveva::s2s::PeerLinks::new(), 48 49 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/away_notify_proptest.rs
··· 42 42 operators: Vec::new(), 43 43 stats_conf: leveva::server::StatsConf::default(), 44 44 klines: leveva::kline::KlineStore::new(), 45 + dlines: leveva::dline::DlineStore::new(), 45 46 net: leveva::s2s::Network::new(), 46 47 peers: leveva::s2s::PeerLinks::new(), 47 48 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/away_proptest.rs
··· 46 46 operators: Vec::new(), 47 47 stats_conf: leveva::server::StatsConf::default(), 48 48 klines: leveva::kline::KlineStore::new(), 49 + dlines: leveva::dline::DlineStore::new(), 49 50 net: leveva::s2s::Network::new(), 50 51 peers: leveva::s2s::PeerLinks::new(), 51 52 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/bot_mode_proptest.rs
··· 46 46 operators: Vec::new(), 47 47 stats_conf: leveva::server::StatsConf::default(), 48 48 klines: leveva::kline::KlineStore::new(), 49 + dlines: leveva::dline::DlineStore::new(), 49 50 net: leveva::s2s::Network::new(), 50 51 peers: leveva::s2s::PeerLinks::new(), 51 52 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/bot_tag_proptest.rs
··· 36 36 operators: Vec::new(), 37 37 stats_conf: leveva::server::StatsConf::default(), 38 38 klines: leveva::kline::KlineStore::new(), 39 + dlines: leveva::dline::DlineStore::new(), 39 40 net: leveva::s2s::Network::new(), 40 41 peers: leveva::s2s::PeerLinks::new(), 41 42 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/cap_proptest.rs
··· 37 37 operators: Vec::new(), 38 38 stats_conf: leveva::server::StatsConf::default(), 39 39 klines: leveva::kline::KlineStore::new(), 40 + dlines: leveva::dline::DlineStore::new(), 40 41 net: leveva::s2s::Network::new(), 41 42 peers: leveva::s2s::PeerLinks::new(), 42 43 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/channel_lifecycle_proptest.rs
··· 78 78 operators: Vec::new(), 79 79 stats_conf: leveva::server::StatsConf::default(), 80 80 klines: leveva::kline::KlineStore::new(), 81 + dlines: leveva::dline::DlineStore::new(), 81 82 net: leveva::s2s::Network::new(), 82 83 peers: leveva::s2s::PeerLinks::new(), 83 84 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/channels_proptest.rs
··· 52 52 operators: Vec::new(), 53 53 stats_conf: leveva::server::StatsConf::default(), 54 54 klines: leveva::kline::KlineStore::new(), 55 + dlines: leveva::dline::DlineStore::new(), 55 56 net: leveva::s2s::Network::new(), 56 57 peers: leveva::s2s::PeerLinks::new(), 57 58 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/control_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+260
leveva/tests/dline_proptest.rs
··· 1 + //! Property-based fuzzing of the DLINE/UNDLINE control-plane at the 2 + //! [`dispatch`](leveva::command::dispatch) layer (P11 slice 284) — the IP-level analogue of 3 + //! `kline_proptest`. 4 + //! 5 + //! DLINE has side effects (it stores a ban and reaps matching connections by their connect IP) 6 + //! rather than a rich reply, so the fuzzing surface is the **param/privilege gate**, the **reap 7 + //! fan-out**, and panic-freedom: 8 + //! 9 + //! - **`gate_is_param_count_then_privilege`** — too few args → `461`; with enough args the gate is 10 + //! `481` iff the actor lacks `dline`; a privileged, well-formed request returns no reply. 11 + //! - **`reap_hits_exactly_the_covered_set`** — a privileged DLINE delivers exactly one 12 + //! [`Envelope::Eject`] to every registered connection the mask covers (by `orighost`) and nothing 13 + //! to the rest, and the store ends holding exactly that one ban. 14 + //! - **`exempt_clients_are_never_reaped`** — a `kline-exempt` client is never reaped, even by a 15 + //! covering mask. 16 + //! - **`dline_then_undline_is_empty`** — laying then lifting the same mask leaves the store empty. 17 + //! - **`arbitrary_never_panics`** — DLINE/UNDLINE with arbitrary printable args never panics and 18 + //! only ever returns `{461, 481, NOTICE}` (or nothing). 19 + 20 + use std::sync::Arc; 21 + 22 + use leveva::channel::Channels; 23 + use leveva::command::{dispatch, Registered}; 24 + use leveva::config::{Admin, OperPrivilege}; 25 + use leveva::registry::{Envelope, Registry}; 26 + use leveva::server::{Counters, ServerContext}; 27 + use leveva::session::Session; 28 + use leveva::{Message, Sid, Uid}; 29 + use proptest::prelude::*; 30 + use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; 31 + 32 + const SERVER: &str = "leveva.test"; 33 + 34 + fn ctx() -> Arc<ServerContext> { 35 + Arc::new(ServerContext { 36 + name: SERVER.to_string(), 37 + description: "T".to_string(), 38 + network: "TestNet".to_string(), 39 + version: "leveva-0.0.0", 40 + created: "FIXED".to_string(), 41 + motd: None, 42 + counters: Counters::default(), 43 + registry: Registry::new(), 44 + channels: Channels::new(), 45 + whowas: leveva::whowas::WhowasHistory::default(), 46 + uids: leveva::uid::UidGenerator::new(Sid::try_from("0ABC").unwrap()), 47 + default_user_modes: 0, 48 + default_channel_modes: 0, 49 + operators: Vec::new(), 50 + stats_conf: leveva::server::StatsConf::default(), 51 + klines: leveva::kline::KlineStore::new(), 52 + dlines: leveva::dline::DlineStore::new(), 53 + net: leveva::s2s::Network::new(), 54 + peers: leveva::s2s::PeerLinks::new(), 55 + monitors: leveva::monitor::Monitors::new(), 56 + conn_limits: leveva::connlimit::ConnLimits::new(), 57 + linking: leveva::link::LinkingSet::new(), 58 + knock_throttle: leveva::knock_throttle::KnockThrottle::new(), 59 + knock_user_throttle: leveva::knock_throttle::KnockThrottle::new(), 60 + resvs: leveva::resv::ResvStore::new(), 61 + history: leveva::history::History::disabled(), 62 + auth: leveva::server::AuthConfig::default(), 63 + admin: Admin::default(), 64 + }) 65 + } 66 + 67 + fn uid(i: usize) -> Uid { 68 + let c = (b'A' + i as u8) as char; 69 + Uid::try_from(format!("0ABCAAAA{c}").as_str()).unwrap() 70 + } 71 + 72 + fn oper(privileges: Vec<OperPrivilege>) -> Registered { 73 + Registered { 74 + uid: uid(0), 75 + nick: "root".into(), 76 + user: "u".into(), 77 + host: "127.0.0.1".into(), 78 + realname: "R".into(), 79 + modes: 0, 80 + privileges, 81 + caps: Default::default(), 82 + } 83 + } 84 + 85 + fn codes(ms: &[Message]) -> Vec<&str> { 86 + ms.iter().map(|m| m.command()).collect() 87 + } 88 + 89 + /// Claim `nick` from connect-IP `host` (the `orighost` a D-line matches) with an observable mailbox. 90 + fn claim(ctx: &ServerContext, i: usize, nick: &str, host: &str) -> UnboundedReceiver<Envelope> { 91 + let (tx, rx) = unbounded_channel(); 92 + ctx.registry 93 + .try_claim(&uid(i), nick, "u", host, "r", tx) 94 + .expect("free nick"); 95 + rx 96 + } 97 + 98 + proptest! { 99 + /// Too few args → 461 whatever the privilege; otherwise 481 iff unprivileged. 100 + #[test] 101 + fn gate_is_param_count_then_privilege(privileged in any::<bool>(), give_mask in any::<bool>()) { 102 + let ctx = ctx(); 103 + let privs = if privileged { vec![OperPrivilege::Dline] } else { vec![] }; 104 + let mut actor = oper(privs); 105 + 106 + // DLINE with only the duration (one arg) is always 461. 107 + let r = dispatch(&mut actor, &Message::parse("DLINE 1h").unwrap(), &ctx); 108 + prop_assert_eq!(codes(&r), vec!["461"]); 109 + 110 + // With enough args the gate is the privilege alone. 111 + let line = if give_mask { "DLINE 1h 10.0.0.1 :spam" } else { "UNDLINE 10.0.0.1" }; 112 + let r = dispatch(&mut actor, &Message::parse(line).unwrap(), &ctx); 113 + if privileged { 114 + prop_assert!(r.is_empty(), "privileged + valid → no reply, got {:?}", codes(&r)); 115 + } else { 116 + prop_assert_eq!(codes(&r), vec!["481"]); 117 + prop_assert!(ctx.dlines.is_empty(), "an unprivileged actor laid no ban"); 118 + } 119 + } 120 + 121 + /// A privileged DLINE ejects exactly the connections its mask covers (by connect IP), and the 122 + /// store holds exactly that one ban afterwards. 123 + #[test] 124 + fn reap_hits_exactly_the_covered_set( 125 + ips in proptest::collection::vec("10\\.0\\.0\\.[0-9]", 1..6usize), 126 + ) { 127 + let ctx = ctx(); 128 + let mask = "10.0.0.0/29"; // covers 10.0.0.0 .. 10.0.0.7 129 + let mut mailboxes: Vec<(UnboundedReceiver<Envelope>, bool)> = Vec::new(); 130 + for (i, ip) in ips.iter().enumerate() { 131 + let rx = claim(&ctx, i, &format!("n{i}"), ip); 132 + let covered = leveva::matching::host_component_matches(mask, ip); 133 + mailboxes.push((rx, covered)); 134 + } 135 + 136 + let line = format!("DLINE 1h {mask} :begone"); 137 + let r = dispatch(&mut oper(vec![OperPrivilege::Dline]), &Message::parse(&line).unwrap(), &ctx); 138 + prop_assert!(r.is_empty()); 139 + 140 + for (rx, covered) in &mut mailboxes { 141 + match rx.try_recv() { 142 + Ok(Envelope::Eject { .. }) => prop_assert!(*covered, "an uncovered client was ejected"), 143 + Err(_) => prop_assert!(!*covered, "a covered client was not ejected"), 144 + Ok(other) => prop_assert!(false, "unexpected frame {other:?}"), 145 + } 146 + prop_assert!(rx.try_recv().is_err(), "at most one frame per client"); 147 + } 148 + let e = ctx.dlines.entries(); 149 + prop_assert_eq!(e.len(), 1); 150 + prop_assert_eq!(e[0].mask.as_str(), mask); 151 + } 152 + 153 + /// A `kline-exempt` client is never reaped, even by a covering mask: the ejected set is exactly 154 + /// *covered ∧ ¬exempt*, and the single ban is stored regardless of who it reaped. 155 + #[test] 156 + fn exempt_clients_are_never_reaped( 157 + ips in proptest::collection::vec("10\\.0\\.0\\.[0-9]", 1..6usize), 158 + exempt_flags in proptest::collection::vec(any::<bool>(), 1..6usize), 159 + ) { 160 + let ctx = ctx(); 161 + let mask = "10.0.0.0/29"; 162 + let mut mailboxes: Vec<(UnboundedReceiver<Envelope>, Uid, bool, bool)> = Vec::new(); 163 + for (i, ip) in ips.iter().enumerate() { 164 + let rx = claim(&ctx, i, &format!("n{i}"), ip); 165 + let exempt = *exempt_flags.get(i).unwrap_or(&false); 166 + if exempt { 167 + ctx.registry.set_kline_exempt(&uid(i), true); 168 + } 169 + let covered = leveva::matching::host_component_matches(mask, ip); 170 + mailboxes.push((rx, uid(i), covered, exempt)); 171 + } 172 + 173 + let line = format!("DLINE 1h {mask} :begone"); 174 + let r = dispatch(&mut oper(vec![OperPrivilege::Dline]), &Message::parse(&line).unwrap(), &ctx); 175 + prop_assert!(r.is_empty()); 176 + 177 + for (rx, u, covered, exempt) in &mut mailboxes { 178 + let should_reap = *covered && !*exempt; 179 + match rx.try_recv() { 180 + Ok(Envelope::Eject { .. }) => prop_assert!(should_reap, "reaped a protected client"), 181 + Err(_) => prop_assert!(!should_reap, "failed to reap a covered non-exempt client"), 182 + Ok(other) => prop_assert!(false, "unexpected frame {other:?}"), 183 + } 184 + prop_assert!(rx.try_recv().is_err(), "at most one frame per client"); 185 + if !should_reap { 186 + prop_assert!(ctx.registry.record_of(u).is_some(), "a survivor left the registry"); 187 + } 188 + } 189 + prop_assert_eq!(ctx.dlines.len(), 1); 190 + } 191 + 192 + /// Laying then lifting the same mask leaves the store empty. 193 + #[test] 194 + fn dline_then_undline_is_empty(a in 0u8..=255, b in 0u8..=255) { 195 + let ctx = ctx(); 196 + let mut actor = oper(vec![OperPrivilege::Dline]); 197 + let mask = format!("192.0.{a}.{b}"); 198 + dispatch(&mut actor, &Message::parse(&format!("DLINE 1h {mask} :r")).unwrap(), &ctx); 199 + prop_assert_eq!(ctx.dlines.len(), 1); 200 + dispatch(&mut actor, &Message::parse(&format!("UNDLINE {mask}")).unwrap(), &ctx); 201 + prop_assert!(ctx.dlines.is_empty()); 202 + } 203 + 204 + /// The registration D-line gate, model-lockstep: a fresh connection from `ip` is refused 205 + /// (`465` + `ERROR :... (D-line active: ...)`, never registers) **iff** an active D-line covers 206 + /// `ip` — exactly mirroring [`leveva::dline::DlineStore::find_active`]. The gate runs before the 207 + /// connection is counted, so a refused client never appears in the registry. 208 + #[test] 209 + fn registration_gate_refuses_iff_covered( 210 + bans in proptest::collection::vec("10\\.0\\.0\\.0/(24|29)", 0..3usize), 211 + ip in "10\\.0\\.0\\.[0-9]", 212 + ) { 213 + let ctx = ctx(); 214 + let now = i64::MAX; // every ban is "active" against this clock 215 + for (i, mask) in bans.iter().enumerate() { 216 + // far-future expiry so each is active; distinct reasons are irrelevant to the gate. 217 + ctx.dlines.add(mask, "begone", now); 218 + let _ = i; 219 + } 220 + let covered = ctx.dlines.find_active(&ip, leveva::clock::to_unix_seconds(leveva::clock::unixnano()) as i64).is_some(); 221 + 222 + let mut s = Session::new(ip.clone()); 223 + let out = String::from_utf8_lossy( 224 + &s.feed(b"NICK mallory\r\nUSER m 0 * :M\r\n", &ctx) 225 + ).to_string(); 226 + 227 + if covered { 228 + prop_assert!(out.contains(" 465 mallory "), "covered IP must be refused: {out:?}"); 229 + prop_assert!(out.contains("D-line active: begone"), "wrong close reason: {out:?}"); 230 + prop_assert!(!s.is_registered(), "a refused client must not register"); 231 + prop_assert!(!ctx.registry.contains("mallory")); 232 + } else { 233 + prop_assert!(out.contains(" 001 mallory "), "uncovered IP must register: {out:?}"); 234 + prop_assert!(s.is_registered()); 235 + } 236 + } 237 + 238 + /// Arbitrary args never panic and only ever return 461/481/NOTICE. 239 + #[test] 240 + fn arbitrary_never_panics( 241 + which in "(DLINE|UNDLINE)", 242 + args in proptest::collection::vec("[!-~]{0,12}", 0..4usize), 243 + privileged in any::<bool>(), 244 + ) { 245 + let ctx = ctx(); 246 + let privs = if privileged { vec![OperPrivilege::Dline] } else { vec![] }; 247 + let mut actor = oper(privs); 248 + let line = format!("{which} {}", args.join(" ")); 249 + if let Ok(msg) = Message::parse(line.trim()) { 250 + let r = dispatch(&mut actor, &msg, &ctx); 251 + for m in &r { 252 + let c = m.command(); 253 + prop_assert!( 254 + matches!(c, "461" | "481" | "NOTICE"), 255 + "unexpected reply {c} for {line:?}" 256 + ); 257 + } 258 + } 259 + } 260 + }
+1
leveva/tests/echo_message_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/etrace_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/extended_join_proptest.rs
··· 42 42 operators: Vec::new(), 43 43 stats_conf: leveva::server::StatsConf::default(), 44 44 klines: leveva::kline::KlineStore::new(), 45 + dlines: leveva::dline::DlineStore::new(), 45 46 net: leveva::s2s::Network::new(), 46 47 peers: leveva::s2s::PeerLinks::new(), 47 48 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/extended_monitor_proptest.rs
··· 38 38 operators: Vec::new(), 39 39 stats_conf: leveva::server::StatsConf::default(), 40 40 klines: leveva::kline::KlineStore::new(), 41 + dlines: leveva::dline::DlineStore::new(), 41 42 net: leveva::s2s::Network::new(), 42 43 peers: leveva::s2s::PeerLinks::new(), 43 44 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/feed_framing_proptest.rs
··· 55 55 operators: Vec::new(), 56 56 stats_conf: leveva::server::StatsConf::default(), 57 57 klines: leveva::kline::KlineStore::new(), 58 + dlines: leveva::dline::DlineStore::new(), 58 59 net: leveva::s2s::Network::new(), 59 60 peers: leveva::s2s::PeerLinks::new(), 60 61 monitors: leveva::monitor::Monitors::new(),
+9
leveva/tests/fixtures/dline.kdl
··· 1 + server { name "leveva.test"; description "Leveva Test Server"; sid "0ABC" } 2 + admin { name "Admin"; email "admin@test"; location "Test Lab"; network "TestNet" } 3 + class "c" { ping-freq 90; max-links 10; sendq 512000 } 4 + listen { port 16700 } 5 + allow { host "*@*"; class "c" } 6 + operator "root" { mask "*@*"; password "hunter2"; class "c"; privileges "die" "restart" "rehash" "trace" "tkline" "dline" } 7 + connect "peer.test" { host "192.0.2.1"; port 6667; class "c" } 8 + 9 + options { default-channel-modes "+" } // scenarios authored against modeless channels
+1
leveva/tests/flood_delay.rs
··· 37 37 operators: Vec::new(), 38 38 stats_conf: leveva::server::StatsConf::default(), 39 39 klines: leveva::kline::KlineStore::new(), 40 + dlines: leveva::dline::DlineStore::new(), 40 41 net: leveva::s2s::Network::new(), 41 42 peers: leveva::s2s::PeerLinks::new(), 42 43 monitors: leveva::monitor::Monitors::new(),
+85
leveva/tests/golden_dline.rs
··· 1 + //! Boot-level golden: `DLINE` end-to-end through the real `leveva` binary — the IP-level 2 + //! analogue of `golden_tkline`. Test clients all connect from loopback (`127.0.0.1`), so a 3 + //! D-line on that IP covers them exactly as the feature intends. 4 + //! 5 + //! 1. `bob` and `alice` register (both from loopback). 6 + //! 2. `alice` (a non-oper) `DLINE 1h 127.0.0.1` → `481`, lays no ban. 7 + //! 3. `alice` `OPER root hunter2` (the `dline.kdl` fixture operator carries `dline`). 8 + //! 4. `alice` `DLINE 1h *` (bare-`*`) → `NOTICE :DLINE: Incorrect format`. 9 + //! 5. `alice` `DLINE 1h 10.0.0.0/24 :elsewhere` — a non-matching block; `bob` survives 10 + //! (asserted via a PING round-trip). 11 + //! 6. `alice` `DLINE 1h 127.0.0.0/8 :spamming` — covers loopback, so `bob` is reaped: 12 + //! `465` then `ERROR :... (D-line active: spamming)`. 13 + //! 7. `mallory` reconnects from loopback → refused at registration by the active D-line. 14 + 15 + mod harness; 16 + use harness::{boot_fixture, Client, PORT}; 17 + 18 + /// Keep the `465`/`481`/`NOTICE`/`ERROR` lines from a transcript, labelled. 19 + fn keep(out: &mut String, src: &str, label: &str) { 20 + for line in src.split("\r\n") { 21 + if line.contains(" 465 ") 22 + || line.contains(" 481 ") 23 + || line.contains(" NOTICE ") 24 + || line.starts_with("ERROR ") 25 + { 26 + out.push_str(label); 27 + out.push_str(line); 28 + out.push('\n'); 29 + } 30 + } 31 + } 32 + 33 + #[test] 34 + fn dline_reaps_a_match_and_refuses_a_reconnect() { 35 + let _srv = boot_fixture("dline.kdl", PORT); 36 + 37 + let mut bob = Client::connect(); 38 + bob.send("NICK bob"); 39 + bob.send("USER bobby 0 * :Bob Tester"); 40 + bob.read_until(" 422 "); 41 + 42 + let mut alice = Client::connect(); 43 + alice.send("NICK alice"); 44 + alice.send("USER alice 0 * :Alice Tester"); 45 + alice.read_until(" 422 "); 46 + 47 + // A mortal DLINE is refused (481) and lays no ban. 48 + alice.send("DLINE 1h 127.0.0.1 :early"); 49 + let mortal = alice.read_until(" 481 "); 50 + 51 + // alice opers up (the `dline.kdl` fixture operator carries `dline`). 52 + alice.send("OPER root hunter2"); 53 + alice.read_until(" 381 "); 54 + 55 + // A bare-`*` mask → an "Incorrect format" notice. 56 + alice.send("DLINE 1h * :nope"); 57 + let badfmt = alice.read_until("Incorrect format"); 58 + 59 + // A non-matching block does not reap bob (inverse: he stays alive and idle). 60 + alice.send("DLINE 1h 10.0.0.0/24 :elsewhere"); 61 + bob.send("PING :alive"); 62 + let alive = bob.read_until("PONG"); 63 + assert!( 64 + alive.contains("PONG") && !alive.contains(" 465 "), 65 + "a D-line on a different block must not reap a loopback client: {alive:?}" 66 + ); 67 + 68 + // The real ban: it covers loopback, so bob is reaped. 69 + alice.send("DLINE 1h 127.0.0.0/8 :spamming"); 70 + let reaped = bob.read_until("D-line active: spamming)"); 71 + 72 + // mallory reconnects from loopback → refused at registration by the active D-line. 73 + let mut mallory = Client::connect(); 74 + mallory.send("NICK mallory"); 75 + mallory.send("USER mal 0 * :Mallory"); 76 + let refused = mallory.read_until("D-line active: spamming)"); 77 + 78 + let mut transcript = String::new(); 79 + keep(&mut transcript, &mortal, "alice: "); 80 + keep(&mut transcript, &badfmt, "alice: "); 81 + keep(&mut transcript, &reaped, "bob: "); 82 + keep(&mut transcript, &refused, "mallory: "); 83 + 84 + insta::assert_snapshot!(transcript); 85 + }
+1
leveva/tests/help_users_proptest.rs
··· 53 53 operators: Vec::new(), 54 54 stats_conf: leveva::server::StatsConf::default(), 55 55 klines: leveva::kline::KlineStore::new(), 56 + dlines: leveva::dline::DlineStore::new(), 56 57 net: leveva::s2s::Network::new(), 57 58 peers: leveva::s2s::PeerLinks::new(), 58 59 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/invite_notify_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/invite_proptest.rs
··· 67 67 operators: Vec::new(), 68 68 stats_conf: leveva::server::StatsConf::default(), 69 69 klines: leveva::kline::KlineStore::new(), 70 + dlines: leveva::dline::DlineStore::new(), 70 71 net: leveva::s2s::Network::new(), 71 72 peers: leveva::s2s::PeerLinks::new(), 72 73 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/keepalive_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/kick_proptest.rs
··· 56 56 operators: Vec::new(), 57 57 stats_conf: leveva::server::StatsConf::default(), 58 58 klines: leveva::kline::KlineStore::new(), 59 + dlines: leveva::dline::DlineStore::new(), 59 60 net: leveva::s2s::Network::new(), 60 61 peers: leveva::s2s::PeerLinks::new(), 61 62 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/kill_proptest.rs
··· 56 56 }], 57 57 stats_conf: leveva::server::StatsConf::default(), 58 58 klines: leveva::kline::KlineStore::new(), 59 + dlines: leveva::dline::DlineStore::new(), 59 60 net: leveva::s2s::Network::new(), 60 61 peers: leveva::s2s::PeerLinks::new(), 61 62 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/kline_proptest.rs
··· 47 47 operators: Vec::new(), 48 48 stats_conf: leveva::server::StatsConf::default(), 49 49 klines: leveva::kline::KlineStore::new(), 50 + dlines: leveva::dline::DlineStore::new(), 50 51 net: leveva::s2s::Network::new(), 51 52 peers: leveva::s2s::PeerLinks::new(), 52 53 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/knock_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/mkpasswd_proptest.rs
··· 52 52 operators: Vec::new(), 53 53 stats_conf: leveva::server::StatsConf::default(), 54 54 klines: leveva::kline::KlineStore::new(), 55 + dlines: leveva::dline::DlineStore::new(), 55 56 net: leveva::s2s::Network::new(), 56 57 peers: leveva::s2s::PeerLinks::new(), 57 58 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/modes_proptest.rs
··· 53 53 operators: Vec::new(), 54 54 stats_conf: leveva::server::StatsConf::default(), 55 55 klines: leveva::kline::KlineStore::new(), 56 + dlines: leveva::dline::DlineStore::new(), 56 57 net: leveva::s2s::Network::new(), 57 58 peers: leveva::s2s::PeerLinks::new(), 58 59 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/monitor_proptest.rs
··· 44 44 operators: Vec::new(), 45 45 stats_conf: leveva::server::StatsConf::default(), 46 46 klines: leveva::kline::KlineStore::new(), 47 + dlines: leveva::dline::DlineStore::new(), 47 48 net: leveva::s2s::Network::new(), 48 49 peers: leveva::s2s::PeerLinks::new(), 49 50 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/motd_reload_proptest.rs
··· 43 43 operators: Vec::new(), 44 44 stats_conf: leveva::server::StatsConf::default(), 45 45 klines: leveva::kline::KlineStore::new(), 46 + dlines: leveva::dline::DlineStore::new(), 46 47 net: leveva::s2s::Network::new(), 47 48 peers: leveva::s2s::PeerLinks::new(), 48 49 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/msgid_proptest.rs
··· 37 37 operators: Vec::new(), 38 38 stats_conf: leveva::server::StatsConf::default(), 39 39 klines: leveva::kline::KlineStore::new(), 40 + dlines: leveva::dline::DlineStore::new(), 40 41 net: leveva::s2s::Network::new(), 41 42 peers: leveva::s2s::PeerLinks::new(), 42 43 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/multi_prefix_proptest.rs
··· 35 35 operators: Vec::new(), 36 36 stats_conf: leveva::server::StatsConf::default(), 37 37 klines: leveva::kline::KlineStore::new(), 38 + dlines: leveva::dline::DlineStore::new(), 38 39 net: leveva::s2s::Network::new(), 39 40 peers: leveva::s2s::PeerLinks::new(), 40 41 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/names_list_proptest.rs
··· 52 52 operators: Vec::new(), 53 53 stats_conf: leveva::server::StatsConf::default(), 54 54 klines: leveva::kline::KlineStore::new(), 55 + dlines: leveva::dline::DlineStore::new(), 55 56 net: leveva::s2s::Network::new(), 56 57 peers: leveva::s2s::PeerLinks::new(), 57 58 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/no_implicit_names_proptest.rs
··· 35 35 operators: Vec::new(), 36 36 stats_conf: leveva::server::StatsConf::default(), 37 37 klines: leveva::kline::KlineStore::new(), 38 + dlines: leveva::dline::DlineStore::new(), 38 39 net: leveva::s2s::Network::new(), 39 40 peers: leveva::s2s::PeerLinks::new(), 40 41 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/oper_proptest.rs
··· 54 54 }], 55 55 stats_conf: leveva::server::StatsConf::default(), 56 56 klines: leveva::kline::KlineStore::new(), 57 + dlines: leveva::dline::DlineStore::new(), 57 58 net: leveva::s2s::Network::new(), 58 59 peers: leveva::s2s::PeerLinks::new(), 59 60 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/oper_tag_proptest.rs
··· 47 47 }], 48 48 stats_conf: leveva::server::StatsConf::default(), 49 49 klines: leveva::kline::KlineStore::new(), 50 + dlines: leveva::dline::DlineStore::new(), 50 51 net: leveva::s2s::Network::new(), 51 52 peers: leveva::s2s::PeerLinks::new(), 52 53 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/ping_proptest.rs
··· 42 42 operators: Vec::new(), 43 43 stats_conf: leveva::server::StatsConf::default(), 44 44 klines: leveva::kline::KlineStore::new(), 45 + dlines: leveva::dline::DlineStore::new(), 45 46 net: leveva::s2s::Network::new(), 46 47 peers: leveva::s2s::PeerLinks::new(), 47 48 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/prereg_flood_proptest.rs
··· 41 41 operators: Vec::new(), 42 42 stats_conf: leveva::server::StatsConf::default(), 43 43 klines: leveva::kline::KlineStore::new(), 44 + dlines: leveva::dline::DlineStore::new(), 44 45 net: leveva::s2s::Network::new(), 45 46 peers: leveva::s2s::PeerLinks::new(), 46 47 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/privmsg_proptest.rs
··· 49 49 operators: Vec::new(), 50 50 stats_conf: leveva::server::StatsConf::default(), 51 51 klines: leveva::kline::KlineStore::new(), 52 + dlines: leveva::dline::DlineStore::new(), 52 53 net: leveva::s2s::Network::new(), 53 54 peers: leveva::s2s::PeerLinks::new(), 54 55 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/query_routing_proptest.rs
··· 84 84 operators: Vec::new(), 85 85 stats_conf: leveva::server::StatsConf::default(), 86 86 klines: leveva::kline::KlineStore::new(), 87 + dlines: leveva::dline::DlineStore::new(), 87 88 net: Network::new(), 88 89 peers: PeerLinks::new(), 89 90 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/redact_proptest.rs
··· 36 36 operators: Vec::<Operator>::new(), 37 37 stats_conf: leveva::server::StatsConf::default(), 38 38 klines: leveva::kline::KlineStore::new(), 39 + dlines: leveva::dline::DlineStore::new(), 39 40 net: leveva::s2s::Network::new(), 40 41 peers: leveva::s2s::PeerLinks::new(), 41 42 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/registration_proptest.rs
··· 38 38 operators: Vec::new(), 39 39 stats_conf: leveva::server::StatsConf::default(), 40 40 klines: leveva::kline::KlineStore::new(), 41 + dlines: leveva::dline::DlineStore::new(), 41 42 net: leveva::s2s::Network::new(), 42 43 peers: leveva::s2s::PeerLinks::new(), 43 44 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/registry_proptest.rs
··· 41 41 operators: Vec::new(), 42 42 stats_conf: leveva::server::StatsConf::default(), 43 43 klines: leveva::kline::KlineStore::new(), 44 + dlines: leveva::dline::DlineStore::new(), 44 45 net: leveva::s2s::Network::new(), 45 46 peers: leveva::s2s::PeerLinks::new(), 46 47 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/resv_proptest.rs
··· 42 42 operators: Vec::new(), 43 43 stats_conf: leveva::server::StatsConf::default(), 44 44 klines: leveva::kline::KlineStore::new(), 45 + dlines: leveva::dline::DlineStore::new(), 45 46 net: leveva::s2s::Network::new(), 46 47 peers: leveva::s2s::PeerLinks::new(), 47 48 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/server_query_proptest.rs
··· 45 45 operators: Vec::new(), 46 46 stats_conf: leveva::server::StatsConf::default(), 47 47 klines: leveva::kline::KlineStore::new(), 48 + dlines: leveva::dline::DlineStore::new(), 48 49 net: leveva::s2s::Network::new(), 49 50 peers: leveva::s2s::PeerLinks::new(), 50 51 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/server_time_proptest.rs
··· 110 110 operators: Vec::new(), 111 111 stats_conf: leveva::server::StatsConf::default(), 112 112 klines: leveva::kline::KlineStore::new(), 113 + dlines: leveva::dline::DlineStore::new(), 113 114 net: leveva::s2s::Network::new(), 114 115 peers: leveva::s2s::PeerLinks::new(), 115 116 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/server_topology_proptest.rs
··· 43 43 operators: Vec::new(), 44 44 stats_conf: leveva::server::StatsConf::default(), 45 45 klines: leveva::kline::KlineStore::new(), 46 + dlines: leveva::dline::DlineStore::new(), 46 47 net: leveva::s2s::Network::new(), 47 48 peers: leveva::s2s::PeerLinks::new(), 48 49 monitors: leveva::monitor::Monitors::new(),
+10
leveva/tests/snapshots/golden_dline__dline_reaps_a_match_and_refuses_a_reconnect.snap
··· 1 + --- 2 + source: leveva/tests/golden_dline.rs 3 + expression: transcript 4 + --- 5 + alice: :leveva.test 481 alice :Permission Denied- You're not an IRC operator 6 + alice: :leveva.test NOTICE alice :DLINE: Incorrect format 7 + bob: :leveva.test 465 bob :You are banned from this server: spamming 8 + bob: ERROR :Closing Link: 127.0.0.1 (D-line active: spamming) 9 + mallory: :leveva.test 465 mallory :You are banned from this server: spamming 10 + mallory: ERROR :Closing Link: 127.0.0.1 (D-line active: spamming)
+1
leveva/tests/snotice_proptest.rs
··· 41 41 operators: Vec::new(), 42 42 stats_conf: leveva::server::StatsConf::default(), 43 43 klines: leveva::kline::KlineStore::new(), 44 + dlines: leveva::dline::DlineStore::new(), 44 45 net: leveva::s2s::Network::new(), 45 46 peers: leveva::s2s::PeerLinks::new(), 46 47 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/summon_proptest.rs
··· 40 40 operators: Vec::new(), 41 41 stats_conf: leveva::server::StatsConf::default(), 42 42 klines: leveva::kline::KlineStore::new(), 43 + dlines: leveva::dline::DlineStore::new(), 43 44 net: leveva::s2s::Network::new(), 44 45 peers: leveva::s2s::PeerLinks::new(), 45 46 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/tagmsg_proptest.rs
··· 44 44 operators: Vec::new(), 45 45 stats_conf: leveva::server::StatsConf::default(), 46 46 klines: leveva::kline::KlineStore::new(), 47 + dlines: leveva::dline::DlineStore::new(), 47 48 net: leveva::s2s::Network::new(), 48 49 peers: leveva::s2s::PeerLinks::new(), 49 50 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/topic_proptest.rs
··· 56 56 operators: Vec::new(), 57 57 stats_conf: leveva::server::StatsConf::default(), 58 58 klines: leveva::kline::KlineStore::new(), 59 + dlines: leveva::dline::DlineStore::new(), 59 60 net: leveva::s2s::Network::new(), 60 61 peers: leveva::s2s::PeerLinks::new(), 61 62 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/trace_proptest.rs
··· 46 46 operators: Vec::new(), 47 47 stats_conf: leveva::server::StatsConf::default(), 48 48 klines: leveva::kline::KlineStore::new(), 49 + dlines: leveva::dline::DlineStore::new(), 49 50 net: leveva::s2s::Network::new(), 50 51 peers: leveva::s2s::PeerLinks::new(), 51 52 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/userhost_in_names_proptest.rs
··· 37 37 operators: Vec::new(), 38 38 stats_conf: leveva::server::StatsConf::default(), 39 39 klines: leveva::kline::KlineStore::new(), 40 + dlines: leveva::dline::DlineStore::new(), 40 41 net: leveva::s2s::Network::new(), 41 42 peers: leveva::s2s::PeerLinks::new(), 42 43 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/userhost_ison_proptest.rs
··· 48 48 operators: Vec::new(), 49 49 stats_conf: leveva::server::StatsConf::default(), 50 50 klines: leveva::kline::KlineStore::new(), 51 + dlines: leveva::dline::DlineStore::new(), 51 52 net: leveva::s2s::Network::new(), 52 53 peers: leveva::s2s::PeerLinks::new(), 53 54 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/userip_proptest.rs
··· 44 44 operators: Vec::new(), 45 45 stats_conf: leveva::server::StatsConf::default(), 46 46 klines: leveva::kline::KlineStore::new(), 47 + dlines: leveva::dline::DlineStore::new(), 47 48 net: leveva::s2s::Network::new(), 48 49 peers: leveva::s2s::PeerLinks::new(), 49 50 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/usermode_proptest.rs
··· 48 48 operators: Vec::new(), 49 49 stats_conf: leveva::server::StatsConf::default(), 50 50 klines: leveva::kline::KlineStore::new(), 51 + dlines: leveva::dline::DlineStore::new(), 51 52 net: leveva::s2s::Network::new(), 52 53 peers: leveva::s2s::PeerLinks::new(), 53 54 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/wallops_proptest.rs
··· 55 55 }], 56 56 stats_conf: leveva::server::StatsConf::default(), 57 57 klines: leveva::kline::KlineStore::new(), 58 + dlines: leveva::dline::DlineStore::new(), 58 59 net: leveva::s2s::Network::new(), 59 60 peers: leveva::s2s::PeerLinks::new(), 60 61 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/who_proptest.rs
··· 50 50 operators: Vec::new(), 51 51 stats_conf: leveva::server::StatsConf::default(), 52 52 klines: leveva::kline::KlineStore::new(), 53 + dlines: leveva::dline::DlineStore::new(), 53 54 net: leveva::s2s::Network::new(), 54 55 peers: leveva::s2s::PeerLinks::new(), 55 56 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/whois_modes_proptest.rs
··· 42 42 operators: Vec::new(), 43 43 stats_conf: leveva::server::StatsConf::default(), 44 44 klines: leveva::kline::KlineStore::new(), 45 + dlines: leveva::dline::DlineStore::new(), 45 46 net: leveva::s2s::Network::new(), 46 47 peers: leveva::s2s::PeerLinks::new(), 47 48 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/whois_proptest.rs
··· 49 49 operators: Vec::new(), 50 50 stats_conf: leveva::server::StatsConf::default(), 51 51 klines: leveva::kline::KlineStore::new(), 52 + dlines: leveva::dline::DlineStore::new(), 52 53 net: leveva::s2s::Network::new(), 53 54 peers: leveva::s2s::PeerLinks::new(), 54 55 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/whowas_proptest.rs
··· 60 60 operators: Vec::new(), 61 61 stats_conf: leveva::server::StatsConf::default(), 62 62 klines: leveva::kline::KlineStore::new(), 63 + dlines: leveva::dline::DlineStore::new(), 63 64 net: leveva::s2s::Network::new(), 64 65 peers: leveva::s2s::PeerLinks::new(), 65 66 monitors: leveva::monitor::Monitors::new(),
+1
leveva/tests/whox_proptest.rs
··· 40 40 operators: Vec::new(), 41 41 stats_conf: leveva::server::StatsConf::default(), 42 42 klines: leveva::kline::KlineStore::new(), 43 + dlines: leveva::dline::DlineStore::new(), 43 44 net: leveva::s2s::Network::new(), 44 45 peers: leveva::s2s::PeerLinks::new(), 45 46 monitors: leveva::monitor::Monitors::new(),