dIRC Services Overview (dircservices)
The dircservices package is a standalone IRC services daemon β a server-to-server (S2S) peer that links into UnrealIRCd as a U:Lined services host (services.dlan) and spawns 14 pseudoclients services (NickServ, ChanServ, GroupServ, OperServ, Global, HostServ, BotServ, ALIS, GameServ, StatServ, SecureServ, IdleRPGServ, LoveServ, HelpServ). It is the dLAN alternative to Anope/OperStats, written in pure Go with no CGO and no AI dependencies.
Scope note:
dircservicesbuilds as a standalone binary (dircservices/cmd/dircservices/) for dev/smoke use, but in production it runs in-process inside the maindLANDiscordmonolith, launched non-blocking viadircservices.Start()(commitd336f939) β same process, same crash domain as the Discord bot. It remains explicitly decoupled in responsibility from thedircclient package and the AI assistant system β the dirc client connects as a regular IRC user, while dircservices links as an ircd peer that owns nickname/channel registration, operator tooling, and games.
Origin & Status
Built in three waves starting 2026-07-23 (see docs/dLAN_Services_DevLog.md and docs/dIRC_Analysis.md):
| Milestone | Scope | Verified |
|---|---|---|
| M1 | S2S handshake (TS6/UID), SQLite store (WAL+integrity+backup), scaffold | Link established on irc.dlan:6700 (commit 5686dafb) |
| M2 | Global, OperServ essentials, NickServ, HostServ, ChanServ | 5 services live |
| M3 | BotServ (kick engine), ALIS + ChannelRegistry, GroupServ | 8 services live |
| M4+M5 | StatServ, SecureServ (DNSBL/clone/flood/proxyscan), GameServ, IdleRPGServ, LoveServ | 13 services smoked (commit 23db538c) |
| Gap fill (07-24) | HelpServ (multi-language), full NickServ (~20 cmds), OperServ expansion (AKILL/CHANKILL/SNLINE/SQLINE/FORBID), ChanServ SET system, HostServ approval queue, lifecycle hardening | 14 services, ~95%+ coverage |
Current state: 14 services operational, lifecycle hardened (panic recovery + auto-reconnect with exponential backoff), SJOIN trailing-member bug fixed, per-service .md docs shipped in 13 EN.*.md files.
Architecture
graph TB
subgraph ircd[UnrealIRCd irc.dlan:6700]
UL[U:Lined services.dlan link]
end
subgraph dircservices[dircservices binary]
CFG[config TopLevelConfig]
ST[store.Store SQLite WAL]
P[protocol/unreal4 S2S]
UR[state.UserRegistry]
CR[state.ChannelRegistry]
REG[services.Registry]
ID[pseudoclient.IdentifiedMap]
SVCS[14 Service pseudoclients]
end
UL <--> P
P --> UR
P --> CR
CFG --> ST
REG --> SVCS
SVCS --> P
SVCS --> ST
SVCS --> UR
SVCS --> CR
SVCS --> ID
Package Layout
| Path | Purpose |
|---|---|
dircservices/cmd/dircservices/main.go |
Entrypoint β config load β store open β protocol connect β registries β 14 services β read loop + reconnect β SIGINT/stopCh shutdown |
dircservices/config/ |
TopLevelConfig (JSON), Load(path), DefaultConfig(), Validate(); Uplink/ServerInfo/Store/Log/OperTypes sections |
dircservices/access/opertypes.go |
OperType/Oper with glob (path.Match) privilege checks (Anope-style); InstallOperTypes |
dircservices/crypto/password.go |
bcrypt HashPassword/VerifyPassword |
dircservices/store/ |
SQLite store with WAL, integrity check + restore, bg backup rotation, schema.go (~40 tables, 21 indexes), per-service store files (chanserv.go, groupserv.go, vhost.go, botserv.go, statserv.go, secureserv.go, idlerpg.go, loveserv.go, gameserv.go), backup.go (VACUUM INTO snapshots + prune) |
dircservices/protocol/protocol.go |
Protocol interface (28 methods incl. S2S ops + 11 On* event hooks); Message/UID/SID shared types + Parse/String |
dircservices/protocol/unreal4/ |
Concrete UnrealIRCd4 impl β handshake.go (PASS/PROTOCTL EAUTH+SID+TS/SERVER), uid.go (base62 UID counter), svs.go (PrivmsgUID/NoticeUID/SVSNick/SVSJoin/SVSPart/SVSMode/Kill/Kick/TKLAdd/Del/Wallops), dispatch.go (read loop, PING/PONG keepalive, On* multi-subscriber dispatch, SJOIN trailing-member fix + 6 tests) |
dircservices/pseudoclient/pseudoclient.go |
Pseudoclient mix-in (Bind/Register/Join/Reply/AddCmd/Help + panic-recovering dispatch), IdentifiedMap (sync.Map wrapper shared NickServβChanServ) |
dircservices/state/users.go |
UserRegistry keyed by UID + nick; wires OnUIDIntroduce/OnNickChange/OnQuit |
dircservices/state/channels.go |
ChannelRegistry + Channel{Modes,Topic,Members,Ops,TS}; wires join/part/kick/mode/topic handlers. Ops (2026-07-25) is tracked from MODE +o/-o only, not from burst SJOIN (prefix already stripped before it reaches us) β a member opped before we linked won't show as opped until they're re-opped. IsMember/IsOp back ChanServ REGISTER's founder-control check |
dircservices/services/services.go |
Service interface (Name/Start(proto,store,log,cfg)/Stop), Registry (NewRegistry/Register/StartAll/StopAll reverse-order/Get) |
dircservices/services/<svc>/ |
One package per pseudoclient |
dircservices/docs/ |
Per-service command docs β EN.<Svc>.md (English canonical) + <LANG>.<Svc>.md translations (FR, ES skeletons); HelpServ parses ## headers for per-command help |
The 14 Services
| Service | UID | Real name | Purpose |
|---|---|---|---|
| Global | 00X001 | dLAN Global Noticer | GLOBAL <text> to all users, LOGONNEWS/OPERNEWS ADD/DEL/LIST |
| NickServ | 00X002 | dLAN Nickname Service | REGISTER/IDENTIFY/LOGOUT/GHOST/RECOVER/RELEASE/REGAIN/DROP/FDROP/INFO/STATUS/ACC, 10+ SET sub-cmds (DISPLAYNAME/DOB/FAVCHANNEL/socisl/TIMEZONE/LANGUAGE/KILL), ACCESS, AJOIN, LISTCHANS, LIST; profile schema (8 social/identity columns); shared IdentifiedMap |
| OperServ | 00X003 | dLAN Operator Service | INFO/STATS (per-type AKILL/UPLINK/UPTIME/ALL/RESET), SHUTDOWN/RESTART/QUIT/UPDATE, SET (READONLY/DEBUG/NOEXPIRE/SUPERADMIN), AKILL/CHANKILL/SNLINE/SQLINE/FORBID ADD/DEL/LIST, MODE/KICK, OPER ADD/DEL/LIST + INFO, LOGSEARCH |
| ChanServ | 00X004 | dLAN Channel Service | REGISTER/DROP/INFO, ACCESS (ADD/DEL/LIST/SET/CLEAR) + AOP/HOP/VOP/SOP/FSOP shortcuts, AKICK (ADD/DEL/LIST/VIEW/ENFORCE/CLEAR), MODE/OP/DEOP/HALFOP/DEHALFOP/VOICE/DEVOICE/OWNER/DEOWNER/PROTECT/DEPROTECT, KICK/KICKBAN/BAN/UNBAN/TOPIC/TOPICAPPEND/TOPICPREPEND, GETKEY, 19 SET sub-cmds (FOUNDER/SUCCESSOR/PASSWORD/DESC/URL/EMAIL/ENTRYMSG/MLOCK/TOPICLOCK/KEEPTOPIC/SECUREOPS/RESTRICTED/VERBOSE/PRIVATE/FANTASY/GAMESERV/PREFIX/GUARD/PROPERTY), ENFORCE/SYNC/STATUS/WHY/UP/DOWN/RECOVER/LIST; access levels 0/100/200/300/400 |
| HostServ | 00X005 | dLAN VHost Service | SET/GET/DEL/LIST/ON/OFF/ASSIGN, approval queue (REQUEST/WAITING/ACTIVATE/REJECT), oper bulk (SETALL/DELALL/GROUP/VHOSTNICK), shared vhost pool (OFFER/OFFERLIST/TAKE/UNOFFER) |
| BotServ | 00X006 | dLAN Bot Service | BOT ADD/CHANGE/DEL/BOTLIST (oper), ASSIGN/UNASSASS/ACT/SAY, BADWORDS ADD/DEL/LIST/CLEAR (SINGLE/START/END match), KICK settings (9 types: CAPS/FLOOD/REPEAT/BOLDS/COLORS/ITALICS/REVERSES/UNDERLINES/AMSG), SET (FANTASY/NOBOT/PRIVATE), INFO, HELP; real-time kick enforcement engine via OnPrivmsg hook (in-memory flood/repeat/amsg trackers); fantasy !cmd routing to GameServ |
| ALIS | 00X007 | dLAN Advanced List Service | LIST with filters (pattern glob, -min/-max members, -topic, -mode, -noinvite, -nosecret), 10s rate-limit per UID |
| GroupServ | 00X008 | dLAN Group Service | REGISTER/DROP/JOIN/PART/INVITE/FLAGS (r/F/c)/SET (OPEN/PUBLIC/DESCRIPTION/PASSWORD/JOINFLAGS/GROUPFLAGS)/INFO/LIST; case-insensitive; founder/+r/+F permission model |
| StatServ | 00X009 | dLAN Statistics Service | Configurable snapshot loop (default 5m): counts users/channels/ops β per-channel + per-user rows; commands NETWORKINFO/CHANNELINFO/TOP (chan/user by messages/time)/REPORT; resets per-user line counters each snapshot |
| SecureServ | 00X00A | dLAN Security Service | DNSBL (3 default providers, reverse-DNS lookup OnUIDIntroduce β AKILL), clone detection (per-host counter, warn/kill/akill thresholds), flood defense (sliding-window per-user/per-channel, escalation warnβmute SVSMODE +mβkillβakill), proxyscan (off by default); STATS/EXEMPT ADD/DEL/LIST/PROVIDER ADD/DEL/LIST/CLONE LIST/SET/FLOOD STATS/SET; WALLOPS oper notification |
| GameServ | 00X00B | dLAN Game Service | CALC (recursive-descent parser, no eval), EIGHTBALL (20 classic answers), ROLL (XdY+Z + 4dF fudge), RPS, NAMEGEN (Markov fantasy names), LOTTERY (per-channel rolling jackpot: BUY/STATUS/DRAW), TRIVIA (category quiz from data/trivia/*.txt: START/STOP/SCORE/CATEGORIES) |
| IdleRPGServ | 00X00C | dLAN IdleRPG | Idle-to-level game in #IdleRPG; 30s tick + timer event rolls (battle/treasure/quest), penalties for talking/leaving; REGISTER/WHOAMI/STATUS/QUEST/PENALTY/TOP/STORY/ADMIN (FREEZE/UNFREEZE/RESET/SETLEVEL)/HELP; quest engine 30β90min; standalone (no dmud coupling) |
| LoveServ | 00X00D | dLAN Love Service | 16 gesture commands (ROSE/HUG/KISS/SLAP/POKE/BEER/COFFEE/COOKIE/PIZZA/HEART/FORTUNE/LOVE/HIGHFIVE/FACEPALM/SHRUG/DANCE), 3 random variants each; LOVE STATS + LOVE HELP; weekly reset; per-channel MUTE; most-slapped leaderboard category |
| HelpServ | β | dLAN Help Service | LIST (available docs, hides <LANG>. prefixed), HELP <service> [command]; reads ## headers from docs/*.md; multi-language: caller nick β NickGetLanguage β try {LANG}.{Svc}.md then {Svc}.md |
Connection & Wire Protocol
- Uplink:
irc.dlan:6700(LAN plaintext; ircdset::plaintext-policy::server allow), services SID00X, server nameservices.dlan(U:Lined) - Handshake order (UnrealIRCd requires this exact sequence):
PASSβPROTOCTL NOQUIT TOKEN SSJ3 ... PROTOCOL=1202βPROTOCTL EAUTH=services.dlanβPROTOCTL SID=00XβPROTOCTL TS=<unix>βSERVER services.dlan 1 :dLAN IRC Servicesβ peerSERVER+EOSmarks sync (Unreal uses EOS, not Anope'sSVINFO) - Read loop:
Run() boolβ returnstrueon read error,falseon clean stop (Close()). Either outcome ends the currentrunOnce()cycle; the outer loop inrun()waits 60s then re-dials and re-bursts every pseudoclient from scratch (no partial reconnect). - Pseudoclient intro:
:00X UID NickServ 1 1234567890 services services.dlan 00XAAAAAA 0 +BioS * * * :dLAN Nickname Serviceβ UID is 9 chars total (3-char SID + 6-char base62 suffix, per UnrealIRCd's UID command spec). A prior bug generated 6-char UIDs (3+3): the SERVER link still came up fine, but UnrealIRCd silently dropped every introduced pseudoclient β/MAPshowed the link with 0 users and/whois NickServreturned "No such nick", with no error anywhere in our own logs. Fixed inprotocol/unreal4/uid.go'sencodeUID.
SQLite Store
- Driver:
modernc.org/sqlite(pure-Go, no CGO) - DSN: WAL mode +
foreign_keys=ON+busy_timeout=5000+MaxOpenConns=1(serial) - Startup:
integrity_checkβ if fail,restoreFromBackup()(newest OK.dbfromdata/backups/) or reinit - ~40 tables across all services + 21 hot indexes;
schema_versionrow,INSERT OR IGNORE - Background tickers:
wal_checkpoint(TRUNCATE)every 5m + snapshotVACUUM INTO services-YYYYMMDD-HHMM.dbevery 24h, prune keep N (default 7) - Known gotcha:
group_members.grouprenamed togroup_namebecausegroupis a SQLite reserved word (commitae91b304) OperServ UPDATEcallsstore.ForceBackup()(snapshot + prune immediately)
Build & Run
go build -o dircservices ./dircservices/cmd/dircservices/
./dircservices # reads dircservices/config.example.json (or ../config.example.json)
go test ./dircservices/... # 4 unreal4 tests (dispatch/parse round-trip)
Configuration: dircservices/config.example.json (mirrors Β§7 of docs/dLAN_Services_Plan.md, LAN-only creds shipped in tree). DefaultConfig() fallback if no file found.
Lifecycle & Robustness
- Panic recovery: every pseudoclient
dispatch()wraps handler withdefer recover()β logs[PANIC]+ replies "Internal error" (no goroutine death) - Cycle-level panic:
runOnce()'sdefer recover()logs[FATAL], clears the published status snapshot, and closes the protocol connection β it does not close the sharedstore.Storeor callos.Exit, since dircservices runs as a goroutine inside the main dLANDiscord process (same shared-fate risk asdmud) and exiting the process would take the whole Discord bot down with it. The outerrun()loop then re-dials after 60s. - Reconnect: any link loss (read error) or OperServ SHUTDOWN/RESTART/QUIT ends the current
runOnce()cycle;run()'s outer loop rebuilds everything from scratch next pass β freshProtocol, fresh registries, fresh 14-service UID burst + EOS. No partial-resume: UnrealIRCd forgets our pseudoclients the moment the S2S link drops, so a bare TCP re-dial without a fresh burst would leave services silently ghosted on the network. - Shutdown: OperServ
SHUTDOWN/RESTART/QUITsignalsstopChβregistry.StopAll()(reverse order) ends the cycle β outer loop sleeps 60s and re-links (in-process module, neveros.Exits itself). The standalonecmd/dircservices/main.gowrapper additionally listens for SIGINT/SIGTERM, but only logs and returns β it does not callregistry.StopAll()/store.Close()first, so a signaled standalone process skips graceful teardown. - File logging:
cfg.Log.Targetβ log dir creation, rotate rename to.bakif older thanLogAgeDays,io.MultiWriter(stdout + file)
Integration with the dIRC Client
Explicitly decoupled: dirc (the IRC client package) connects as a regular IRC user, while dircservices is the S2S services peer. The dev work that touched dirc for this effort was a pre-M1 fix (c0f7683e): default SASL off + Anope-compatible NickServ flow for UnrealIRCd β to make the Unreal link behave well alongside services.
See: Features/dircservices/services-suite.md for per-service deep-dive Β· Key-Packages/dirc/overview.md for the IRC client
Dashboard (dircserviceweb)
dircserviceweb (separate package, dircserviceweb/) is a read-only HTTP dashboard on :16667 β a nod to the classic IRC client port (6667), not an IRC listener itself; it's plain HTTP on the bot host, launched non-blocking (go dircserviceweb.Start() in main.go, right after dircservices.Start()). Plain :6667 doesn't work as an HTTP port: Chrome/Firefox/Edge all block http:// requests to the 6660-6669 + 6697 range (their built-in "unsafe ports" list, a legacy cross-protocol-attack defense) β 16667 dodges that. It has no dependency on the ircd β it reads dircservices.Status(), a Snapshot published by runOnce() after each successful burst (dircservices/status.go), covering:
- Link state, uplink host/SID, registered service list
- Live user/channel counts + listings from
UserRegistry/ChannelRegistry(network-wide, since we're U:Lined β not just our own pseudoclients) - Registration stats pulled straight from
store.Storeeach request: registered nicks/channels/groups, vhosts assigned, active AKILLs - SecureServ's persisted lifetime event counts: DNSBL hits, clone events, flood events, proxy detections
- StatServ's most recent snapshot: top 5 channels/users by activity (empty until the snapshot loop has run once, default every 5m)
GET / renders the HTML view (auto-refresh every 10s); GET /status.json exposes the same Snapshot as JSON. No auth β matches the existing LAN single-admin trust model (see config.example.json's shipped plaintext link password), so don't expose :16667 outside the LAN without adding one first.
Guidance for AI Agents
- No AI integration: dircservices deliberately excludes all
assistant/imports β any "smart" IRC behavior still goes through thedircclient + assistant pipeline, not the services daemon. - Schema edits: DB is not yet in production deployment β schema changes can use clean
CREATE TABLE(no migration). Once deployed, add ALTER paths. - New service: implement
Serviceinterface inservices/<name>/<name>.go, add docdocs/EN.<Name>.md, register inmain.go, document here. - Event wiring: services hook
OnPrivmsg/OnUIDIntroduce/etc. viaproto.On*setters β the protocol appends multiple subscribers safely (RLock + snapshot copy). - Oper gate:
pseudoclient.IsOper(cfg, nick, identified)requires BOTH a nick match againstcfg.OpersANDidentified(the caller's UID present in the sharedIdentifiedMap, i.e. they IDENTIFY'd with NickServ) β nickname alone is not proof of identity, since anyone can/nickinto an oper's name before enforcement reclaims it. Every service exposing oper-only commands wiresSetIdentifiedMap. - Channel presence (2026-07-25):
cfg.OpersChannel(default#Opers) is auto-joined by every pseudoclient insidePseudoclient.Register()β one config knob, all 13 services idle there for opers to see at a glance.protocol.JoinChannel(self-introducing SJOIN) now always includes the@op-status prefix, since a services pseudoclient sitting in a channel unopped can't enforce anything there;SVSJoin(forcing an existing UID β human or another pseudoclient like an assigned BotServ bot) still grants no status by design, so callers that need the target opped (e.g.BotServ.joinBot) follow it with an explicitMODE +o. - Speaking as a pseudoclient in a channel: use
pc.ReplyChannel(channel, text)/proto.PrivmsgAs(uid, target, text), notPrivmsgUID/NoticeUIDβ those two always send from the bare server SID, which isn't a channel member and isn't who should visibly be talking.
Cross-References
dircserviceweb/β HTTP dashboard package (:16667), readsdircservices.Status()- Features/dircservices/services-suite.md β per-service command reference
- Key-Packages/dirc/overview.md β IRC client (separate from services daemon)
- Recent-Changes-and-Evolution.md β July 2026 entry
docs/dLAN_Services_DevLog.mdβ chronological build logdocs/dLAN_Services_Plan.mdβ original spec (~20 commands per service, schema, milestones)docs/dIRC_Analysis.mdβ dirc/Anope analysis that motivated the rewrite