Bahamut 3.0: IRCv3 + Gossip (services via U:line) - #254
Open
eaescob wants to merge 34 commits into
Open
Conversation
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
force-pushed
the
bahamut-mods-gossip
branch
from
March 13, 2026 02:18
4fb726d to
e913bb1
Compare
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>
This reverts commit ebd8880.
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>
eaescob
force-pushed
the
bahamut-mods-gossip
branch
from
March 17, 2026 14:40
740dd8f to
77cf097
Compare
progval
reviewed
Mar 17, 2026
- 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
Contributor
Author
Merge blockers — two gossip-core correctness bugsA whole-branch review surfaced three pre-existing gossip-core issues (verified
Tracking, not a merge blocker:
|
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
HandlerTypedispatch +MsgBuftag parsing; MAPI v3 hot-reloadablemodules (
MODULE RELOAD/INFO)setup.hgenerated via feature detectionIRCv3 — 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
mixed 2.x/3.0 networks
server-time/msgid are carried from the origin (not regenerated), and
extended-join / away-notify / setname / tagmsg / invite-notify are relayed
time — no
server_idconfig tokenRESUMEwith cross-server token propagationTransport / TLS
ssl {}block, client cert fingerprint extraction, STARTTLS, outbound gossip TLSServices model
/NS,/CS, …)forwards to external U:line services when
local_handleris NULL; otherwise thenick is simply unreachable (expected)
Operator config notes
gossip {}— usually justsync_window.fanoutdefaults to0= flood allpeers; set it only to deliberately limit fan-out.
gopeer {}—host/port/passwd/name(+ optionaltls). Noserver_id(derived from name). Any connected topology works.doc/reference.conf,doc/template.conf,doc/MIGRATION.md.Testing
chathistory, gossip, gossip_ts5_interop, module_reload, monitor, session,
websocket, whox) — real ircd processes via the harness in
tests/tests/live_chain.py): verified IRCv3 relayenrichment (23/23), all-see-all matrix, and nick/topic/channel burst across hops
🤖 Generated with Claude Code