- Erlang 75.3%
- Python 8.6%
- CSS 6.8%
- Go Template 4.6%
- JavaScript 2.9%
- Other 1.8%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| .github/workflows | ||
| apps | ||
| config | ||
| scripts | ||
| systemd | ||
| .gitignore | ||
| README.md | ||
| rebar.config | ||
| rebar.lock | ||
dayzbot
A DayZ server browser: it polls game servers over the A2S (Source query) protocol, stores their state in PostgreSQL, and serves a server-rendered, htmx-driven web UI with live updates over Server-Sent Events.
Built on Erlang/OTP, it pairs a server-side-rendered UI with PostgreSQL
(via pgo) and htmx, and is structured so the polling and web tiers can run
as two nodes in an Erlang cluster with no code changes.
Architecture
Three OTP applications under a rebar3 umbrella:
| App | Responsibility |
|---|---|
dayzbot_core |
PostgreSQL access (pgo), shared types/records, and the cluster-wide pg event bus (dayzbot_event). |
dayzbot_poller |
A2S polling and refresh scheduling. UDP transport sits behind the dayzbot_a2s behaviour so it is fully testable. |
dayzbot_web |
Cowboy listener, htmx server-rendered pages, and an SSE endpoint fed by the event bus. |
Views and assets
HTML lives in Mustache templates under
apps/dayzbot_web/priv/templates (*.tpl), rendered by
erlstache. dayzbot_templates
compiles every template once at startup and caches them (with each template
also registered as a partial, so {{>_head}}/{{>_csrf}} work) in a
persistent_term entry. dayzbot_render is a thin view layer: it shapes domain
records into template variables and renders the templates. Because Mustache is
logic-less, every conditional is precomputed in Erlang as a boolean
(logged_in, has_servers, has_map, has_mods, has_workshop) rather than
expressed in the template — this also keeps the logged_in gate correct inside
the {{#servers}} loop, where a {{#user}} section would otherwise shadow the
per-row context. erlstache HTML-escapes {{ }} output, so untrusted values
(server names, mods, usernames) are escaped by the engine, not in Erlang.
Client-side JavaScript is served by cowboy_static at
/static/...: js/app.js (filter minimize toggle, copy-to-clipboard buttons,
expandable-row persistence across htmx swaps, click-to-sort column headers) and
js/map.js (the Leaflet pin map + sidebar pin list).
Styling is built on Pico CSS as the base, plus app.css
and map.css under priv/static/css/. The markup is an app header with account
nav, an add-server form, a filter-panel with segmented-control radios and a
minimize toggle, the server list as expandable <details> cards with sortable
column headers (status, name, map, players — click to toggle asc/desc), admin
row actions and a copy button on the address, a detail-grid, an
admin-user-list, and the map layout with a pin sidebar. Fields the backend
doesn't track (official flag, time-of-day) render as —. Not yet implemented
(they need backend data/features not in place): the map's tiled background and
POI labels (need per-map tile + POI data), pin notes / the edit modal, and the
per-row SSE update animation (the live refresh reloads the whole list via the
htmx SSE extension).
Cross-tier communication (the seam)
The two cross-tier interactions never call each other directly — they go through the BEAM, so they work identically on one node or two:
- Poller → Web (live updates): the poller calls
dayzbot_event:publish(server_updated, …); SSE handlers on any node are subscribed viapgand receive it.pggroup membership is cluster-wide. - Web → Poller (force refresh): the web tier calls
dayzbot_poller_client:refresh(ServerId)(a core module, so web doesn't depend on the poller app) — a cast routed to the poller'sglobal-registered process wherever it runs.
Two-node deployment
Two relx releases hold different app sets but share a cookie:
- web node (
dayzbot_web_node):dayzbot_core+dayzbot_web - poller node (
dayzbot_poller_node):dayzbot_core+dayzbot_poller
Each node lists the other in its peers config; dayzbot_cluster (re)connects
them, so the pg event bus meshes regardless of boot order. Build and deploy:
rebar3 as web release -n dayzbot_web_node # -> _build/web/rel/dayzbot_web_node
rebar3 as poller release -n dayzbot_poller_node # -> _build/poller/rel/dayzbot_poller_node
Node names/cookie live in config/{web,poller}.vm.args, DB + peers in
config/{web,poller}.sys.config. Systemd units are in systemd/ — including
dayzbot.service for the plain single-node release (rebar3 as prod release,
deployed to /opt/dayzbot). dayzbot_cluster_SUITE
proves both cross-node event delivery and the connector, using local peer nodes.
Prerequisites
- Erlang/OTP 29, rebar3 (this repo was built with OTP 29.0.2 / rebar3 3.27.0)
- PostgreSQL (tested against 18)
Database setup
CREATE ROLE dayzbot LOGIN PASSWORD 'dayzbot' CREATEDB;
CREATE DATABASE dayzbot OWNER dayzbot; -- runtime
CREATE DATABASE dayzbot_test OWNER dayzbot; -- tests
Connection settings live in config/sys.config (runtime) and default to the
above. Tests read standard PG* environment variables, falling back to the
same defaults. The schema is created on boot by dayzbot_servers:migrate/0.
Build & run
rebar3 compile
rebar3 shell # starts all three apps with config/sys.config
Then open http://localhost:8080.
Tests & quality gate
rebar3 eunit # pure unit tests + PropEr properties (parser, renderer)
rebar3 ct # integration: DB, poller, web (gun), and multi-node cluster
rebar3 dialyzer # success-typing / spec checking
rebar3 xref # undefined/unused call analysis
rebar3 cover # coverage report (after eunit/ct)
- EUnit — pure functions: A2S parsing (+ PropEr round-trip and never-crash properties) and HTML rendering (+ escaping).
- Common Test
dayzbot_db_SUITE— data access against a real PostgreSQL.dayzbot_poller_SUITE— drives the scheduler with a fake A2S transport; asserts DB updates and published events.dayzbot_a2s_udp_SUITE— real UDP query against a local responder, including the challenge handshake and multi-packet reassembly.dayzbot_web_SUITE— HTTP + SSE via thegunclient.dayzbot_auth_SUITE— users and sessions against PostgreSQL.dayzbot_auth_web_SUITE— login/logout, cookies, and admin authorization.dayzbot_admin_web_SUITE— super_admin user-administration UI + authz.dayzbot_mods_SUITE/dayzbot_pins_SUITE— mod and pin storage.dayzbot_pins_web_SUITE— pin JSON API (public read, auth'd mutations).dayzbot_cluster_SUITE— cross-nodepgpublish/subscribe + peer connector.
Everything is -spec'd and Dialyzer-clean; warnings_as_errors is on.
Documentation
Modules and functions are documented with the OTP -moduledoc/-doc
attributes (Markdown). Generate browsable HTML with
ex_doc:
rebar3 ex_doc # one doc set per app under apps/<app>/doc/
Status
Implemented:
- List / poll / live-update servers (htmx + SSE)
- Server listing filters: status, min players, map (substring match with discovered-map suggestions), perspective, name search (parameterised SQL; live updates preserve active filters)
- Server listing sorting: click status / name / map / players headers to
toggle asc/desc (whitelisted
ORDER BY; held in hidden form fields so it composes with filters and survives live updates) - Per-server detail pages at
/servers/:id - Admin auth + sessions: DB-backed sessions, PBKDF2-HMAC-SHA256 password
hashing,
admin/super_adminroles. Admin-gated actions: add / delete servers and force-refresh. Cookies are HttpOnly + SameSite=Lax (setsecure_cookiesenv behind HTTPS). CSRF protection via a per-session synchronizer token (meta tag + hidden_csrfform fields for HTML posts,x-csrf-tokenheader for the JSON pin API; login is exempt). Expired sessions are swept by a periodic collector (dayzbot_session_gc). - Mod lists + Steam Workshop links: the poller fetches mods via A2S_RULES
and stores them per server; the detail page links each mod to its workshop
page when a workshop id is known. Split (multi-packet) A2S_RULES responses
are reassembled. (The exact DayZ rules→mods mapping is a documented
best-effort in
dayzbot_a2s_parser; bzip2-compressed split responses are rejected rather than decoded.) - Collaborative pin map: a Leaflet map per server (
/servers/:id/map) backed by a JSON pin API (/servers/:id/pins). Any logged-in user can drop, edit, and delete annotations; pins are soft-deleted (kept for audit). - User administration (super_admin only) at
/admin/users: list, create (with role), reset password, and delete users — with a guard that refuses to remove the last super_admin. - Two-node packaging: separate
dayzbot_web_node/dayzbot_poller_noderelx releases,dayzbot_clusterpeer auto-connect, and systemd units (see Two-node deployment).
Not yet implemented:
Security notes: CSRF tokens, SameSite=Lax + HttpOnly cookies (enable
secure_cookies behind HTTPS), PBKDF2 password hashing, and periodic session
expiry are all in place. bzip2-compressed split A2S_RULES responses are
rejected rather than decoded (uncommon for DayZ).
Map tiles (topographic + satellite)
The pin map renders real map layers when tiles for the server's world exist
under apps/dayzbot_web/priv/static/maps/<world>/. Extract both layers from
your DayZ installation with:
scripts/export-map path/to/worlds_chernarusplus.pbo \
path/to/worlds_chernarusplus_data.pbo
The world PBO (.wrp) yields the topographic "Map" layer — hillshade,
contour lines, sea, forests (tree-object density), roads (the terrain's road
network) and buildings, all rendered from the raw OPRW terrain data. The
data PBO yields the "Satellite" layer from the terrain's PAA satellite
grid. Either input alone works; with both, the map page gets a layer
switcher (topographic by default). Everything is decoded in the tool itself
(python3 + numpy; armake2 only unpacks PBOs, ImageMagick only encodes
tiles).
Pins on tiled maps are stored in DayZ world coordinates (meters, origin at
the south-west corner). The world size is read from the wrp; for a
satellite-only export that isn't 1 px/m, pass --world-size. Without tiles
the pin map falls back to a blank coordinate plane. Extracted content is
Bohemia Interactive's — keep it to your own deployment (the directory is
gitignored).
For deployments, set the maps_dir env of dayzbot_web (see
config/sys.config) to a directory outside the release, e.g.
/var/lib/dayzbot/maps, and put each map's tile directory there. Tiles then
survive release upgrades, and the release itself stays small — a release
built from a clean checkout contains no tiles (they are gitignored), so
without maps_dir you would have to copy tiles into the deployed release's
lib/dayzbot_web-*/priv/static/maps/ after every upgrade.
Bootstrapping an admin
There is no self-service signup. Create the first super_admin with the
bootstrap script (it starts dayzbot_core, which applies the schema, so it
works on a fresh database):
rebar3 compile
scripts/create-superadmin # config/sys.config by default
scripts/create-superadmin config/web.sys.config
It prompts for a username and password (asked twice, minimum 8 characters)
and refuses to run once a super_admin exists — further accounts are
managed in the web UI at /admin/users. Note that the password prompt is
echoed (escripts cannot reliably turn off terminal echo), so run it on a
trusted terminal.