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

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.