499 lines
17 KiB
Markdown
499 lines
17 KiB
Markdown
# 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.
|