Compare commits

...

22 commits

Author SHA1 Message Date
a6e0f83fbc
deploy mount data dir 2026-01-19 11:35:03 +01:00
48778fe928
fix spawn server 2026-01-19 11:30:54 +01:00
b0b81e024c
remove ecplicit load 2026-01-19 11:17:35 +01:00
26ffd27f7d
fix kamal deploy 2026-01-19 11:11:33 +01:00
024bbe2ec7
configurable paths 2026-01-18 19:24:39 +01:00
7c4177d749
nested css 2026-01-18 19:18:53 +01:00
3671636487
fix logout button 2026-01-18 19:16:56 +01:00
453a9137b9
fix links and server form 2026-01-18 19:15:05 +01:00
2afec412bb
more nested routes 2026-01-18 19:07:38 +01:00
6909497996
nested routes 2026-01-18 19:06:21 +01:00
154d2c48e8
everything as jobs 2026-01-18 19:00:07 +01:00
34be944b96
job logs 2026-01-18 18:50:31 +01:00
7022b48bda
add spawn start stop buttons 2026-01-18 18:43:37 +01:00
d3579504aa
server view fixes 2026-01-18 18:35:16 +01:00
a9a93d2657
config and params are text fields 2026-01-18 18:32:07 +01:00
9a65958d2d
options and params fixes 2026-01-18 18:24:14 +01:00
7ade38ecad
manage overlays 2026-01-18 18:16:01 +01:00
17fca8fae5
header 2026-01-18 18:01:58 +01:00
11d5baf130
fix spawn server form 2026-01-18 17:59:11 +01:00
3b82be0b7d
fix edit 2026-01-18 17:54:57 +01:00
35cb0e2ce8
steam login works 2026-01-18 17:49:53 +01:00
73e4e8a52f
init 2026-01-18 17:42:32 +01:00
76 changed files with 3042 additions and 24 deletions

View file

@ -174,6 +174,7 @@ bin/rails db:reset # Wipe dev DB and re-run schema + seeds
- **JS**: Controllers auto-discovered from `app/javascript/controllers/`; no bundling required
- **Images**: Reference via `image_tag "name"` (fingerprinted in production)
- **PWA**: Service worker template available in `app/views/pwa/` (commented out in routes)
- **Template Engine**: Use slim for views; prefer partials for reusable components
### Job Enqueue (Solid Queue)
```ruby

487
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,487 @@
# 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, config, startup_params)
│ ├── TemplateOverlay (overlay + position)
│ │ └── Overlay (name, type: system|custom, slug)
├── 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)
Edit Server Config:
→ Multi-line text area for server.cfg contents
→ Enter raw config format: sv_pure 2\nsv_maxplayers 4\n...
→ Saved directly as template.config text field
Edit Startup Parameters:
→ Multi-line text area for command line parameters
→ Enter raw format: +map c1m1_hotel\n+difficulty Hard\n...
→ Saved directly as template.startup_params text field
```
### 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
GET /overlays → OverlaysController#index
GET /overlays/new → OverlaysController#new
POST /overlays → OverlaysController#create
DELETE /overlays/:id → OverlaysController#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
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"])
- `slug` (string, NOT NULL—POSIX-safe directory name, auto-derived from name)
- `description` (text)
- Validates: name uniqueness per user, overlay_type inclusion, slug is single directory (no slashes)
- Before validation: normalizes slug to lowercase, replaces special chars with underscores
- Scopes: system_overlays, custom_overlays, for_user
**ServerTemplate**
- `user_id` (references, NOT NULL)
- `name` (string, NOT NULL)
- `config` (text, nullable—server.cfg contents)
- `startup_params` (text, nullable—command line parameters)
- Validates: name uniqueness per user
- Has many: template_overlays → overlays, 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
**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`
- Writes template.config text directly to server.cfg file
**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.

View file

@ -18,7 +18,13 @@ gem "stimulus-rails"
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
gem "bcrypt", "~> 3.1.7"
# OmniAuth strategy for Steam authentication
gem "omniauth-steam"
# Slim template engine
gem "slim-rails"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

View file

@ -79,6 +79,7 @@ GEM
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
bigdecimal (4.0.1)
bindex (0.8.1)
@ -125,6 +126,8 @@ GEM
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
hashie (5.1.0)
logger
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@ -174,6 +177,7 @@ GEM
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-imap (0.6.2)
date
net-protocol
@ -203,6 +207,19 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-openid (2.0.2)
omniauth (>= 1.1)
rack-openid (~> 1.4)
ruby-openid (~> 2.1, >= 2.1.8)
version_gem (~> 1.1, >= 1.1.8)
omniauth-steam (1.0.6)
multi_json
omniauth-openid
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.1)
@ -225,6 +242,13 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack-openid (1.4.2)
rack (>= 1.1.0)
ruby-openid (>= 2.1.8)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@ -300,6 +324,7 @@ GEM
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-openid (2.9.2)
ruby-progressbar (1.13.0)
ruby-vips (2.3.0)
ffi (~> 1.12)
@ -312,6 +337,13 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
slim (5.2.1)
temple (~> 0.10.0)
tilt (>= 2.1.0)
slim-rails (4.0.0)
actionpack (>= 3.1)
railties (>= 3.1)
slim (>= 3.0, < 6.0, != 5.0.0)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@ -345,11 +377,13 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
temple (0.10.4)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-linux)
tilt (2.7.0)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.21)
@ -362,6 +396,7 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
version_gem (1.1.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
@ -388,6 +423,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
@ -397,11 +433,13 @@ DEPENDENCIES
importmap-rails
jbuilder
kamal
omniauth-steam
propshaft
puma (>= 5.0)
rails (~> 8.1.2)
rubocop-rails-omakase
selenium-webdriver
slim-rails
solid_cable
solid_cache
solid_queue
@ -428,6 +466,7 @@ CHECKSUMS
addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
@ -456,6 +495,7 @@ CHECKSUMS
ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
@ -475,6 +515,7 @@ CHECKSUMS
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
@ -490,6 +531,9 @@ CHECKSUMS
nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c
nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4
omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c
omniauth-openid (2.0.2) sha256=dabfe9f319ec2b23044d7aac4a7d9e55b6b82201dbd015a8bc83657db316dec1
omniauth-steam (1.0.6) sha256=30aec81aad72d739887de50822d58685650616baf8e78bb418004c710e8d98e2
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
@ -503,6 +547,8 @@ CHECKSUMS
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
rack-openid (1.4.2) sha256=8cd2305e738463a7da98791f9ac4df4cf3f6ed27908d982350430694ac2fe869
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
@ -521,11 +567,14 @@ CHECKSUMS
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-openid (2.9.2) sha256=7f8e39426b9833172a79f4696bc63b66b0d2c766971919a69b3db5be400d17a4
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826
slim (5.2.1) sha256=72351dff7e2ff20e2d5c393cfc385bb9142cef5db059141628fd7163ac3c13e7
slim-rails (4.0.0) sha256=2cfee5c4ba00d4d935676db1e74a05dbaf9dd6f789903a1006e3bb687bbe36e2
solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428
@ -539,11 +588,13 @@ CHECKSUMS
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
thruster (0.1.17) sha256=6f3f1de43e22f0162d81cbc363f45ee42a1b8460213856c1a899cbf0d3297235
thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba
thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7
@ -552,6 +603,7 @@ CHECKSUMS
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4
web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962

254
README.md
View file

@ -1,24 +1,252 @@
# README
# L4D Tools
This README would normally document whatever steps are necessary to get the
application up and running.
A Rails 8.1 application for managing Left4Dead 2 game servers with a web interface.
Things you may want to cover:
## Features
* Ruby version
- **Steam Authentication**: Login via Steam OpenID
- **Server Templates**: Define reusable server configurations with overlays, config options, and startup parameters
- **Overlays Management**: Support for both system and custom overlays with file uploads
- **Server Lifecycle**: Spawn, start, stop, restart, and delete L4D2 servers
- **Live Logs**: Stream server logs in real-time via WebSocket (ActionCable)
- **Health Checks**: Periodic monitoring of server status via systemd and RCON
- **Activity Logging**: Track user actions (server creation, deletion, start/stop)
- **User-Level Systemd**: Servers run as systemd user units (steam user)
* System dependencies
## System Requirements
* Configuration
- Ruby 4.0+
- Rails 8.1+
- SQLite 3.8.0+
- Linux (for L4D2 server and systemd support)
- Left4Dead 2 base installation at `/opt/l4d2/installation/`
- Overlays in `/opt/l4d2/overlays/`
- `fuse-overlayfs` installed and available
* Database creation
## Setup Instructions
* Database initialization
### 1. Install Dependencies
* How to run the test suite
```bash
bundle install
```
* Services (job queues, cache servers, search engines, etc.)
### 2. Database Setup
* Deployment instructions
```bash
bin/rails db:migrate
```
* ...
### 3. Enable Systemd User Services (One-Time Setup)
Allow the `steam` user to run persistent systemd services:
```bash
loginctl enable-linger steam
```
Without this, user-level systemd services will stop when the user logs out.
### 4. Create Systemd Helper Scripts
Create `/opt/l4d2/bin/` directory and add helper scripts for server lifecycle:
```bash
mkdir -p /opt/l4d2/bin
```
Create `/opt/l4d2/bin/start-server` (referenced by systemd units):
```bash
#!/bin/bash
SERVER_ID=$1
exec /opt/l4d2/servers/${SERVER_ID}/merged/srcds_run -norestart -pidfile /opt/l4d2/servers/${SERVER_ID}/pid -game left4dead2 -ip 0.0.0.0 -port $(grep "^Port:" /opt/l4d2/servers/${SERVER_ID}/server.cfg | cut -d' ' -f2) +hostname "Server" +map c1m1_hotel
```
Create `/opt/l4d2/bin/stop-server`:
```bash
#!/bin/bash
SERVER_ID=$1
# Systemd handles killing the process
exit 0
```
Make scripts executable:
```bash
chmod +x /opt/l4d2/bin/start-server /opt/l4d2/bin/stop-server
```
### 5. Configure Steam API Key (Optional for Development)
For Steam authentication in production, set the Steam API key:
```bash
export STEAM_API_KEY="your-steam-api-key"
```
In development, a test key is used by default.
### 6. Run the Application
Development:
```bash
bin/dev
```
This starts:
- Rails server on `http://localhost:3000`
- Solid Queue job processor (in Puma)
- Asset pipeline watcher
## Architecture
### Domain Models
- **User** — Steam-authenticated users
- **Overlay** — Filesystem directories layered via overlayfs (system or custom)
- **ServerTemplate** — Reusable server configuration (overlays + config + params)
- **Server** — Active instance of a ServerTemplate
- **ConfigOption** — Key-value server configuration (server.cfg)
- **StartupParam** — Command-line arguments passed to srcds_run
- **Activity** — Audit log of user actions
### Workflow
1. **Create ServerTemplate**
- Select overlays (in priority order)
- Define config options (key=value for server.cfg)
- Define startup parameters
2. **Spawn Server from Template**
- User specifies: server name, port, optional parameter overrides
- Rails generates `/opt/l4d2/servers/{id}/server.cfg`
- Mounts overlayfs stack using fuse-overlayfs
- Creates systemd user unit at `~/.config/systemd/user/left4dead2-{id}.service`
- Starts service via `systemctl --user start`
3. **Monitor Server**
- StatusUpdateJob polls every 30 seconds
- Checks systemd status + RCON health
- Updates Server.status and last_health_check_at
4. **Manage Server**
- Start/Stop/Restart via UI buttons
- Stream live logs via ActionCable + journalctl
- Delete server (stops service, removes config, unmounts overlayfs)
### Directory Structure
```
/opt/l4d2/
├── installation/ # L4D2 base game files (read-only lower layer)
├── overlays/ # Overlay directories
│ ├── system_overlay_1/ # System overlays (created by setup.sh)
│ ├── custom_{id}/ # Custom overlays (user-created)
├── servers/ # Active server instances
│ └── {server_id}/
│ ├── server.cfg # Generated config
│ ├── upper/ # Overlayfs writable layer
│ ├── work/ # Overlayfs work directory
│ ├── merged/ # Overlayfs mount point (active game filesystem)
│ └── pid # Process ID file
├── configs/ # (Legacy - configs now live in /opt/l4d2/servers/{id}/)
└── bin/
├── start-server # Systemd ExecStart script
└── stop-server # Systemd ExecStop script
```
### Key Libraries
- **L4dServer::ConfigGenerator** — Renders server.cfg from ConfigOptions
- **L4dServer::Launcher** — Mounts overlayfs, generates systemd unit
- **L4dServer::SystemdManager** — Wraps systemctl commands
- **L4dServer::HealthChecker** — Monitors server health via systemd + RCON
### Background Jobs
- **SpawnServerJob** — Executed when user spawns server (mounts, starts)
- **StatusUpdateJob** — Recurring job (every 30s) polling server status
### WebSocket
- **LogChannel** — Streams `journalctl` output to connected clients in real-time
## Development Notes
### Running Tests
```bash
bin/rails test
```
### Console Access
```bash
bin/rails console
```
### Viewing Logs
```bash
journalctl --user -u left4dead2-{server_id}.service -f
```
### Database Migrations
```bash
bin/rails db:migrate # Apply pending migrations
bin/rails db:rollback # Revert last migration
```
## Configuration
### Environment Variables
- `STEAM_API_KEY` — Steam OpenID API key (optional, test key used in dev)
- `RAILS_ENV` — Rails environment (development/production)
- `RAILS_MASTER_KEY` — Encryption key for credentials (production only)
### Rails Credentials
```bash
bin/rails credentials:edit
```
Store sensitive data here (encrypted in Git).
## Deployment (Kamal)
Docker deployment via Kamal:
```bash
kamal deploy
```
See `config/deploy.yml` for Kamal configuration.
**Important**: Ensure `RAILS_MASTER_KEY` is set in production environment.
## Known Limitations / Future Work
1. **Single-Machine Only** — Servers must run on same machine as Rails app
2. **No Backup/Restore** — Server state is ephemeral; no state snapshots yet
3. **Basic Health Checks** — RCON health check is simple; doesn't parse game state
4. **Manual Setup Scripts**`setup.sh` and wrapper scripts must be installed manually
5. **No API** — Web UI only; no REST/GraphQL API yet
## Contributing
Follow Rails conventions and the style guide:
```bash
bundle exec rubocop -a # Auto-fix style issues
bundle exec brakeman # Security scanning
bundle exec bundler-audit # Gem vulnerability check
```
## License
[Add your license here]

View file

@ -8,3 +8,279 @@
*
* Consider organizing styles into separate files for maintainability.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
header.navbar {
background-color: #222;
color: white;
padding: 15px 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
h1 {
margin-bottom: 10px;
}
nav {
ul {
list-style: none;
display: flex;
gap: 20px;
}
a {
color: white;
text-decoration: none;
&:hover {
color: #ccc;
}
}
}
}
main {
padding: 30px 0;
}
h1, h2, h3, h4, h5, h6 {
margin-bottom: 15px;
color: #222;
}
p {
margin-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: white;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
thead {
background-color: #f0f0f0;
border-bottom: 2px solid #ddd;
}
th, td {
padding: 12px 15px;
text-align: left;
}
tbody {
tr {
&:hover {
background-color: #f9f9f9;
}
&:nth-child(even) {
background-color: #fafafa;
}
}
}
.btn {
display: inline-block;
padding: 10px 15px;
background-color: #ddd;
color: #333;
text-decoration: none;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
margin-right: 5px;
&:hover {
background-color: #ccc;
}
&--primary {
background-color: #007bff;
color: white;
&:hover {
background-color: #0056b3;
}
}
&--success {
background-color: #28a745;
color: white;
&:hover {
background-color: #1e7e34;
}
}
&--danger {
background-color: #dc3545;
color: white;
&:hover {
background-color: #c82333;
}
}
&--warning {
background-color: #ffc107;
color: #333;
&:hover {
background-color: #e0a800;
}
}
&--small {
padding: 6px 10px;
font-size: 12px;
}
}
.form-group {
margin-bottom: 15px;
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
input,
textarea,
select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
&:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0,123,255,0.25);
}
}
}
.form-actions {
margin-top: 20px;
}
.alert {
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
border-left: 4px solid;
&--success {
background-color: #d4edda;
border-color: #28a745;
color: #155724;
}
&--error {
background-color: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
}
.status {
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
display: inline-block;
&--stopped {
background-color: #e9ecef;
color: #6c757d;
}
&--starting {
background-color: #fff3cd;
color: #856404;
}
&--running {
background-color: #d4edda;
color: #155724;
}
&--failed {
background-color: #f8d7da;
color: #721c24;
}
}
section {
background: white;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
#log-output {
background: #1e1e1e;
color: #00ff00;
padding: 15px;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 13px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.server-info {
p {
margin-bottom: 10px;
}
}
.overlays-list {
list-style: decimal;
padding-left: 20px;
li {
padding: 8px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
}
.template-preview {
background: #f9f9f9;
padding: 15px;
border-radius: 4px;
margin: 15px 0;
border-left: 4px solid #007bff;
}

View file

@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View file

@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View file

@ -0,0 +1,27 @@
class LogChannel < ApplicationCable::Channel
def subscribed
server = Server.find(params[:server_id])
stream_for server
@log_thread = Thread.new { stream_logs(server) }
end
def unsubscribed
@log_thread&.kill
end
private
def stream_logs(server)
unit_name = "left4dead2-#{server.id}.service"
cmd = "journalctl --user -u #{unit_name} -f --since '10 minutes ago' 2>&1"
IO.popen(cmd) do |io|
io.each_line do |line|
broadcast_to(server, { line: line.chomp })
end
end
rescue StandardError => e
broadcast_to(server, { error: "Failed to stream logs: #{e.message}" })
end
end

View file

@ -4,4 +4,23 @@ class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
before_action :authenticate_user!
helper_method :current_user
private
def authenticate_user!
unless current_user
redirect_to root_path, alert: "Please log in first"
end
end
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
def skip_authentication_check
true
end
end

View file

@ -0,0 +1,7 @@
class DashboardController < ApplicationController
def index
@server_templates = current_user.server_templates.includes(:overlays, :servers)
@servers = current_user.servers.order(created_at: :desc)
@recent_activities = current_user.activities.recent.limit(10)
end
end

View file

@ -0,0 +1,36 @@
class JobLogsController < ApplicationController
before_action :set_server, only: [ :index ], if: -> { params[:server_id].present? }
before_action :set_job_log, only: [ :show ]
def index
@job_logs = if @server
@server.job_logs.recent
else
JobLog.where(server_id: current_user.servers.pluck(:id))
.or(JobLog.where(server_id: nil))
.recent
end
@job_logs = @job_logs.page(params[:page]).per(20) if defined?(Kaminari)
end
def show
end
private
def set_server
@server = current_user.servers.find(params[:server_id])
end
def set_job_log
@job_log = JobLog.find(params[:id])
# Authorize: user must own the server or job must be global (no server)
unless @job_log.server_id.nil? || current_user.servers.exists?(id: @job_log.server_id)
redirect_to job_logs_path, alert: "Not authorized"
end
end
def authorize_user!
redirect_to root_path, alert: "Please log in" unless current_user
end
end

View file

@ -0,0 +1,77 @@
class OverlaysController < ApplicationController
before_action :set_overlay, only: [ :destroy ]
before_action :set_server_template, only: [ :create, :destroy ]
def index
@system_overlays = Overlay.system_overlays
@custom_overlays = current_user.overlays.custom_overlays.order(:name)
end
def new
@overlay = current_user.overlays.build
end
def create
if @server_template.present?
# Attach existing overlay to a template
@overlay = Overlay.find(params[:overlay_id])
position = @server_template.template_overlays.maximum(:position).to_i + 1
@template_overlay = @server_template.template_overlays.build(overlay_id: @overlay.id, position: position)
if @template_overlay.save
Activity.log(current_user, "added_overlay", "ServerTemplate", @server_template.id, { overlay: @overlay.name })
redirect_to @server_template, notice: "Overlay added successfully!"
else
redirect_to @server_template, alert: "Failed to add overlay"
end
else
# Create a new custom overlay for the current user
@overlay = current_user.overlays.build(overlay_params.merge(overlay_type: "custom"))
if @overlay.save
Activity.log(current_user, "created_overlay", "Overlay", @overlay.id, { name: @overlay.name, slug: @overlay.slug })
redirect_to overlays_path, notice: "Overlay created"
else
render :new, status: :unprocessable_entity
end
end
end
def destroy
if @server_template.present?
authorize_user_for_template_overlay!
@overlay.template_overlays.where(server_template_id: @server_template.id).destroy_all
Activity.log(current_user, "removed_overlay", "ServerTemplate", @server_template.id, { overlay: @overlay.name })
redirect_to @server_template, notice: "Overlay removed successfully!"
else
authorize_owner!
name = @overlay.name
@overlay.destroy
redirect_to overlays_path, notice: "Overlay '#{name}' deleted"
end
end
private
def set_overlay
@overlay = Overlay.find(params[:id]) if params[:id].present?
end
def set_server_template
return unless params[:server_template_id].present?
@server_template = current_user.server_templates.find(params[:server_template_id])
end
def overlay_params
params.require(:overlay).permit(:name, :slug)
end
def authorize_user_for_template_overlay!
redirect_to dashboard_path, alert: "Not authorized" unless @overlay.user_id.nil? || @overlay.user_id == current_user.id
end
def authorize_owner!
redirect_to overlays_path, alert: "Not authorized" unless @overlay.user_id == current_user.id
end
end

View file

@ -0,0 +1,10 @@
class PagesController < ApplicationController
before_action -> { @skip_auth = true }
skip_before_action :authenticate_user!
def home
if current_user
redirect_to dashboard_path
end
end
end

View file

@ -0,0 +1,63 @@
class ServerTemplatesController < ApplicationController
before_action :set_server_template, only: [ :show, :edit, :update, :destroy ]
def index
@server_templates = current_user.server_templates.order(created_at: :desc)
end
def show
authorize_user!
@overlays = Overlay.for_user(current_user).order(:name)
end
def new
@server_template = current_user.server_templates.build
end
def create
@server_template = current_user.server_templates.build(server_template_params)
if @server_template.save
Activity.log(current_user, "created", "ServerTemplate", @server_template.id, { name: @server_template.name })
redirect_to @server_template, notice: "Template created successfully!"
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize_user!
end
def update
authorize_user!
if @server_template.update(server_template_params)
Activity.log(current_user, "updated", "ServerTemplate", @server_template.id, { name: @server_template.name })
redirect_to @server_template, notice: "Template updated successfully!"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize_user!
@server_template.destroy
Activity.log(current_user, "deleted", "ServerTemplate", @server_template.id, { name: @server_template.name })
redirect_to server_templates_path, notice: "Template deleted successfully!"
end
private
def set_server_template
@server_template = ServerTemplate.find(params[:id])
end
def server_template_params
params.require(:server_template).permit(:name, :config, :startup_params)
end
def authorize_user!
redirect_to server_templates_path, alert: "Not authorized" unless @server_template.user_id == current_user.id
end
end

View file

@ -0,0 +1,85 @@
class ServersController < ApplicationController
before_action :set_server, only: [ :show, :destroy, :start, :stop, :restart, :spawn ]
def index
@servers = current_user.servers.includes(:server_template).order(created_at: :desc)
end
def show
authorize_user!
end
def new
@server_templates = current_user.server_templates.order(:name)
@server = current_user.servers.build
if params[:server_template_id]
@server_template = current_user.server_templates.find(params[:server_template_id])
@server.server_template = @server_template
end
end
def create
@server_template = current_user.server_templates.find(server_params[:server_template_id])
@server = current_user.servers.build(server_params)
@server.server_template = @server_template
@server.status = :stopped
if @server.save
Activity.log(current_user, "spawned", "Server", @server.id, { name: @server.name, port: @server.port })
SpawnServerJob.perform_later(@server.id)
redirect_to @server, notice: "Server spawning..."
else
@server_templates = current_user.server_templates.order(:name)
render :new, status: :unprocessable_entity
end
end
def spawn
authorize_user!
Activity.log(current_user, "spawning", "Server", @server.id, { name: @server.name })
SpawnServerJob.perform_later(@server.id)
@server.update(status: :starting)
redirect_to @server, notice: "Server spawning..."
end
def start
authorize_user!
StartServerJob.perform_later(@server.id)
redirect_to @server, notice: "Server starting..."
end
def stop
authorize_user!
StopServerJob.perform_later(@server.id)
redirect_to @server, notice: "Server stopping..."
end
def restart
authorize_user!
RestartServerJob.perform_later(@server.id)
redirect_to @server, notice: "Server restarting..."
end
def destroy
authorize_user!
name = @server.name
@server.destroy
Activity.log(current_user, "deleted", "Server", @server.id, { name: name })
redirect_to servers_path, notice: "Server deleted."
end
private
def set_server
@server = Server.find(params[:id])
end
def server_params
params.require(:server).permit(:name, :port, :server_template_id)
end
def authorize_user!
redirect_to servers_path, alert: "Not authorized" unless @server.user_id == current_user.id
end
end

View file

@ -0,0 +1,98 @@
require "net/http"
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :auth_request, :steam_callback ]
def auth_request
# Build Steam OpenID URL
steam_openid_url = "https://steamcommunity.com/openid/login"
# Use the actual request host/protocol for both return_to and realm to avoid signature mismatch
base_url = request.base_url
return_url = "#{base_url}#{steam_callback_path}"
Rails.logger.info("Steam auth_request return_url=#{return_url} realm=#{base_url}")
params = {
"openid.ns" => "http://specs.openid.net/auth/2.0",
"openid.identity" => "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id" => "http://specs.openid.net/auth/2.0/identifier_select",
"openid.mode" => "checkid_setup",
"openid.return_to" => return_url,
"openid.realm" => base_url,
"openid.ns.sreg" => "http://openid.net/extensions/sreg/1.1",
"openid.sreg.required" => "email"
}
redirect_to "#{steam_openid_url}?#{params.to_query}", allow_other_host: true
end
def steam_callback
# Get the OpenID response
openid_response = request.params
# Verify the response with Steam
if verify_steam_response(openid_response)
# Extract Steam ID from identity URL
# Format: http://steamcommunity.com/openid/id/[STEAMID]
identity_url = openid_response["openid.identity"]
if identity_url && identity_url.include?("/id/")
steam_id = identity_url.split("/id/").last
# Create mock auth_hash for compatibility with our User model
auth_hash = {
"uid" => steam_id,
"info" => {
"nickname" => "Steam User #{steam_id}"
}
}
user = User.find_or_create_from_steam(auth_hash)
session[:user_id] = user.id
redirect_to dashboard_path, notice: "Logged in successfully!"
else
redirect_to root_path, alert: "Could not extract Steam ID from response."
end
else
redirect_to root_path, alert: "Steam authentication failed: Invalid response signature."
end
end
def logout
session[:user_id] = nil
redirect_to root_path, notice: "Logged out successfully!"
end
private
def verify_steam_response(response)
# Steam expects only openid.* keys and mode=check_auth for validation
openid_params = response.to_h.select { |k, _| k.to_s.start_with?("openid.") }
return false unless openid_params["openid.mode"] == "id_res"
# Per OpenID 2.0 spec, the verification mode is "check_authentication"
verify_params = openid_params.merge("openid.mode" => "check_authentication")
Rails.logger.info("Steam verify payload: #{verify_params.inspect}")
uri = URI.parse("https://steamcommunity.com/openid/login")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.path)
request.set_form_data(verify_params)
begin
response = http.request(request)
Rails.logger.info("Steam verify response body: #{response.body.inspect}")
response.body.include?("is_valid:true")
rescue StandardError => e
Rails.logger.error("Steam verification error: #{e.message}")
false
end
end
def omniauth_failure
redirect_to root_path, alert: "Steam authentication failed: #{params[:message]}"
end
end

View file

@ -0,0 +1,2 @@
module JobLogsHelper
end

View file

@ -4,4 +4,54 @@ class ApplicationJob < ActiveJob::Base
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
around_perform do |_job, block|
log_entry = create_job_log
@job_log = log_entry
begin
log_entry.update!(status: :running, started_at: Time.current)
log("Job started: #{self.class.name}")
block.call
log("Job completed successfully")
log_entry.update!(status: :completed, finished_at: Time.current)
rescue => e
log("Job failed: #{e.class.name} - #{e.message}")
log(e.backtrace.first(5).join("\n"))
log_entry.update!(
status: :failed,
finished_at: Time.current,
error_message: "#{e.class.name}: #{e.message}"
)
raise
end
end
private
def create_job_log
server_id = extract_server_id_from_arguments
JobLog.create!(
job_class: self.class.name,
job_id: job_id,
arguments: arguments.to_json,
server_id: server_id,
status: :pending
)
end
def extract_server_id_from_arguments
# First argument is typically server_id for server-related jobs
return nil if arguments.empty?
arg = arguments.first
arg.is_a?(Integer) ? arg : nil
end
def log(message)
return unless @job_log
@job_log.append_log(message)
end
end

View file

@ -0,0 +1,28 @@
class RestartServerJob < ApplicationJob
queue_as :default
def perform(server_id)
server = Server.find(server_id)
log "Restarting server: #{server.name}"
begin
log "Calling SystemdManager.restart..."
L4dServer::SystemdManager.restart(server)
log "SystemdManager.restart completed successfully"
server.update(status: :starting)
log "Server status updated to starting"
Activity.log(server.user, "restarted", "Server", server.id, {})
log "Activity logged"
rescue StandardError => e
log "ERROR: Failed to restart server: #{e.message}"
log "Backtrace: #{e.backtrace.first(3).join("\n")}"
server.update(status: :failed)
Activity.log(server.user, "restart_failed", "Server", server.id, { error: e.message })
raise
end
end
end

View file

@ -0,0 +1,34 @@
class SpawnServerJob < ApplicationJob
queue_as :default
def perform(server_id)
server = Server.find(server_id)
log "Found server: #{server.name}"
begin
log "Starting server spawn process..."
L4dServer::Launcher.spawn(server)
log "Server spawned successfully"
server.update(status: :starting)
log "Server status updated to starting"
# Generate a random RCON password for health checks
rcon_password = SecureRandom.hex(16)
server.update(rcon_password: rcon_password)
log "RCON password generated and saved"
Activity.log(server.user, "spawned_success", "Server", server.id, { name: server.name })
log "Activity logged"
rescue StandardError => e
log "ERROR: Server spawn failed: #{e.message}"
log "Backtrace: #{e.backtrace.first(3).join("\n")}"
Rails.logger.error("Server spawn failed: #{e.message}")
server.update(status: :failed)
Activity.log(server.user, "spawn_failed", "Server", server.id, { error: e.message })
raise
end
end
end

View file

@ -0,0 +1,28 @@
class StartServerJob < ApplicationJob
queue_as :default
def perform(server_id)
server = Server.find(server_id)
log "Starting server: #{server.name}"
begin
log "Calling SystemdManager.start..."
L4dServer::SystemdManager.start(server)
log "SystemdManager.start completed successfully"
server.update(status: :starting)
log "Server status updated to starting"
Activity.log(server.user, "started", "Server", server.id, {})
log "Activity logged"
rescue StandardError => e
log "ERROR: Failed to start server: #{e.message}"
log "Backtrace: #{e.backtrace.first(3).join("\n")}"
server.update(status: :failed)
Activity.log(server.user, "start_failed", "Server", server.id, { error: e.message })
raise
end
end
end

View file

@ -0,0 +1,20 @@
class StatusUpdateJob < ApplicationJob
queue_as :default
def perform
Server.active.find_each do |server|
health_status = L4dServer::HealthChecker.check(server)
server.update(last_health_check_at: Time.current)
case health_status
when :running
server.update(status: :running) if server.starting?
when :stopped
server.update(status: :stopped)
when :failed
server.update(status: :failed)
end
end
end
end

View file

@ -0,0 +1,27 @@
class StopServerJob < ApplicationJob
queue_as :default
def perform(server_id)
server = Server.find(server_id)
log "Stopping server: #{server.name}"
begin
log "Calling SystemdManager.stop..."
L4dServer::SystemdManager.stop(server)
log "SystemdManager.stop completed successfully"
server.update(status: :stopped)
log "Server status updated to stopped"
Activity.log(server.user, "stopped", "Server", server.id, {})
log "Activity logged"
rescue StandardError => e
log "ERROR: Failed to stop server: #{e.message}"
log "Backtrace: #{e.backtrace.first(3).join("\n")}"
Activity.log(server.user, "stop_failed", "Server", server.id, { error: e.message })
raise
end
end
end

23
app/models/activity.rb Normal file
View file

@ -0,0 +1,23 @@
class Activity < ApplicationRecord
belongs_to :user
validates :action, :resource_type, presence: true
scope :recent, -> { order(created_at: :desc) }
scope :for_user, ->(user) { where(user_id: user.id) }
def self.log(user, action, resource_type, resource_id = nil, details = {})
create(
user_id: user.id,
action: action,
resource_type: resource_type,
resource_id: resource_id,
details: details.to_json
)
end
def details_hash
return {} if details.blank?
JSON.parse(details)
end
end

19
app/models/job_log.rb Normal file
View file

@ -0,0 +1,19 @@
class JobLog < ApplicationRecord
belongs_to :server, optional: true
enum :status, { pending: 0, running: 1, completed: 2, failed: 3 }
scope :recent, -> { order(created_at: :desc) }
scope :for_server, ->(server_id) { where(server_id: server_id) }
def duration
return nil unless started_at && finished_at
finished_at - started_at
end
def append_log(message)
self.log_output ||= ""
self.log_output += "[#{Time.current.strftime('%Y-%m-%d %H:%M:%S')}] #{message}\n"
save
end
end

39
app/models/overlay.rb Normal file
View file

@ -0,0 +1,39 @@
class Overlay < ApplicationRecord
belongs_to :user, optional: true
has_many :template_overlays, dependent: :destroy
has_many :server_templates, through: :template_overlays
validates :name, :overlay_type, :slug, presence: true
validates :overlay_type, inclusion: { in: %w[system custom] }
validates :name, uniqueness: { scope: :user_id, message: "must be unique per user" }
validate :slug_is_single_directory
before_validation :normalize_slug
scope :system_overlays, -> { where(overlay_type: "system").where(user_id: nil) }
scope :custom_overlays, -> { where(overlay_type: "custom") }
scope :for_user, ->(user) { where("user_id IS NULL OR user_id = ?", user.id) }
private
def normalize_slug
return if slug.blank? && name.blank?
# Use provided slug or derive from name
source = slug.presence || name
self.slug = source.to_s.strip
.downcase
.gsub(%r{[^\w\-.]}, "_") # Replace non-word chars (except - and .) with underscore
.gsub(%r{_+}, "_") # Collapse multiple underscores
.sub(%r{^_+}, "") # Strip leading underscores
.sub(%r{_+$}, "") # Strip trailing underscores
end
def slug_is_single_directory
return if slug.blank?
if slug.match?(%r{[\\/]})
errors.add(:slug, "must be a single directory name (no slashes)")
end
end
end

37
app/models/server.rb Normal file
View file

@ -0,0 +1,37 @@
class Server < ApplicationRecord
belongs_to :user
belongs_to :server_template
has_many :job_logs, dependent: :destroy
enum :status, { stopped: 0, starting: 1, running: 2, failed: 3 }
validates :name, presence: true
validates :name, uniqueness: { scope: :user_id }
validates :port, presence: true, uniqueness: true, numericality: { only_integer: true, greater_than_or_equal_to: 27016, less_than_or_equal_to: 27999 }
validates :status, presence: true
before_validation :assign_port, if: -> { port.blank? }
scope :for_user, ->(user) { where(user_id: user.id) }
scope :active, -> { where(status: [ :starting, :running ]) }
after_destroy :cleanup_server
def self.next_available_port
(27016..27999).each do |port|
return port unless exists?(port: port)
end
nil
end
private
def assign_port
self.port = self.class.next_available_port
end
def cleanup_server
L4dServer::SystemdManager.cleanup(self)
L4dServer::Launcher.cleanup(self)
end
end

View file

@ -0,0 +1,11 @@
class ServerTemplate < ApplicationRecord
belongs_to :user
has_many :template_overlays, dependent: :destroy
has_many :overlays, through: :template_overlays, source: :overlay
has_many :servers, dependent: :destroy
validates :name, presence: true, uniqueness: { scope: :user_id }
validates :user_id, presence: true
scope :for_user, ->(user) { where(user_id: user.id) }
end

View file

@ -0,0 +1,10 @@
class TemplateOverlay < ApplicationRecord
belongs_to :server_template
belongs_to :overlay
validates :server_template_id, :overlay_id, :position, presence: true
validates :overlay_id, uniqueness: { scope: :server_template_id }
validates :position, uniqueness: { scope: :server_template_id }
scope :ordered, -> { order(position: :asc) }
end

20
app/models/user.rb Normal file
View file

@ -0,0 +1,20 @@
class User < ApplicationRecord
has_many :server_templates, dependent: :destroy
has_many :overlays, dependent: :destroy
has_many :servers, dependent: :destroy
has_many :activities, dependent: :destroy
validates :steam_id, :steam_username, presence: true
validates :steam_id, uniqueness: true
def self.find_or_create_from_steam(auth_hash)
user = find_by(steam_id: auth_hash["uid"])
unless user
user = create!(
steam_id: auth_hash["uid"],
steam_username: auth_hash["info"]["nickname"]
)
end
user
end
end

View file

@ -0,0 +1,59 @@
.dashboard
h2 Dashboard
section.templates
h3 Server Templates
= link_to "New Template", new_server_template_path, class: "btn btn--primary"
- if @server_templates.any?
table
thead
tr
th Name
th Overlays
th Servers
th Actions
tbody
- @server_templates.each do |template|
tr
td = link_to template.name, template
td = template.overlays.count
td = template.servers.count
td
= link_to "Edit", edit_server_template_path(template), class: "btn btn--small"
= link_to "Delete", server_template_path(template), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger"
- else
p No templates yet. = link_to "Create one!", new_server_template_path
section.servers
h3 Servers
- if @servers.any?
table
thead
tr
th Name
th Port
th Template
th Status
th Actions
tbody
- @servers.each do |server|
tr
td = link_to server.name, server
td = server.port
td = server.server_template.name
td
span[class="status status--#{server.status}"] = server.status.humanize
td
= link_to "View", server, class: "btn btn--small"
- else
p No servers yet.
section.activities
h3 Recent Activities
- if @recent_activities.any?
ul
- @recent_activities.each do |activity|
li
= "#{activity.user.steam_username} #{activity.action} #{activity.resource_type}"
small = time_ago_in_words(activity.created_at)

View file

@ -0,0 +1,45 @@
h1 Job Logs
- if @server
p
= link_to "← Back to Server", server_path(@server), class: "btn btn--secondary"
p.text-muted Showing all job logs for your servers
- if @job_logs.any?
table.table
thead
tr
th Job Class
th Server
th Status
th Started
th Duration
th Actions
tbody
- @job_logs.each do |job_log|
tr class="job-status-#{job_log.status}"
td= job_log.job_class
td
- if job_log.server
= link_to job_log.server.name, server_path(job_log.server)
- else
span.text-muted System
td
span class="badge badge--#{job_log.status}"
= job_log.status.titleize
td
- if job_log.started_at
= job_log.started_at.strftime("%Y-%m-%d %H:%M:%S")
- else
span.text-muted Pending
td
- if job_log.duration
= "#{job_log.duration.round(2)}s"
- elsif job_log.running?
span.text-muted Running...
- else
span.text-muted -
td
= link_to "View Log", job_log_path(job_log), class: "btn btn--small"
- else
p.text-muted No job logs found.

View file

@ -0,0 +1,56 @@
h1 Job Log Details
.job-log-header
.job-info
h2= @job_log.job_class
dl.info-grid
dt Status:
dd
span class="badge badge--#{@job_log.status}"
= @job_log.status.titleize
- if @job_log.server
dt Server:
dd= link_to @job_log.server.name, server_path(@job_log.server)
dt Job ID:
dd= @job_log.job_id || "N/A"
dt Started:
dd
- if @job_log.started_at
= @job_log.started_at.strftime("%Y-%m-%d %H:%M:%S")
- else
span.text-muted Not started
dt Finished:
dd
- if @job_log.finished_at
= @job_log.finished_at.strftime("%Y-%m-%d %H:%M:%S")
- else
span.text-muted -
- if @job_log.duration
dt Duration:
dd= "#{@job_log.duration.round(2)} seconds"
- if @job_log.error_message.present?
dt Error:
dd.error-message= @job_log.error_message
.job-arguments
h3 Arguments
pre.code-block= JSON.pretty_generate(JSON.parse(@job_log.arguments)) rescue @job_log.arguments
.job-log-output
h3 Log Output
- if @job_log.log_output.present?
pre.log-output= @job_log.log_output
- else
p.text-muted No log output available.
.actions
= link_to "← Back to Jobs", @job_log.server ? server_job_logs_path(@job_log.server) : job_logs_path, class: "btn btn--secondary"
- if @job_log.server
= link_to "View Server", server_path(@job_log.server), class: "btn btn--secondary"

View file

@ -24,6 +24,21 @@
</head>
<body>
<header class="nav-bar" style="display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid #ddd;margin-bottom:16px;">
<%= link_to "L4D.tools", dashboard_path, style: "font-weight:bold;" %>
<%= link_to "Servers", servers_path %>
<%= link_to "Templates", server_templates_path %>
<%= link_to "Overlays", overlays_path %>
<span style="margin-left:auto;"><%= link_to "Logout", logout_path %></span>
</header>
<% if flash.any? %>
<div class="flash-messages">
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>"><%= message %></div>
<% end %>
</div>
<% end %>
<%= yield %>
</body>
</html>

View file

@ -0,0 +1,40 @@
doctype html
html
head
title = content_for(:title) || "L4d Tools"
meta name="viewport" content="width=device-width,initial-scale=1"
meta name="apple-mobile-web-app-capable" content="yes"
meta name="application-name" content="L4d Tools"
meta name="mobile-web-app-capable" content="yes"
= csrf_meta_tags
= csp_meta_tag
= yield :head
/ Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!)
/ = tag.link rel: "manifest", href: pwa_manifest_path(format: :json)
link rel="icon" href="/icon.png" type="image/png"
link rel="icon" href="/icon.svg" type="image/svg+xml"
link rel="apple-touch-icon" href="/icon.png"
/ Includes all stylesheet files in app/assets/stylesheets
= stylesheet_link_tag :app, "data-turbo-track": "reload"
= javascript_importmap_tags
body
header.nav-bar style="display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid #ddd;margin-bottom:16px;"
= link_to "L4D.tools", dashboard_path, style: "font-weight:bold;"
= link_to "Servers", servers_path
= link_to "Templates", server_templates_path
= link_to "Overlays", overlays_path
= link_to "Jobs", job_logs_path
span style="margin-left:auto;"
= link_to "Logout", logout_path
- if flash.any?
.flash-messages
- flash.each do |type, message|
.flash class="flash-#{type}" = message
= yield

View file

@ -0,0 +1,24 @@
.overlays
h2 Overlays
= link_to "New Overlay", new_overlay_path, class: "btn btn--primary"
- all_overlays = (@system_overlays + @custom_overlays).sort_by(&:name)
- if all_overlays.any?
table
thead
tr
th Name
th Dir (slug)
th Type
th Actions
tbody
- all_overlays.each do |overlay|
tr
td = overlay.name
td = overlay.slug
td = overlay.overlay_type.humanize
td
- if overlay.overlay_type == "custom" && overlay.user_id == current_user.id
= link_to "Delete", overlay_path(overlay), method: :delete, data: { confirm: "Delete this overlay?" }, class: "btn btn--small btn--danger"
- else
p No overlays available.

View file

@ -0,0 +1,22 @@
.overlay_form
h2 New Overlay
= form_with model: @overlay, url: overlays_path, local: true do |f|
- if @overlay.errors.any?
.alert.alert--error
h4 = pluralize(@overlay.errors.count, "error")
ul
- @overlay.errors.full_messages.each do |msg|
li = msg
.form-group
= f.label :name
= f.text_field :name, placeholder: "e.g., custom_addons"
.form-group
= f.label :slug, "Directory slug (optional, auto-derived from name)"
= f.text_field :slug, placeholder: "Leave blank to auto-generate"
.form-actions
= f.submit "Create Overlay", class: "btn btn--primary"
= link_to "Cancel", overlays_path, class: "btn"

View file

@ -0,0 +1,9 @@
.home
h2 Welcome to L4D Tools
p Manage your Left4Dead 2 servers with ease.
- if current_user
p = link_to "Go to Dashboard", dashboard_path, class: "btn"
- else
p
= link_to "Login with Steam", "/auth/steam", class: "btn btn--primary"

View file

@ -0,0 +1,18 @@
.server_template_form
h2 = @server_template.persisted? ? "Edit Template" : "New Template"
= form_with model: @server_template, local: true do |f|
- if @server_template.errors.any?
.alert.alert--error
h4 = pluralize(@server_template.errors.count, "error")
ul
- @server_template.errors.full_messages.each do |msg|
li = msg
.form-group
= f.label :name
= f.text_field :name
.form-actions
= f.submit class: "btn btn--primary"
= link_to "Back", server_templates_path, class: "btn"

View file

@ -0,0 +1 @@
= render "form"

View file

@ -0,0 +1,22 @@
.server_templates
h2 Server Templates
= link_to "New Template", new_server_template_path, class: "btn btn--primary"
- if @server_templates.any?
table
thead
tr
th Name
th Overlays
th Actions
tbody
- @server_templates.each do |template|
tr
td = link_to template.name, template
td = template.overlays.count
td
= link_to "Edit", edit_server_template_path(template), class: "btn btn--small"
= link_to "Spawn", new_server_path(server_template_id: template.id), class: "btn btn--small btn--success"
= link_to "Delete", server_template_path(template), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger"
- else
p No templates yet.

View file

@ -0,0 +1 @@
= render "form"

View file

@ -0,0 +1,45 @@
.server_template_show
h2 = @server_template.name
= link_to "Edit", edit_server_template_path(@server_template), class: "btn btn--small"
= link_to "Delete", server_template_path(@server_template), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger"
= link_to "Spawn Server", new_server_path(server_template_id: @server_template.id), class: "btn btn--primary"
section.overlays
h3 Overlays
= form_with url: server_template_overlays_path(@server_template), method: :post, local: true do |f|
.form-group
= f.label "Select Overlay"
= f.select :overlay_id, options_from_collection_for_select(@overlays, :id, :name), { prompt: "Choose overlay" }, class: "form-control"
= f.submit "Add Overlay", class: "btn btn--small"
- if @server_template.overlays.any?
ol.overlays-list
- @server_template.template_overlays.ordered.each do |to|
li
= to.overlay.name
span.small
| (dir: #{L4dServer::Config.overlay_path(to.overlay.slug)})
= link_to "Remove", server_template_overlay_path(@server_template, to.overlay), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger"
- else
p No overlays selected.
section.config
h3 Server Config
= form_with model: @server_template, url: server_template_path(@server_template), method: :patch, local: true do |f|
.form-group
= f.label :config, "server.cfg contents"
= f.text_area :config, placeholder: "sv_pure 2\nsv_maxplayers 4\n...", rows: 10
= f.submit "Save Config", class: "btn btn--small"
section.startup-params
h3 Startup Parameters
= form_with model: @server_template, url: server_template_path(@server_template), method: :patch, local: true do |f|
.form-group
= f.label :startup_params, "Command line parameters"
= f.text_area :startup_params, placeholder: "+map c1m1_hotel\n+difficulty Hard\n...", rows: 10
= f.submit "Save Parameters", class: "btn btn--small"
= link_to "Back", server_templates_path, class: "btn"

View file

@ -0,0 +1,33 @@
.servers
h2 Servers
p
= link_to "New Server", new_server_path, class: "btn btn--primary"
- if @servers.any?
table
thead
tr
th Name
th Port
th Template
th Status
th Last Check
th Actions
tbody
- @servers.each do |server|
tr
td = link_to server.name, server
td = server.port
td = server.server_template.name
td
span[class="status status--#{server.status}"] = server.status.humanize
td
- if server.last_health_check_at
= time_ago_in_words(server.last_health_check_at)
- else
em never
td
= link_to "View", server, class: "btn btn--small"
= link_to "Delete", server_path(server), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger"
- else
p No servers yet. = link_to "Create a template first", server_templates_path

View file

@ -0,0 +1,26 @@
.server_form
h2 Spawn New Server
= form_with model: @server, url: servers_path, method: :post, local: true do |f|
- if @server.errors.any?
.alert.alert--error
h4 = pluralize(@server.errors.count, "error")
ul
- @server.errors.full_messages.each do |msg|
li = msg
.form-group
= f.label :server_template_id, "Template"
= f.select :server_template_id, options_from_collection_for_select(@server_templates, :id, :name, @server.server_template_id), { prompt: "Select a template" }, class: "form-control", required: true
.form-group
= f.label :name
= f.text_field :name, placeholder: "e.g., server1"
.form-group
= f.label :port, "Port (optional, auto-assigned if blank)"
= f.number_field :port, placeholder: "Auto: 27016-27999", min: 27016, max: 27999
.form-actions
= f.submit "Spawn Server", class: "btn btn--primary"
= link_to "Back", servers_path, class: "btn"

View file

@ -0,0 +1,82 @@
.server_show
h2 = @server.name
.server-info
p
strong Status:
span[class="status status--#{@server.status}"] = @server.status.humanize
p
strong Port:
= @server.port
p
strong Template:
= link_to @server.server_template.name, @server.server_template
- if @server.last_health_check_at
p
strong Last Health Check:
= time_ago_in_words(@server.last_health_check_at)
.server-actions
- case @server.status
- when "stopped", "failed"
= button_to "Spawn Server", spawn_server_path(@server), method: :post, class: "btn btn--primary"
= button_to "Start Server", start_server_path(@server), method: :post, class: "btn btn--success"
- when "starting", "running"
= button_to "Stop Server", stop_server_path(@server), method: :post, class: "btn btn--warning"
= button_to "Restart Server", restart_server_path(@server), method: :post, class: "btn btn--warning"
= button_to "Delete Server", server_path(@server), method: :delete, data: { confirm: "Are you sure? This will stop and remove the server." }, class: "btn btn--danger"
= link_to "Back to Servers", servers_path, class: "btn"
section.job-logs
h3 Job History
- if @server.job_logs.recent.limit(5).any?
table.table.table--compact
thead
tr
th Job
th Status
th Started
th Duration
th Actions
tbody
- @server.job_logs.recent.limit(5).each do |job_log|
tr
td= job_log.job_class
td
span class="badge badge--#{job_log.status}"
= job_log.status.titleize
td= job_log.started_at&.strftime("%H:%M:%S") || "-"
td= job_log.duration ? "#{job_log.duration.round(1)}s" : "-"
td= link_to "View", job_log_path(job_log), class: "btn btn--small"
p= link_to "View All Jobs →", server_job_logs_path(@server), class: "link"
- else
p.text-muted No jobs executed yet.
section.logs
h3 Live Logs
#log-output
em Loading logs...
javascript:
document.addEventListener('DOMContentLoaded', function() {
const serverId = #{@server.id};
const consumer = ActionCable.createConsumer();
const logOutput = document.getElementById('log-output');
consumer.subscriptions.create({channel: "LogChannel", server_id: serverId}, {
received: function(data) {
if (data.line) {
const line = document.createElement('div');
line.textContent = data.line;
logOutput.appendChild(line);
logOutput.scrollTop = logOutput.scrollHeight;
} else if (data.error) {
const error = document.createElement('div');
error.className = 'error';
error.textContent = data.error;
logOutput.appendChild(error);
}
}
});
});

View file

@ -7,7 +7,7 @@ image: l4d_tools
# Deploy to these servers.
servers:
web:
- 192.168.0.1
- 10.0.0.176
# job:
# hosts:
# - 192.168.0.1
@ -45,6 +45,9 @@ env:
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# L4D2 base directory (default: /opt/l4d2)
# L4D2_BASE_PATH: /opt/l4d2
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
@ -70,6 +73,7 @@ aliases:
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "l4d_tools_storage:/rails/storage"
- "/opt/l4d2:/opt/l4d2"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old

View file

@ -0,0 +1,3 @@
# Ensure Action Cable base classes are loaded before channels (production eager load)
require Rails.root.join("app/channels/application_cable/channel").to_s
require Rails.root.join("app/channels/application_cable/connection").to_s

View file

@ -0,0 +1,55 @@
# Configuration for L4D2 server paths
module L4dServer
class Config
class << self
# Base directory for all L4D2 server files
def base_path
@base_path ||= ENV.fetch("L4D2_BASE_PATH", "/opt/l4d2")
end
def base_path=(path)
@base_path = path
end
# Directory containing the base L4D2 installation
def installation_path
File.join(base_path, "installation")
end
# Directory containing overlay filesystems
def overlays_path
File.join(base_path, "overlays")
end
# Directory containing individual server instances
def servers_path
File.join(base_path, "servers")
end
# Directory containing helper scripts (start-server, stop-server)
def bin_path
File.join(base_path, "bin")
end
# Full path to a specific server directory
def server_path(server_id)
File.join(servers_path, server_id.to_s)
end
# Full path to a specific overlay directory
def overlay_path(slug)
File.join(overlays_path, slug)
end
end
end
end
systemd_user_dir = File.expand_path("~/.config/systemd/user")
unless Dir.exist?(systemd_user_dir)
begin
FileUtils.mkdir_p(systemd_user_dir)
rescue => e
Rails.logger.warn("Could not create systemd user directory: #{e.message}")
end
end

View file

@ -0,0 +1,2 @@
# OmniAuth is not used - we implement direct Steam OpenID 2.0 protocol
# This file is kept for reference but the middleware is disabled

View file

@ -9,7 +9,16 @@
# priority: 2
# schedule: at 5am every day
development:
status_update:
class: StatusUpdateJob
schedule: every 30 seconds
production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
status_update:
class: StatusUpdateJob
schedule: every 30 seconds

View file

@ -1,14 +1,34 @@
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
# Health check
get "up" => "rails/health#show", as: :rails_health_check
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
# Authentication
root "pages#home"
get "auth/steam", to: "sessions#auth_request"
get "auth/steam/callback", to: "sessions#steam_callback", as: :steam_callback
post "auth/steam/callback", to: "sessions#steam_callback"
get "logout", to: "sessions#logout", as: :logout
get "dashboard", to: "dashboard#index", as: :dashboard
# Defines the root path route ("/")
# root "posts#index"
# Server resources
resources :server_templates do
resources :overlays, only: [ :create, :destroy ]
end
resources :overlays, only: [ :index, :new, :create, :destroy ]
resources :job_logs, only: [ :index, :show ]
resources :servers, only: [ :index, :show, :new, :create, :destroy ] do
resources :job_logs, only: [ :index ]
member do
post :spawn
post :start
post :stop
post :restart
end
end
# WebSocket for logs
mount ActionCable.server => "/cable"
end

View file

@ -0,0 +1,12 @@
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :steam_id, null: false
t.string :steam_username, null: false
t.timestamps
end
add_index :users, :steam_id, unique: true
end
end

View file

@ -0,0 +1,15 @@
class CreateOverlays < ActiveRecord::Migration[8.1]
def change
create_table :overlays do |t|
t.references :user, foreign_key: true
t.string :name, null: false
t.text :description
t.string :overlay_type, null: false, default: "system"
t.string :path, null: false
t.timestamps
end
add_index :overlays, [ :user_id, :name ], unique: true
end
end

View file

@ -0,0 +1,12 @@
class CreateServerTemplates < ActiveRecord::Migration[8.1]
def change
create_table :server_templates do |t|
t.references :user, null: false, foreign_key: true
t.string :name, null: false
t.timestamps
end
add_index :server_templates, [ :user_id, :name ], unique: true
end
end

View file

@ -0,0 +1,14 @@
class CreateTemplateOverlays < ActiveRecord::Migration[8.1]
def change
create_table :template_overlays do |t|
t.references :server_template, null: false, foreign_key: true
t.references :overlay, null: false, foreign_key: true
t.integer :position, null: false
t.timestamps
end
add_index :template_overlays, [ :server_template_id, :overlay_id ], unique: true, name: "index_template_overlays_unique"
add_index :template_overlays, [ :server_template_id, :position ], unique: true, name: "index_template_overlays_position"
end
end

View file

@ -0,0 +1,19 @@
class CreateServers < ActiveRecord::Migration[8.1]
def change
create_table :servers do |t|
t.references :user, null: false, foreign_key: true
t.references :server_template, null: false, foreign_key: true
t.string :name, null: false
t.integer :port, null: false
t.integer :status, null: false, default: 0
t.string :unit_file_path
t.string :rcon_password
t.datetime :last_health_check_at
t.timestamps
end
add_index :servers, [ :user_id, :name ], unique: true
add_index :servers, :port, unique: true
end
end

View file

@ -0,0 +1,15 @@
class CreateActivities < ActiveRecord::Migration[8.1]
def change
create_table :activities do |t|
t.references :user, null: false, foreign_key: true
t.string :action, null: false
t.string :resource_type, null: false
t.integer :resource_id
t.text :details
t.timestamps
end
add_index :activities, [ :user_id, :created_at ]
end
end

View file

@ -0,0 +1,5 @@
class RenameOverlayPathToSlug < ActiveRecord::Migration[8.1]
def change
rename_column :overlays, :path, :slug
end
end

View file

@ -0,0 +1,6 @@
class AddConfigAndStartupParamsToServerTemplates < ActiveRecord::Migration[8.1]
def change
add_column :server_templates, :config, :text
add_column :server_templates, :startup_params, :text
end
end

View file

@ -0,0 +1,6 @@
class DropConfigOptionsAndStartupParamsTables < ActiveRecord::Migration[8.1]
def change
drop_table :config_options
drop_table :startup_params
end
end

View file

@ -0,0 +1,22 @@
class CreateJobLogs < ActiveRecord::Migration[8.1]
def change
create_table :job_logs do |t|
t.string :job_class
t.string :job_id
t.text :arguments
t.integer :server_id
t.text :log_output
t.integer :status, default: 0, null: false
t.datetime :started_at
t.datetime :finished_at
t.text :error_message
t.timestamps
end
add_index :job_logs, :server_id
add_index :job_logs, :job_id
add_index :job_logs, :status
add_index :job_logs, :created_at
end
end

111
db/schema.rb generated Normal file
View file

@ -0,0 +1,111 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_01_18_200001) do
create_table "activities", force: :cascade do |t|
t.string "action", null: false
t.datetime "created_at", null: false
t.text "details"
t.integer "resource_id"
t.string "resource_type", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id", "created_at"], name: "index_activities_on_user_id_and_created_at"
t.index ["user_id"], name: "index_activities_on_user_id"
end
create_table "job_logs", force: :cascade do |t|
t.text "arguments"
t.datetime "created_at", null: false
t.text "error_message"
t.datetime "finished_at"
t.string "job_class"
t.string "job_id"
t.text "log_output"
t.integer "server_id"
t.datetime "started_at"
t.integer "status", default: 0, null: false
t.datetime "updated_at", null: false
t.index ["created_at"], name: "index_job_logs_on_created_at"
t.index ["job_id"], name: "index_job_logs_on_job_id"
t.index ["server_id"], name: "index_job_logs_on_server_id"
t.index ["status"], name: "index_job_logs_on_status"
end
create_table "overlays", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.string "name", null: false
t.string "overlay_type", default: "system", null: false
t.string "slug", null: false
t.datetime "updated_at", null: false
t.integer "user_id"
t.index ["user_id", "name"], name: "index_overlays_on_user_id_and_name", unique: true
t.index ["user_id"], name: "index_overlays_on_user_id"
end
create_table "server_templates", force: :cascade do |t|
t.text "config"
t.datetime "created_at", null: false
t.string "name", null: false
t.text "startup_params"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id", "name"], name: "index_server_templates_on_user_id_and_name", unique: true
t.index ["user_id"], name: "index_server_templates_on_user_id"
end
create_table "servers", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "last_health_check_at"
t.string "name", null: false
t.integer "port", null: false
t.string "rcon_password"
t.integer "server_template_id", null: false
t.integer "status", default: 0, null: false
t.string "unit_file_path"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["port"], name: "index_servers_on_port", unique: true
t.index ["server_template_id"], name: "index_servers_on_server_template_id"
t.index ["user_id", "name"], name: "index_servers_on_user_id_and_name", unique: true
t.index ["user_id"], name: "index_servers_on_user_id"
end
create_table "template_overlays", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "overlay_id", null: false
t.integer "position", null: false
t.integer "server_template_id", null: false
t.datetime "updated_at", null: false
t.index ["overlay_id"], name: "index_template_overlays_on_overlay_id"
t.index ["server_template_id", "overlay_id"], name: "index_template_overlays_unique", unique: true
t.index ["server_template_id", "position"], name: "index_template_overlays_position", unique: true
t.index ["server_template_id"], name: "index_template_overlays_on_server_template_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "steam_id", null: false
t.string "steam_username", null: false
t.datetime "updated_at", null: false
t.index ["steam_id"], name: "index_users_on_steam_id", unique: true
end
add_foreign_key "activities", "users"
add_foreign_key "overlays", "users"
add_foreign_key "server_templates", "users"
add_foreign_key "servers", "server_templates"
add_foreign_key "servers", "users"
add_foreign_key "template_overlays", "overlays"
add_foreign_key "template_overlays", "server_templates"
end

View file

@ -0,0 +1,33 @@
module L4dServer
class ConfigGenerator
def self.generate(server)
new(server).generate
end
def initialize(server)
@server = server
@template = server.server_template
end
def generate
config_path = config_file_path
config_dir = File.dirname(config_path)
FileUtils.mkdir_p(config_dir) unless Dir.exist?(config_dir)
File.write(config_path, render_config)
config_path
end
def config_file_path
"#{L4dServer::Config.server_path(@server.id)}/server.cfg"
end
private
def render_config
# config is now a text field, not an association
@template.config.to_s
end
end
end

View file

@ -0,0 +1,47 @@
module L4dServer
class HealthChecker
def self.check(server)
new(server).check
end
def initialize(server)
@server = server
end
def check
# First check if systemd thinks the service is active
status = SystemdManager.status(@server)
case status
when "active"
check_rcon
when "inactive", "failed"
:stopped
else
:unknown
end
end
private
def check_rcon
# Try to connect via RCON to verify server is actually responding
socket = UDPSocket.new
socket.connect("127.0.0.1", @server.port)
# Build a simple RCON auth packet for L4D2
# Format: 0xFFFFFFFF + "challenge rcon_password"
packet = "\xFF\xFF\xFF\xFFchallenge #{@server.rcon_password}\n"
socket.send(packet, 0)
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, 1) # 1 second timeout
response = socket.recv(4096)
socket.close
response.length > 0 ? :running : :failed
rescue Timeout::Error, Errno::ECONNREFUSED, StandardError => e
Rails.logger.debug("RCON health check failed: #{e.message}")
:failed
end
end
end

View file

@ -0,0 +1,85 @@
module L4dServer
class Launcher
def self.spawn(server)
new(server).spawn
end
def self.cleanup(server)
new(server).cleanup
end
def initialize(server)
@server = server
@template = server.server_template
end
def spawn
setup_directories
generate_config
mount_overlayfs
generate_systemd_unit
enable_and_start_service
rescue StandardError => e
Rails.logger.error("Failed to spawn server: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
@server.update(status: :failed)
raise
end
def cleanup
unmount_overlayfs
cleanup_directories
end
private
def setup_directories
server_dir = L4dServer::Config.server_path(@server.id)
FileUtils.mkdir_p("#{server_dir}/upper")
FileUtils.mkdir_p("#{server_dir}/work")
FileUtils.mkdir_p("#{server_dir}/merged")
end
def generate_config
ConfigGenerator.generate(@server)
end
def mount_overlayfs
server_dir = L4dServer::Config.server_path(@server.id)
overlays = @template.template_overlays.ordered.map { |to| L4dServer::Config.overlay_path(to.overlay.slug) }.join(":")
lower_dirs = "#{overlays}:#{L4dServer::Config.installation_path}"
mount_cmd = "fuse-overlayfs -o lowerdir=#{lower_dirs},upperdir=#{server_dir}/upper,workdir=#{server_dir}/work #{server_dir}/merged"
success = system(mount_cmd)
raise "Failed to mount overlayfs" unless success
end
def generate_systemd_unit
SystemdManager.new(@server).create_unit_file
end
def enable_and_start_service
SystemdManager.new(@server).enable_and_start
@server.update(status: :starting)
end
def unmount_overlayfs
server_dir = L4dServer::Config.server_path(@server.id)
merged = "#{server_dir}/merged"
if Dir.exist?(merged)
system("mountpoint -q #{merged} && umount #{merged}")
end
rescue StandardError => e
Rails.logger.warn("Failed to unmount overlayfs: #{e.message}")
end
def cleanup_directories
server_dir = L4dServer::Config.server_path(@server.id)
FileUtils.rm_rf(server_dir) if Dir.exist?(server_dir)
rescue StandardError => e
Rails.logger.warn("Failed to cleanup directories: #{e.message}")
end
end
end

View file

@ -0,0 +1,117 @@
module L4dServer
class SystemdManager
SYSTEMD_USER_DIR = File.expand_path("~/.config/systemd/user").freeze
def self.start(server)
new(server).start
end
def self.stop(server)
new(server).stop
end
def self.restart(server)
new(server).restart
end
def self.status(server)
new(server).status
end
def self.cleanup(server)
new(server).cleanup
end
def initialize(server)
@server = server
end
def start
execute("systemctl --user start #{unit_name}")
end
def stop
execute("systemctl --user stop #{unit_name}")
end
def restart
execute("systemctl --user restart #{unit_name}")
end
def status
output = `systemctl --user is-active #{unit_name} 2>&1`
output.strip
rescue StandardError => e
Rails.logger.error("Failed to get status: #{e.message}")
"unknown"
end
def cleanup
begin
stop
execute("systemctl --user disable #{unit_name}")
execute("systemctl --user daemon-reload")
File.delete(unit_file_path) if File.exist?(unit_file_path)
rescue StandardError => e
Rails.logger.error("Failed to cleanup systemd unit: #{e.message}")
end
end
def create_unit_file
FileUtils.mkdir_p(SYSTEMD_USER_DIR) unless Dir.exist?(SYSTEMD_USER_DIR)
File.write(unit_file_path, render_unit)
@server.update(unit_file_path: unit_file_path)
execute("systemctl --user daemon-reload")
end
def enable_and_start
execute("systemctl --user enable #{unit_name}")
start
end
private
def unit_name
"left4dead2-#{@server.id}.service"
end
def unit_file_path
File.join(SYSTEMD_USER_DIR, unit_name)
end
def render_unit
template = @server.server_template
# startup_params is now a text field, not an association
startup_args = template.startup_params.to_s.strip
<<~UNIT
[Unit]
Description=L4D2 Server #{@server.name}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=steam
ExecStart=#{L4dServer::Config.bin_path}/start-server #{@server.id}
ExecStop=#{L4dServer::Config.bin_path}/stop-server #{@server.id}
Restart=on-failure
RestartSec=10
Nice=-10
[Install]
WantedBy=default.target
UNIT
end
def execute(command)
output = `#{command} 2>&1`
raise "Command failed: #{command}\n#{output}" unless $?.success?
output
rescue StandardError => e
Rails.logger.error("Systemd command failed: #{e.message}")
raise
end
end
end

View file

@ -0,0 +1,13 @@
require "test_helper"
class JobLogsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get job_logs_index_url
assert_response :success
end
test "should get show" do
get job_logs_show_url
assert_response :success
end
end

23
test/fixtures/job_logs.yml vendored Normal file
View file

@ -0,0 +1,23 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
job_class: MyString
job_id: MyString
arguments: MyText
server_id: 1
log_output: MyText
status: 1
started_at: 2026-01-18 18:46:23
finished_at: 2026-01-18 18:46:23
error_message: MyText
two:
job_class: MyString
job_id: MyString
arguments: MyText
server_id: 1
log_output: MyText
status: 1
started_at: 2026-01-18 18:46:23
finished_at: 2026-01-18 18:46:23
error_message: MyText

9
test/fixtures/users.yml vendored Normal file
View file

@ -0,0 +1,9 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
steam_id: MyString
steam_username: MyString
two:
steam_id: MyString
steam_username: MyString

View file

@ -0,0 +1,7 @@
require "test_helper"
class RestartServerJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class StartServerJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class StopServerJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class JobLogTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

7
test/models/user_test.rb Normal file
View file

@ -0,0 +1,7 @@
require "test_helper"
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end