No description
  • Erlang 75.3%
  • Python 8.6%
  • CSS 6.8%
  • Go Template 4.6%
  • JavaScript 2.9%
  • Other 1.8%
Find a file
dxtr 8f7cff7620
All checks were successful
CI / test (27) (push) Successful in 9m54s
CI / test (28) (push) Successful in 10m11s
CI / test (29) (push) Successful in 10m17s
Bump version to 0.2.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:26:24 +02:00
.forgejo/workflows CI: bump the Forgejo postgres service to 18 2026-07-02 21:34:15 +02:00
.github/workflows Scaffold dayzbot Erlang/OTP port 2026-06-16 14:10:59 +02:00
apps Bump version to 0.2.0 2026-07-03 22:26:24 +02:00
config config: document listen_ip in the single-node sys.config 2026-07-03 01:59:45 +02:00
scripts export-map: encode topo tiles as lossless WebP 2026-07-03 01:06:47 +02:00
systemd systemd: add a unit for the single-node release 2026-07-03 01:35:07 +02:00
.gitignore Add a satellite-map extractor and render tiles behind the pin map 2026-07-02 22:23:00 +02:00
README.md Filters: match map as a substring and suggest discovered maps 2026-07-03 15:33:34 +02:00
rebar.config Bump version to 0.2.0 2026-07-03 22:26:24 +02:00
rebar.lock Discover server names from A2S; externalize schema; swap pgo for epgsql/pooler 2026-06-27 02:22:16 +02:00

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 via pg and receive it. pg group 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's global-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 the gun client.
    • 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-node pg publish/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_admin roles. Admin-gated actions: add / delete servers and force-refresh. Cookies are HttpOnly + SameSite=Lax (set secure_cookies env behind HTTPS). CSRF protection via a per-session synchronizer token (meta tag + hidden _csrf form fields for HTML posts, x-csrf-token header 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_node relx releases, dayzbot_cluster peer 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.