New

dot-server-security

Hardening a server as configuration — sliding-window rules over chat, connections, authentication and RCON that escalate warn to gag to kick to ban, anti-cheat that separates what the server proved from what a human should look at, and external ban feeds chained into dot-server.

Rules over events, punishments that escalate, and an answer when somebody asks why. It watches chat, connections, authentication, the remote console and the anti-cheat detectors, and everything it does is a JSON document an operator can read.

Requires dot-core and dot-server. dot-moderation, dot-chat and dot-auth are optional and are named nowhere in the source — all three are reached at run time.

var guard := DotSecurityManager.new()
server.add_child(guard)

var watch := DotSecurityWatch.new()      # wires it to everything worth watching
server.add_child(watch)

It ships in dry run

Every rule counts, logs and fills in sec_status, and punishes nobody until you say so. Run it for a week, read sec_status, then sv_security_dryrun 0.

The shape of a rule

This many of that event, from one subject, within this window — then take the next step on the ladder.

{
  "id": "chat_flood",
  "event": "chat.message",
  "threshold": 6,
  "window_sec": 10,
  "scope": "uid",
  "cooldown_sec": 15,
  "steps": [
    {"action": "warn", "message": "Slow down, please."},
    {"action": "gag",  "duration_sec": 300},
    {"action": "gag",  "duration_sec": 1800},
    {"action": "kick"}
  ]
}

Six messages in ten seconds. First time a warning, then five minutes, then half an hour, then out. Offences are remembered for offence_memory_sec, so somebody who spammed this morning does not start again at “warn” tonight — and somebody who spammed a fortnight ago does.

The ladder is why this is more than a rate limiter. A flat rule has one answer for a first offence and a fiftieth, so you end up choosing between punishing a new player for typing fast and letting a spammer run.

`cooldown_sec` is the setting you would not have thought of

Without it, a rule whose action does not stop the behaviour — a warning — trips again on the very next message and walks its whole ladder in one second, banning somebody over four lines.

What it watches

chat.message chat.duplicate chat.caps chat.link chat.refused chat.command text chat, from the server’s own path and from dot-chat’s router
connect.attempt connect.rejected connect.churn connections, including the reconnect loop no single connection looks wrong in
auth.failed auth.rejected authentication, when dot-auth is installed
rcon.auth_failed rcon.command the remote console
command.denied somebody probing for commands they do not hold
cheat.* the anti-cheat detectors

The vocabulary is open. guard.report_session(&"arena.buy_menu_spam", session) is one line, and that event gets the whole engine: windows, thresholds, ladders, exemptions, the ledger, dry run and sec_why. A closed enum would have meant every game forking this.

Scope: a person, or a connection

Count against a connection and you have counted nothing — a peer id dies with the socket, so “five messages in ten seconds” is dodged by reconnecting.

uid behaviour that is a person’s: chat, commands, votes. Follows them to a new address; does not punish the sibling on the same connection.
address anything that happens before there is an identity, which is most of what an attack is: connection floods, wrong RCON passwords, authentication that never completed.
both counts twice, separately — one rule catches “this account is spamming” and another “this address is spamming from a new account each time”.

Anti-cheat, and the split that matters

Detections are ordinary events, so all the rule machinery applies to them. There are two kinds and conflating them is the mistake every home-grown anti-cheat makes.

  • Impossible — the server re-simulated what the client claimed and the claim does not fit: moving further in a tick than the movement code can produce, firing faster than the weapon allows, claiming more simulated time than has elapsed. These are facts about arithmetic, and a rule may act on one.
  • Suspicious — aim that snaps, aim too smooth to be a hand, firing inside human reaction time, a headshot rate three deviations out. Every one of these also describes a very good player on a very good day. They accumulate, they warn, they tell the admins.

The shipped behavioural rules never punish, and the detector refuses at boot to let you configure one that punishes on a single detection — because a community remembers a wrongly banned good player far longer than it remembers a cheat.

The strongest check is a re-simulation:

ac.movement_reference = func(session, from, velocity, command, delta) -> Vector3:
    return my_controller.simulate(from, velocity, command, delta)

Given the command a client sent and the state it started from, deterministic movement says exactly where it should have ended up; anything else is a claim the server can reject. dot-player-controller’s first-person half is command-driven and deterministic precisely so a server can reconcile it. Without one, the envelope thresholds are the fallback — and they ship at 0, meaning off, because the honest way to set them is to read the peaks off sec_ac_status after a week of your own game.

External ban lists

Any number of endpoints, merged, cached, and consulted on every join.

{
  "id": "network",
  "url": "https://bans.example.net/api/v1/active",
  "auth": "bearer",
  "token_file": "user://cfg/network.token",
  "list_path": "data.bans",
  "on_failure": "keep_last"
}
  • Auth: public, bearer (a static key or a JWT alike), basic, a custom header, a query parameter, or a timestamp-nonce-HMAC that sends nothing replayable.
  • Formats: a bare array of strings, an array of objects with any of several field names, a wrapper object, or {"uids": [...], "ips": [...]}. A blocklist you cannot point this at is one you will copy into a file by hand, and then it is stale.
  • IDs, addresses and CIDR ranges. A /24 is matched, not expanded — expanding a /16 is sixty-five thousand entries for one line of somebody’s list.
  • Expiries are honoured. A list that publishes them and a consumer that ignores them is how somebody stays banned for a week after their day was up.
  • It caches to disk, so a restart during an outage still enforces.
  • on_failure is keep_last (default), ignore, or refuse_all — fail open or fail closed, your call.

It chains rather than replaces. dot-moderation registers under the same dot_ban_source name, and whichever readied second would otherwise silently win — leaving a deployment with both enforcing exactly one. Both are asked, and both must say yes.

Credentials come from a file

token_file, not the environment and not the command line. Both of those are readable by other processes and both end up in ps output and in pasted bug reports — the same rule DotConfig.sensitive_keys applies everywhere else.

With and without dot-moderation

With it, a gag is a record: stored, expiring, revocable, surviving a reconnect and a restart, sitting in that addon’s listing beside the ones a human issued.

Without it, a gag is two booleans on a session object — real, felt, and lost when they reconnect. Both are supported, because refusing to act without dot-moderation would make this addon conditional on another optional one, and a five-minute gag is still worth having against the ninety-nine per cent who will not think to reconnect out of it.

Explaining yourself

] sec_why Someone
2 record(s) matching 'Someone':
  2026-09-12T14:02:11  chat_flood  account u-8813  offence 2: 7 x chat.message in 10s -> gag 5m
  2026-09-12T14:39:40  chat_repeat account u-8813  offence 1: 3 x chat.duplicate in 30s -> warn

An automatic punishment nobody can explain gets lifted blindly or refused blindly, and after the second time the operator turns the guard off. sec_forget is the other half: clear somebody’s counters after lifting a punishment by hand, or the next event escalates from where the ladder left off and the person you just forgave is gagged again for one message.

Also sec_status, sec_rules, sec_rule <id>, sec_log, sec_enable / sec_disable, sec_reload, sec_dump, sec_test (feed it synthetic events to prove a rule fires), sec_bans, sec_bans_refresh, sec_bans_check, sec_ac_status, sec_ac_dryrun — plus sv_security and sv_security_dryrun, both live.