Documentation menu

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")

  1. Dashboard sends POST /api/actions/execute to Backend
  2. Backend routes through the Provider System, selecting InHouseProvider
  3. InHouseProvider sends POST http://sidecar:9100/api/commands to Sidecar
  4. Sidecar writes a JSON command file to $profile:Citadel/commands/{id}.json
  5. @CitadelAdmin mod picks up the file, executes the action, writes a response to $profile:Citadel/responses/{id}.json
  6. Sidecar watches for the response file, reads it, and returns the result via HTTP
  7. Backend relays the result back to the Dashboard via REST + Socket.IO

Player Data Flow

  1. @CitadelAdmin mod writes $profile:Citadel/players.json every few seconds
  2. Sidecar watches the file with Chokidar and caches the latest state
  3. Backend polls GET http://sidecar:9100/api/players on an interval
  4. Dashboard receives real-time updates via Socket.IO

Event Streaming

  1. @CitadelAdmin mod appends events (kills, connections, etc.) to $profile:Citadel/events.jsonl
  2. Sidecar tails the file and exposes events via GET /api/events
  3. Backend polls or streams events and broadcasts to connected clients

License activation (local → cloud)

  1. Citadel desktop app prompts for the customer's citadels.cc email + password.
  2. Backend sends POST https://api.citadels.cc/api/v1/license/activate with { email, password, machineId }.
  3. 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 computed entitlements: ['citadel'] | ['citadel', 'cloud'] array.
  4. Desktop app caches the JWT locally and verifies its signature using an embedded RS256 public key — that's what enables the offline-grace window.
  5. Periodic refresh: the backend calls GET /api/v1/license/verify ahead 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)

  1. Local backend sends POST https://api.citadels.cc/api/v1/cloud-bans/submit with { steamId, reasonCategory, notesLocal? } and the desktop's license JWT in the Authorization: Bearer header.
  2. api.citadels.cc verifies the JWT, re-fetches the user's cloudSubscriptionStatus from 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.
  3. Subscribers' local installs poll GET /api/v1/cloud-bans/sync?since=<cursor> periodically and merge the results into a local cache.
  4. 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)

  1. Paddle.com delivers webhook events (subscription.created, subscription.canceled, transaction.completed, etc.) to https://api.citadels.cc/webhooks/paddle.
  2. api.citadels.cc verifies the Paddle signature on the raw body, claims the eventId for idempotency, processes inside a database transaction, and stamps processedAt only on success — so a transient blip during processing leaves the event reclaimable for Paddle's retry.
  3. The handler routes by priceId to 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:

ProviderTransportUse Case
InHouseProviderHTTP → Sidecar → File IPCFull feature set — commands, players, events, vehicle management
RCONProviderBattlEye RCON protocolBasic 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.json from 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.