Architecture
Citadel runs in two halves connected by a single trust boundary.
The local half is what you install — the desktop app, the dashboard, the backend that talks to your DayZ servers, the sidecar, and the in-game mod. It's local-first by design: everything operationally important happens on your machine.
The cloud half is what citadels.cc runs — the license server, the Paddle billing webhook receiver, and (for Cloud subscribers) the Cloud Bans pool. It does the minimum needed for licensing and any cloud-only features you've subscribed to.
High-level overview
Internet / public network
─────────────────────────────────────────────────────────────────────
Local install (your Windows box) │ citadels.cc cloud
│
┌───────────────────────────────────────────┐ │
│ Citadel Desktop App │ │ ┌──────────────────────┐
│ (Electron wrapper) │ │ │ citadels.cc Web │
│ │ │ │ (Next.js marketing │
│ ┌──────────────────────────────────────┐ │ │ │ + /account dashboard)│
│ │ Web Dashboard (React+Vite) │ │ │ └──────────┬───────────┘
│ │ Socket.IO + REST + cookie/CSRF │ │ │ │
│ └──────────────────┬───────────────────┘ │ │ │ Paddle.js
│ │ │ │ │ checkout
│ ┌──────────────────▼───────────────────┐ │ │ ▼
│ │ Citadel Backend (Node.js) │ │ │ ┌──────────────────────┐
│ │ - Provider System (InHouse/RCON) │ │ │ │ api.citadels.cc │
│ │ - Scheduler / Backup / Mod manager │ │ │ │ (Fastify v5 API) │
│ │ - Auth (cookie + CSRF + Bearer) │ │ │ │ │
│ │ - Audit log + fail2ban + lockouts │ │ │ │ /license/activate │
│ └──────────────────┬───────────────────┘ │ │ │ /license/verify │
│ │ HTTPS+JWT │ │ │ /cloud-bans/* │
│ │ (license activation, │ │ │ /webhooks/paddle │
│ │ /verify, /cloud-bans)│ │ │ /telemetry/events │
│ │ │ │ └──┬───────────────┬──┘
│ ┌──────────────────▼───────────────────┐ │ │ │ │
│ │ Citadel Sidecar (:9100) │ │ │ │ writes │ pubsub
│ │ Authorization: Bearer auth │ │ │ ▼ ▼
│ │ File-based IPC to the mod │ │ │ ┌─────────┐ ┌──────┐
│ └──────────────────┬───────────────────┘ │ │ │ Postgres│ │Redis │
│ │ commands/responses │ │ │TimescaleDB │ rate│
│ │ (filesystem) │ │ │ users / │ │ limit│
│ ▼ │ │ │ webhooks│ │ │
│ ┌──────────────────────────────────────┐ │ │ │ cloud- │ └──────┘
│ │ @CitadelAdmin DayZ Mod │ │ │ │ bans │
│ │ (EnScript) │ │ │ └─────────┘
│ └──────────────────────────────────────┘ │ │
│ │ │ ┌──────────────────────┐
│ ┌──────────────────────────────────────┐ │ │ │ Paddle.com │
│ │ Discord Bot (discord.js) │ │◀──┼───┤ (Merchant of Record)│
│ │ 3-layer security model │ │ │ │ webhook → /paddle │
│ └──────────────────────────────────────┘ │ │ └──────────────────────┘
└───────────────────────────────────────────┘ │
│
─────────────────────────────────────────────────┴───────────────────
The trust boundary is the public-internet line in the middle. Everything to the left runs on the customer's box and never sees the public internet directly except for the labeled flows. Everything to the right runs on Cloudflare-fronted Linux infrastructure managed by Citadel.
Communication Flow
Command Execution (e.g., "kick player")
- Dashboard sends
POST /api/actions/executeto Backend - Backend routes through the Provider System, selecting
InHouseProvider - InHouseProvider sends
POST http://sidecar:9100/api/commandsto Sidecar - Sidecar writes a JSON command file to
$profile:Citadel/commands/{id}.json - @CitadelAdmin mod picks up the file, executes the action, writes a response to
$profile:Citadel/responses/{id}.json - Sidecar watches for the response file, reads it, and returns the result via HTTP
- Backend relays the result back to the Dashboard via REST + Socket.IO
Player Data Flow
- @CitadelAdmin mod writes
$profile:Citadel/players.jsonevery few seconds - Sidecar watches the file with Chokidar and caches the latest state
- Backend polls
GET http://sidecar:9100/api/playerson an interval - Dashboard receives real-time updates via Socket.IO
Event Streaming
- @CitadelAdmin mod appends events (kills, connections, etc.) to
$profile:Citadel/events.jsonl - Sidecar tails the file and exposes events via
GET /api/events - Backend polls or streams events and broadcasts to connected clients
License activation (local → cloud)
- Citadel desktop app prompts for the customer's
citadels.ccemail + password. - Backend sends
POST https://api.citadels.cc/api/v1/license/activatewith{ email, password, machineId }. - api.citadels.cc verifies credentials (timing-safe bcrypt; per-(IP, username) fail2ban), looks up the user's two subscription statuses, and signs a short-lived RS256 JWT carrying
subscriptionStatus,cloudSubscriptionStatus, and a computedentitlements: ['citadel'] | ['citadel', 'cloud']array. - Desktop app caches the JWT locally and verifies its signature using an embedded RS256 public key — that's what enables the offline-grace window.
- Periodic refresh: the backend calls
GET /api/v1/license/verifyahead of token expiry. If the customer's subscription state changed (e.g. Cloud was canceled), the new token reflects it within hours.
The trust boundary here: the private key never leaves api.citadels.cc; the public key is embedded in the desktop binary.
Cloud Bans submit / sync (Cloud subscribers only)
- Local backend sends
POST https://api.citadels.cc/api/v1/cloud-bans/submitwith{ steamId, reasonCategory, notesLocal? }and the desktop's license JWT in theAuthorization: Bearerheader. - api.citadels.cc verifies the JWT, re-fetches the user's
cloudSubscriptionStatusfrom the database (refusing if not active — the JWT claim isn't trusted alone for paid features), runs the submission through the reputation engine, and inserts/updates the pool entry. - Subscribers' local installs poll
GET /api/v1/cloud-bans/sync?since=<cursor>periodically and merge the results into a local cache. - At player connect time, the backend can also do a just-in-time
GET /api/v1/cloud-bans/check?steamId=…for sub-second freshness.
Paddle billing webhook (cloud-only)
- Paddle.com delivers webhook events (
subscription.created,subscription.canceled,transaction.completed, etc.) tohttps://api.citadels.cc/webhooks/paddle. - api.citadels.cc verifies the Paddle signature on the raw body, claims the
eventIdfor idempotency, processes inside a database transaction, and stampsprocessedAtonly on success — so a transient blip during processing leaves the event reclaimable for Paddle's retry. - The handler routes by
priceIdto either Citadel or Cloud subscription columns on the user row. Webhooks for unknown priceIds are refused with no state writes.
Provider System
The provider system is the core abstraction for executing server actions. Each provider implements the same interface:
class BaseProvider {
async executeAction(serverId, actionType, params) { }
async getPlayers(serverId) { }
async getServerInfo(serverId) { }
}
Available providers:
| Provider | Transport | Use Case |
|---|---|---|
InHouseProvider | HTTP → Sidecar → File IPC | Full feature set — commands, players, events, vehicle management |
RCONProvider | BattlEye RCON protocol | Basic commands when Sidecar is not available |
Providers are configured per-server and can be stacked. The system tries each provider in priority order until one succeeds.
Data Storage
Citadel uses a JSON file-based data store (no database required):
data/
├── servers.json # Server profiles and configuration
├── users.json # User accounts and roles
├── bans.json # Global ban database (UUID IDs, synced to ban.txt)
├── audit.json # Action audit trail
├── webhooks.json # Webhook configurations
├── leaderboard.json # Player leaderboard cache
├── ip-bans.json # fail2ban escalating-ban state
├── lockouts.json # Per-(IP, username) login lockout counters
├── tokens-revoked.json # Revoked JWT IDs (logout, force-revoke)
├── discord-user-roles.json # Per-Discord-user → Citadel role mappings
├── .first-run-completed # Setup-wizard one-shot lock
├── .jwt-secret # Persisted JWT signing secret (auto-generated on first run)
└── setup_complete.json
This makes Citadel portable and easy to back up — just copy the data/ directory.
Data directory permissions and safety
Two safety behaviors apply to every write into data/:
- Symlink-write refusal. The data store refuses to write to a target whose resolved path is a symlink. This prevents an attacker (or a misbehaving backup tool) who can place a symlink at e.g.
data/users.jsonfrom redirecting writes into other parts of the filesystem. The check fires on every write — there is no opt-out. If you see EPERM-like errors from the data layer, check whether your backup tool replaced a real file with a symlink to its versioned copy. - 0o600 mode on POSIX for sensitive files. On Linux/macOS, the following files are written with
mode 0o600(read/write owner only):users.json,webhooks.json,audit.json,lockouts.json,ip-bans.json,tokens-revoked.json,.jwt-secret. Backups need to run as the same Unix user that owns the Citadel process, or they will see EPERM. On Windows the OS NTFS permissions inherited from the install directory apply instead — there's no Citadel-side mode tightening on that platform.
These are belt-and-suspenders alongside whatever filesystem permissions your operator has set on the install root. The point is to ensure that even if the install root is loose, the most sensitive JSONs aren't world-readable.