The inbound parser ignored METADATA entirely, so on a services netburst
(after a restart/relink) the ircd's replayed 'accountname' metadata was
dropped — every logged-in user looked logged out to services until they
re-identified, losing ChanServ access. Parse METADATA <uid> accountname
into a new AccountLogin event that restores account_of (empty = logout);
our own echoes and other keys are filtered.
A local AKILL/SQLINE/etc. pushes an ADDLINE to the ircd at once, but a ban
arriving by gossip was only held for the next netburst — so a user banned on
one network could keep hopping to peers until their next relink. ingest now
signals BanAdded/BanLifted (all xline kinds), and gossip_ingest pushes the
ADDLINE/DELLINE. Renamed AccountChange to IngestEffect to match its broader
role.
A local SUSPEND logs out the account's active sessions, but a suspension
arriving by gossip only set the flag — a session already identified on a
remote node stayed logged in despite the network-wide suspension. ingest
now signals AccountChange::Suspended, and gossip_ingest ends those sessions
(the account and its channels are kept). Factors the per-session logout into
a shared logout_uid helper.
When a peer's gossiped entry drops or takes over an account, the local node
releases the channels that account founded locally (Local-scope channels can
be founded by a Global, gossiped account). That cleanup runs outside the
dispatch path, so a bot assigned to a released channel lingered until the
next netburst. gossip_ingest now reconciles bots after an account is removed,
matching the expiry sweep.
Command-driven channel drops reconcile bots via the dispatch path, but the
inactivity-expiry sweep runs outside it — so a bot assigned to an expired
(or expired-founder-orphaned) channel lingered there until the next
netburst. The sweep now reconciles at the end, parting any stranded bot.
A user authenticated via SASL during registration never runs the NickServ
IDENTIFY path, so they silently missed their auto-join channels, vhost, and
waiting-memo notice. The UserConnect handler now applies these login
side-effects when the arriving user is already authenticated, mirroring
identify.rs. Non-SASL connections are unaffected (no account set yet).
FORBID accepted and stored EMAIL patterns, but nothing ever checked them:
a forbidden address still registered and could be set via NickServ SET
EMAIL. Both paths now reject a matching address (new ForbiddenEmail
outcome), matching how NICK and CHAN forbids already gate registration.
Founders can attach a contact email to a channel, shown in INFO and
cleared when set empty. Mirrors SET URL exactly; Local-scope channel
metadata. Completes the channel DESC/URL/EMAIL trio.
Founders can attach a homepage URL to a channel; it shows in INFO and
clears when set empty, mirroring SET DESC. Local-scope (channel metadata),
not gossiped, consistent with the entrymsg/description events.
Lets an account keep its last-seen/online line in INFO visible only to
itself and to opers. Default stays public (Anope's default). Accepts
STATUS (with USERMASK as an alias) per Anope's SET HIDE <field> grammar.
Nick protection was unconditional. SET KILL OFF lets an account opt out —
an unidentified user keeping one of its nicks is no longer prompted or
renamed to a guest. Stored inverted (no_protect) so protection stays the
default. Accepts Anope's ON/QUICK/IMMED/OFF; grace is a fixed interval
here, so the finer variants simply enable protection like ON.
A user with SET AUTOOP OFF is never auto-opped on join, even where they
hold channel access — they op themselves via ChanServ UP. Stored inverted
(no_autoop) so the default stays auto-op. Join gates on both the channel
setting and the account preference; either can opt out.
SET AUTOOP {ON|OFF} controls whether access members are auto-opped/voiced
on join (on by default). Stored inverted as a settings flag so the
default stays on, and gated in the join handler.
SET NOTIFY {ON|OFF} controls whether you're told about new memos on login;
SET LIMIT <n>|NONE sets your mailbox cap (honoured on SEND, NONE = the
network default). New memo_notify/memo_limit account fields + one
MemoPrefsSet event.
IGNORE ADD/DEL/LIST manages a per-account memo-ignore list; a memo from
an ignored account is silently dropped, and the sender is still told it
was sent so the ignore isn't revealed. New memo_ignore account field +
events, and a check in SEND.
GETEMAIL (auspex) lists accounts whose email matches a glob. SET
RESTRICTED makes a channel kick anyone without access (or oper) as they
join, mirroring the SECUREOPS on-join enforcement.
FORBID ADD/DEL/LIST bans a NICK, CHAN, or EMAIL glob from being
registered — a network-wide policy that gossips like an AKILL and every
node enforces. Nick registration is blocked in pre_register_check (both
NickServ REGISTER and the account-registration relay), and channel
registration in ChanServ REGISTER. New Forbid store + events, mirroring
the akill subsystem.
SET <#channel> SUCCESSOR <account>|OFF names an account that inherits the
channel if the founder's account is dropped or expires, instead of the
channel being released. Adds a ChannelInfo.successor field + event, and
centralises the founder-gone channel release into a single
release_founded_channels store method used by DROP, expiry, and the
gossip account-gone path so all three transfer consistently.
On connect or nick-change to a registered nick the user isn't identified
to, NickServ prompts them to IDENTIFY and schedules a rename; a short
enforcement sweep renames them to a guest nick after a grace period if
they still haven't. Gated on the uplink's initial burst so a relink does
not enforce every already-online user, and cancelled the moment they
identify (via IDENTIFY or SASL) or leave the nick.
Echo is the authority the ircd account module forwards to: parse
ENCAP <us> SWACCTREG <reqid> <origin> <kind> <account> <p2> :<p3> into an
account request, and answer the origin server with
ENCAP <origin> SWACCTRES <reqid> <kind> <account> <status> <code> :<message>.
Thread the origin through the reply so responses route back to the
requesting server. Replaces the placeholder ACCTREGISTER/ACCTREGRESULT
names that nothing on the wire actually spoke.
Credentials: the SCRAM-SHA-256 verifier is now the only password
credential. PLAIN and IDENTIFY verify the plaintext against it via
scram::verify_plain, exactly as a SCRAM login proves knowledge of it, so
an account provisioned from a verifier alone (external authority) works
over every mechanism, not only SCRAM. Removed the redundant argon2 hash
entirely: the field, the Credentials member, the AccountPasswordSet
field, hash_password/verify_password, and the argon2 crate. SASL SCRAM
already forces a PBKDF2 verifier into the store, so the hash never raised
the at-rest floor. OS RNG now comes from rand_core.
Registration: finished draft/account-registration over the ircd relay.
VERIFY confirms the emailed code, RESEND reissues it, STATUS reports
state, and REGISTER now emails the code and replies verification_required
on the relay path too, not only through NickServ.
Each service now exposes its help catalog via Service::help_topics; the
engine collects them into a network-wide index on Network, reachable
through two new NetView methods. HelpServ uses it: HELP <service>
[command] (or a bare service name) serves any service's per-command
help, bare HELP lists the services, and its own ticket commands still
work. No cross-crate dependency: the catalog flows through the SDK's
NetView, keeping every module dependent only on echo-api.
Rebrand the daemon and workspace: the binary is now `echo`, every crate
is `echo-*` (`echo-api` and the module crates), Rust imports use
`echo_*`, the gRPC proto is `proto/echo.proto` with package `echo.v1`,
and the log filter, gossip peer-name default, and on-disk store default
(`echo.db.jsonl`) follow. Docs updated throughout. No behaviour change;
crate directories and the config schema are untouched.
engine/mod.rs had grown past 5k lines, most of it one test module and
a few large method clusters. Lift the tests into engine/tests.rs, and
move the cohesive method groups into sibling files — sasl, kicker
(chat policing), register (account-authority relay), and dispatch
(service routing and fantasy). The Engine type, its master event
handler, shared helpers, and the small state structs stay in mod.rs;
each sibling adds its own impl Engine block. Pure reorganisation: no
behaviour change, tests unchanged and green.
Extends the fantasy router so a dice command (!roll/!calc/!exroll/!excalc)
in a bot channel routes to DiceServ (if loaded) instead of ChanServ, and
its reply is turned into the assigned bot speaking the result to the
channel — so a tabletop game rolls in the open, not in a private notice.
Everything else still routes to ChanServ. Factored the dispatch into
service_uid()/route_to() helpers shared by both branches.
Users open tickets with REQUEST (or HELPME); the queue is event-sourced
and, because a request is an event, the audit feed announces it to staff
and records it in the searchable log — so OperServ LOGSEARCH finds a ticket
by any word, the same trail as reports. A user can CANCEL their own open
ticket. Operators work the queue: LIST [ALL], VIEW, TAKE (claim), NEXT
(claim the oldest unassigned), CLOSE. Filing is rate-limited. Opt-in.
That completes the six third-party modules — all interconnected: they
share the account/channel/oper identity, the event-sourced store, and the
one audit/incident trail.
The engine samples live op state on a short cadence, scoring op-time per
identity (account, else host) on the network view — a net +1 per pass
while opped, -1 while idle, so trusted regulars rise and stale ops fade.
ChanFix (operator-only) reads those scores: SCORES <#chan> shows the
standings, CHANFIX <#chan> reops the present regulars scoring above a
share of the top score, once the channel is opless enough and has real
history. It defers to ChanServ — a registered channel is refused. Opt-in.
C++ note: the original persists scores to the services DB and runs its own
timers; here scoring rides the existing periodic loop and lives on the
ephemeral network view, so it's node-local and rebuilds itself.
User groups (!name) — a founder plus member accounts, each with group-
access flags (the shared flag primitive: F founder, f manage, i invite,
c chan-access, s set, m memo). REGISTER/DROP/INFO/LIST/ADD/DEL/FLAGS;
event-sourced and federated like accounts.
The flagship interconnection: a channel access entry may name a !group, so
every member inherits that channel access. Resolution is group-aware — a
new db.channel_caps() unions a user's direct access with any group they
belong to, and the engine's join handler auto-ops/voices through it. So
"ChanServ FLAGS #chan !team +o" auto-ops every member of !team, and
removing someone from the group revokes it. Opt-in.
A granular access system, replacing the op/voice-only levels with per-user
flag letters (f full, o auto-op, O op, h auto-halfop, v auto-voice, t
topic, i invite, a modify-access, s settings, g greet). One shared
primitive in the api — level_caps() resolves a stored level to capabilities
(recognising the legacy op/voice/founder presets so nothing breaks) and
apply_flags() applies +/- deltas — so join_mode/is_op/access_rank all
route through it and a flag-granted +o auto-ops exactly like a legacy op
entry.
ChanServ FLAGS <#chan> [account [+/-flags]] lists or edits access;
changing needs the founder or the 'a' flag. This is the foundation
GroupServ will reuse for group membership flags.
Any user can REPORT <nick|#channel> <reason>; filing is rate-limited per
reporter. Reports are an event-sourced, federated queue operators review
with LIST / VIEW / CLOSE / DEL. The interconnection: because a filed report
is an event, the engine's audit feed already announces it to the staff
channel and records it in the searchable action log — so OperServ
LOGSEARCH finds a report by any word in it, tying reports into the same
trail as bans, kicks, and everything else. Opt-in (add "reportserv").
InfoServ becomes the dedicated home for network information bulletins,
backed by the same event-sourced news store OperServ NEWS used — one place
to post, not two. POST/LIST/DEL manage public bulletins (shown to every
user on connect); OPOST/OLIST/ODEL the oper ones (shown to operators on
login). Anyone may LIST the public bulletins; posting/removing is admin-
only. The engine's existing connect/login display hooks render them
unchanged. The OperServ NEWS command is retired accordingly (the news
events and the display path stay). Default service.
Adds Accounts.Provision(name, scram256, scram512, email) so an external
authority that stores verifiers rather than passwords (a website) can bulk-
backfill accounts it never had the plaintext for. It creates the account
with the given SCRAM verifiers — SCRAM and keycard login work immediately;
typed-password login stays disabled (empty password_hash) until a real
password lands via Register/SetPassword. Refuses an existing account, so a
re-run can't clobber a fully-credentialed one. scram512 may be empty
(SCRAM-SHA-512 then falls back to 256).
By default fedserv owns accounts itself — nothing changes, no external
service required. Set [auth] external = true and an outside authority (the
website) owns identity instead: NickServ REGISTER / DROP / SET
PASSWORD|EMAIL / RESETPASS / CONFIRM / CERT / GROUP and the IRCv3
registration relay are all refused ("managed on the website"), so IRC
can't mint or change a second identity. Login is unchanged — fedserv still
authenticates locally against the accounts the authority pushes in via the
existing gRPC Accounts API, so lookups stay in-memory fast.
fedserv keeps owning all IRC-domain data (channels, access, vhosts, bans,
memos) keyed by the account name in both modes. One config bool, off by
default, so standalone deployments are unaffected.
DEFCON [1-5] reads or sets a network-wide defence posture, each level
adding a restriction the engine and services enforce:
- 4: channel registrations frozen (ChanServ REGISTER)
- 3: all registrations frozen (the pre_register_check choke-point, covering
NickServ and the IRCv3 relay, plus channels)
- 2: sessions additionally capped to one per host
- 1: full lockdown — new connections are turned away on connect
Setting a level announces it to the whole network. Node-local level on the
db so services can read the derived freezes; admin-only.
OPER ADD <account> <priv[,priv]> [+duration] now takes an optional window,
after which the grant lazily stops conferring privileges — no timer, the
oper_privs merge just hides an expired grant (evaluated on the engine's
clock so it's testable). Permanent grants are unchanged; LIST flags the
temporary ones and hides expired entries. Runtime opers now carry an
OperGrant{privs, expires} instead of a bare priv list.
CBAN ADD <#channel-glob> <reason> / DEL / LIST blocks users from creating
or joining a matching channel (m_cban is loaded). Another kind ("CBAN")
in the generalized X-line handler with a channel-mask normalizer; the
too-wide guard now ignores a leading channel prefix so #* is refused.
LOGSEARCH [pattern] searches the incident log newest-first: match a bare
id (as stamped into a kick reason), or any word from the summary; no
argument lists the most recent. Auspex-gated (read-only investigation).
To make it comprehensive, the command audit trail now records every
notable action to the same incident log (not just to the audit channel,
and regardless of whether one is configured) — so registrations, drops,
akills, suspensions, vhosts, notes, and the like are all findable
alongside the kicks and kills stamped at the removal choke-point.
A single choke-point in handle() gives each outgoing Kick / KillUser a
short id, appends it to the reason the user sees ([#3F]), and records a
searchable summary in a bounded per-node incident ring on the live network
view. So a bot flood-kick, a fantasy kick, a votekick, and an operator
KICK are all logged the same way, and the id in a kick reason ties back to
the log entry. Exposed via NetView::search_incidents (id-exact or summary
substring, newest first) for the LOGSEARCH command to come.
- SHUN <user@host> silences a matching user network-wide without
disconnecting them — another kind ("SHUN") in the generalized X-line
handler (m_shun is loaded), same event-sourced list and lazy expiry as
AKILL/SQLINE/SNLINE.
- CHANKILL <#channel> [reason] AKILLs every distinct member host in one
shot to clear a spam or attack channel; the G-lines the ircd applies
also kill the sessions. Deduped by host, and never bans the operator who
ran it. Both admin-only.
JUPE <server.name> [reason] introduces a fake server holding the name so
the real one can't link; JUPE DEL <name> squits it; JUPE LIST shows them.
Node-local (the introducing node owns the jupe, so it isn't federated —
that would collide SIDs across nodes) and re-asserted at burst. A fake
server id is allocated per jupe. New JupeServer/Squit actions serialise to
the mid-link SERVER introduction and SQUIT. Admin-only.
The connection that puts an IP over its allowance is killed on connect.
The ircd's per-user IP (UID param 7) is now plumbed through UserConnect and
tracked per-IP in the live network view (incremented on connect, released
on quit).
- [session] default_limit sets the base allowance (0/absent = off).
- OperServ SESSION LIST <min> / VIEW <ip> inspect live counts.
- OperServ EXCEPTION ADD <ip-mask> <limit> [reason] / DEL / LIST override
the limit per IP-mask (limit 0 = unlimited); event-sourced so they
federate and survive restart, matched most-permissive-wins.
All admin-gated.
SNLINE ADD [+expiry] <realname-regex> <reason> / DEL / LIST — a realname
R-line the ircd matches against connecting users' realnames. It's just
another kind ("R") in the generalized X-line handler: same event-sourced
list, lazy expiry, and burst re-assertion as AKILL/SQLINE, keyed by
(kind, mask). The mask is kept verbatim (a single-token regex; use . or
\\s for spaces) and an all-wildcard pattern is refused. STATS now counts
it too.
OPER ADD <account> <priv[,priv]> / DEL <account> / LIST grants or revokes
services-operator privileges (auspex, suspend, admin) at runtime, without
editing config and restarting. Runtime grants are event-sourced (Global,
so they federate and survive restart) and UNIONED with the declarative
[[oper]] config at the engine, so either source makes an account an
operator; a config oper can't be revoked here. Admin-only.
Factored the privilege-name parsing into Privs::from_names (+ names/union)
so config loading and runtime grants share one definition.
NEWS ADD <LOGON|OPER> <text> / DEL <LOGON|OPER> <number> / LIST. Logon news
is shown to every user as they connect; oper news is shown to an operator
when they log in. Admin-only to manage. Each item carries a stable id
assigned at add time and embedded in the log, so deletion is order-
independent under replay (DEL by number resolves to that id).
Enabling groundwork: the replicated network-wide lists (bans, now news)
are consolidated into one NetData struct threaded through apply() as a
single parameter — this replaces the akill parameter rather than adding
one, so the fold stays within its argument budget as the lists grow.
INFO ADD <target> <note> / DEL <target> / <target> attaches a staff note
to an account or channel (a #name is a channel, else an account). The note
is shown in that service's INFO — but to operators only (Priv::Auspex),
never to the account's own owner. Admin-only to set.
Follows the typed-field-on-the-entity pattern: an oper_note field folded
through AccountOperNoteSet (Global) / ChannelOperNoteSet (Local) events,
read via dedicated Store getters so it stays out of the public views and
the directory feed.
Force a user's nick change or channel join, reusing the ForceNick and
ForceJoin actions the daemon already relies on (guest renames, auto-join)
so the ircd support is proven. SVSNICK validates the new nick; SVSJOIN
takes an optional key. Both admin-gated and resolve the target via
NetView.
STATS reports the live counts OperServ holds — AKILL and SQLINE bans by
kind, and services ignores — as a quick at-a-glance check. Read-only, so
any operator can run it.