Overview

Last updated: July 25, 2026

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: dircservices builds as a standalone binary (dircservices/cmd/dircservices/) for dev/smoke use, but in production it runs in-process inside the main dLANDiscord monolith, launched non-blocking via dircservices.Start() (commit d336f939) β€” same process, same crash domain as the Discord bot. It remains explicitly decoupled in responsibility from the dirc client 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; ircd set::plaintext-policy::server allow), services SID 00X, server name services.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 β†’ peer SERVER + EOS marks sync (Unreal uses EOS, not Anope's SVINFO)
  • Read loop: Run() bool β€” returns true on read error, false on clean stop (Close()). Either outcome ends the current runOnce() cycle; the outer loop in run() 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 β€” /MAP showed the link with 0 users and /whois NickServ returned "No such nick", with no error anywhere in our own logs. Fixed in protocol/unreal4/uid.go's encodeUID.

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 .db from data/backups/) or reinit
  • ~40 tables across all services + 21 hot indexes; schema_version row, INSERT OR IGNORE
  • Background tickers: wal_checkpoint(TRUNCATE) every 5m + snapshot VACUUM INTO services-YYYYMMDD-HHMM.db every 24h, prune keep N (default 7)
  • Known gotcha: group_members.group renamed to group_name because group is a SQLite reserved word (commit ae91b304)
  • OperServ UPDATE calls store.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 with defer recover() β†’ logs [PANIC] + replies "Internal error" (no goroutine death)
  • Cycle-level panic: runOnce()'s defer recover() logs [FATAL], clears the published status snapshot, and closes the protocol connection β€” it does not close the shared store.Store or call os.Exit, since dircservices runs as a goroutine inside the main dLANDiscord process (same shared-fate risk as dmud) and exiting the process would take the whole Discord bot down with it. The outer run() 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 β€” fresh Protocol, 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/QUIT signals stopCh β†’ registry.StopAll() (reverse order) ends the cycle β†’ outer loop sleeps 60s and re-links (in-process module, never os.Exits itself). The standalone cmd/dircservices/main.go wrapper additionally listens for SIGINT/SIGTERM, but only logs and returns β€” it does not call registry.StopAll()/store.Close() first, so a signaled standalone process skips graceful teardown.
  • File logging: cfg.Log.Target β†’ log dir creation, rotate rename to .bak if older than LogAgeDays, 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.Store each 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 the dirc client + 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 Service interface in services/<name>/<name>.go, add doc docs/EN.<Name>.md, register in main.go, document here.
  • Event wiring: services hook OnPrivmsg/OnUIDIntroduce/etc. via proto.On* setters β€” the protocol appends multiple subscribers safely (RLock + snapshot copy).
  • Oper gate: pseudoclient.IsOper(cfg, nick, identified) requires BOTH a nick match against cfg.Opers AND identified (the caller's UID present in the shared IdentifiedMap, i.e. they IDENTIFY'd with NickServ) β€” nickname alone is not proof of identity, since anyone can /nick into an oper's name before enforcement reclaims it. Every service exposing oper-only commands wires SetIdentifiedMap.
  • Channel presence (2026-07-25): cfg.OpersChannel (default #Opers) is auto-joined by every pseudoclient inside Pseudoclient.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 explicit MODE +o.
  • Speaking as a pseudoclient in a channel: use pc.ReplyChannel(channel, text) / proto.PrivmsgAs(uid, target, text), not PrivmsgUID/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), reads dircservices.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 log
  • docs/dLAN_Services_Plan.md β€” original spec (~20 commands per service, schema, milestones)
  • docs/dIRC_Analysis.md β€” dirc/Anope analysis that motivated the rewrite