# L4D Tools - Architecture & Implementation Plan **Date**: January 18, 2026 **Status**: MVP Implementation Complete **Framework**: Rails 8.1.2 with SQLite + Hotwire + Solid Queue --- ## Executive Summary L4D Tools is a Rails web application for managing Left4Dead 2 game servers. Users authenticate via Steam, create reusable server templates (with overlays, config options, and startup parameters), and spawn/manage server instances via a modern web UI. Servers run independently as user-level systemd services, managed entirely by the Rails application. --- ## Architecture Overview ### Domain Model ``` User (Steam ID + username) ├── ServerTemplate (name) │ ├── TemplateOverlay (overlay + position) │ │ └── Overlay (name, type: system|custom, path) │ ├── ConfigOption (config_key, config_value) │ └── StartupParam (param_key, param_value) ├── Server (name, port, status, template_id) │ └── (references ServerTemplate) └── Activity (action, resource_type, resource_id, details) ``` ### Key Design Decisions 1. **Overlay Types**: Two types - system (created by scripts, shared) and custom (user-uploaded) 2. **Template vs Instance**: ServerTemplate is reusable; Server is ephemeral instance 3. **Systemd Integration**: User-level services (`~/.config/systemd/user/`) for steam user 4. **Fuse-Overlayfs**: Userspace overlayfs to avoid root permissions 5. **Health Checks**: Systemd status + RCON UDP queries (not TCP) 6. **Audit Logging**: Simple Activity model (no ORM overhead) 7. **No External Dependencies**: No Redis, no Sidekiq—Solid Queue database-backed --- ## User Workflow ### 1. Authentication ``` User visits home page → Clicks "Login with Steam" → Redirected to Steam auth → Rails receives auth_hash → User created/found via steam_id → Session set, redirected to dashboard ``` ### 2. Create Server Template ``` Dashboard → Click "New Template" → Enter template name → Save → Now user can add overlays, configs, params ``` ### 3. Configure Template ``` Template Show Page: Add Overlays: → Select from available overlays (system + user's custom) → Position auto-assigned (0, 1, 2, ...) → First overlay = highest priority (mounted last, visible first) Add Config Options: → Enter key (e.g., "sv_pure") and value (e.g., "2") → Renders as: sv_pure "2" in server.cfg Add Startup Parameters: → Enter param key (e.g., "+map") and value (e.g., "c1m1_hotel") → Renders as: +map c1m1_hotel in launch command ``` ### 4. Spawn Server from Template ``` Template Show → Click "Spawn Server" → Enter server name and port → (Optional) Override startup parameters → Click "Spawn Server" Background (SpawnServerJob): → Create /opt/l4d2/servers/{server_id}/ with subdirs → Generate server.cfg from template ConfigOptions → Mount overlayfs: overlays stacked + base installation → Create systemd unit file → Enable and start service → Poll health (systemd + RCON) → Update status to :running on success ``` ### 5. Manage Server ``` Server Show Page: Status Display: → Shows current status (stopped/starting/running/failed) → Last health check timestamp Controls (context-dependent): → If stopped: Show "Start" button → If running: Show "Stop" and "Restart" buttons → Always: "Delete Server" button Live Logs: → ActionCable LogChannel subscribed → Streams journalctl output in real-time → Auto-scrolling log viewer Activities: → Recent activities sidebar → Who did what and when ``` ### 6. Delete Server ``` User clicks "Delete Server" → Systemd stops service → Unmounts overlayfs → Removes /opt/l4d2/servers/{server_id}/ → Deletes systemd unit file → Server record deleted from DB ``` --- ## Technical Implementation ### Controllers & Routes ``` GET / → PagesController#home (skip auth) GET /auth/steam → SessionsController#steam_auth (skip auth) GET /auth/steam/callback → SessionsController#steam_callback (skip auth) GET /logout → SessionsController#logout (skip auth) GET /dashboard → DashboardController#index GET /server_templates → ServerTemplatesController#index GET /server_templates/:id → ServerTemplatesController#show GET /server_templates/new → ServerTemplatesController#new POST /server_templates → ServerTemplatesController#create GET /server_templates/:id/edit → ServerTemplatesController#edit PATCH /server_templates/:id → ServerTemplatesController#update DELETE /server_templates/:id → ServerTemplatesController#destroy POST /server_templates/:st_id/overlays/:id → OverlaysController#create DELETE /server_templates/:st_id/overlays/:id → OverlaysController#destroy POST /server_templates/:st_id/config_options → ConfigOptionsController#create DELETE /server_templates/:st_id/config_options/:id → ConfigOptionsController#destroy POST /server_templates/:st_id/startup_params → StartupParamsController#create DELETE /server_templates/:st_id/startup_params/:id → StartupParamsController#destroy GET /servers → ServersController#index GET /servers/:id → ServersController#show GET /servers/new → ServersController#new POST /servers → ServersController#create POST /servers/:id/start → ServersController#start POST /servers/:id/stop → ServersController#stop POST /servers/:id/restart → ServersController#restart DELETE /servers/:id → ServersController#destroy GET /overlays → OverlaysController#index WS /cable → ActionCable (LogChannel) ``` ### Models & Validations **User** - `steam_id` (string, NOT NULL, unique) - `steam_username` (string, NOT NULL) - Has many: server_templates, overlays, servers, activities **Overlay** - `user_id` (references, nullable—system overlays have NULL user_id) - `name` (string, NOT NULL) - `overlay_type` (string, NOT NULL, default: "system", enum: ["system", "custom"]) - `path` (string, NOT NULL) - `description` (text) - Validates: name uniqueness per user, overlay_type inclusion - Scopes: system_overlays, custom_overlays, for_user **ServerTemplate** - `user_id` (references, NOT NULL) - `name` (string, NOT NULL) - Validates: name uniqueness per user - Has many: template_overlays → overlays, config_options, startup_params, servers **TemplateOverlay** (join table) - `server_template_id` (references, NOT NULL) - `overlay_id` (references, NOT NULL) - `position` (integer, NOT NULL) - Validates: uniqueness of (server_template_id, overlay_id) and (server_template_id, position) - Ordered by position ascending **ConfigOption** - `server_template_id` (references, NOT NULL) - `config_key` (string, NOT NULL) - `config_value` (string, NOT NULL) - Validates: key uniqueness per template **StartupParam** - `server_template_id` (references, NOT NULL) - `param_key` (string, NOT NULL) - `param_value` (string, NOT NULL) - Validates: key uniqueness per template **Server** - `user_id` (references, NOT NULL) - `server_template_id` (references, NOT NULL) - `name` (string, NOT NULL) - `port` (integer, NOT NULL) - `status` (integer, NOT NULL, default: 0, enum: [stopped:0, starting:1, running:2, failed:3]) - `unit_file_path` (string) - `rcon_password` (string) - `last_health_check_at` (datetime) - Validates: name uniqueness per user, port global uniqueness - After destroy: calls cleanup (systemd + filesystem) **Activity** - `user_id` (references, NOT NULL) - `action` (string, NOT NULL) - `resource_type` (string, NOT NULL) - `resource_id` (integer) - `details` (text, JSON) - Indexed on (user_id, created_at) - Class method: `log(user, action, resource_type, resource_id, details_hash)` ### Libraries (`lib/l4d_server/`) **ConfigGenerator** - `generate(server)` → Creates `/opt/l4d2/servers/{server_id}/server.cfg` - Iterates template.config_options, renders as `key "value"` format **Launcher** - `spawn(server)` → Setup dirs, generate config, mount overlayfs, create systemd unit, start - `cleanup(server)` → Unmount overlayfs, remove directories - Uses `fuse-overlayfs` for userspace mounting (no root required) **SystemdManager** - `create_unit_file(server)` → Writes systemd unit to `~/.config/systemd/user/left4dead2-{id}.service` - `enable_and_start(server)` → Runs `systemctl --user enable` and `start` - `start`, `stop`, `restart`, `status`, `cleanup` methods wrap systemctl commands - Unit file references: `/opt/l4d2/bin/start-server {server_id}` and `/opt/l4d2/bin/stop-server {server_id}` **HealthChecker** - `check(server)` → Returns :running, :stopped, :failed, :unknown - Checks systemd status first, then RCON UDP ping if active - Simple UDP packet to server port with RCON auth ### Background Jobs (Solid Queue) **SpawnServerJob** - Queued when user spawns server - Calls `L4dServer::Launcher.spawn(server)` - Generates RCON password - Logs activity on success/failure **StatusUpdateJob** - Recurring job (every 30 seconds) - Iterates all active servers - Calls `L4dServer::HealthChecker.check(server)` - Updates Server.status and last_health_check_at ### ActionCable **LogChannel** - Subscribed when viewing server detail page - Spawns background thread running: `journalctl --user -u left4dead2-{server_id} -f --since "10 minutes ago"` - Broadcasts each line to connected clients - Gracefully handles disconnects (kills thread on unsubscribe) ### Frontend (Slim + Hotwire) **Templating**: All views use Slim (not ERB) **Interactivity**: Hotwire (Turbo + Stimulus) for SPA-like experience **Styling**: Basic CSS in `app/assets/stylesheets/application.css` Key Views: - `pages/home.html.slim` — Login page - `dashboard/index.html.slim` — Overview (templates, servers, activities) - `server_templates/*` — Template CRUD - `servers/*` — Server CRUD with live logs --- ## Directory Structure ``` /opt/l4d2/ ├── installation/ # L4D2 base installation (lower layer, read-only) ├── overlays/ │ ├── sourcemod/ # System overlays (created by setup.sh) │ ├── metamod/ │ └── custom_{overlay_id}/ # User-uploaded custom overlays ├── servers/ # Active server instances │ ├── 1/ # Server with id=1 │ │ ├── server.cfg # Generated from ConfigOptions │ │ ├── upper/ # Overlayfs writable layer │ │ ├── work/ # Overlayfs work directory │ │ ├── merged/ # Overlayfs mount point (game filesystem) │ │ └── pid # Process ID file │ ├── 2/ │ └── ... ├── bin/ │ ├── start-server # Called by systemd ExecStart │ └── stop-server # Called by systemd ExecStop └── scripts/ # (Optional) Setup scripts └── overlays/ # Overlay build scripts ~/.config/systemd/user/ ├── left4dead2-1.service # Systemd unit for server 1 ├── left4dead2-2.service # Systemd unit for server 2 └── ... ``` --- ## Data Flow: Server Spawn ``` 1. User submits form (name, port) ↓ 2. ServersController#create - Creates Server record (status: :stopped) - Enqueues SpawnServerJob - Returns to UI ↓ 3. SpawnServerJob (async) - Setup: mkdir /opt/l4d2/servers/{id}/{upper,work,merged} - Config: Generate /opt/l4d2/servers/{id}/server.cfg - Mount: fuse-overlayfs -o lowerdir=overlay1:overlay2:base,upperdir=upper,workdir=work merged - Systemd: Write unit file to ~/.config/systemd/user/ - Start: systemctl --user enable && start left4dead2-{id}.service - Health: Poll systemd + RCON ↓ 4. StatusUpdateJob (every 30s) - Checks systemd is-active - Pings RCON if active - Updates Server.status and last_health_check_at ↓ 5. LogChannel (WebSocket) - User opens server detail page - Subscribes to LogChannel - Browser receives journalctl lines in real-time ``` --- ## Key Design Principles ### 1. Stateless Overlay Mounting - Overlays are mounted fresh each server spawn - No persistent state in overlays themselves - Upper layer writable directory stores server data ### 2. User-Level Systemd - No root access required from Rails - `loginctl enable-linger steam` required once - Units in `~/.config/systemd/user/` are steam user-owned ### 3. Asynchronous Spawn - Spawn operation in background job (can take time) - User sees "starting" status immediately - StatusUpdateJob polls and updates status ### 4. Simple Health Checking - Systemd tells us if process is alive - RCON ping tells us if server is responding - No game state parsing (simple MVP) ### 5. Activity Logging Without Overhead - Simple Activity model (no paper_trail gem) - Manual log calls in controllers - JSON stored in text field for flexibility ### 6. Overlay Ordering - Position integer (0, 1, 2, ...) - Position 0 = first overlay = highest priority = mounted last - Standard overlayfs semantics --- ## Setup & Deployment Requirements ### System Prerequisites - Linux (systemd required) - Ruby 4.0+ - SQLite 3.8.0+ - fuse-overlayfs installed - L4D2 base installation at `/opt/l4d2/installation/` - Steam user exists with home at `/opt/l4d2/` ### One-Time Setup ```bash loginctl enable-linger steam # Allow user-level systemd persistence mkdir -p /opt/l4d2/bin # Create wrapper script directory # Create start-server and stop-server scripts chmod +x /opt/l4d2/bin/* ``` ### Rails Setup ```bash bundle install bin/rails db:migrate # Set STEAM_API_KEY for production auth export STEAM_API_KEY="..." bin/dev # Start in development ``` ### Deployment (Kamal/Docker) - Docker image includes fuse-overlayfs - RAILS_MASTER_KEY env var required - Mount `/opt/l4d2/` as volume - Run as steam user (or handle permissions in entrypoint) --- ## Known Limitations & Future Work ### Current Limitations 1. **Single-machine only** — Servers must run on same machine as Rails 2. **No external API** — Web UI only (REST/GraphQL possible) 3. **No state backup** — Server instances are ephemeral 4. **Simple health checks** — No game state parsing or RCON console output capture 5. **Manual setup scripts** — systemd helper scripts must be created manually 6. **Single L4D2 install** — Assumes one base installation at /opt/l4d2/installation/ ### Potential Enhancements 1. **State snapshots** — Backup/restore upper layer state per server 2. **RCON console** — Execute commands and capture output via web UI 3. **Multi-machine** — SSH support for spawning servers on other machines 4. **Overlay versioning** — Track overlay changes over time 5. **Server templates as code** — YAML/JSON import/export for IaC 6. **Resource limits** — Per-server memory/CPU constraints via systemd 7. **Game state queries** — Parse server info command responses for UI display 8. **Web API** — REST endpoints for automation/CI integration 9. **Statistics** — Track server uptime, player counts, etc. 10. **Multi-game support** — Extend to other Source engine games --- ## Testing Strategy ### Unit Tests - Model validations (uniqueness, presence) - Scope tests (for_user, active, etc.) - Activity.log static method ### Controller Tests - Authorization checks (user can't access other users' servers) - CRUD operations - Failure cases (invalid inputs) ### Integration Tests - Full server spawn flow (create → job → systemd start) - Server start/stop/restart - Template creation with nested attributes ### Manual Testing - Steam login flow (requires valid Steam API key) - Spawn a server (requires L4D2 installation + overlays) - View live logs (requires journalctl access) --- ## Security Considerations 1. **Authorization** — All controllers verify user ownership before allowing modifications 2. **Port collision** — Port uniqueness constraint prevents duplicate ports 3. **Shell injection** — All user inputs sanitized (no shell commands built from input) 4. **Credentials** — RCON passwords randomly generated, stored in DB 5. **Audit trail** — Activity logging tracks all user actions 6. **Systemd security** — Services run as steam user (non-privileged) --- ## Performance Notes - **Database indexes** — Created on foreign keys, steam_id, ports, user combinations - **N+1 queries** — Controllers use `.includes()` to eager-load relationships - **Health checks** — 30-second interval reasonable for typical deployments - **Log streaming** — One journalctl process per client (simple, not optimized for 100+ concurrent users) --- ## Conclusion L4D Tools provides a clean, modern interface for managing L4D2 servers using Rails 8.1, Solid Queue, Hotwire, and standard Linux tools (systemd, overlayfs). The architecture separates concerns (Rails ↔ systemd ↔ overlayfs) and avoids external dependencies (Redis, Sidekiq) by leveraging Rails' built-in capabilities. The implementation is opinionated (Steam-only auth, user-level systemd, fuse-overlayfs) but flexible enough to extend with new features as needed.