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.
A stateless service for tabletop games over IRC. ROLL/CALC evaluate a dice
+ math expression (ROLL rounds to a whole number, CALC keeps decimals);
EXROLL/EXCALC also show each die. Supports NdM / dM / d% dice, + - * / ^ %,
parentheses, implicit multiplication, unary minus, pi/e, a set of functions
(sqrt, floor, sin, min, …), and N~expr to repeat a roll.
Modernises the C++ original: it ships its own Mersenne-Twister RNG in C
and parses via shunting-yard with a variadic-argument hack; this is a
typed AST from a small recursive-descent parser plus the rand crate —
shorter, and the dice/sides/total caps make abuse (999999d6) a clean error
instead of a hang. Opt-in (add "diceserv" to [modules].services).
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.
IGNORE ADD [+expiry] <mask> [reason] / DEL <mask> / LIST. While a user
matches, the dispatch choke-point drops their service commands silently.
A mask with an @ matches nick!*@host (ident isn't tracked); a bare mask
matches the nick; matching is case-insensitive with lazy expiry. Admin-
only, and an operator is never ignored (so staff can't lock themselves
out). Node-local and in-memory, like the auth and request throttles — a
fast transient moderation tool, not persisted or federated.
Two channel operator tools:
- MODE <#chan> <modes> [params] forces a channel mode change (TS 1, so it
applies regardless of the current TS). A status-mode target may be given
as a nick — it's resolved to the uid the ircd's FMODE expects.
- KICK <#chan> <nick> [reason] removes a user, sourced from OperServ and
attributed to the operator.
Both admin-gated. To resolve status-mode params, channel-mode parameter
arity became one shared helper (chanmode_takes_param + STATUS_MODES in the
api): the ircd module now delegates to it instead of keeping its own copy,
so burst-parsing and MODE-building can never drift.
Generalise the network-ban store to carry the ircd X-line kind (defaulted
to the user@host G-line for existing records) so one event-sourced,
lazily-expiring, burst-reasserted list backs every ban type, and drive
ADD/DEL/LIST from one implementation per kind:
- AKILL stays the user@host G-line; SQLINE is a nick Q-line, its mask
validated as a nick glob (an @ is refused so an AKILL-shaped mask can't
become an over-broad nick ban). Both are keyed by (kind, mask).
- GLOBAL <message> announces to every user via a $* server-glob notice.
- KILL <nick> [reason] disconnects a user, attributed to the operator.
All four are admin-gated, and OperServ itself stays invisible to non-opers.
Bans and kills go out as typed actions (AddLine/DelLine/KillUser) the ircd
link serialises to ADDLINE/DELLINE/KILL.
When [expire] warn_days is set and email is configured, the expiry sweep
now emails a heads-up to owners entering the warning window (inactive past
the threshold minus the lead time, but not yet expired) for whom an
address is on file — the founder's address for a channel. It's a chance to
keep the record by simply using it before the deadline.
Each idle spell warns at most once: a new AccountExpiryWarned /
ChannelExpiryWarned event marks the record, and the next activity stamp
clears the mark so a later idle spell warns afresh. The warning email is a
first-class api template (fedserv_api:📧:expiry_warning), alongside
the reset and confirm mails.
The first operator module. AKILL manages network bans as a typed,
event-sourced list with lazy expiry, mirroring the patterns the other
modules use:
- AKILL ADD [+expiry] <user@host> <reason> stores the ban and drives an
ircd G-line (applied to matching users already online and to future
connections); AKILL DEL <mask|number> lifts it; AKILL LIST [pattern]
shows the live list, numbered. Admin-only.
- Bans are Global events, so every node holds the list and re-asserts each
one at burst with its remaining duration. Expired bans are hidden lazily
and dropped at compaction.
- Masks are normalised to user@host (a nick! prefix is stripped, both
sides lowercased) and an all-wildcard mask is refused.
Email (send_email) is already reachable from the api via ServiceCtx, and
adds AddLine/DelLine to the action vocabulary for any future X-line kinds.
Accounts and channels now carry a last-activity stamp and are dropped
once inactive past a configured threshold. A login stamps the account, a
join stamps the channel — both coalesced to at most one log entry per day
so routine traffic doesn't bloat the log, and stamps replicate so
activity on any node keeps a record alive network-wide.
Expiry is lazy in the proven style: a periodic pass computes it from the
stored stamps with no per-record timer, reusing the same relation the
vhost and suspension expiries use. The sweep spares operator accounts,
accounts with a live session, occupied channels, and anything an operator
has pinned with the new NOEXPIRE command (NickServ NOEXPIRE <account>,
ChanServ NOEXPIRE <#channel>, both Priv::Admin). Each expiry is announced
to the audit channel and clears the channel's +r.
Configured under [expire] (accounts_days / channels_days); a zero or
omitted field leaves that kind never expiring, and omitting the section
disables expiry entirely.
A [log] channel now receives a one-line announcement of each notable
state change a service command makes — registrations, drops, vhosts,
suspensions, akicks, access and founder changes, bot and oper host-config
changes — naming the actor (by nick, plus account when it differs) and
what changed. The feed reads the typed event log directly: dispatch marks
the log before running a command and diffs it after, so each announcement
is exactly that command's footprint, sourced from the services server so
it reaches the channel without a pseudo-client having to be present.
Private and cosmetic events are deliberately never surfaced: memo bodies,
password/verifier material, greets, auto-join, personal and channel
cosmetic settings. Omitting [log] disables the feed.
- Refuse to start the gRPC accounts API on an empty token: the
constant-time compare treats two empty slices as equal, so a blank
token would let an unauthenticated request through. Mirror the guard
the JSON-RPC endpoint already had.
- Sweep expired votekick/voteban tallies on each vote. A passed vote
removed its own key, but a tally that never reached threshold lingered
forever, so the map could be grown without bound. The now-redundant
per-key TTL reset goes away.
Free-form trailing text (message bodies, kick reasons, topics, metadata)
now passes through a single sanitizing choke-point in the serializer, so
no such text can carry an embedded line break that would smuggle a second
command onto the link. Line-based input closes the obvious path today,
but sanitizing at the point of serialization removes the whole class
regardless of where the text originated (federation, config, future
non-line sources).
- Box the oversized enum variants (Event::AccountRegistered, gossip
Msg::Entry) so small events aren't sized to a full Account; serde is
transparent so the wire/log format is unchanged.
- Use the configured server description in the SERVER line (it was
hardcoded and the config field went unread).
- Derive the ChanServ SET label from the ChanSetting (drops toggle's 8th
arg); rename inspircd from_us -> sourced.
- allow the two framework-idiom lints with rationale (tonic's Status in
authorize; the guest-rename handler's inherent arg count).
- Auto-fixed map_or/contains/split_once style lints.
Canonicalise a requested vhost the way the ircd displays it (a disallowed
underscore becomes a hyphen) before storing and applying, so what we keep
matches what shows on the network. A vhost can no longer be assigned to two
accounts: prepare_vhost normalises, validates, and checks vhost_owner at
every assignment point (SET/REQUEST/ACTIVATE/TAKE/DEFAULT), so two inputs
that collapse to the same host are caught as one.
Approach recommended by siniStar (the ns_nethost normalisation logic).
SET <account> <host> [duration] assigns a vhost that lapses after the
duration (e.g. 30d); a Vhost carries an optional expiry and the store hides
an expired one lazily (like a suspension), so it stops applying on identify/
ON without a sweep. LIST flags temporary vhosts.
A member may request a vhost at most once a minute; a request sooner is
refused with the wait time. In-memory per-account (not event-logged), the
same shape as the identify brute-force throttle.
FORBID <regex> / FORBIDLIST / FORBIDDEL let operators block impersonating
user requests (e.g. (?i)(admin|staff)); REQUEST refuses a matching host.
Matching reuses the linear-time RegexSet, so untrusted patterns are safe.
TEMPLATE <$account...> sets a network auto-vhost pattern; DEFAULT gives a
user $account.users.example with their sanitised account name. HostServ's
node config (offers/forbidden/template) is consolidated into one HostConfig
threaded through the log replay.
A curated self-serve menu: operators OFFER <host> / OFFERDEL <n>, anyone
sees OFFERLIST, and a user picks one with TAKE <n> (assigned and applied
at once). Offers are a global event-sourced list (VhostOfferAdded/Removed,
threaded through apply like bots). SETALL/DELALL alias SET/DEL and GROUP
is a no-op reassurance, since the per-account vhost already covers every
grouped nick.
A vhost may be ident@host, not just host: apply_vhost splits it and sends
CHGIDENT for the ident plus CHGHOST for the host. valid_vhost validates
the optional ident part; ON/SET/ACTIVATE/identify all route through
apply_vhost, so a bare host still works unchanged. New SetIdent action.
REQUEST <host> records a pending vhost (typed Option<PendingVhost> on the
account, VhostRequested/VhostRequestCleared events); operators review with
WAITING and either ACTIVATE (assign + apply to online sessions) or REJECT
(clear it). Host validation and the operator gate are shared with SET.
New HostServ pseudo-service assigns and applies vhosts. A vhost is a typed
Option<Vhost>{host,setter,ts} on the account (VhostSet/VhostDeleted events),
applied to the displayed host on identify and re-applied by ON; OFF restores
the normal host for the session. Operators SET/DEL (applied at once to
online sessions) and LIST; hosts are validated as dot-separated labels.
New SetHost NetAction -> InspIRCd ENCAP <target-sid> CHGHOST <uid> <host>.
[jsonrpc].tls = { cert, key } makes the endpoint terminate TLS itself,
which also enables HTTP/2 (ALPN), so traffic is encrypted right up to
fedserv — for when it runs on a different host from the site, or the
browser's TLS should reach it directly. Absent = plain HTTP for a
reverse-proxy front. Reuses the same cert/key shape as [grpc]; a crypto
provider is pinned before building the config (the tree carries more than
one). Verified the TLS config builds against a real cert without a
provider panic.
[jsonrpc].origins allowlists the websites whose browser JS may call the
stats API cross-site. The request Origin is echoed back only when it is
on the list (never *), OPTIONS preflight is answered with the allowed
method/headers, and Vary: Origin is always set. Non-listed origins get
no allow-origin header, so browsers block them.
Plain HTTP + JSON-RPC 2.0 (axum, already in the tree) so a site backend
can pull stats without gRPC stubs. Methods: stats.get (counters + gauges)
and stats.channel { channel } (lines + top talkers). Optional [jsonrpc]
config, off unless set.
Security: bearer-token authenticated with a constant-time compare, refuses
to start with an empty token, read-only (only stats.* methods — no way to
mutate state), method-allowlisted, a 64 KiB body limit, and meant to bind
to localhost beside the backend.
Stats now have their own service. StatServ answers /msg StatServ <#channel>
(per-channel activity — lines + top talkers, founder-or-admin) and
/msg StatServ SERVER (the shared cross-service counter registry + gauges,
operators only). The shared counter map moved from the engine onto the
network view so both StatServ (via NetView::stat_counters) and the gRPC
Stats API read one source. BotServ's BOTSTATS command is removed; the
per-channel activity is still recorded in the dispatch loop.
SET <#channel> VOTEKICK <n> enables in-channel voting: members type
!votekick <nick> (or !voteban), each voter counts once, and when n votes
are reached within the window the assigned bot kicks (or bans then kicks)
the target. Votes are ephemeral and lapse after two minutes. Crowd
moderation with no op online.
Responses can now use $1..$9 to substitute regex capture groups (e.g.
ADD hi (\w+)|Hi $1!), and an optional trailing |<secs> sets a per-trigger
cooldown so a trigger can't be spammed. The cache compiles each pattern to
its own Regex (for captures) and tracks last-fired for the cooldown.
BOTSTATS <#channel> reports the lines the bot has seen this session and
the top talkers. Tracked live in the network view (bounded per-nick map),
recorded for bot-assigned channels only, and also fed to the shared stats
registry as botserv.messages.