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
- Overlay Types: Two types - system (created by scripts, shared) and custom (user-uploaded)
- Template vs Instance: ServerTemplate is reusable; Server is ephemeral instance
- Systemd Integration: User-level services (
~/.config/systemd/user/) for steam user - Fuse-Overlayfs: Userspace overlayfs to avoid root permissions
- Health Checks: Systemd status + RCON UDP queries (not TCP)
- Audit Logging: Simple Activity model (no ORM overhead)
- 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, startcleanup(server)→ Unmount overlayfs, remove directories- Uses
fuse-overlayfsfor userspace mounting (no root required)
SystemdManager
create_unit_file(server)→ Writes systemd unit to~/.config/systemd/user/left4dead2-{id}.serviceenable_and_start(server)→ Runssystemctl --user enableandstartstart,stop,restart,status,cleanupmethods 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 pagedashboard/index.html.slim— Overview (templates, servers, activities)server_templates/*— Template CRUDservers/*— 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 steamrequired 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
- Single-machine only — Servers must run on same machine as Rails
- No external API — Web UI only (REST/GraphQL possible)
- No state backup — Server instances are ephemeral
- Simple health checks — No game state parsing or RCON console output capture
- Manual setup scripts — systemd helper scripts must be created manually
- Single L4D2 install — Assumes one base installation at /opt/l4d2/installation/
Potential Enhancements
- State snapshots — Backup/restore upper layer state per server
- RCON console — Execute commands and capture output via web UI
- Multi-machine — SSH support for spawning servers on other machines
- Overlay versioning — Track overlay changes over time
- Server templates as code — YAML/JSON import/export for IaC
- Resource limits — Per-server memory/CPU constraints via systemd
- Game state queries — Parse server info command responses for UI display
- Web API — REST endpoints for automation/CI integration
- Statistics — Track server uptime, player counts, etc.
- 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
- Authorization — All controllers verify user ownership before allowing modifications
- Port collision — Port uniqueness constraint prevents duplicate ports
- Shell injection — All user inputs sanitized (no shell commands built from input)
- Credentials — RCON passwords randomly generated, stored in DB
- Audit trail — Activity logging tracks all user actions
- 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.