11 KiB
echo — native anti-abuse
echo has a native anti-abuse subsystem built into the engine core (not a service pseudo-client). It watches the connect / nick / join / part / quit / message events echo already processes as a linked pseudo-server and, when armed, enforces bans server-side. The detection algorithms are modelled on Sigyn/ozone (the bot that guarded freenode/Libera), but because echo is a linked server it receives those events first-class and issues bans over S2S — none of that bot's oper-login, snote-scraping or IP-resolution plumbing is needed.
It ships inert (enabled = false). Turned on, it defaults to report-only:
every trigger is announced to the log channel but nothing is killed or banned, so
thresholds can be trusted before the engine is armed.
Enforcement model
enabled = false → the whole subsystem does nothing (default)
enabled = true, report_only = true → detect + alert only (never kill/ban) ← start here
enabled = true, report_only = false → armed: also KILL + lay a timed G-line
A trigger always writes a [SECURITY] line to the log channel (rate-limited, see
announce_*). When armed, it additionally KILLs the offender and adds a
ban_duration-second G-line on the offending IP/range (or drops it to a kill-only
action when ban_duration = 0).
Exemptions (checked before any detector runs):
exempt_ips— IPs/CIDRs never screened. Defaults to loopback so an armed connection-flood can't G-line127.0.0.1and cut off local bots/services. Widen it to cover every trusted infra IP (gateways, bouncers, web-chat) before arming.exempt_opers(default on) — network operators are never screened.exempt_accounts(default off) — logged-in accounts; off because a compromised account can still spam.exempt_voice(default on) — for content checks, voiced/opped channel members are trusted.
Detectors
| Group | Detector | Fires on | Keyed by |
|---|---|---|---|
| Connection | connection flood | connect | IP, and its /24 (v4) / /64 (v6) |
| Connection | pattern DB | connect | nick!ident@host(#gecos) glob/regex |
| Behavioural | nick-change flood | NICK | uid |
| Behavioural | join/part cycle | PART | uid |
| Behavioural | join-spam-part | JOIN→PART | uid (quick part after join) |
| Behavioural | mass-join | JOIN | channel × /24-//64 (clone raid) |
| Behavioural | broken-client quit-flood | QUIT | IP (reason contains a marker) |
| Content¹ | highlight-spam | channel message | uid (distinct members pinged) |
| Content¹ | bad-unicode | channel message | uid (zalgo / zero-width fraction) |
| Content¹ | repeat-wave | channel message | channel × line-hash (copy-paste spam) |
| Behavioural | channel-crawl | JOIN | uid (join rate across many channels) |
| Auth | login brute-force | failed IDENTIFY/SASL | IP |
| Auth | registration flood | REGISTER | IP |
| Auth | ban-evasion | account login | account (re-kill if recently security-banned) |
The behavioural/content detectors are suppressed for netsplit_grace seconds after any
server split or link — a netsplit rejoin re-joins/re-nicks everyone at once and would
otherwise trip mass-join/nick/quit in a false-positive storm. This is what makes arming safe.
¹ Content detectors only see channels where an echo bot is present (the same limit
as the kickers). For network-wide content filtering use OperServ SPAMFILTER
(pushed to the ircd's m_filter); the repeat-wave alert surfaces the offending line
as a ready-made filter pattern. Connection-time DNSBL is the ircd's job, not echo's.
Every detector uses one sliding-window counter engine: "more than permit events keyed by K within life seconds → trip". Windows self-expire; a periodic GC keeps the key set bounded.
Configuration
All keys have safe defaults — set only what you want to change. Reload with OperServ REHASH (counters and in-flight state survive; thresholds and patterns re-apply).
[security]
[security]
enabled = true # false (default) = subsystem off
report_only = true # true (default when enabled) = alert only, never enforce
exempt_ips = ["127.0.0.0/8", "::1"] # default; add trusted infra before arming
exempt_opers = true # never screen network operators
exempt_accounts = false # screen logged-in accounts too (compromised = spam)
exempt_voice = true # content: skip voiced/opped members
announce_permit = 8 # at most N [SECURITY] alerts to the log channel...
announce_life = 10 # ...per this many seconds (a flood can't spam it)
cascade_permit = 15 # > N total triggers within cascade_life...
cascade_life = 30 # ...raises a one-shot "consider raising DEFCON" alert
[security.connect] — connection screening
[security.connect]
enabled = true
flood_permit = 6 # > N connections from one IP within flood_life
flood_life = 10
range_permit = 12 # ...or > N across its /24 (v4) / /64 (v6) within range_life
range_life = 20
ban_duration = 3600 # G-line seconds when armed (0 = kill the connection only)
[[security.pattern]] — connection pattern DB (repeatable)
[[security.pattern]]
mask = "*!*@*.knownbad.example" # glob (default) or regex
field = "mask" # mask (nick!ident@host, default) | full (…#gecos) | nick | ident | host | gecos
regex = false # true = treat `mask` as a case-insensitive regex
reason = "known spam host"
ban = 86400 # G-line seconds when armed (0 = kill only)
[security.behavior] — behavioural heuristics
[security.behavior]
enabled = true
nick_permit = 5 # nick-change flood: > N changes...
nick_life = 30 # ...within this many seconds
cycle_permit = 6 # join/part cycling
cycle_life = 20
joinpart_permit = 4 # join-spam-part (a quick part after joining)
joinpart_life = 30
joinpart_grace = 10 # "quick" = parted within this many seconds of joining
massjoin_permit = 8 # per channel × /24-//64 (clone raid)
massjoin_life = 8
quit_permit = 4 # broken-client quit flood, from one IP
quit_life = 30
quit_reasons = ["Excess Flood", "Max SendQ exceeded"] # markers (case-insensitive substring)
ban_duration = 3600
[security.content] — channel content (bot channels)
[security.content]
enabled = true
highlight_nicks = 6 # a line pinging >= N distinct members...
highlight_min_len = 3 # ...each nick at least this many chars
highlight_permit = 1 # more than N such lines from one user...
highlight_life = 15 # ...within this window
badunicode_score = 0.30 # combining-mark/zalgo + invisible fraction that flags a line
badunicode_min = 8 # minimum length to consider
badunicode_permit = 1
badunicode_life = 20
repeat_min = 10 # min length of a line to track for repeat-wave
repeat_permit = 5 # same line > N times in a channel...
repeat_life = 20 # ...within this window
ban_duration = 3600
[security.auth] — login/registration abuse
echo is NickServ and a linked server, so it sees failed logins, registrations, and account logins first-class — no oper-snote scraping.
[security.auth]
enabled = true
fail_permit = 8 # > N failed password logins from one IP within fail_life s → ban
fail_life = 60
register_permit = 3 # > N REGISTERs from one IP within register_life s → reject
register_life = 300
evade_ttl = 172800 # remember a security-banned account this long; re-kill on re-login
ban_duration = 3600
Also on [security]: netsplit_grace = 60 (suppression window after a split/link), and on
[security.behavior]: crawl_permit = 12 / crawl_life = 15 (the channel-crawl detector).
[mxbl] — registration MX-blacklist
Rejects a signup when the email domain's mail servers are blocklisted — the stable way to stop disposable-email abuse (spammers rotate domains but reuse a small set of mail servers). Resolved with echo's native async DNS, off the engine lock; the account is never created. Default off.
[mxbl]
enabled = true
resolver = "" # "ip[:port]"; empty = first nameserver in /etc/resolv.conf
mx_globs = ["*.disposable-mail.example"] # MX hostname globs to block
ip_cidrs = ["203.0.113.0/24"] # MX-server IP CIDRs (costs an extra A/AAAA lookup)
timeout_ms = 2000
To persist a specific bad address (skip the lookup next time), an operator can also
OperServ FORBID EMAIL <glob> <reason> — checked in the same register path.
Arming: report-only → enforcing
- Run
report_only = trueand watch the[SECURITY]lines in the log channel for a while — confirm the thresholds only trip on real abuse. - Make sure
exempt_ipscovers every trusted infrastructure IP (loopback is covered by default; add gateways, bouncers, web-chat, other services hosts). - Set
report_only = false. Keep an eye on the log channel; theannounce_*limit stops a flood from drowning it, and an "ABUSE CASCADE" line recommends DEFCON if triggers spike.
DEFCON is not auto-raised — echo's existing defcon(1) lockdown handles the
severe case, and auto-escalation is risky; the cascade alert recommends, the
operator acts.
Observing it
- Metrics (Prometheus, the
[health]endpoint): each trip bumps a counter surfaced asecho_security_<detector>_trips— e.g.echo_security_connect_trips,echo_security_nick_trips,echo_security_massjoin_trips,echo_security_highlight_trips,echo_security_pattern_hits. These are cumulative and persist across restarts — compare a delta, not absolute presence. - Log channel:
[SECURITY]lines describe each trigger (report-onlyvsacting on), the offender, and why.
Design notes
- Engine-core, not a module. Detectors are called directly at the event sites in
Engine::handle(like the kickers), not fanned to a pseudo-client — the engine already has every event. - Report-only, exemptions, rate-limit first. The subsystem was built so that arming is safe: loopback/oper exemptions, an announce rate-limit, and no auto-DEFCON.
- Mining, not auto-filtering. The repeat-wave detector surfaces a mined spam line as a suggested filter rather than auto-pushing it network-wide — that pipe (m_filter) is owned by OperServ SPAMFILTER, and auto-pushing without review is risky.
- No new i18n. The
[SECURITY]feed is an operator audit log (English, like the rest of the DebugServ feed); the MX-reject reuses NickServ's existing localized "forbidden email" reply. There are no new end-user-facing strings.