Skip to content

promote dev -> master: etc_clientblocker (#81) + cross-blocker IP-in-report + cmd_disconnect i18n#351

Merged
Aybook merged 9 commits into
masterfrom
dev
Jun 26, 2026
Merged

promote dev -> master: etc_clientblocker (#81) + cross-blocker IP-in-report + cmd_disconnect i18n#351
Aybook merged 9 commits into
masterfrom
dev

Conversation

@Aybook

@Aybook Aybook commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Promote dev to master after testhub validation.

What's in this PR

Nine squash-merge commits from dev, three feature lines:

#81 etc_clientblocker arc (CLOSED)

  1. feat(etc_clientblocker): bundled client AP/VE blocklist (#81) (#340)
    • Plugin promoted from luadch-ng/scripts into core. ADC +blocker add/del/list + HTTP /v1/clientblocker CRUD + audit fires + smoke + 110-check unit test
    • Listener after hub_inf_manager; F-INF-1d nil-guard preserved
  2. feat(etc_clientblocker): Sopor patterns + opchat report + SBOT exempt (#81) (#341)
    • 40 bundled patterns (CleanDC++/FearDC/etc); kick fires op-chat report; SBOT (hub-level 55) exempt from filter
  3. feat(cfg): make etc_clientblocker API-toggleable (#81 / #261) (#347)
  4. fix+feat(etc_clientblocker): testhub-sync defaults loss + AirDC++ display + +delblocker by row index (#81) (#348)
    • BUNDLED_DEFAULTS moved into plugin Lua source so script-sync upgrades don't lose them; multi-line +blocker reply switched to 3-arg DMSG (AirDC++ swallows multi-line BMSG); numbered list + +delblocker N index-delete UX
  5. fix(etc_clientblocker): seed defaults only when .tbl is missing (#81) (#349)
    • Variante B semantics: empty .tbl (operator deleted everything) is respected; defaults seed only on file-MISSING. Stub scripts/data/etc_clientblocker.tbl removed from repo so first-run path is the documented one

Cross-plugin: IP in op-chat reports (#344)

  1. feat(blockers): include target IP in op-chat reports across 4 plugins (#344)
    • etc_dhtblocker, usr_nick_prefix, etc_trafficmanager (6 sites), etc_msgmanager (is_online() 4th return) all report nick + IP consistently

cmd_disconnect (#342 / #343)

  1. i18n(cmd_disconnect): English source defaults (Sopor's #342 + grammar fix) (#345)
  2. feat(cmd_disconnect): per-call TL override (closes #343) (#346)
  3. Revert "feat(cmd_disconnect): per-call TL override (closes #343) (#346)" (#350)
    • Net no-op pair. Testhub showed AirDC++ ignores positive TL on IQUI (ADC TL is advisory, not hub-enforced); reverted. cmd_disconnect.lua - Add TL #343 closed wontfix. Lesson captured for future ADC-field work

Testhub validation

Maintainer pulled ghcr.io/luadch-ng/luadch:dev across the arc:

  • +blocker add/list/del round-trip works in main chat (AirDC++ + DC++)
  • DMSG reply renders the numbered list; +delblocker 3 removes the 3rd row
  • .tbl deletion -> +reload reseeds defaults; empty .tbl stays empty across reloads
  • Op-chat reports show nick + IP on dht / nick-prefix / trafficmanager / msgmanager kicks
  • +disconnect <nick> <reason> works post-revert (no TL parser path)

Scope

Merge-commit promotion path per GitFlow A. All 9 dev commits go to master as a unit; no additional changes here.

Test plan

  • CI green on dev (Smoke Linux + Smoke Windows + Docker + CodeQL on fbaf8ef)
  • Live testhub validation by maintainer (per-PR + after revert)
  • Post-merge: master :master Docker image rebuild

Aybook and others added 9 commits June 24, 2026 13:40
* feat(cfg): add etc_clientblocker cfg keys + scripts.tbl entry (#81)

Foundation commit for the AP/VE-based client blocker. Adds the cfg
keys (oplevel, check_levels table, default_reason, max_pattern_len)
to core/cfg_defaults.lua and the corresponding operator-facing
section to examples/cfg/cfg.tbl. The scripts array gains an entry
right after hub_inf_manager so the listener chain runs the
structural validator BEFORE the policy filter - that ordering is
load-bearing per §1a.2 and is called out in an inline comment.

No behaviour change yet - the plugin file lands in the next commit.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(etc_clientblocker): bundled client AP/VE blocker plugin (#81)

Promotes etc_clientblocker from luadch-ng/scripts (v0.2 by pulsar)
into the core bundle and rewrites the persistence + operator
surfaces:

  - patterns + per-pattern kick reasons live in
    scripts/data/etc_clientblocker.tbl (was hardcoded inline)
  - ADC commands: +addblocker <pattern> [reason] / +delblocker
    <pattern> / +blocker (list). Pattern is the first whitespace-
    token; everything after it is the kick reason. oplevel-gated
    via etc_clientblocker_oplevel.
  - HTTP API: GET /v1/clientblocker (read), POST + DELETE
    /v1/clientblocker (admin). DELETE takes the pattern as a
    URL-percent-encoded path var; matches the etc_aliases shape.
  - audit-fires client.block.kick on each block + client.block.add
    / .remove on each operator edit. Same actor/target conventions
    as cmd_ban / etc_aliases.
  - pattern validation at edit time: non-empty + length-capped via
    etc_clientblocker_max_pattern_len (default 200) + compile-probe
    via pcall(string.find, "", pat) so a bad pattern fails loud at
    POST time, not silent at onConnect kick time.
  - F-INF-1d nil-VE guard preserved from upstream v0.2: a client
    without a VE field has nothing to match against, so the check
    skips - mirrors "no rule applies" semantics for missing input.
  - operator levels (60..80) exempt by default via
    etc_clientblocker_check_levels so adding a self-matching
    pattern does not self-lockout.

Public surface unchanged-style getters (resolve / get_patterns_tbl)
follow the #239 / #238 rebind-hazard convention.

Empty default .tbl ships with the bundle so the plugin loads as
a no-op until the operator adds the first pattern.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(etc_clientblocker): unit test + smoke.yml wiring (#81)

61 checks across:
  - HTTP API: POST happy + default reason + bad_pattern (empty /
    over-length / compile-probe failure) + exists + GET sort +
    DELETE happy + not_found
  - check_clients onConnect listener: matching VE on covered
    level emits ISTA 231 + audit `client.block.kick`; matching
    VE on exempt operator level skips; non-matching VE skips;
    nil VE skips (F-INF-1d guard)
  - ADC handlers: +addblocker / +delblocker / +blocker hubcmd
    happy paths + level-denied + missing-args
  - resolve() input sanity (nil / number / empty)
  - +reload semantic: re-running onStart re-reads from disk

Wired into both smoke.yml jobs (Linux + Windows msys2) next to
the existing audit / aliases tests so a regression here fails
the same CI gate.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(smoke): #81 etc_clientblocker end-to-end (test_clientblocker_81)

Six-stage smoke test that owns the issue's primary acceptance
criterion (blocked client -> kicked at connect):

  1. POST /v1/clientblocker adds a unique smoke pattern
  2. Connect with VE matching the pattern -> ISTA 231 + drop
     (the kick anchors on "ISTA 231" + the custom reason
     literal "smoke_blocked_81" to avoid the BINF-echo
     false-match noted in the #84 audit-test pattern 4)
  3. Connect with a non-matching VE -> normal login
  4. DELETE /v1/clientblocker/{pattern} -> 200
  5. Reconnect with the previously-blocked VE -> normal login
     (verifies the in-memory patterns_tbl mutation took
     effect without a hub restart)
  6. audit JSONL has client.block.{add,kick,remove} lines with
     the right meta.pattern field

`_adc_login` gains optional `ve` / `ap` / `expect_kill` params.
With expect_kill=True the helper watches for ISTA instead of
the BINF echo / IGPA challenge - etc_clientblocker fires its
kill on the onConnect listener BEFORE IGPA (verified at
core/hub_dispatch.lua:589), so on a kick the password
exchange never happens.

Runs immediately after test_audit_log_84 so the same audit
JSONL file is exercised by both checks. Idempotent: the
pattern is unique to this test and a best-effort DELETE
runs before the POST to clear any leftover from a prior
crashed run.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(scripts,http): etc_clientblocker + /v1/clientblocker (#81)

SCRIPTS.md: new etc_clientblocker section after etc_auditlog.
Documents the cmds (+addblocker / +delblocker / +blocker),
the data file under scripts/data/, the listener-chain
placement (MUST sit AFTER hub_inf_manager), the
operator-self-lockout footgun (operator levels exempt by
default), the pattern-validation at edit time (length cap +
pcall compile probe), the audit-event vocabulary
(client.block.{add,remove,kick}), the HTTP triplet, the
F-INF-1d nil-VE guard, and the cfg keys.

HTTP_API.md: GET/POST/DELETE /v1/clientblocker rows in the
"Bans + blacklist" catalog (the closest topical neighbour
to a write-action on user identity), plus three new
footnotes covering the request/response shape, error codes,
and the cache-immediacy guarantee on DELETE.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(etc_clientblocker): two-pass review findings (#81)

Addresses three issues found by the §1a.6 independent reviewer:

BLOCKER B1 - HTTP DELETE was undeletable for patterns containing
URL-special characters. The router captures `{pattern}` via
`([^/]+)` with NO percent-decoding (verified at
core/http_router.lua:282-298), so a pattern carrying `/`, `?`,
`#` or `&` could be POSTed but never DELETEd (silent 404). The
docs ALSO incorrectly claimed the router decodes path-vars.

Fix: reject those four chars at POST/+addblocker time so the
edit-time error is loud instead of silent at delete time.
Real-world AP/VE strings are alphanumeric + `%+.-`, so this
rules out zero legitimate patterns. Other Lua-pattern
punctuation (`%`, `+`, `.`, `(`, `)`, `[`, `]`, `*`, `-`, `^`,
`$`) is RFC-3986 URL-safe in path components and passes through
unmodified. Docs corrected.

CONCERN C1 - system-fired kick events used a novel
`{ nick="<auto>", sid="<system>" }` actor shape that exists
nowhere else in the codebase. Switched to the canonical
string-shorthand actor (= the plugin name) which core/audit.lua's
_snapshot_actor supports at line 106 (string -> `{nick=<string>,
level=0, sid="", cid="", ip=""}`). Downstream filters now see a
consistent `actor.nick = "etc_clientblocker"` for system-fired
events vs operator names for the .add/.remove events.

CONCERN C2 - reason-defaulting was duplicated across three call
sites (do_add_pattern + ADC handler audit + HTTP handler audit).
Extracted to a single `_effective_reason()` helper to prevent
future drift.

NIT N3 - check_clients iterated `pairs(patterns_tbl)` which has
non-deterministic order. With two patterns matching the same VE,
the kick reason was hash-order dependent. Switched to
`util_spairs` so the alphabetically-first matching pattern always
wins. format_list already used spairs - matches the convention.

NIT N2 - long-pattern unit test now uses a realistic pattern
body (mix of `%a` classes) over the 200-char cap rather than
plain `aaaa...` so the cap branch is exercised independently of
the compile-probe branch.

Unit test grew from 61 to 70 checks. Smoke suite still green
locally on Windows.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(etc_clientblocker): security review R1 - spairs orderedIndex leak (#81)

The check_clients hot loop iterated patterns_tbl via `util_spairs`
and returned PROCESSED mid-loop on the kick. `util.spairs`
(core/util.lua:816-843) mutates the iterated table with an
internal `orderedIndex` sorted-keys array on first call and only
clears it on iterator exhaustion. Breaking out early leaves the
artifact attached to `patterns_tbl`.

Observable side effects pre-fix:
  - next persist() serialized `orderedIndex = {...}` into
    scripts/data/etc_clientblocker.tbl (filtered out on reload by
    the type guard, but the on-disk artifact is confusing)
  - next GET /v1/clientblocker emitted a spurious row with
    pattern="orderedIndex", breaking the documented response
    schema (response_schema says pattern is a string + reason is
    a string; orderedIndex's value is a Lua array of strings)
  - +blocker chat output showed `[orderedIndex] -> table: 0x...`

No RCE / no auth bypass / no leak across users. Audit log
shape and the kick semantics themselves are unaffected. The
sandbox boundary still holds.

Fix: snapshot the keys into a sorted local array BEFORE the
iteration, then ipairs the array. Same deterministic ordering
the original spairs call gave us (= cosmetically-consistent
audit-attribution on a collision-with-multiple-matches) without
the internal-state mutation.

Regression test per §1a.7: the new unit test (10b) replaces the
test stub's util.spairs with the REAL util.spairs impl from
core/util.lua so the kick path exercises the same mutate-then-
clear contract the production code sees. Verified the test
FAILS on pre-fix code (`got="table: 0x..." want="nil"`) and
PASSES on the post-fix snapshot+ipairs form. Unit count: 70 -> 71.

Other plugins using `util.spairs` (cmd_ban, cmd_usercleaner,
cmd_usersearch, etc_aliases, etc_trafficmanager) all iterate
the spairs loop to completion - no early-return leak there.
The footgun shape applies broadly though; documenting in a
follow-up issue ([anti-pattern]: util.spairs + return inside
loop body) would be worth doing if it ever resurfaces.

Adversarial security review (general-purpose subagent) audited
12 additional surfaces (catastrophic Lua-pattern DoS, .tbl Lua
code injection, HTTP path-traversal, pattern enumeration via
timing, malformed-VE crash, pcall hiding bypass, forged audit
via token comment, HUBOWNER self-lockout, onset-of-block
timing, unbounded pattern count, UTF-8 in string.find, audit
forgery via scriptname). All theoretical or non-issues under
the existing Phase 7 / 7d / 7e / 8a guardrails; reasoning
captured in the PR thread.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#81) (#341)

* feat(etc_clientblocker): Sopor patterns + opchat report + SBOT exempt (#81)

Imports Sopor's etc_clientblocker.lua v0.4 contributions on top of
the v0.10 promoted-to-core baseline.

Functional changes (cfg-keyed, default-on for opt-out):

- `etc_clientblocker_report*` family: on every block kick fire
  etc_report.send with a human-readable banner
  `[ CLIENT BLOCKER ]--> The user <nick> with IP <ip> is running
  <version> and is not allowed in this hub. Matching pattern: <p>`
  so staff see kicks live in op-chat (defaults: report=true,
  report_opchat=true, report_hubbot=false, llevel=60). The audit
  log already carries the same fields structured for forensics;
  this banner is for live operational awareness. Matches the
  sibling-plugin convention (etc_aliases / etc_msgmanager).
- Level 55 (SBOT) added to the check_levels default with
  `false`. Bots have no BINF VE/AP to check against; the kick
  path should never reach them.

Data changes:

- scripts/data/etc_clientblocker.tbl (bundled default) now ships
  with 6 universally-malicious cheat/mod client patterns
  (CleanDC++, RSX++, CrZ++, SmVDC++, DC@fe++, FearDC). These are
  always-unwanted across DC hubs - operators almost never need
  them, so out-of-the-box blocking is the right default. Each
  entry is removable via `+delblocker <pattern>` for the rare
  operator who needs an exception.
- examples/data/etc_clientblocker.tbl.example: 40-pattern curated
  list from Sopor's production hub. Blocks outdated stable
  releases of DC++ (0.0xx-0.8xx), AirDC++ (1.0-4.29 + Web 0.x-
  2.14b + nano), EiskaltDC++ (<2.4.1), ApexDC++ (<1.6.4), ncdc
  (<1.18), Jucy (<0.86), plus legacy mods (StrgDC++, IceDC++,
  PDC++, PWDC++). Operators copy this over the bundled file if
  they want the broader policy.

Sopor-isms normalised on import:

- `%w` in his AirDC++ Web Client / nano patterns was used as
  if it were literal "w" / "n", but Lua's `%w` matches ANY
  word character. The patterns silently over-blocked any
  hypothetical "AirDC++Q 0.5" etc. Switched to literal `w`.
  The unit-test sweep `should_not_match` set covers the
  regression (AirDC++Q 0.5 must NOT match the Web Client
  pattern).
- `%n` is fine as written - Lua 5.4 treats `%X` for non-class
  X as literal X (verified empirically). Left as-is for
  readability against the original source.

Tests:

- Unit: 71 -> 80 checks. New asserts cover report fire shape
  (opchat=true, hubbot=false, contains nick + pattern) and the
  SBOT-level-55-exempt path (no kill, no report, no audit fired).
- Smoke: existing `test_clientblocker_81` extended with a 7th
  stage that connects with `VEFearDC/2.0` and asserts the
  hub kicks WITHOUT any operator configuration (= the bundled
  default works out of the box).

Docs: docs/SCRIPTS.md gains the report behaviour, the bundled-
default list, and the example-file reference.

Part of #81 (the parent issue stays open until v3.2.0 ships).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(etc_clientblocker): two-pass review C1 + C2 + N2 (#81)

Addresses the three findings from the §1a.6 review of the Sopor
import:

C1 (regression test for the %w -> w substitution): the original
Sopor patterns used %w (Lua word-class, matches any letter/digit)
where the intent was literal "w" for "AirDC++w" (Web Client). The
substitution was made but never regression-tested. Per §1a.7 the
test must FAIL on the unpatched pattern and PASS on the fix. Two
two-direction assertions added at the end of the unit test:
  - ^AirDC%+%+w%s matches "AirDC++w 0.5" (positive)
  - ^AirDC%+%+w%s does NOT match "AirDC++Q 0.5" (negative)
Verified the buggy %w version: positive passes, negative fails.
The fixed literal-w: both pass.

C2 (op-chat flood risk): documented in docs/SCRIPTS.md that the
report fires synchronously on every kick and that flood-control
relies on the upstream per-IP / per-CID connect rate-limit in
core/ratelimit.lua. Operators with a noisy hub can flip
etc_clientblocker_report=false and rely on the audit log alone.

N2 (%n -> literal n in the example file): the example file had
already replaced %n with literal n in the pattern source during
the original Sopor import (the value was equivalent in Lua 5.4
but reads as if it were a character class). Updated the header
rationale + an inline comment at the AirDC++ nano entry to make
the normalisation explicit + match the %w fix narrative.

Unit count: 80 -> 82 checks. Smoke still green.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(etc_clientblocker): catchall for unauthorised AirDC++ forks (#81)

Aybo flagged that the legitimate-AirDC++ AP-suffix set is small
and known (plain "AirDC++", "AirDC++w" for Web Client,
"AirDC++n" for nano - that's it). Anything else - "AirDC++Q",
"AirDC++3", "AirDC++pirate", random one-off forks operators in
the wild keep spotting - is by definition modified.

Adds one catchall pattern to examples/data/etc_clientblocker.tbl.example:

    ["^AirDC%+%+[^%snw]"] = "Modified / unauthorised AirDC++ fork is
                             not allowed in this hub. Please download
                             the official AirDC++ from
                             https://airdcpp.net"

Character class `[^%snw]` is the negative form: NOT whitespace
AND NOT "n" AND NOT "w". Verified to leave plain AirDC++,
AirDC++w, and AirDC++n alone while catching arbitrary fork
suffixes incl. multi-char ("AirDC++Z-mod") and named
("AirDC++pirate") variants.

8 new regression-test assertions (3 legit must-not-match + 5
fork must-match cases). Unit count: 82 -> 90 checks.

Bundled `scripts/data/etc_clientblocker.tbl` stays minimal-policy
(6 well-known cheat clients). The fork catchall is operator-
opt-in via the example file.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#344)

* feat(blockers): include target IP in op-chat reports across 4 plugins

Surveyed all bundled blocker / kicker plugins and found four that
fire opchat reports WITHOUT the target's IP address. Operators
investigating a kick post-mortem had to cross-reference cmd.log /
audit.log to recover it. Adds the IP consistently across all four:

  - etc_dhtblocker.lua: report_msg now carries the IP of the
    DHT-banned user. Format becomes
    "[ DHT BLOCKER ]--> User: %s | IP: %s | was banned for ..."

  - usr_nick_prefix.lua: previously fired no report at all
    (onConnect can't fire onFailedAuth without recursion). Adds
    a direct report.send (safe - no listener re-entry) with
    new cfg keys matching the etc_aliases convention:
    usr_nick_prefix_report / _report_hubbot / _report_opchat
    / _llevel (defaults: opchat=true, hubbot=false, llevel=60).
    Creates the missing scripts/lang/usr_nick_prefix.lang.{en,de}
    along the way.

  - etc_trafficmanager.lua: msg_op_report_block /
    msg_op_report_unblock gain an `IP: %s` field. Six call sites
    updated to derive `target_ip = (target and target:ip()) or
    msg_unknown` - offline-target blocks correctly fall back to
    the localised <unknown> string.

  - etc_msgmanager.lua: msg_report_block / msg_report_unblock
    gain `IP: %s`. is_online() extended to return target:ip() as
    a fourth value; four +msgmanager block/unblock branches
    threaded.

`user:ip()` returns the TCP source IP - whatever family the
client connected via (v4 or v6, never both - a single TCP
connection has one source). For dual-stack users with HBRI
(#214), the OTHER family is in the user INF but is not the
connection source; the v4-or-v6 returned here is always the
authoritative "where did this user actually connect from".

All lang strings updated in both en and de. cfg_defaults +
examples/cfg/cfg.tbl carry the new usr_nick_prefix_report*
keys.

Local lua54 + Windows MinGW smoke suite both green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(etc_dhtblocker): restore 'fuer' -> 'fuer' UTF-8 'fuer' (lang.de)

Sorry, retyping the commit msg properly.

Restore UTF-8 'fuer' regression in the de lang file.

The previous commit downgraded 'fuer' to ASCII fallback 'fuer' on
the report_msg line, while the rest of the same line (`aktiver`,
`Funktion`) stayed UTF-8. Inconsistent + visibly worse in op-
chat. §1a.6 review C1 - restore the umlaut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
… fix) (#345)

* Changed internal messages to English

cmd_disconnect.lua

* fix(cmd_disconnect): align english defaults with lang.en (#342)

Two small fixes on top of Sopor's #342 to keep the source-code
fallback strings exactly in sync with scripts/lang/cmd_disconnect.lang.en:

  - report_msg: "were disconnected" -> "was disconnected" (the
    subject %s is the singular user nick, lang.en already had
    the correct form).
  - msg_denied1..4 + msg_bot: trailing "!" -> "." (lang.en uses
    full stops; mixing punctuation between the source-default
    and the loaded lang file would look inconsistent if the lang
    file ever fails to load).

The defaults only fire if cfg.loadlanguage fails (corrupt /
missing lang file) so the visible effect on a healthy hub is
zero - but per project i18n hygiene the source defaults should
match the lang file character-for-character.

Verified via a one-off script that diffs all 13 string-valued
default fallbacks against the corresponding lang.en keys: 0
mismatches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: I'm an OSK user, are you? <5789283+Sopor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cmd_disconnect): per-call TL override (closes #343)

Sopor requested the ability to override the default TL30 on
+disconnect, e.g. TL-1 for "permanent kick, don't auto-reconnect".

ADC syntax extension (backward-compatible):

    +disconnect <NICK> [TL<SECONDS>] <REASON>

Parser peeks at the second whitespace-token: if it matches
`^TL(-?%d+)$` AND the integer is in -1..86400, that's the TL
override and the reason starts at token 3. Otherwise the second
token is treated as part of the reason and the cfg-default TL
applies. The "TL" first-token-prefix is unambiguous against
real-world disconnect reasons (operators don't write reasons
starting with literal "TL<number>" against a hub bot).

A TL<N> token with N out of bounds fails LOUD via msg_bad_tl
("Invalid TL value. Must be an integer between -1 and 86400.")
- silent clamping would mask the operator's intent.

HTTP API: `DELETE /v1/users/{sid}` body gains an optional
`tl: integer optional (-1..86400)` field. Schema validation
runs at the router layer (same min/max bounds), so a malformed
body returns 400 before the handler fires. Response data now
includes `tl` (the effective value used) alongside `reason`.

cfg key: `cmd_disconnect_default_tl` (default 30, range
-1..86400) lets the operator change the global default. ADC
sites use the cfg default when no per-call TL token is present;
HTTP body without a `tl` field falls through to the same path.

Audit meta: every `user.kick` event from cmd_disconnect now
carries `{ tl = <effective_tl> }` so the audit trail records
which TL value the kick used.

Plugin grew from 1.4 -> 1.5. Public surface gains
`parse_tl_token(string) -> (n|nil|false, rest)` for unit tests.

Tests: new `tests/unit/cmd_disconnect_tl_test.lua` (34 checks)
exercises no-TL, valid TL (-1, 0, 30, 3600, 86400), out-of-
bounds (-2, 86401, 999999), TL-prefix-but-not-numeric
(TLabc, TL30abc, TL-alone), trailing-TL-in-reason, empty/
whitespace/nil/non-string inputs, and the "TL-only without
reason" quirk. Wired into smoke.yml Linux + Windows blocks.

Docs: docs/SCRIPTS.md cmd_disconnect entry documents the new
syntax + cfg key + audit meta; docs/HTTP_API.md adds
[^http-disconnect-1] footnote describing the body shape +
response shape + audit emission.

Lang en+de updated for help_usage / msg_usage / help_desc + the
new msg_bad_tl error string.

Local Windows MinGW smoke suite passes.

Closes #343.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cmd_disconnect): two-pass review NITs (#343)

Two minor cleanups from the §1a.6 review of the original
TL-override commit:

NIT 1 - lang.de umlaut consistency. msg_bad_tl had "Ungueltiger"
(ae-fold) while the rest of the de lang file uses proper umlauts
(`höheres`, `Begründung`). Restored to "Ungültiger" so the file
is consistent.

NIT 2 - "+disconnect Bob TL30" (TL token but no reason) used to
treat the literal string "TL30" as the kick reason. parse_tl_token
now accepts both shapes:
  - "TL<N> <reason ...>"  -> standard, reason populated
  - "TL<N>"               -> reason becomes "", N still applies
Operators should still type a reason in practice, but the
previous quirk (where the TL prefix bled into the reason) was
surprising. Out-of-bounds N still rejected without a reason.

Unit test grew from 34 -> 39 checks: covers TL<N>-alone for both
positive (TL30) and negative (TL-1) values, trailing whitespace,
and the out-of-bounds-without-reason path.

Smoke green locally.

Part of #343.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Wraps the cfg.scripts entry for etc_clientblocker in the
`{ "name", enabled = true }` table form so the plugin becomes
listable via `GET /v1/plugins` and toggleable via
`PUT /v1/plugins/etc_clientblocker/enabled` per the #261
plugin-management endpoints.

Matches the existing convention used by etc_motd / etc_banner /
etc_chatlog / etc_records / etc_prometheus / hub_runtime /
bot_regchat / bot_session_chat / etc_userlogininfo / etc_dhtblocker
/ etc_regserver_announce in the same cfg.scripts array.

Default `enabled = true` preserves the current behaviour (plugin
loads on hub start). Operators who want to disable client-policy
filtering without removing the cfg entry can now flip it via
the API.

Listener-chain placement unchanged: still AFTER hub_inf_manager
per #81 invariant. Inline comment now also calls out the #261
toggleability.

Smoke green locally incl. the `HTTP API plugins (#261)` test
which exercises the toggle endpoint.

Part of #81.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…play + +delblocker by row index (#81) (#348)

* fix(etc_clientblocker): testhub-sync defaults loss + AirDC++ list display (#81)

Two issues reported during testhub validation of the v0.10 plugin.

ISSUE A (BUNDLED DEFAULTS LOST ON SCRIPT-SYNC UPGRADE)

When an existing testhub gets the new etc_clientblocker.lua via
docker-compose script-sync, the sync copies scripts/*.lua but
preserves scripts/data/ at its pre-upgrade state. On a hub that
previously had the v0.0 empty .tbl stub, the v0.10 plugin loaded
fine but found NO bundled defaults (CleanDC++, RSX++, CrZ++,
SmVDC++, DC@fe++, FearDC) because they had been shipped IN the
data file rather than in the plugin source.

Fix: move BUNDLED_DEFAULTS into the plugin's Lua source. onStart
now seeds them into the operator-managed .tbl when the loaded
patterns_tbl is empty (covers both fresh install AND the script-
sync scenario above). After the first seed the .tbl is non-empty
on disk and subsequent +reload cycles use it as-is. Operator who
genuinely wants zero patterns should disable the plugin via
cfg.scripts; emptying the file just re-seeds on next reload.

The bundled scripts/data/etc_clientblocker.tbl resets back to an
empty stub with a comment explaining the seed mechanism.

ISSUE B (`+blocker` LIST OUTPUT EMPTY IN AirDC++)

The multi-line +blocker output was sent via 2-arg user:reply
(BMSG / broadcast to main chat). DC++ rendered it correctly but
AirDC++ (latest) showed no output. cmd_help and other multi-
line operator-only output already use 3-arg user:reply (DMSG /
hub-to-user private message), which is also more semantically
correct - the pattern list is operator-only information, not
something to broadcast to all users in main chat.

Fix: switch on_listblocker to 3-arg user:reply. Single-line
replies (+addblocker / +delblocker confirmations) stay on BMSG
since those are short enough that all clients render them.

TESTS

Unit test grows from 90 -> 100 checks:
  - 21. Seed-on-empty: bundled count == 6, FearDC + CleanDC keys
       present, defaults persisted to disk.
  - 22. Seed-on-non-empty: operator .tbl with at least one entry
       leaves bundled defaults out (protects operator who has
       +delblocker'd a default from having it come back).
  - 23. +blocker reply: 3-arg DMSG verified via captured arity.

Pre-existing tests adjusted: setup pre-populates _next_loaded
with a `__test_sentinel__` entry so the seed-on-empty logic does
not fire during setup; sentinel is cleared right after onStart
so the existing test cases run on a clean empty map.

Smoke green locally on Windows.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(etc_clientblocker): +delblocker by row index from +blocker output (#81)

Sopor + Aybo testhub feedback: typing `+delblocker ^FearDC.+` to
remove a bundled default is unintuitive because the `^` anchor is
easy to miss / forget. UX fix: +blocker now numbers each row,
and +delblocker accepts either the literal pattern OR the row
number from the latest +blocker list.

Mechanics:

  - format_list output now starts each line with `N. ` where N is
    the alphabetic-sort position (util_spairs already gives us a
    stable order across reloads).
  - +delblocker arg parser: if the operator-supplied token is a
    pure positive integer, resolve it as a 1-based index into the
    sorted pattern list. Out-of-range falls through to the
    existing literal-pattern path (which will not_found and tell
    the operator).
  - Literal-pattern delete still works for any string. Only
    pure-digit input is reinterpreted - a pattern that LITERALLY
    is a positive integer (extremely unlikely - real DC client
    AP/VE strings never look like "42") cannot be deleted via
    chat-cmd; documented edge case (edit the .tbl directly).

Help / msg_usage / lang.en + lang.de now show `<pattern|N>` so
the operator-facing syntax is self-documenting.

Unit test grew from 100 -> 107 checks: 3-row sorted setup, delete
by index 2 (middle), index 1 (post-shift), out-of-range (99) ->
literal-fallback not_found, then literal-pattern delete works as
before.

Local smoke green incl. client blocker end-to-end.

Part of #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#349)

Aybo testhub follow-up: after PR #348 shipped, the question came
up "if I +delblocker every bundled default to keep the plugin
active with zero patterns, do they come back on +reload?". The
answer was yes - and that's not what an operator means by
"delete all". Switch to Variante B: seed BUNDLED_DEFAULTS only
when the .tbl file is MISSING on disk (util.loadtable returns
nil), not when it exists with an empty patterns table.

Concrete semantics:

  - First run / fresh install (file doesn't exist):
    -> seed 6 bundled defaults + persist
  - Operator empties the file deliberately (+delblocker all):
    -> no seed, stays empty across +reload cycles
  - Operator has at least one entry:
    -> use as-is (no merge with bundled)
  - Recovery from a deliberate empty: `rm
    scripts/data/etc_clientblocker.tbl` + +reload (triggers the
    file-missing branch) OR cherry-pick patterns from
    examples/data/etc_clientblocker.tbl.example.

Side effect: scripts/data/etc_clientblocker.tbl is no longer
committed to the repo as an empty stub. The data dir's other
files (cmd_ban_bans.tbl etc) still exist; the clientblocker
plugin auto-creates its file on first onStart via util.savetable.
This also avoids the confusing "file looks empty in the repo
but the docs say defaults are there" gap.

Plugin bumped 0.10 -> 0.11 so the version string disambiguates
which behavior the operator's hub is on (previous v0.10 had two
distinct codepaths under the same version label across #341 and
#348 - my mistake, not bumping then).

Tests:
  - Test 21 inverted: now "seed-on-missing" instead of
    "seed-on-empty" (utility load returns nil, not {})
  - New test 21b: "no seed when .tbl exists but is empty" -
    regression test for Aybo's empty-by-operator scenario
  - Test 22 unchanged (non-empty .tbl: use as-is)
  - Total 107 -> 110 checks

Smoke (test_clientblocker_81 stage 7 "out-of-the-box FearDC
block") now exercises the file-missing seed path: the smoke
harness wipes the .tbl from the staging tree before hub start,
onStart auto-creates it with defaults, FearDC connect gets
kicked.

Plugin header docstring + docs/SCRIPTS.md updated with the new
recovery instructions.

Part of #81.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@Aybook
Aybook merged commit ccdf892 into master Jun 26, 2026
13 checks passed
Aybook added a commit to luadch-ng/scripts that referenced this pull request Jun 26, 2026
Plugin promoted into the luadch-ng/luadch core bundle via
luadch-ng/luadch#81, shipped in the 9-PR arc culminating
in luadch-ng/luadch#351. Operators get the blocker out of the
box now, plus ADC `+blocker` CRUD, HTTP `/v1/clientblocker`,
audit fires, opchat IP-in-report, and 40 bundled patterns -
strictly better than the standalone v0.1 copy that lived here.

Closes #27.

- delete scripts/etc_clientblocker/ subdir (.lua + lang/)
- IMPORT_NOTES.md: add Status line to the existing entry so the
  import history is preserved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant