l4d.tools/ARCHITECTURE.md
2026-01-18 17:42:32 +01:00

17 KiB

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

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

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.