Skip to content

Fix spurious CAP NEW sasl on netsplit of a half-linked SASL server - #100

Open
MrIron-no wants to merge 3 commits into
UndernetIRC:mainfrom
MrIron-no:fix/sasl-cap-burst-split
Open

Fix spurious CAP NEW sasl on netsplit of a half-linked SASL server#100
MrIron-no wants to merge 3 commits into
UndernetIRC:mainfrom
MrIron-no:fix/sasl-cap-burst-split

Conversation

@MrIron-no

Copy link
Copy Markdown
Contributor

Problem

A SASL server (channels.*) sat behind a hub (shub) that linked half-way — it introduced its downlinks but never finished its burst — and then pinged out. cap-notify clients on a remote hub saw, on the split:

CAP hubby NEW sasl=plain,scram-sha-256,external
CAP hubby DEL :sasl

Root causes

  1. Availability only re-evaluated on END_OF_BURST_ACK. Correct that the half-linked server was never advertised, but the capability flag went stale relative to find_match_server(), which could already see the SASL server.
  2. Per-server re-check during the split (exit_one_client()). A sibling of the SASL server was torn down first; the check still found the SASL server in server_list[] and emitted the spurious CAP NEW one message before the real CAP DEL.
  3. Off-by-one in cap_new()/cap_del(): i < HighestFd instead of <=, so the local client holding the highest fd never received CAP NEW/DEL at all.

Fix

  • sasl.c: sasl_available() walks the uplink chain from the SASL server to &me and returns unavailable if any hop IsBurst(). Gates both advertising and m_sasl's runtime routing check.
  • m_endburst.c: re-check on END_OF_BURST (the moment IsBurst clears) instead of END_OF_BURST_ACK, which a services package may never send.
  • s_misc.c: one re-check at the end of exit_client() after the whole subtree is gone.
  • m_cap.c: <= HighestFd; CAP NEW now prefixes the trailing parameter with : like CAP DEL.

No CAP NEW is emitted while any hop on the path to the SASL server is still bursting.

Tests

tests/pr66_capsasl/test_sasl_cap_burst_split.py (4 tests, fake P10 hub via P10Server):

  • half-linked hub introduces SASL server + sibling, drops mid-burst → no CAP at all (fails on old code with exactly [NEW sasl=PLAIN, DEL sasl])
  • indirect SASL server (P10) → NEW exactly at the uplink's EB, nothing on EA, single DEL on split
  • indirect SASL server introduced with J10 → stays hidden after the uplink's EB, NEW only at its own EB
  • directly linked SASL server with sasl.server already set → NEW exactly at its EB, single DEL on disconnect

P10Server.handshake() is split into begin_handshake() / send_end_of_burst() / complete_handshake() (the composite handshake() is unchanged for existing tests); send_downstream_server() gains bursting= (P10 vs J10) and send_end_of_burst_for().

Full run of pr66_capsasl/ pr_iauthverify/ pr64_netconf/ pr_msgtags_compat/test_s2s_service_rpc_untagged.py: 49 passed, 0 failed.

A SASL server (channels.*) behind a hub that linked half-way -- introduced
its downlinks but never completed its burst -- and then pinged out caused
cap-notify clients to see "CAP NEW sasl" immediately followed by
"CAP DEL sasl" on the split.

Three defects combined:

* SASL availability was only re-evaluated on END_OF_BURST_ACK, so the
  capability flag went stale relative to find_match_server() while the
  SASL server was linked through a bursting hop.
* exit_one_client() re-checked availability for every server torn down
  in the split. A sibling of the SASL server exited first, the check
  still found the SASL server in server_list[], and emitted CAP NEW one
  message before the real CAP DEL.
* cap_new()/cap_del() iterated i < HighestFd instead of <=, so the local
  client holding the highest fd never received CAP NEW/DEL at all.

Fixes:

* sasl_available() walks the uplink chain from the SASL server to &me and
  reports unavailable if any hop IsBurst(). This gates both advertising
  and m_sasl's runtime routing check.
* Re-check on END_OF_BURST (the moment IsBurst clears) instead of on
  END_OF_BURST_ACK, which a services package may never send.
* Re-check once at the end of exit_client() after the whole subtree is
  gone, instead of per server inside exit_one_client().
* cap_new()/cap_del() loop to <= HighestFd.
* CAP NEW now prefixes the trailing parameter with ':' like CAP DEL.

Tests: tests/pr66_capsasl/test_sasl_cap_burst_split.py reproduces the
incident topology with a fake P10 hub introducing the SASL server plus a
sibling, dropping mid-burst (fails on the old code with exactly
[NEW sasl=PLAIN, DEL sasl]), and covers NEW-on-EB, single DEL on split,
and a J10-introduced SASL server staying hidden until its own EB.
P10Server.handshake() is split into begin_handshake()/send_end_of_burst()/
complete_handshake(), and send_downstream_server() gains bursting= (P10
vs J10) plus send_end_of_burst_for().
Covers the direct-link case where sasl.server already points at the
bursting link: no CAP NEW while bursting, NEW exactly at EB, nothing on
EA, a single DEL on disconnect.
@Ratler

Ratler commented Aug 29, 2026

Copy link
Copy Markdown
Member

@MrIron-no a few findings:

  1. HIGH — Wildcard sasl.server mask can validate the wrong server (ircd/sasl.c:92)

sasl_available() now walks the burst-path of whichever server find_match_server() returns — which is the lowest-numnick match, not necessarily the established one. With a wildcard mask like channels.*, if a second matching server with a lower numnick relinks and is bursting, availability flips to 0 even though a fully-linked SASL server exists. The next trigger then sends every cap-notify client a spurious CAP DEL :sasl / CAP NEW — the exact churn this PR is meant to fix — and AUTHENTICATE is rejected during the window. The old code only checked existence, so it didn't care which match came back. Fix: walk all matches and prefer a non-bursting one.

  1. HIGH — No sasl_check_capability() on server introduction (ircd/m_server.c:809)

A server matching sasl.server introduced with protocol P (non-bursting) over an established link sends no END_OF_BURST, so none of the PR's triggers fire: availability becomes 1 but clients never get CAP NEW :sasl until an unrelated netjoin/split. Pure ircu emits J+EB, but ms_server accepts P from any peer, and services packages introducing virtual servers (and the PR's own test helper with bursting=False) produce exactly this shape. Fix: trigger the capability re-check from server introduction too.

  1. MEDIUM — AUTHENTICATE routes to a server that may not be the one validated (ircd/m_sasl.c:169)

m_sasl.c does its own find_match_server() lookup while sasl_available() repeats the scan internally — the routed-to server is only coincidentally the validated one. This is a trap: fixing finding #1 (prefer a non-bursting match) would silently route AUTHENTICATE to a server whose path was never checked. Fix: add a sasl_server() accessor returning the validated struct Client * and use it at both call sites; it also drops a linear scan per AUTHENTICATE.

  1. LOW — Triplicated fake-link setup in tests (tests/pr66_capsasl/test_sasl_cap_burst_split.py:132)

The P10Server connect + handshake + config setup appears three times (the helper plus inline copies in tests 3 and 4), differing only in sasl.server value, bursting flag, and extra downstreams. Fix: parameterize _half_link_with_sasl_server(...). Caveat from verification: do not replace _capnotify_client with cap_helpers.make_cap_client — cap-notify is CAPFL_HIDDEN_302, so that helper would pytest.skip these tests.

  1. LOW — Timeout clamp masks an exhausted handshake budget (tests/p10_server.py:244)

max(remaining, 0.1) grants a fabricated 100 ms window when begin_handshake() has already consumed the whole timeout, so the failure surfaces 100 ms late as a confusing "Timed out waiting for EA" instead of "handshake timed out" at the declared deadline. Fix: pass remaining straight through — complete_handshake() already raises when it's ≤ 0.

…duction hook

Review findings on UndernetIRC#100:

1. With a wildcard sasl.server mask, find_match_server() returns the
   lowest-numnick match, so a matching server that is re-linking and
   still bursting could hide an established one and cause DEL/NEW churn.
   Add find_match_server_next() to enumerate all matches; sasl_server()
   walks them and returns the first whose path to us is not bursting.

2. A server matching sasl.server introduced as already past its burst (P)
   over an established link never sends END_OF_BURST, so nothing
   re-checked availability. Call sasl_check_capability() at the end of
   ms_server(); it is a no-op for J introductions and for servers behind
   a bursting hop, so nothing is advertised during a burst.

3. m_sasl() did its own find_match_server() lookup, only coincidentally
   routing AUTHENTICATE to the server sasl_available() validated. Both now
   use sasl_server(), which is the single source of truth and returns the
   validated struct Client *. This also stops collapse()ing the netconf
   value in place: the mask is copied first.

4. Tests: _half_link_with_sasl_server() is parameterized on the
   sasl.server value and the downstream servers to introduce, replacing
   the inline copies. New tests cover the P-introduction-over-established-
   link case, and the wildcard case with a bursting lower-numnick sibling
   including that AUTHENTICATE is routed to the validated server (XQ
   target numeric).

5. P10Server.handshake() passes the remaining budget straight to
   complete_handshake() instead of clamping to 100 ms.
@MrIron-no

MrIron-no commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all five addressed in 7e4b5a7.

1. Wildcard sasl.server mask (HIGH) — Added find_match_server_next() in numnicks.c to enumerate every server matching a (pre-collapsed) mask. New sasl_server() walks all matches and returns the first whose path to us has no bursting hop, so a re-linking lower-numnick match no longer hides an established one.

2. No re-check on server introduction (HIGH)ms_server() now calls sasl_check_capability() after the new server is fully set up. It's a no-op for J introductions and for anything behind a bursting hop (sasl_server() still reports those unusable), so nothing is advertised mid-burst; the END_OF_BURST handler picks those up later.

3. AUTHENTICATE routing (MEDIUM)m_sasl() and sasl_available() both go through sasl_server() now, so the server that was validated is the one the XQUERY is sent to. One scan per AUTHENTICATE instead of two, and the netconf value is copied before collapse() rather than mutated in place.

4. Test setup duplication (LOW)_half_link_with_sasl_server() is parameterized on the sasl.server value and the downstream servers to introduce; tests 3 and 4 use it. Kept _capnotify_client as-is per your note about make_cap_client skipping.

5. Timeout clamp (LOW)handshake() passes the remaining budget straight to complete_handshake().

New tests:

  • test_cap_new_when_sasl_server_introduced_past_burst_over_established_link — P-introduced SASL server behind an established link gets CAP NEW immediately (finding 2).
  • test_wildcard_mask_prefers_fully_linked_match_and_routes_to_itchan*.test.net with channels (numeric 6, established) and chanb (numeric 5, bursting): no DEL/NEW churn, and the XQUERY target numeric is channels (findings 1 + 3).

Both fail without the fixes and pass with them. pr66_capsasl/ pr_iauthverify/ pr64_netconf/ pr_msgtags_compat/test_s2s_service_rpc_untagged.py: 49 passed, 2 skipped (the pre-existing conditional cap-notify skips in test_edge_cases.py).

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.

2 participants