Skip to content

Bahamut 3.0: IRCv3 + Gossip (services via U:line) - #254

Open
eaescob wants to merge 34 commits into
masterfrom
bahamut-mods-gossip
Open

Bahamut 3.0: IRCv3 + Gossip (services via U:line)#254
eaescob wants to merge 34 commits into
masterfrom
bahamut-mods-gossip

Conversation

@eaescob

@eaescob eaescob commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Brings Bahamut to a 3.0 architecture: a modular (MAPI v2/v3) ircd with a full
IRCv3 capability suite and a gossip-based multi-uplink network protocol. Native
services are removed by design — services now run externally and are reached via
U:line alias dispatch.

The diff is large because this lands the whole 3.0 stack at once (modular core,
IRCv3 caps, gossip protocol, WebSocket/TLS transports, sessions, and an integration
test suite). Nothing generated or vendored is included.

Highlights

Modular core

  • MAPI v2 HandlerType dispatch + MsgBuf tag parsing; MAPI v3 hot-reloadable
    modules (MODULE RELOAD / INFO)
  • meson build (autotools removed); setup.h generated via feature detection

IRCv3 — full cap suite (SASL intentionally excluded; auth is external)

cap list

multi-prefix · away-notify · echo-message · server-time · message-tags / TAGMSG ·
labeled-response · batch · message-ids · userhost-in-names · invite-notify ·
setname · chghost · bot mode · monitor · WHOX · chathistory · STARTTLS / tls

Gossip network

  • Multi-uplink event-log protocol (GHELLO / GSYNCED / GEVENT / GACK / GPING / GPONG)
  • Full-state burst, materialization, and message routing; TS5 legacy bridge for
    mixed 2.x/3.0 networks
  • IRCv3 caps propagate cap-aware to remote members across the chain:
    server-time/msgid are carried from the origin (not regenerated), and
    extended-join / away-notify / setname / tagmsg / invite-notify are relayed
  • Server ids derive from the server name (FNV-1a, 6-bit) and are exchanged at link
    time — no server_id config token
  • Partition-aware: services mutations are gated while gossip-partitioned
  • Sessions / RESUME with cross-server token propagation

Transport / TLS

  • WebSocket transport (RFC 6455), including WSS
  • ssl {} block, client cert fingerprint extraction, STARTTLS, outbound gossip TLS

Services model

  • No native NickServ/ChanServ/MemoServ/etc. Alias dispatch (/NS, /CS, …)
    forwards to external U:line services when local_handler is NULL; otherwise the
    nick is simply unreachable (expected)
  • S2S handlers for external services retained (SVSNICK, SVSMODE, AKILL, …)

Operator config notes

  • gossip {} — usually just sync_window. fanout defaults to 0 = flood all
    peers
    ; set it only to deliberately limit fan-out.
  • gopeer {}host / port / passwd / name (+ optional tls). No
    server_id
    (derived from name). Any connected topology works.
  • See doc/reference.conf, doc/template.conf, doc/MIGRATION.md.

Testing

  • 87 integration tests across 11 pytest suites (basic, cap, channel,
    chathistory, gossip, gossip_ts5_interop, module_reload, monitor, session,
    websocket, whox) — real ircd processes via the harness in tests/
  • Live 4-node gossip-chain harness (tests/live_chain.py): verified IRCv3 relay
    enrichment (23/23), all-see-all matrix, and nick/topic/channel burst across hops

🤖 Generated with Claude Code

Major rewrite of Bahamut with modern IRC features:

- MAPI v2/v3 modular command system with hot-reload support
- IRCv3 capabilities: multi-prefix, away-notify, echo-message,
  server-time, message-tags, userhost-in-names, invite-notify,
  setname, chghost, batch, labeled-response, draft/chathistory,
  account-notify, account-tag, extended-join, MONITOR, WHOX
- Gossip-based S2S protocol: multi-uplink mesh, anti-netsplit,
  event log with vector clocks, deduplication, legacy TS5 bridge
- Persistent sessions with cross-server RESUME (draft/resume-0.5)
- WebSocket transport (RFC 6455) with WSS support
- TLS enhancements: STARTTLS, client cert fingerprints, draft/tls tag
- MsgBuf IRCv3 tag parsing, outbound tag registry
- Meson build system replacing autoconf

Native services removed in favor of U:line external services
(DALnet services/stats). All S2S command handlers preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@eaescob
eaescob force-pushed the bahamut-mods-gossip branch from 4fb726d to e913bb1 Compare March 13, 2026 02:18
eaescob and others added 6 commits March 12, 2026 19:21
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove setup.h from .gitignore and commit it with standard
Linux defaults. This eliminates the need to run autotools
configure before building with meson.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The configure script generates include/setup.h with system-specific
feature detection (e.g. OpenSSL paths). Commit it so users don't
need autotools installed — just ./configure then meson setup/ninja.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ndency

Meson now detects headers, functions, and libraries natively using
cc.has_header/cc.has_function and generates setup.h via configure_file().
No autotools, configure script, or auxiliary files needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…RATION

Update documentation to reflect that services are provided by external
U:lined packages, not built-in. Remove SASL, SRA, account/chanreg/memo
references. Update test counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/parse.c Outdated
eaescob and others added 19 commits March 17, 2026 19:22
- RPL_LUSERME in hide.c: missing client count arg (read garbage from stack)
- ERR_NOTREGISTERED in parse.c: missing command arg
- ERR_NOSHAREDCHAN in s_err.c: format missing %s for target nick
- ERR_GHOSTEDCLIENT in send.c: remove extra args (incl. raw pointer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Gossip peers now materialize remote users, servers, and channels as
real IRC state. An all-gossip network is fully functional:

State materialization (gossip.c):
- EVT_USER_JOIN creates aClient entries for remote users
- EVT_USER_QUIT/NICK/MODE/AWAY update or remove them
- EVT_SERVER_LINK/SPLIT manage phantom server entries
- EVT_CHAN_JOIN/PART/KICK/MODE/TOPIC manage channel state
- Nick collision resolution with TS-based logic
- FLAGS_GOSSIP_MAT flag prevents double-emission in hooks

Message routing:
- EVT_PRIVMSG/EVT_CHANMSG event types for cross-server messaging
- CHOOK_USERMSG hook intercepts PRIVMSG to gossip-materialized targets
- CHOOK_CHANMSG hook emits events for channels with gossip members
- CTCP works automatically (rides on PRIVMSG payload)

Full-state burst (s_gopeer.c):
- gopeer_start_burst sends complete user/channel/session state to
  fresh peers (all-zero clock), not just event log deltas
- Unique synthetic sequence numbers per burst event (dedup fix)

Infrastructure:
- CHOOK_KICK hook added (hooks.h, modules.c, channel.c)
- m_gossip_eventlog.c: all hooks emit+gossip (was emit-only),
  IsGossipMaterialized guards prevent re-emission
- send.c: skip gossip-materialized members in 5 channel send
  functions (prevents SIGSEGV from accessing beyond REMOTE_SIZE)
- Channel hash fix: gossip-created channels added to hash table
- Test harness: binary_path, connect_configs, m_gossip_eventlog

Verified: 51/51 pytest tests, 10/10 scenario tests (4-node),
25/25 integration tests (3-node mesh covering WHOIS, PRIVMSG,
NOTICE, CTCP, JOIN, NAMES, TOPIC, NICK, AWAY, QUIT, PART, LINKS).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gossip peers now propagate all IRC state bidirectionally: users,
channels, topics, modes (+ntskl, +o/h/v, +b bans), kicks, kills,
quits, nick changes, and parts. The TS5 legacy bridge translates
in both directions (gossip→TS5 and TS5→gossip).

Design (Sable-inspired):
- No netsplits: users persist across temporary gossip link loss
- Events propagate via GEVENT; burst syncs on reconnect
- TS5 bridge handles SQUIT/QUIT storms for legacy servers only

Key changes:

Gossip state propagation:
- Move m_gossip_eventlog to core module (auto-loaded, can't be forgotten)
- Switch from CHOOK_JOIN to CHOOK_POSTJOIN (fires after flags are set)
- Fix channel mode materialization (apply to chptr->mode, not Link flags)
- Fix chanMember flags for +o/+h/+v (was updating wrong struct)
- Fix EVT_USER_JOIN serialization (realname with spaces caused ghost servers)
- Add client IP propagation for network-wide clone tracking
- Add gossip_emit_user_quit for KILL of gossip-materialized users
- Remove IsGossipMaterialized guard from kick/part/quit handlers
- Add CHOOK_POSTJOIN + CHOOK_SIGNOFF hooks for TS5 remote users

TLS for gossip peers:
- Add FLAGS_SSL_OUTBOUND to distinguish outbound SSL connections
- Fix readwrite_client to call safe_ssl_connect (not safe_ssl_accept)
- Fix ssl_verify_callback NULL guard for gopeer (no aConnect ex_data)
- Initiate SSL handshake in completed_connection after TCP connects
- Send GHELLO after SSL handshake completes in readwrite_client

Crash fixes:
- Fix double-free in gossip_split_server (remove_client_from_list
  already frees serv + client)
- Fix gossip_remove_user to use remove_client_from_list (was doing
  manual heap manipulation, skipping WHOWAS cleanup)
- Fix clones_remove crash for gossip-materialized users
- Guard gossip_materialize_server against connect{} block conflicts

Network-level bans (CODERS-37):
- Add EVT_AKILL/RAKILL/SQLINE/UNSQLINE/SGLINE/UNSGLINE event types
- Emit gossip events from s_serv.c alongside sendto_serv_butone
- Apply on receiving peers using existing userban/simban infrastructure
- Bridge translations forward to TS5 servers

Bridge improvements:
- Rewrite bridge_burst_gossip_to_server to walk live state (not event log)
- Add channel modes + ban list to bridge burst
- Fix ghost server materialization (skip servers with connect{} blocks)

Tests: 87 tests (was 51), all passing:
- TestGossipPropagation: user, channel, topic, mode, nick, quit, part
- TestGossipTLS: TLS link, user propagation, client connection
- TestGossipTS5Bridge: user, channel, topic visibility across bridge
- TestGossipTS5Interop: full bidirectional kill, kick, ban, quit, modes
- TestGossipNetsplitResilience: hub death, state persistence

Also: Fix nameser.h for aarch64/arm/riscv byte order detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Propagate the full IRCv3 cap set cap-aware to remote members across the
gossip chain (was delivering bare lines, dropping enrichment):

- server-time/msgid on relayed channel PRIVMSG/NOTICE: EvPayloadChanmsg
  now carries the origin out-tags so remote cap clients get the same
  @time/@msgid rather than regenerated values.
- extended-join on relayed JOIN; away-notify on relayed AWAY.
- New EVT_SETNAME / EVT_TAGMSG / EVT_INVITE for cross-server setname,
  tagmsg, and invite-notify. gossip_apply_* fires the cap module's
  CHOOK_*; cap modules broadcast from a relaxed hook listener (drops the
  !MyClient guard so materialized sources notify local members);
  m_gossip_eventlog emits guarded by IsGossipMaterialized to avoid loops.

Fixes:
- Topic dropped during full-state burst (empty leading wire field caused
  strtoken misalignment) — burst now sets pl.nick = me.name.
- Fanout: 0 (the new default) = flood all peers; clamp/forward so events
  reach every peer regardless of cluster topology.

Config simplification:
- Remove the gopeer server_id token entirely. The gossip id (0-63) is
  derived from the server name via FNV-1a and exchanged via GHELLO, so it
  was redundant. Drop the parse branch, struct field, confparse defines,
  and all harness/doc references. Old configs must drop server_id.
- reference.conf / template.conf / MIGRATION.md updated; s_conf.c block
  comments corrected.

Tests/tooling:
- tests/live_chain.py: live tailnet harness (smoke|chain|matrix|burst|ircv3).
- Test harness no longer emits server_id; fixture names verified
  collision-free under the 6-bit FNV id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security/reliability audit findings (apply-layer guards that the newer
functions failed to carry forward from their siblings):

1. gossip_apply_chan_join: a gossip-materialized *server* (created by
   gossip_materialize_server, which never calls make_user) passes the
   IsGossipMaterialized check but has user == NULL. A peer sending
   EVT_SERVER_LINK name=X then EVT_CHAN_JOIN nick=X dereferenced
   acptr->user->channel -> remote crash. Add !IsClient(acptr) to the
   guard (matches gossip_remove_user).

2/3. gossip_apply_tagmsg / gossip_apply_invite lacked the
   IsGossipMaterialized guard that setname/away already have. If the
   sender/inviter resolved to a non-materialized client (e.g. a local
   nick collision after a merge), the eventlog emit hook re-emitted and
   re-gossiped with a fresh seq, creating an amplification loop the
   dedup table cannot stop. Also closes a source-spoofing gap.

5. ms_gsynced incremented gopeer_connected_count unconditionally, so a
   duplicate GSYNCED inflated the count while the disconnect path
   decrements only once -- corrupting gossip_is_partitioned() and the
   services read-only gate. Guard the increment with !gp->burst_complete.

Findings 4 (gopeer disconnect teardown leak) and 6 (gopeer_conf_list
rehash leak) are tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
exit_client() early-returned for IsGoPeer after only running
gopeer_handle_disconnect() (which frees the GossipPeer struct + emits
split events), skipping close_connection() and exit_one_client(). Every
gopeer link drop therefore leaked the fd, send/recv buffers, the
local[fd] slot, the SSL object, and the aClient struct -- and left an
EOF'd fd registered in the socket engine. Under peer flap / partition /
rehash churn this exhausts fds and can spin the engine.

The early return existed to avoid exit_server()'s QUIT cascade for
materialized users. That is preserved: a gopeer is STAT_GOPEER (neither
IsServer nor IsPerson), so exit_one_client() does not cascade -- it just
removes the client from the hash/list. So we now run the normal local
teardown (close_connection + exit_one_client) after the gossip-specific
cleanup, releasing all resources while materialized users persist.

Verified on node1 (aarch64): clean build; test_gossip_ts5_interop.py
21/21 (repeated gossip+TS5 link teardown incl. quit/kill/netsplit);
test_gossip.py 19/20 (the one miss is a known 4-node fixture timing
flake -- passes in isolation, unrelated to this change).

Audit finding #4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
confadd_gopeer() prepended straight to the live gopeer_conf_list, which
nothing ever freed -- so every REHASH re-parsed and duplicated every
gopeer entry (leaked conf structs, inflated gopeer_configured_count which
feeds gossip_is_partitioned(), and risked double-dialing peers), and a
failed rehash couldn't roll back.

Make gopeer follow the established new_*/merge_*/clear_newconfs staging
pattern used by connects/allows/opers:
- new_gopeer_conf_list staging global; confadd_gopeer() fills it.
- merge_gopeers() (called from merge_confs(), which runs at both boot and
  rehash) frees the old live list, swaps in the staged one, recomputes the
  configured count.
- clear_newconfs() frees the staged list on a failed rehash.
- free_gopeer_conf_list() helper.

Safe because a live link is tracked by GossipPeer (copies name + id, no
pointer into the conf), so freeing the old conf list never dangles an
active connection; gopeer_try_connect() re-reads the new list on its next
tick.

Verified on node1 (aarch64): clean build; gossip link/propagation 16/16
and TS5 interop 21/21 (boot-time gopeer loading via the new swap); new
TestGossipRehash regression test confirms the srv1<->srv2 link still
propagates users after two REHASHes.

Audit finding #6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…depth)

NOT a live vulnerability: gossip events arrive as IRC lines, so framing
strips CR/LF before any payload field is parsed, and positional fields are
tokenised with strtoken(" ") so they cannot contain a space. The classic
"space -> extra params / CRLF -> second command" injection is already
structurally blocked on the gossip->bridge path.

But the bridge is the boundary to legacy TS5 servers, and relying on a
distant parse invariant for safety at the wire-emit point is fragile: a
future EVT type or parse change could slip a space/control char into a
positional field. Add sanitize_param() (truncate at the first space/control
byte) and apply it in bridge_apply_event() to fields emitted in positional
TS5 slots (nick, channel, user, host, server, setter, kicker, target,
mask), via a per-event mutable copy of the payload. Server names are
sanitized inside bridge_introduce_server/bridge_split_server.

Left intact: trailing ":%s" params (realname, reason, topic) and the
space-separated MODE parabuf legitimately contain spaces; the burst path
reads canonical aClient/aChannel fields the whole ircd already trusts.

Verified on node1 (aarch64): clean build; test_gossip_ts5_interop.py 21/21
(no regression -- legitimate nicks/channels/topics still cross the bridge).

Audit finding #7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EVT_CHANMSG serialized as "sender channel is_notice <tags> :text" — tags
was a bare positional field followed by the trailing text. A space in the
tags value would be consumed as the token boundary, leaking the remainder
into the text field and corrupting the relayed message. Not currently
reachable (tags come from build_outbound_tags(), which is space-free by
construction), but the format was fragile.

Pack tags and text together as the trailing field separated by a tab —
"sender channel is_notice :<tags>\t<text>" — and split on the tab at parse
time. tags never contains a tab, so the split is unambiguous and a stray
space in tags can no longer bleed into text. This mirrors the existing
EVT_SESSION_CREATE pattern (realname\taway_msg). serialize + parse are
changed together; all gossip nodes run the same build.

Adds TestGossipPropagation::test_channel_message_propagates — a cross-server
channel PRIVMSG with spaces in the body, which had no prior coverage.

Verified on node1 (aarch64): clean build; TestGossipPropagation 8/8
(new message test + 7 existing, no regression).

Audit finding #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old code redeclared random()/srandom() with K&R externs AND defined
_DEFAULT_SOURCE *after* including sys.h (which already pulls in <stdlib.h>).
Result:
  - FreeBSD/clang: <stdlib.h> declares `void srandom(unsigned int)`, which
    conflicts with `extern int srandom(unsigned)` -> hard build error.
  - glibc/Linux: the late _DEFAULT_SOURCE meant <stdlib.h> never declared
    them, so the (wrong) externs were used; removing them alone left
    implicit-declaration warnings.

Fix: define _DEFAULT_SOURCE before the first system header and drop the
redundant externs, so both platforms get the correct prototypes from
<stdlib.h>. Verified: clean build on Linux (gcc) and FreeBSD 15 (clang;
only pre-existing K&R-prototype deprecation warnings on getpass/crypt/main
remain, unrelated to this change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CHOOK_CHANMSG (which emits EVT_CHANMSG to gossip peers) was gated on
ismine = MyClient(sptr), so a channel PRIVMSG relayed from a legacy TS5
server never produced an EVT_CHANMSG and was never delivered to gossip
peers -- even when the channel held gossip-materialized members. User
introductions and joins already bridge in this direction
(CHOOK_POSTREGISTER/POSTJOIN fire for remote clients); channel messages
were the missing case.

Fire CHOOK_CHANMSG for remote clients too, and honor FLUSH_BUFFER only
for local ones. The consumers are safe for remote sources: echo-message
no-ops on !MyClient, chathistory captures the message (desirable), and
the gossip eventlog already skips gossip-origin senders and only emits
when the channel has a gossip-materialized member.

Verified live on a TS5 <-> bridge <-> gossip testnet: a channel message
from a user on the legacy TS5 server now reaches a user on the gossip
leaf, and vice-versa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The autoload list was only acted on at boot (init_modules); rehash
re-parsed it into the config struct but never loaded or unloaded
anything. So commenting a module out + REHASH/SIGHUP could not remove it
(only a restart would), and adding one would not load it.

Add rehash_modules(), called from rehash() after merge_confs() (success
path only):
  - unload modules dropped from the autoload list
  - load autoload entries not yet loaded

Only config-managed modules are unloaded. A per-module `autoloaded` flag
is set at the autoload call sites (boot loop + rehash load pass) and left
0 for core-dir modules (load_module_dir) and manual MODULE LOADs, so
neither is disturbed by a rehash. MAPI_CORE modules are skipped as a
second safety net, which also covers a core module that happens to be
autoloaded (e.g. m_gossip_eventlog).

Verified on FreeBSD against the live testnet: commenting out m_starttls +
SIGHUP unloads it (tls cap removed) while every [core] module and the
gossip link survive; re-adding + SIGHUP reloads it; pid stable (no crash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hook_signoff reserved a session (holding the client's nick for
SESSION_TIMEOUT) on every /QUIT, including clients that never
negotiated draft/resume-0.5.  Such a client can never issue RESUME, so
the only observable effect was blocking an immediate reconnect with the
same nick for no benefit — users saw "nick in use" after quitting and
reconnecting.

Gate session creation on HasCap(sptr, cap_resume_bit) at the top of
hook_signoff, and drop the now-dead best-effort "Session token:" NOTICE
that targeted non-cap clients (they had no way to use it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document every shipped module and which to enable by default:

- modules/README.md: new catalog — core (auto-loaded), IRCv3 ratified
  caps (recommended on), traditional commands, IRCv3 draft extensions
  (off by default), and optional/situational modules; plus the runtime
  MODULE commands and the rehash-reconcile behaviour.
- doc/reference.conf, doc/template.conf: align the modules{} block with
  the README — stable caps + traditional commands autoloaded; draft
  extensions (chathistory/tls_tag/session/bot), STARTTLS, and the webirc
  gateway commented out; point at modules/README.md instead of the old
  drifted inline list. Drop the stale "external services only" wording.

bot and webirc are now opt-in rather than default-loaded; STARTTLS is
commented out by default (it offers TLS on non-S ports once a cert is
configured, which is rarely what an operator wants by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tagged delivery decided whether to attach tags with a coarse OR-mask
(tag_delivery_caps): any client holding ANY tag cap received the full
built tag set.  A client that negotiated only server-time would still be
sent msgid=, account=, draft/bot, draft/tls — tags it never asked for and
may not parse.  The same leak applied to channel messages relayed over
gossip (gossip.c EVT_CHANMSG carries the origin's out-tags verbatim).

Make the tag registry self-describing and filter per-recipient:

- register_outbound_tag() now also takes the bare tag key it emits
  ("time", "msgid", "account", "draft/bot", "draft/tls"), stored in the
  registry. tag_delivery_caps is kept only as a fast "has ANY tag cap?"
  pre-check.
- filter_tags_for(tags, to): tokenises a tag string and drops each token
  whose key is registry-known AND whose cap_bit the recipient has not
  negotiated; unknown tokens (label, batch, client-only tags) pass
  through untouched. It reads the stored key and never invokes a
  generator, so relayed tags from other servers are gated correctly
  without being regenerated locally.
- sendto_channel_butone_tags filters per local member (the set now
  differs between members, so the build-once cache is gone; falls back to
  a plain send when a member is entitled to nothing).
- sendto_one_tags filters for the target client before resolving
  to->from, covering PRIVMSG-to-user, echo-message, TAGMSG, labeled
  responses and chathistory uniformly.

New extensions that need tagging now register one (fn, cap_bit, key)
triple and get both emission and per-recipient gating for free.

This changes the register_outbound_tag binary symbol signature: the
binary and the five tag modules must be rebuilt and deployed together
(a stale 2-arg .so reloaded against the new binary would pass a garbage
key). Verified on box (Linux) and rootfs (FreeBSD): a server-time-only
client gets only time=; server-time+msgid gets time+msgid; an
account-tag client gets time+account; a no-cap client gets a plain line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A STAT_GOPEER link is dispatched as HANDLER_SERVER (parse.c) but the
read/buffering layer keyed on IsServer() — which a gopeer is not — so it
was dispatched as a server yet buffered as a client in two places:

  - read_packet(): the gopeer fell to the client else-branch
    (sbuf_put + do_client_queue) instead of the server block path
    (dopacket).
  - do_client_queue(): the `if (IsServer(cptr))` whole-block branch
    excluded the gopeer, so it used sbuf_getmsg() client 512-byte
    message framing.

Client message-framing is wrong for a server-style event stream: gossip
GEVENT bursts are large and back-to-back, and the client path calls
SBufClear() on a >512-byte chunk with no newline ("someone's trying to
trick us"), which drops burst data — corrupting a sync so the peer ends
up "seeing nothing" after a (re)sync. It also forced the gopeer through
client queue/timing semantics never intended for a server link.

Fix: treat STAT_GOPEER like a server in both read paths (add IsGoPeer to
the read_packet server check and to do_client_queue's whole-block
branch), so a gopeer is buffered the same way it is dispatched. Safe:
dopacket's only cptr->serv dereference is its zip branch, and gossip
never negotiates ziplinks (FLAGS_ZIPPED_IN is never set on a gopeer,
whose cptr->serv is a GossipPeer, not an aServer).

Verified on box (Linux) + rootfs (FreeBSD): the gossip link syncs
through the new whole-block path (LINKS shows full topology, LUSERS
shows remote users — the "sees nothing" symptom is gone); hub rehashes
during a sync no longer corrupted/dropped the link in testing. The
data-loss symptom is cleanly explained and fixed; an intermittent
rehash-time link drop seen during diagnosis did not recur but its exact
exit path was not isolated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gossip peer links (STAT_GOPEER) were invisible to /STATS l, which only
reported IsServer() connections, and there was no way to see a gopeer's
liveness or latency: the GPING/GPONG keepalive handlers existed but
nothing ever *sent* a GPING, so it was dead code.

- m_gossip: send GPING to each burst-complete peer from the 10s timer,
  stamping the current millisecond time as the nonce. The peer echoes it
  in GPONG; ms_gpong derives RTT = now - nonce (both on our own clock, so
  no peer clock-skew). The nonce is formatted with standard snprintf,
  not ircsnprintf (the latter mishandles %llu). Adds rtt_ms / last_pong
  to GossipPeer (rtt_ms = -1 until the first GPONG).
- m_stats: /STATS l now also lists gopeers, using the gossip peer name
  (a gopeer has no cptr->name) and a flag column of
  "gossip/<synced|syncing>/rtt=<n>ms". The numeric link-stat args are now
  cast to unsigned long to match what ircvsprintf's %u reads (an int arg
  left the upper 32 bits undefined, which surfaced as a garbage SendBytes
  on the outbound gopeer).

/STATS g is GCOS bans, so this lives in /STATS l as the user requested.
Verified on box (Linux) + rootfs (FreeBSD): both ends list the peer with
a live, settling RTT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
STATS l with no argument lists local server and gossip-peer links, but
opers.txt only described the per-nick lookup form and said nothing about
the gossip-peer line or its flag column.

- opers.txt: document the no-argument link list — the per-link counter
  columns and the <flag> field ("TS"/"NoTS" for TS5 servers,
  "gossip/<synced|syncing>/rtt=<n>ms" for gossip peers).
- MIGRATION.md: add a "Monitoring gossip peers" section showing the
  STATS l layout and how to read per-peer counters + RTT/sync state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the open-mesh hole: previously any host that could reach the S2S
port could speak GHELLO, join the gossip mesh, receive the full state
burst, and inject forged events. The gopeer{} passwd was parsed but
never checked.

Gopeer links now authenticate, fail-closed for inbound:
- The dialer sends its gopeer{} shared secret as a trailing GHELLO
  param, but only over a TLS link (never in cleartext).
- The listener requires TLS, a matching gopeer{} block with a passwd,
  and a correct secret; otherwise it sends ERROR and exits the peer.
- The secret is compared crypt-aware (crypt(3) when options
  { crypt_oper_pass } is set, else plain), mirroring the oper check, so
  the accepting side may store it crypted at rest.
- gopeer{} blocks may now be host-less (accept-only); a block with no
  passwd is warned about and cannot link.

The outbound GHELLO send is factored into gopeer_send_ghello() so the
plaintext and TLS paths in s_bsd.c can't drift. Docs updated
(reference.conf, template.conf, MIGRATION.md).

This is one-directional auth (listener authenticates dialer) over
mandatory TLS; mutual proof and TLS cert pinning remain follow-ons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
@eaescob

eaescob commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Merge blockers — two gossip-core correctness bugs

A whole-branch review surfaced three pre-existing gossip-core issues (verified
against the code; none are introduced by the gopeer-auth commit — they
predate it). Two are correctness blockers for merging this branch:

Tracking, not a merge blocker:

This PR should not merge until #260 and #261 are resolved.

eaescob and others added 7 commits June 27, 2026 14:57
Two PR-blocking gossip fixes, interleaved in the same handshake code so
committed together.

#260 — replace the 6-bit FNV-1a ServerId (only 64 ids; silent birthday
collisions conflated two servers' (server,seq) and corrupted GEVENT dedup +
clock reconciliation):
- The server NAME is now the canonical wire identity; in memory a dense
  uint16 index registry (new gossip_idmap.{c,h}) maps name<->index, capped at
  MAX_GOSSIP_SERVERS=256 with a loud snotice on exhaustion (never silent).
  Indices never leave the process.
- ServerId widened to uint16; EventClock stays fixed-size so the POD event
  ring/memcpy paths are untouched. fnv1a_6bit and the dead clock_*_b64 path
  removed. Sparse clock and gossip-id are now name-keyed ("name:seq").
- GHELLO drops its numeric id (GHELLO <name> <ver> :<secret>);
  EVT_SERVER_LINK serialises the name only; GACK/GSYNCING are name-keyed.

Auth-review fixes (security-reliability-auditor on the prior auth commit):
- CRITICAL: the dialer/listener discriminator keyed on cptr->name, which an
  unregistered client can pre-set via NICK to bypass the TLS+secret check.
  Now keyed on a server-controlled fd-indexed "we dialed this" marker
  (set in gopeer_try_connect, cleared in close_connection).
- crypt_oper_pass no longer silently breaks links: gopeer_secret_ok tries a
  cleartext compare first, then crypt — accepting either at-rest form.
- constant-time secret compare; confparse_warn for non-fatal warnings;
  parse-time warning for a passwd block missing tls.

Wire-breaking but within the unmerged branch. Not yet compiled locally
(toolchain on box/rootfs). Out of scope: #261 (name-keyed clock is longer,
sparse in practice) and #262 (clock_advance semantics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
Post-deploy security-reliability-auditor pass (verdict: approve, low risk;
the fd-marker discriminator and name<->index translation verified sound).
Two zero-runtime cleanups it flagged:
- m_gossip.c: ms_ghello header comment still showed the old
  "GHELLO <name> <server-id> <version>" wire format; corrected to the
  current "GHELLO <name> <version> :<secret>".
- gossip.c: drop the now-dead `int id` parameter from
  gossip_materialize_server (all callers passed 0 after #260).

confparse_warn kept as a local extern in s_conf.c to match the existing
confparse_error idiom (also a local extern, not in confparse.h).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
dopacket assembled every inbound line into aClient.buffer[512] and silently
truncated at 511, so any gossip event over ~511 bytes (a near-max TOPIC, a
SESSION_CREATE with realname+away, a CHANMSG) was corrupted/dropped on receipt
and diverged state. The sender already emits up to ~2KB.

Fix, scoped for a 10+ server / few-peers-per-leaf topology:
- A gopeer assembles its inbound lines in a dedicated GossipPeer.linebuf
  (GOSSIP_LINESIZE=2048) instead of the 512-byte aClient.buffer; clients and
  TS5 servers keep 512. dopacket selects the buffer once per call by IsGoPeer
  (the GHELLO that flips it is handled on the client path, so there's no
  mid-call transition). Only the few peer links pay the extra memory.
- Safety gate: a widened line feeds every HANDLER_SERVER handler, and TS5
  handlers (m_services strcpy, channel strcat) assume <=512-byte params. parse
  now drops (loudly) any >512 line from a gopeer whose command isn't a gossip
  command, so an oversized line can only reach the bounded gossip handlers.
- Sender budget: gossip_send_event sizes the clock so framing + payload(<=1024)
  + clock <= GOSSIP_LINESIZE, with a sendto_realops + drop tripwire instead of
  silent truncation. Clock truncation is safe (only over-sends; dedup absorbs).

MsgBuf.raw is vestigial (parse_msgbuf tokenizes in place in the line buffer),
so nothing to widen there. Builds clean on Linux + FreeBSD.

The per-event clock reduction (the real 10+ server scaling lever) is tracked
separately in #262.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
Found by live testing: a 305-char topic propagated over gossip arrived
truncated to ~300 even with the receiver-side gp->linebuf in place. Root
cause is on the SENDER — send_message() appends CRLF and clips any line to a
client/server at 512. A gopeer is STAT_GOPEER (not IsServer) and is not a
WebSocket, so it fell into the client branch and the gossip line was clipped
to ~510 before it ever left the box.

- send.c: add an IsGoPeer(to) branch in send_message that caps at
  GOSSIP_LINESIZE-4 (room for CRLF+NUL in the 2048-byte sendbuf) instead of
  512, so a budgeted GEVENT line goes out whole. Receiver already assembles
  it in GossipPeer.linebuf (prior commit).
- s_gopeer.c: bound the GSYNCING burst clock so that line also stays within
  GOSSIP_LINESIZE / sendbuf (gossip_send_event was already budgeted; GSYNCING
  was not). Clock truncation is safe (only over-sends; dedup absorbs).

Builds clean on Linux + FreeBSD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
Every GEVENT carried the emitter's full vector clock (@gossip-clock), whose
only consumer was clock_advance() at apply. At 10+ servers that tag grows
~linearly and dominates S2S bandwidth and (post-#260, name-keyed) GEVENT line
length. It is not used for ordering, apply, dedup, or conflict resolution.

- Drop @gossip-clock from GEVENT (gossip_send_event); the line is now just
  @gossip-id + payload + framing (cluster-size-independent). Removes the now-
  moot #261 clock budget; keeps the loud-drop tripwire.
- Receiver point-updates local_clock from the gossip-id origin instead of
  merging a full per-event clock: clock_advance() -> clock_mark(origin, seq)
  at apply. emit_event no longer stamps ev->clock.
- The full clock still goes once per link-up at GSYNCING; peer_clock /
  get_events_since burst path unchanged.
- Remove the dead write-only gp->sent_clock.

Safe because burst replay keys off my_id: every ring event is re-stamped under
our own id (#260), so get_events_since only reads the peer clock's my_id slot
-- transitive per-event-clock knowledge was never load-bearing, even after a
partition. In a fanout=0 flood mesh every event reaches each node directly, so
point-updating each (origin,seq) still converges local_clock.

Staged: NetworkEvent.clock is left in place (now unused) and removed in a
follow-up to keep this behavior change bisectable. Part B (TS conflict-
resolution hardening) split to #264. Shrinks #263's attack surface
(clock-decode runs only at burst now). Builds clean on Linux + FreeBSD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
Staged tail of 2352879: Part A stopped using the per-event vector clock but
left the struct field in place to keep the behavior change bisectable. With
#262 validated live (steady-state + burst), remove it. The field decl was the
only reference left (no reader/writer). The EventClock type stays (local_clock,
peer_clock, burst encode/decode).

Shrinks NetworkEvent by sizeof(EventClock)=2048 bytes -> the 8192-entry event
ring drops ~16 MB. Pure struct shrink, no behavior change. ABI: binary +
gossip-header modules rebuilt/redeployed together. Builds clean Linux + FreeBSD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
…Tiers 1-3)

Tier 1 — doc/gossip-consistency.md: document the per-state convergence model
(nick/channel/topic LWW + tiebreakers), incl. the cardinal rule that tiebreakers
key on the server NAME, never the per-process dense index (#260).

Tier 2 — nick equal-TS no longer kills BOTH: tiebreak deterministically on the
server name (lexicographically-smaller wins, loser killed) so one survivor
converges on every node. Missing TS still kills both (can't compare).

Tier 3 — gossip_apply_chan_topic is now TS-resolved: apply only if newer by
topic_time (equal-time tiebreak on the topic string), so two nodes that saw two
topic changes in different orders converge instead of last-arrival-wins.

Channel mode/op convergence (Tier 4) is its own design — issue #265
(channelts-gated CRDT; reevaluate whether it's even needed in an all-gossip
network). Builds clean Linux + FreeBSD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
WebSocket (transport, config-gated — unchanged default-off):
- Negotiate the ratified `text.ircv3.net` / `binary.ircv3.net` subprotocols in
  the client's preference order; keep legacy `irc` as a text alias; echo the
  chosen subprotocol in the 101 response.
- Track text/binary mode per connection: emit BINARY (0x82) frames to binary
  clients, TEXT (0x81) to text clients; accept inbound TEXT and BINARY frames
  identically as IRC lines.
- For text clients, guarantee valid UTF-8 outbound (scrub invalid bytes to
  U+FFFD) per the spec MUST — bounded to the frame buffer so the 3x worst case
  can't overflow. Share one WS_FRAME_BUFSIZE sized off the largest send.c
  feeder (tagbuf[2560]), fixing a latent truncation of large tagged lines.
- template.conf: comment out the W/WS ports so a stock install has no WS
  listener (WebSocket stays opt-in via a port flag).

WEBIRC (module) — IRCv3 spec compatibility:
- Parse the optional options parameter; honor `secure` ONLY when the flag is
  present AND the gateway<->server link is TLS (spec MUST). Unknown options are
  ignored (MUST-tolerate). certfp-* is deliberately not consumed — a gateway
  can assert any fingerprint, so honoring it would be a spoof vector.
- Introduce a policy/transport split: new webirc_secure state + IsSecureConn(),
  which returns a WEBIRC client's honored-secure state and otherwise falls back
  to IsSSL(). Repoint the three user-security-policy sites (WHOIS SSL display,
  UMODE_S at registration, +S secure-only channel gate) to IsSecureConn();
  raw TLS I/O and the STATS "TLS encrypted" transport diagnostic keep IsSSL().
- Delete the orphaned src/m_webirc.c (dead pre-Phase-1 copy, never built).

Tests: extend the WS harness for subprotocol/binary/raw-frame; add WEBIRC
harness support (config.py gateway ports + allow blocks) and tests/suites/
test_webirc.py covering the secure invariant (plaintext+secure ignored,
TLS+secure honored, TLS-without-secure downgraded). WebSocket 11/11 + WEBIRC
5/5; clean builds on Linux and FreeBSD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E3pp4mygXFS51sW6Xfvsz
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