A privacy-first Attorney Online 2 server written in Rust.
Built with async-first design using Tokio, Ferris-AO implements the full AO2 protocol over both TCP and WebSocket transports, with a strong emphasis on user privacy — raw IP addresses and hardware IDs are never stored.
Supports both AO2 desktop clients and (https://github.com/AttorneyOnline/webAO) browser clients simultaneously — they share the same areas and can communicate with each other in real time.
- What is Ferris-AO
- Features
- Architecture
- Build Guide
- Configuration
- Database Setup
- Data Files
- Running the Server
- nginx Setup
- Connecting
- Command Reference
- Permission System
- Privacy Model
- Protocol Support
- Project Structure
- Contributing
Attorney Online is a courtroom roleplay game where players take on the roles of lawyers, witnesses, and judges to act out cases. Players communicate through in-character speech bubbles, evidence presentation, music, and animations tied to a roster of characters.
Ferris-AO is a server backend for the AO2 protocol. It manages areas (rooms), character slots, evidence, music, moderation, and accounts. It was written from scratch in Rust as a clean, modern alternative to existing C++ and Python-based servers.
Design philosophy:
- Raw IP addresses are hashed immediately on receipt and discarded — they are never logged or stored
- Hardware IDs undergo a permanent keyed hash — bans persist across reconnects without storing the original identifier
- All sensitive database records are encrypted at rest with AES-256-GCM
- Passwords are hashed with Argon2id
Drop the binary and run it — Ferris-AO writes config.toml and the entire data/ layout on first run. All defaults are embedded in the binary. Edit config.toml, set NYAHAO_DB_KEY, and run again.
- Dual transport — Accepts both legacy TCP (AO2 desktop) and WebSocket (WebAO browser client) connections simultaneously
- WebAO support — Full interoperability with WebAO; browser and desktop clients share areas, see each other's IC messages, and interact in real time
- Full AO2 protocol — IC messages, music changes, evidence, health points, rebuttals, case alerts, and pairing
- Privacy-by-design — IPs hashed to daily-rotating IPIDs; HDIDs permanently hashed; nothing sensitive is ever logged
- Encrypted database — All ban and account records encrypted with AES-256-GCM at rest via redb
- Per-restart forward secrecy — DB encryption key is re-derived on every startup via HMAC-SHA256; a memory dump from one session cannot decrypt the next
- Startup DB integrity check — Verifies all tables are readable after WAL replay before accepting connections
- Argon2id passwords — Configurable memory/iteration/parallelism parameters for future-proof account hashing
- Role-based permissions — Fine-grained permission bitmask (admin, mod, trial, CM, DJ roles)
- Area system — Multiple configurable areas with per-area evidence modes, backgrounds, locks, CMs, and HP tracking
- Moderation suite — Kick, ban (temporary or permanent), mute (IC/OOC/music/judge/shadow variants), warn, announce, private messaging, watchlist
- Watchlist — Flag player HDIDs with notes; all authenticated mods are alerted when a watched player connects
- Shadow mute — Victim's messages appear sent to them but nobody else sees them
- Pairing system —
cccc_ic_supportpairing: players appear side-by-side in IC messages - Private messaging —
/pmand/rcommands - Narrator mode — Speak without a character sprite
- DJ role — Players with the DJ permission can use
/playand stream audio URLs in any area without CM status - Dice and coins —
/roll [NdM](up to 20 dice, 2–10000 sides) and/flipbroadcast to the area - Radio stations — Config-defined stations played via
/radio <n>; DJs/CMs or anyone (configurable) - Blankpost toggle — Per-area
allow_blankpostsetting; whenfalse, empty IC messages are rejected with a notice - Area rename — Mods can rename areas at runtime with
/rename <new name>without restarting
- Backpressure — Each client has a bounded outbound channel; persistently slow clients are shed gracefully without blocking others
- Packet batching — Burst messages are coalesced into a single write per flush cycle
- Delta ARUP — Area update broadcasts are skipped when area state hasn't changed since the last send
- Binary protocol — Optional MessagePack encoding over WebSocket for reduced bandwidth (opt-in via
BINARY#1#%) - DB write serialisation — Explicit write guard prevents contention across concurrent
spawn_blockingcallers - Graceful restart (SIGUSR1) — Broadcasts a 10-second countdown to all clients before exit (Linux only)
- Zero-downtime reload (SIGHUP) — Hot-reloads characters, music, backgrounds, and censor words without restarting (Linux only)
- Secret rotation —
/rotatesecretgenerates a new HMAC key; applied on next restart withsecret_rotation_enabled = true - DB key rotation —
/rotatekeygenerates a new AES-256 key file; activated on next restart withkey_rotation_enabled = true - Minimal logging mode — Set
log_level = "minimal"to suppress everything below warnings - TOTP two-factor authentication — Accounts can enable RFC 6238 TOTP (
/2fa enable); login requires a 6-digit code after the password. Compatible with any TOTP app (Google Authenticator, Aegis, Authy). - Argon2id pepper — Optional server-side pepper (HMAC-SHA256) mixed into every password before Argon2id. Set via
NYAHAO_PEPPERenv var or[security] password_pepperin config. - Panic backtrace — Set
panic_backtrace = truein[server]to enableRUST_BACKTRACE=fulland a crash hook that prints the full backtrace on panic.
- WebSocket keepalive — Configurable Ping/Pong intervals to detect stale connections
- Native TLS WebSocket — Set
tls_cert_pathandtls_key_pathin[network]to acceptwss://directly without a reverse proxy (tokio-rustls + rustls, PEM certificates) - PROXY Protocol v2 — Recovers real client IPs behind nginx
- Cloudflare-ready — Handles
CF-Connecting-IP,X-Forwarded-For,X-Real-IP - Cluster gossip — Optional UDP gossip heartbeat with consistent-hash ring for multi-node routing (requires shared backend — see
cluster.enabledin config)
- Word censor — Hot-reloadable
data/censor.txt; matched messages silently intercepted sender-side - Packet size enforcement — Hard limit on incoming bytes; oversized packets dropped before parsing
- Aggressive release optimization — LTO + single codegen unit for minimal binary size and maximum throughput
┌──────────────┐
│ Cloudflare │ DDoS protection, TLS termination
└──────┬───────┘
│ HTTPS / TCP (Spectrum)
┌──────▼───────┐
│ nginx │ Reverse proxy, rate limiting, PROXY Protocol
└──────┬───────┘
┌───────────┴───────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ TCP :27017 │ │ WS :27018 │
└──────┬───────┘ └──────┬──────┘
└───────────┬────────────┘
┌───────▼────────┐
│ Ferris-AO │
│ ServerState │ Arc<RwLock<_>> shared state
├────────────────┤
│ Areas │ Per-area slots, evidence, lock, HP
│ Clients │ HashMap<uid, ClientHandle>
│ Auth / Privacy │ HMAC hashing, Argon2id
│ Database │ redb + AES-256-GCM
│ Moderation │ Bans, kicks, mutes
└────────────────┘
Module map:
| Module | Responsibility |
|---|---|
main |
Startup, CLI, config loading |
server |
ServerState, ClientHandle, broadcast logic |
client |
Per-connection session state |
protocol |
Packet parsing, serialization, handler dispatch |
network |
TCP and WebSocket transports, AoTransport abstraction |
auth |
Account CRUD, Argon2id hashing |
privacy |
IPID and HDID hashing via HMAC-SHA256 |
moderation |
Ban records (BanManager), watchlist (WatchlistManager) |
storage |
Encrypted redb wrapper |
game |
Areas, character slots, SM packet builder |
commands |
All /command implementations |
config |
TOML config deserialization |
If you don't have Rust installed, get it from rustup.rs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/envFerris-AO requires Rust 1.75 or later. Check your version:
rustc --versiongit clone https://github.com/SyntaxNyah/Ferris-AO.git
cd Ferris-AODevelopment build (faster to compile, slower to run — for testing):
cargo build
./target/debug/nyahaoRelease build (optimised, LTO enabled — use this for production):
cargo build --release
./target/release/nyahaoThe binary is named nyahao (or nyahao.exe on Windows).
Ferris-AO encrypts all ban records, accounts, and watchlist entries with AES-256-GCM. You need to provide a 32-byte key as a 64-character hex string via the NYAHAO_DB_KEY environment variable.
Generate a secure key (run once, save it somewhere safe):
openssl rand -hex 32
# example output: a3f1c2e4b5d6789012345678abcdef01234567890abcdef1234567890abcdef12Important: If you lose this key or change it, the database becomes unreadable. Store it securely (e.g. in a password manager or a secrets manager). Never commit it to git.
The repo includes a ready-to-use config.toml. At minimum, set your server name and description:
[server]
name = "My AO Server"
description = "A cool roleplay server"
motd = "Welcome!"Everything else has sensible defaults. See the Configuration section for all options.
NYAHAO_DB_KEY="your_64_char_hex_key" ./target/release/nyahaoOn first launch, Ferris-AO will:
- Create
nyahao.db(the encrypted database) - Generate a
server_secretfor IPID/HDID hashing and store it in the DB - Start listening on TCP port 27017 and WebSocket port 27018
While the server is running, type in the same terminal (stdin CLI):
mkusr admin yourpassword admin
Then log in from any connected client with /login admin yourpassword.
Create /etc/systemd/system/ferris-ao.service:
[Unit]
Description=Ferris-AO Attorney Online Server
After=network.target
[Service]
Type=simple
User=ao
WorkingDirectory=/opt/ferris-ao
ExecStart=/opt/ferris-ao/nyahao
Environment=NYAHAO_DB_KEY=your_64_char_hex_key_here
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetThen enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable ferris-ao
sudo systemctl start ferris-ao
sudo journalctl -u ferris-ao -f # follow logsFerris-AO is configured via config.toml in the working directory.
| Key | Type | Default | Description |
|---|---|---|---|
name |
string | "NyahAO Server" |
Server name shown in the master server list and lobby |
description |
string | "A privacy-first AO2 server." |
Short description shown in the server browser |
motd |
string | "Welcome to NyahAO!" |
Message of the day sent to clients on join |
max_players |
integer | 100 |
Maximum number of simultaneous connected players |
max_message_len |
integer | 256 |
Maximum character length of a single IC message |
asset_url |
string | "" |
URL to an asset bundle for clients to download (leave empty to disable) |
multiclient_limit |
integer | 8 |
Maximum simultaneous connections sharing the same IPID |
max_packet_bytes |
integer | 8192 |
Hard limit on incoming packet size in bytes. Packets larger than this are dropped before parsing. |
outbound_queue_cap |
integer | 256 |
Maximum packets queued in each client's outbound channel. Excess packets are silently dropped. Increase for high-traffic areas; decrease to shed slow consumers sooner. |
secret_rotation_enabled |
boolean | false |
When true, applies a pending HMAC secret on startup (generated by /rotatesecret). Existing HDID-keyed records will no longer match after rotation — review bans/watchlist first. |
key_rotation_enabled |
boolean | false |
When true, loads a new AES key from data/db_key_new.hex on startup and renames it to db_key_active.hex. Starts a fresh database — back up first. |
argon2_memory_kib |
integer | 65536 |
Argon2id memory cost in KiB (64 MiB default). Increase for stronger password hashing. |
argon2_iterations |
integer | 3 |
Argon2id iteration count (time cost). |
argon2_parallelism |
integer | 2 |
Argon2id parallelism (thread count). |
binary_protocol |
boolean | false |
Allow clients to negotiate MessagePack binary encoding via BINARY#1#%. Reduces bandwidth for WebSocket clients. |
packet_batch_size |
integer | 0 |
Reserved for timer-based packet batching. 0 = disabled (current burst-drain batching always active). |
panic_backtrace |
boolean | false |
When true, sets RUST_BACKTRACE=full and installs a crash hook that prints the full backtrace on panic before exiting. |
| Key | Type | Default | Description |
|---|---|---|---|
tcp_port |
integer | 27017 |
Port for legacy TCP (AO2) connections |
ws_port |
integer | 27018 |
Port for WebSocket connections |
bind_addr |
string | "0.0.0.0" |
Address to bind both listeners to. Use "127.0.0.1" when running behind nginx |
reverse_proxy_mode |
boolean | false |
When true, trust X-Forwarded-For and X-Real-IP headers for real client IPs, and detect PROXY Protocol v2 on TCP. Must be false for direct (no proxy) deployments — trusting these headers without a proxy is a security risk. |
reverse_proxy_http_port |
integer | 80 |
External HTTP port advertised to the master server when reverse_proxy_mode = true |
reverse_proxy_https_port |
integer | 443 |
External HTTPS/WSS port advertised to the master server when reverse_proxy_mode = true |
ws_ping_interval_secs |
integer | 30 |
Seconds between WebSocket Ping frames for keepalive. Set to 0 to disable. |
ws_ping_timeout_secs |
integer | 90 |
Seconds to wait for a Pong response before treating the connection as stale and closing it. Set to 0 to disable. |
ws_compression |
boolean | false |
Reserved for WebSocket permessage-deflate support (requires future tungstenite upgrade). |
tls_cert_path |
string | "" |
Path to a PEM certificate file (e.g. fullchain.pem). When both TLS paths are set, the WebSocket port accepts wss:// directly without a reverse proxy. |
tls_key_path |
string | "" |
Path to a PEM private key file (e.g. privkey.pem). |
| Key | Type | Default | Description |
|---|---|---|---|
password_pepper |
string | "" |
Server-side pepper mixed into all password hashes via HMAC-SHA256(pepper, password) before Argon2id. The NYAHAO_PEPPER env var takes priority. Warning: Changing this after accounts are created invalidates all existing passwords. |
| Key | Type | Default | Description |
|---|---|---|---|
server_secret |
string | (generated) | Optional: 64-character hex string (32 bytes) used as the HMAC key for hashing. If omitted, a random secret is generated at first startup and stored in the database. Do not change this after launch — all existing IPIDs and HDID hashes will be invalidated. |
| Key | Type | Default | Description |
|---|---|---|---|
advertise |
boolean | false |
When true, posts server info to the master server so players can discover it |
addr |
string | "https://servers.aceattorneyonline.com/servers" |
Master server URL |
hostname |
string | (unset) | Optional hostname/IP to include in the advertisement. If unset, the master server infers it from the request |
When reverse_proxy_mode = true, the server advertises both ws_port (reverse_proxy_http_port, e.g. 80) and wss_port (reverse_proxy_https_port, e.g. 443) to the master server. nginx routes both external ports to the same single internal ws_port listener, so only one Ferris-AO WebSocket process is needed. When reverse_proxy_mode = false, only ws_port (plain WebSocket, no TLS) is advertised.
The server posts immediately on startup, every 5 minutes, and whenever the player count changes.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
When true, IC messages containing any word from data/censor.txt are silently intercepted — the sender sees their message as delivered but it is not broadcast to others. Has no effect if data/censor.txt is absent or contains no active words. |
| Key | Type | Default | Description |
|---|---|---|---|
log_level |
string | "info" |
Tracing log level: trace, debug, info, warn, error, or "minimal" (alias for warn — warnings and errors only) |
log_chat |
boolean | false |
Whether to log IC message content. Disabled by default for privacy. |
Optional. Defines radio stations playable via /radio. Disabled by default.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Enable the /radio command |
anyone_can_use |
boolean | true |
When true, any player can use /radio; when false, only DJs and area CMs |
stations |
array | [] |
List of [[radio.stations]] entries, each with name, url, and optional genre |
[radio]
enabled = true
anyone_can_use = true
[[radio.stations]]
name = "Lo-fi Hip-Hop"
url = "https://example.com/lofi.mp3"
genre = "Lo-fi"Optional. Enables UDP gossip heartbeat for multi-node deployments. All fields default to disabled.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Enable the gossip protocol |
node_id |
string | "" |
Unique identifier for this node. Auto-generated from hostname + port if empty. |
peers |
array | [] |
Peer addresses to gossip with ("host:port" strings) |
gossip_port |
integer | 27019 |
UDP port for incoming gossip messages |
hash_replicas |
integer | 150 |
Virtual nodes per physical node in the consistent-hash ring |
Example config.toml — direct (no proxy):
[server]
name = "Ferris-AO"
description = "A Rust AO2 server."
motd = "Welcome! Type /help for commands."
max_players = 100
max_message_len = 256
asset_url = ""
multiclient_limit = 8
max_packet_bytes = 8192
[network]
tcp_port = 27017
ws_port = 27018
bind_addr = "0.0.0.0"
reverse_proxy_mode = false
ws_ping_interval_secs = 30
ws_ping_timeout_secs = 90
[privacy]
# server_secret = "your_64_char_hex_string_here"
[logging]
log_level = "info"
log_chat = false
[censor]
enabled = false
[master_server]
advertise = true
addr = "https://servers.aceattorneyonline.com/servers"
# hostname = "your.domain.example"
[rate_limits]
ic_rate = 3.0
ic_burst = 5
mc_rate = 1.0
mc_burst = 3
ct_rate = 2.0
ct_burst = 5
evidence_rate = 5.0
evidence_burst = 10
zz_cooldown_secs = 60
conn_rate = 1.0
conn_burst = 5Example config.toml — behind nginx + Cloudflare:
[network]
tcp_port = 27017
ws_port = 27018
bind_addr = "127.0.0.1" # Only accept connections from nginx
reverse_proxy_mode = true
reverse_proxy_http_port = 80 # Advertised as ws:// to master server
reverse_proxy_https_port = 443 # Advertised as wss:// to master server
ws_ping_interval_secs = 30
ws_ping_timeout_secs = 90
[master_server]
advertise = true
hostname = "your.domain.example"Ferris-AO uses redb, an embedded key-value database stored at data/nyahao.db. All sensitive records (bans, accounts) are encrypted with AES-256-GCM before being written to disk.
The database encryption key is read from the environment variable NYAHAO_DB_KEY:
export NYAHAO_DB_KEY="0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"- Must be a 64-character lowercase hex string (32 bytes)
- If unset, a default insecure key is used — do not use the default in production
- The key is never written to the database or logs
Generating a secure key:
openssl rand -hex 32| Table | Encrypted | Description |
|---|---|---|
CONFIG |
No | Server metadata (stores the generated server secret) |
BANS |
Yes | Ban records keyed by ban ID |
BANS_BY_HDID |
No | HDID → ban ID index for fast ban lookups |
ACCOUNTS |
Yes | Moderator accounts keyed by username |
WATCHLIST |
Yes | Watchlist entries keyed by hashed HDID |
IPID_BANS |
Yes | IPID ban records keyed by hashed IPID |
The database files (nyahao.db, nyahao.db-shm, nyahao.db-wal) are excluded from git via .gitignore.
All data files live in the data/ directory.
One character folder name per line. These must match the folder names in the AO2 client's characters/ directory.
Phoenix_Wright
Miles_Edgeworth
Maya_Fey
One background name per line. These must match the folder names in the AO2 client's background/ directory.
gs4
aj
default
Music entries, one per line. Lines that do not contain a . (file extension) are treated as category headers and are displayed as separators in the client's music list.
Turnabout Sisters
trial.opus
cross.opus
Logic and Trick
logic.opus
Optional. One word or phrase per line. Lines that are blank or start with # are ignored. Matching is case-insensitive and checks whether the word appears anywhere in the IC message text.
# Lines starting with # are comments
badword
offensive phrase
Enable the filter in config.toml:
[censor]
enabled = trueThe censor list is hot-reloadable via /reload without restarting the server.
TOML array of area definitions. Each entry creates one area on the server.
[[areas]]
name = "Lobby"
background = "gs4"
evidence_mode = "mods" # "any" | "cms" | "mods"
allow_iniswap = false
allow_cms = false
force_nointerrupt = false
force_bglist = false
lock_bg = false
lock_music = false
[[areas]]
name = "Courtroom"
background = "aj"
evidence_mode = "cms"
allow_iniswap = true
allow_cms = true
force_nointerrupt = false
force_bglist = false
lock_bg = false
lock_music = falseArea options:
| Key | Type | Default | Description |
|---|---|---|---|
name |
string | (required) | Display name of the area |
background |
string | (required) | Default background on area reset |
evidence_mode |
string | (required) | Who can add/edit evidence: any, cms, mods |
allow_iniswap |
bool | (required) | Allow players to use iniswapped characters |
allow_cms |
bool | (required) | Allow players to become case managers (/cm) |
force_nointerrupt |
bool | (required) | Force all messages to be non-interrupting |
force_bglist |
bool | (required) | Restrict backgrounds to the server's backgrounds.txt list |
lock_bg |
bool | (required) | Prevent background changes entirely |
lock_music |
bool | (required) | Prevent music changes via packet (mods can still override) |
max_players |
integer | (none) | Optional cap on players in this area. Omit for unlimited. |
owner |
string | (none) | Optional account username that automatically receives CM status when they join this area. |
allow_blankpost |
bool | true |
When false, players cannot send empty IC messages. |
NYAHAO_DB_KEY="your_64_char_hex_key" ./target/release/nyahaoOverride the log level at any time with RUST_LOG:
RUST_LOG=debug NYAHAO_DB_KEY="..." ./target/release/nyahaoSee the Build Guide above for full setup instructions, systemd service config, and first-run steps.
While the server is running, the process reads commands from stdin:
| Command | Description |
|---|---|
players |
List all connected players with UID, character, and area |
say <message> |
Send a server-wide OOC announcement |
mkusr <username> <password> <role> |
Create a moderator account (admin, mod, trial, cm) |
rmusr <username> |
Delete a moderator account |
setrole <username> <role> |
Change an existing account's role (admin, mod, trial, cm, none) |
shutdown |
Gracefully shut down the server |
help |
List available CLI commands |
| Signal | Effect |
|---|---|
SIGHUP |
Hot-reload characters, music, backgrounds, and censor words — no restart needed |
SIGUSR1 |
Graceful restart — broadcasts a 10-second countdown to all connected clients, then exits. Your process manager (systemd, etc.) handles the restart. |
SIGINT / Ctrl-C |
Immediate shutdown |
# Hot-reload data files after editing characters.txt / music.txt / etc.
kill -HUP $(pidof nyahao)
# Graceful restart (10-second player warning before exit)
kill -USR1 $(pidof nyahao)Running Ferris-AO behind a reverse proxy is strongly recommended for TLS termination, DDoS protection (Cloudflare), and IP privacy. Set reverse_proxy_mode = true in config.toml for any proxy. The real client IP is recovered from X-Forwarded-For or X-Real-IP; Ferris-AO hashes it immediately and never stores the raw address.
| Proxy | Logs IPs by default | TLS | WebSocket | Best for |
|---|---|---|---|---|
| nginx | Yes — disable with access_log off |
certbot (Let's Encrypt) | Manual config | Production, Cloudflare, advanced tuning |
| Caddy | No | Automatic (Let's Encrypt) | Automatic | Simple setups, bare metal |
| Traefik | No | Automatic (Let's Encrypt) | Automatic | Docker / container deployments |
This guide uses a real example layout with two domains. Replace these with your own:
| Domain | Role | Cloudflare |
|---|---|---|
miku.pizza |
Main domain — asset CDN URL. Players download character sprites, music, and backgrounds from here. Cloudflare caches the files globally. | Orange cloud (proxied) |
hatsune.miku.pizza |
Game subdomain — what players connect to. TCP clients hit it directly on port 27017. WebSocket clients connect via nginx on ports 80 and 443. | Gray cloud (DNS only, direct to VPS) |
Why two records and why different cloud settings?
miku.pizza is orange-clouded so Cloudflare's CDN caches your asset bundle and serves it fast worldwide. It never needs to handle game protocol traffic.
hatsune.miku.pizza must be gray-clouded (direct to your VPS) for three reasons:
- AO2 desktop clients connect to it directly on TCP port 27017 — Cloudflare cannot proxy raw TCP on the free tier
- certbot's HTTP-01 challenge needs a direct connection to your VPS on port 80 to issue the TLS certificate
- WebSocket connections are more stable and lower latency without a proxy hop
In your Cloudflare dashboard for your domain, add two A records both pointing to your VPS IP:
Type Name Content Proxy status
A miku.pizza <your VPS IP> Proxied ← orange cloud
A hatsune.miku.pizza <your VPS IP> DNS only ← gray cloud
sudo apt update
sudo apt install nginx certbot python3-certbot-nginxEach domain gets its own file under /etc/nginx/sites-available/. Example files are in the nginx/ directory of this repo.
/etc/nginx/sites-available/hatsune.miku.pizza — game server (gray cloud, players connect here)
# hatsune.miku.pizza — game subdomain (gray cloud, direct to VPS)
#
# Players connect here for the actual game:
# - AO2 desktop: TCP port 27017 (bypasses nginx entirely, direct to Ferris-AO)
# - WebAO ws://: port 80 → nginx → Ferris-AO ws_port
# - WebAO wss://: port 443 → nginx → Ferris-AO ws_port (same listener!)
#
# Must be gray-clouded in Cloudflare so:
# - TCP port 27017 reaches the VPS directly (Cloudflare can't proxy TCP free tier)
# - certbot HTTP-01 challenge can reach the VPS on port 80
# ── Port 80: plain ws:// WebSocket + certbot ACME + redirect ─────────────────
server {
listen 80;
listen [::]:80;
server_name hatsune.miku.pizza;
# Certbot writes ACME challenge files here during cert renewal.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# WebSocket upgrade (ws://) → proxy to Ferris-AO.
# Plain browser HTTP → redirect to https://.
location / {
if ($http_upgrade = "websocket") {
proxy_pass http://127.0.0.1:27018;
}
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 7200s;
proxy_send_timeout 30s;
proxy_buffering off;
return 301 https://$host$request_uri;
}
}
# ── Port 443: wss:// WebSocket (TLS) ─────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name hatsune.miku.pizza;
# Paths filled in automatically by: sudo certbot --nginx -d hatsune.miku.pizza
ssl_certificate /etc/letsencrypt/live/hatsune.miku.pizza/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hatsune.miku.pizza/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# Do not log IPs — Ferris-AO hashes them internally and never stores raw addresses.
access_log off;
location / {
proxy_pass http://127.0.0.1:27018;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 7200s; # Keep WebSocket alive for long RP sessions
proxy_send_timeout 30s;
proxy_buffering off;
}
}Both port 80 and port 443 forward to the same localhost:27018 Ferris-AO listener. Only one WebSocket process is needed.
/etc/nginx/sites-available/miku.pizza — asset server (orange cloud, CDN)
# miku.pizza — main domain (orange cloud, Cloudflare CDN)
#
# Serves the AO2 asset bundle: character sprites, music, backgrounds.
# Cloudflare caches these files globally so players download them fast.
# This domain never handles game protocol traffic.
#
# TLS certificate: because this domain is orange-clouded, certbot's
# HTTP-01 challenge won't reach the VPS. Use a Cloudflare Origin
# Certificate instead (SSL/TLS → Origin Server → Create Certificate).
# Set Cloudflare SSL mode to Full (strict).
# ── Port 80: redirect to HTTPS ────────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name miku.pizza;
location / {
return 301 https://$host$request_uri;
}
}
# ── Port 443: serve asset files ───────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name miku.pizza;
# Cloudflare Origin Certificate paths (replace with your actual paths).
# Generate at: Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate
ssl_certificate /etc/ssl/cloudflare/miku.pizza.pem;
ssl_certificate_key /etc/ssl/cloudflare/miku.pizza.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# Asset bundle root — put your AO2 assets here:
# /var/www/assets/characters/
# /var/www/assets/music/
# /var/www/assets/backgrounds/
root /var/www/assets;
location / {
try_files $uri $uri/ =404;
# Tell Cloudflare it can cache these files for 24 hours.
add_header Cache-Control "public, max-age=86400";
}
}# Symlink both configs into sites-enabled
sudo ln -s /etc/nginx/sites-available/hatsune.miku.pizza /etc/nginx/sites-enabled/hatsune.miku.pizza
sudo ln -s /etc/nginx/sites-available/miku.pizza /etc/nginx/sites-enabled/miku.pizza
# Test the config syntax
sudo nginx -t
# Apply
sudo systemctl reload nginxhatsune.miku.pizza is gray-clouded, so certbot can reach your VPS directly via HTTP-01:
sudo certbot --nginx -d hatsune.miku.pizzamiku.pizza is orange-clouded. Cloudflare proxies port 80, so the standard HTTP-01 challenge won't reach your VPS. Use certbot's standalone mode with a temporary Cloudflare pause, or use a Cloudflare Origin Certificate instead (recommended — free, 15-year validity, no renewal needed):
Cloudflare Origin Certificate (easiest for miku.pizza):
- In Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate
- Save the certificate as
/etc/ssl/cloudflare/miku.pizza.pemand the key as/etc/ssl/cloudflare/miku.pizza.key- Update the
ssl_certificatepaths in themiku.pizzanginx block above to point to those files- In Cloudflare SSL/TLS settings, set mode to Full (strict)
Alternatively, temporarily pause Cloudflare proxying for miku.pizza, run sudo certbot --nginx -d miku.pizza, then re-enable the orange cloud.
Verify renewal works for hatsune:
sudo certbot renew --dry-runsudo ufw allow 27017/tcp # AO2 desktop TCP clients (direct to Ferris-AO)
sudo ufw allow 80/tcp # HTTP — ws:// clients + certbot renewal
sudo ufw allow 443/tcp # HTTPS — wss:// clients + asset CDN
sudo ufw enable[server]
name = "My AO Server"
description = "Hosted with Ferris-AO"
# Asset URL uses the orange-clouded main domain so Cloudflare CDN serves files.
# Players download characters, music, and backgrounds from here.
asset_url = "https://miku.pizza/assets"
[network]
bind_addr = "0.0.0.0" # TCP must bind to all interfaces (direct connection)
tcp_port = 27017 # AO2 desktop clients connect here directly
ws_port = 27018 # nginx forwards hatsune.miku.pizza :80 AND :443 → here
reverse_proxy_mode = true # Trust X-Forwarded-For / X-Real-IP from nginx
reverse_proxy_http_port = 80 # External plain WS port — advertised as ws://
reverse_proxy_https_port = 443 # External WSS port — advertised as wss://
[master_server]
advertise = true
hostname = "hatsune.miku.pizza" # The game subdomain — what clients see in the server listThe server will advertise all three endpoints to the master server:
- TCP:
hatsune.miku.pizza:27017— AO2 desktop clients connect directly - WS:
ws://hatsune.miku.pizza:80— WebAO plain WebSocket via nginx - WSS:
wss://hatsune.miku.pizza:443— WebAO secure WebSocket via nginx
miku.pizza (main domain, orange cloud)
├── DNS: miku.pizza → <VPS IP> (Cloudflare proxied)
├── Purpose: asset bundle CDN
└── https://miku.pizza/assets/ → nginx serves /var/www/assets/
↑ Cloudflare caches and distributes globally
hatsune.miku.pizza (game subdomain, gray cloud)
├── DNS: hatsune.miku.pizza → <VPS IP> (direct, no Cloudflare proxy)
├── Purpose: game connections
│
├── :27017 TCP ──────────────────────────────→ Ferris-AO (direct, no nginx)
│ AO2 desktop clients (direct connection)
│
├── :80 HTTP/WS → nginx → localhost:27018 → Ferris-AO
│ WebSocket clients using ws://hatsune.miku.pizza
│ (plain, unencrypted — same internal listener as :443)
│
└── :443 HTTPS/WSS → nginx → localhost:27018 → Ferris-AO
WebSocket clients using wss://hatsune.miku.pizza
(TLS terminated by nginx using Let's Encrypt cert)
Example configs for both domains are in the nginx/ directory of this repo:
nginx/hatsune.miku.pizza— game subdomain (ws:// + wss://)nginx/miku.pizza— asset CDN domain
Caddy produces no access logs by default and handles TLS and WebSocket upgrades automatically — the simplest privacy-friendly option.
With Cloudflare (Cloudflare terminates TLS, Caddy on port 80):
your.domain.example:80 {
reverse_proxy localhost:27018 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
}
}Without Cloudflare (Caddy handles HTTPS automatically via Let's Encrypt):
your.domain.example {
reverse_proxy localhost:27018 {
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
}
}config.toml:
[network]
ws_port = 27018
bind_addr = "127.0.0.1"
reverse_proxy_mode = true
reverse_proxy_http_port = 80 # Advertised as ws:// to master server
reverse_proxy_https_port = 443 # Advertised as wss:// to master server
[master_server]
advertise = true
hostname = "your.domain.example"Traefik has access logging disabled by default and is well suited to Docker deployments.
traefik.yml:
entryPoints:
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: your@email.com
storage: /letsencrypt/acme.json
tlsChallenge: {}
providers:
file:
filename: /etc/traefik/dynamic.yml
# Do not add an accessLog block — logging is off by default.dynamic.yml:
http:
routers:
ferris-ao:
rule: "Host(`your.domain.example`)"
entryPoints: [websecure]
tls:
certResolver: letsencrypt
service: ferris-ao
services:
ferris-ao:
loadBalancer:
servers:
- url: "http://127.0.0.1:27018"With Cloudflare in front, add
forwardedHeaders.trustedIPsset to Cloudflare's IP ranges soX-Forwarded-Forcannot be spoofed.
Docker Compose:
services:
traefik:
image: traefik:v3.0
command:
- "--accesslog=false"
- "--providers.docker=true"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "--certificatesresolvers.le.acme.email=your@email.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
ferris-ao:
build: .
labels:
- "traefik.enable=true"
- "traefik.http.routers.ferris.rule=Host(`your.domain.example`)"
- "traefik.http.routers.ferris.entrypoints=websecure"
- "traefik.http.routers.ferris.tls.certresolver=le"
- "traefik.http.services.ferris.loadbalancer.server.port=27018"A full example config is at nginx/nyahao.conf. nginx logs IP addresses by default — disable this in the http {} block:
access_log off;
# or strip IPs from the format:
# log_format no_ip '$time_local "$request" $status $body_bytes_sent';
# access_log /var/log/nginx/access.log no_ip;With Cloudflare (nginx on port 80, Cloudflare handles TLS):
server {
listen 80;
listen [::]:80;
server_name your.domain.example;
location / {
proxy_pass http://127.0.0.1:27018;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_set_header X-Real-IP $http_x_forwarded_for;
proxy_read_timeout 7200s;
proxy_send_timeout 30s;
proxy_buffering off;
}
}Without Cloudflare (nginx handles TLS with Let's Encrypt):
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name your.domain.example;
ssl_certificate /etc/letsencrypt/live/your.domain.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your.domain.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:27018;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 7200s;
proxy_send_timeout 30s;
proxy_buffering off;
}
}Legacy TCP clients (requires nginx compiled with --with-stream; Cloudflare Spectrum needed for TCP passthrough):
stream {
server {
listen 27016;
proxy_pass 127.0.0.1:27017;
proxy_protocol on;
proxy_connect_timeout 10s;
proxy_timeout 7200s;
}
}With proxy_protocol on, nginx prepends a PROXY Protocol v2 header so Ferris-AO can recover the real client IP. Requires reverse_proxy_mode = true.
| Transport | Default Port | Notes |
|---|---|---|
| TCP | 27017 |
Used by AO2 desktop clients (e.g. Attorney Online 2) |
| WebSocket | 27018 |
Used by web clients (WebAO); expose via nginx + TLS on port 443 |
In the AO2 client, add the server as:
- IP: your server's IP or domain
- Port: 27017 (TCP) or 443 (WebSocket via nginx)
WebAO is a browser-based AO2 client that connects over WebSocket. Ferris-AO supports full WebAO interoperability — WebAO and AO2 desktop clients can share the same area and see each other's IC messages in real time.
To connect with WebAO, point it at the WebSocket endpoint:
ws://your-domain:27018(plain, no TLS)wss://your-domain:443(TLS via nginx — recommended for production)
WebAO clients and AO2 clients join the same areas, see each other's IC and OOC messages, share evidence, and play music together with no additional configuration.
Commands are entered in the OOC chat box prefixed with /.
| Command | Description |
|---|---|
/help |
List all commands available to you |
/about |
Show server version and info |
/who |
List all connected players (UID, character, area) |
/move <area> |
Move to a different area by name or number |
/charselect |
Return to the character select screen |
/doc [text] |
View or set the area's case document/notes |
/areainfo |
Show current area details (status, lock, CMs, player count) |
/narrator |
Toggle narrator mode (speak without a character sprite) |
/motd |
Display the server's message of the day |
/clear |
Clear your client's chat log |
/cm [uid] |
Become case manager, or designate another player (if area allows) |
/uncm [uid] |
Step down as case manager, or remove another player's CM status |
/bg <background> |
Change the area background (if not locked) |
/status <status> |
Set the area status: idle, rp, casing, looking-for-players, recess, gaming |
/lock [-s] |
Lock the area to new players. -s makes it spectatable (can watch, not speak) |
/unlock |
Unlock the area, allowing anyone to join |
/play <song or URL> |
Change the area music, or stream an http(s):// URL. Requires CM in the area, PERM_CM, or PERM_DJ. |
/roll [NdM] |
Roll dice and broadcast the result. Default: 1d6. Max 20 dice, 2–10000 sides. Example: /roll 2d20 |
/flip |
Flip a coin (Heads/Tails) and broadcast the result. |
/login <user> <pass> |
Authenticate as a moderator account |
/logout |
Log out of your moderator account |
/pair <uid> |
Request to pair with another player (side-by-side IC messages) |
/unpair |
Cancel your current pairing |
/pm <uid> <message> |
Send a private message to a player |
/r <message> |
Reply to the last player who sent you a private message |
/ignore <uid> |
Hide IC and OOC messages from a player (session only — resets on disconnect) |
/unignore <uid> |
Stop ignoring a player |
/ignorelist |
Show which UIDs you are currently ignoring |
/radio |
List available radio stations |
/radio <n> |
Play radio station number n in the current area (DJ/CM required if anyone_can_use = false) |
/2fa enable |
Enable TOTP two-factor authentication on your account. Returns an otpauth:// URI to scan with an authenticator app. |
/2fa disable <code> |
Disable TOTP 2FA after verifying a current code |
/2fa status |
Check whether 2FA is enabled on your account |
These commands require specific permissions (see Permission System).
| Command | Permission | Description |
|---|---|---|
/kick <uid> [reason] |
KICK |
Disconnect a player. Logs reason. |
/ban <uid|hdid> [duration] <reason> |
BAN |
Ban a player by UID or hashed HDID. Duration format: 1h, 7d, 30d; omit for permanent. |
/unban <ban_id> |
BAN |
Nullify an active ban by its ID. |
/baninfo <hdid> |
BAN_INFO |
Check the ban status for a given hashed HDID. |
/mute <uid> [type] |
MUTE |
Silence a player. Types: ic, ooc, all (default: all). |
/unmute <uid> |
MUTE |
Remove a mute from a player. |
/shadowmute <uid> |
MUTE |
Stealth mute a player — their messages appear to go through but are invisible to others. |
/warn <uid> <reason> |
KICK |
Increment a player's warning count and notify them. |
/announce <message> |
MOD_CHAT |
Send a server-wide OOC announcement to all players. |
/modchat <message> |
MOD_CHAT |
Send a message only visible to authenticated moderators. |
/ipban <uid> [duration] <reason> |
KICK |
Ban a player by their current IPID. Duration: 1h, 6h, 12h, 1d, 7d; omit for permanent (until daily IPID rotation). |
/unipban <ipid> |
BAN |
Remove an IPID ban. |
/watchlist add <hdid> [note] |
WATCHLIST |
Add a hashed HDID to the watchlist with an optional note. |
/watchlist remove <hdid> |
WATCHLIST |
Remove a hashed HDID from the watchlist. |
/watchlist list |
WATCHLIST |
List all watchlist entries with who added them and when. |
/rename <name> |
MODIFY_AREA |
Rename the current area at runtime. The new name is shown in ARUP and CT notices immediately. |
/reload |
ADMIN |
Hot-reload characters, music, backgrounds, and censor words without restarting. |
/logoutall |
ADMIN |
Force-logout all authenticated moderator sessions. |
/rotatekey |
ADMIN |
Generate a new AES-256 DB key to data/db_key_new.hex. Set key_rotation_enabled = true and restart to apply. |
/rotatesecret |
ADMIN |
Generate a new HMAC server secret. Set secret_rotation_enabled = true and restart to apply. Existing HDID-keyed records will no longer match after rotation. |
Permissions are stored as a 64-bit bitmask on each account. Multiple permissions can be combined.
| Permission | Bit | Description |
|---|---|---|
CM |
1 |
Can be a case manager in areas that allow CMs |
KICK |
2 |
Can kick and warn players |
BAN |
4 |
Can ban and unban players |
BYPASS_LOCK |
8 |
Can enter locked areas |
MOD_EVI |
16 |
Can modify evidence regardless of area evidence mode |
MODIFY_AREA |
32 |
Can modify area settings (background, etc.) |
MOVE_USERS |
64 |
Can move other players between areas |
MOD_SPEAK |
128 |
Can speak in locked or muted states |
BAN_INFO |
256 |
Can look up ban records by HDID |
MOD_CHAT |
512 |
Can use modchat and send announcements |
MUTE |
1024 |
Can mute/unmute players |
LOG |
2048 |
Can access server logs |
WATCHLIST |
4096 |
Can add/remove/list watchlist entries |
DJ |
8192 |
Can use /play and stream audio URLs in any area regardless of CM status |
ADMIN |
ALL |
All permissions |
When creating accounts via mkusr, specify one of these role names:
| Role | Permissions Granted |
|---|---|
admin |
All permissions (ADMIN) |
mod / moderator |
KICK, BAN, BYPASS_LOCK, MOD_EVI, MODIFY_AREA, MOVE_USERS, MOD_SPEAK, BAN_INFO, MOD_CHAT, MUTE, LOG, WATCHLIST |
trial |
KICK, MOD_CHAT, MUTE |
cm |
CM, BYPASS_LOCK, MOD_EVI |
dj |
DJ |
Ferris-AO is designed so that neither the server operator nor an attacker who obtains the database can recover a player's real IP address or hardware ID.
The IPID is a pseudonymous identifier derived from a player's IP address. It is used for multiclient limiting and moderation without retaining the real IP.
How it works:
- A
daily_saltis derived:HMAC-SHA256(server_secret, current_date_YYYY-MM-DD) - The IPID is computed:
hex(first_16_bytes(HMAC-SHA256(daily_salt, raw_ip))) - The raw IP is discarded immediately
Properties:
- The same IP produces a different IPID each day — protecting long-term tracking
- The IPID is consistent within a single day — allowing ban/multiclient enforcement
- Without the server secret, IPIDs cannot be reversed to IPs
HDIDs are sent by the AO2 client as a persistent hardware fingerprint. Ferris-AO hashes them permanently so bans survive IP changes and reconnects.
How it works:
HMAC-SHA256(server_secret, "hdid:" || raw_hdid)- The result is hex-encoded (first 16 bytes) and stored
- The raw HDID is never stored or logged
Properties:
- The hash is stable across server restarts (uses the fixed server secret)
- Without the server secret, HDID hashes cannot be reversed
- Bans target the hashed HDID — they persist even if the player reconnects from a new IP
- Generated as 32 cryptographically random bytes on first startup
- Stored in the unencrypted
CONFIGdatabase table (protected by theNYAHAO_DB_KEYenv var at the OS level) - Never logged or printed
- Changing the secret invalidates all existing IPIDs and HDID hashes — avoid doing this after launch
An optional server-side pepper is mixed into every password hash via HMAC-SHA256(pepper, password) before the result is fed to Argon2id. This means a database leak alone is not enough to crack passwords — an attacker also needs the pepper, which is kept in the environment variable NYAHAO_PEPPER and never stored in the DB.
Set it once before any accounts are created. Never change it after accounts exist — all existing passwords will become unverifiable.
export NYAHAO_PEPPER="$(openssl rand -hex 32)"Or set it in config.toml:
[security]
password_pepper = "your_secret_pepper_here"- Raw IP addresses
- Raw Hardware IDs
- Plaintext passwords (Argon2id PHC format only)
- IC message content (unless
log_chat = truein config)
Ferris-AO advertises the following AO2 feature flags to connecting clients. All flags are sent to both TCP (AO2 desktop) and WebSocket (WebAO) clients. The server broadcast format correctly includes all 30 IC body fields — including the effects field at position 29 — so WebAO clients receive and display IC messages from all participants.
| Flag | Description |
|---|---|
noencryption |
Disables legacy XOR encryption (modern clients only) |
yellowtext |
Enables yellow-colored text in IC messages |
prezoom |
Pre-zoom desk effects |
flipping |
Character sprite horizontal flipping |
customobjections |
Custom objection animations |
fastloading |
Optimized character/evidence list loading |
deskmod |
Desk visibility control per message |
evidence |
Evidence system support |
cccc_ic_support |
Pairing (Character-Character Concurrent Chat) |
arup |
Area update packets (real-time area status in lobby) |
casing_alerts |
Case announcement/subscription system |
modcall_reason |
Reason field in mod calls |
looping_sfx |
Looping sound effects |
additive |
Additive text (append to previous message) |
effects |
Visual effect overlays |
y_offset |
Vertical sprite offset |
expanded_desk_mods |
Additional desk modifier values |
auth_packet |
Server-side authentication packet support |
Ferris-AO/
├── Cargo.toml # Dependencies and release profile
├── config.toml # Server configuration
├── data/
│ ├── areas.toml # Area definitions
│ ├── characters.txt # Character roster (one per line)
│ ├── backgrounds.txt # Allowed backgrounds
│ ├── music.txt # Music list with category headers
│ └── censor.txt # Optional word censor list (one word/phrase per line)
├── nginx/
│ └── nyahao.conf # Example nginx reverse proxy config
└── src/
├── main.rs # Startup, CLI, initialization
├── server.rs # ServerState, ClientHandle, broadcast
├── client.rs # Per-connection session state
├── config.rs # TOML config structs
├── auth/
│ ├── mod.rs
│ └── accounts.rs # Account CRUD, Argon2id hashing, permissions
├── privacy/
│ ├── mod.rs
│ └── hashing.rs # IPID (daily-rotating) and HDID hashing via HMAC-SHA256
├── moderation/
│ ├── mod.rs
│ ├── bans.rs # BanRecord, BanManager, soft-delete
│ └── watchlist.rs # WatchEntry, WatchlistManager
├── storage/
│ ├── mod.rs
│ └── db.rs # EncryptedDb: redb + AES-256-GCM wrapper
├── network/
│ ├── mod.rs # AoTransport enum, handle_connection entry point
│ ├── tcp.rs # TCP listener, PROXY Protocol v2 detection
│ └── websocket.rs # WebSocket listener, header-based IP recovery
├── protocol/
│ ├── mod.rs
│ ├── packet.rs # Packet struct, AO2 wire encoding/decoding
│ └── handlers.rs # Full AO2 packet handler dispatch (~930 lines)
├── game/
│ ├── mod.rs
│ ├── areas.rs # Area struct, character slots, evidence, lock/CM logic
│ └── characters.rs # Character list loader, SM packet builder
├── commands/
│ ├── mod.rs
│ └── registry.rs # All /command implementations
├── cluster.rs # Gossip protocol, consistent-hash ring, cluster scaffolding
├── ratelimit.rs # TokenBucket implementation
└── ms.rs # Master server advertisement
Pull requests are welcome. For significant changes, open an issue first to discuss the approach.
Please ensure:
- No raw IPs, HDIDs, or passwords appear in logs or stored data
- New commands include appropriate permission checks
- Database writes use the encrypted helpers in
storage/db.rs
Ferris-AO is not affiliated with the official Attorney Online project.