Compare commits
22 commits
main
...
vibecoding
| Author | SHA1 | Date | |
|---|---|---|---|
| a6e0f83fbc | |||
| 48778fe928 | |||
| b0b81e024c | |||
| 26ffd27f7d | |||
| 024bbe2ec7 | |||
| 7c4177d749 | |||
| 3671636487 | |||
| 453a9137b9 | |||
| 2afec412bb | |||
| 6909497996 | |||
| 154d2c48e8 | |||
| 34be944b96 | |||
| 7022b48bda | |||
| d3579504aa | |||
| a9a93d2657 | |||
| 9a65958d2d | |||
| 7ade38ecad | |||
| 17fca8fae5 | |||
| 11d5baf130 | |||
| 3b82be0b7d | |||
| 35cb0e2ce8 | |||
| 73e4e8a52f |
76 changed files with 3042 additions and 24 deletions
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
|
|
@ -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
|
- **JS**: Controllers auto-discovered from `app/javascript/controllers/`; no bundling required
|
||||||
- **Images**: Reference via `image_tag "name"` (fingerprinted in production)
|
- **Images**: Reference via `image_tag "name"` (fingerprinted in production)
|
||||||
- **PWA**: Service worker template available in `app/views/pwa/` (commented out in routes)
|
- **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)
|
### Job Enqueue (Solid Queue)
|
||||||
```ruby
|
```ruby
|
||||||
|
|
|
||||||
487
ARCHITECTURE.md
Normal file
487
ARCHITECTURE.md
Normal 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.
|
||||||
8
Gemfile
8
Gemfile
|
|
@ -18,7 +18,13 @@ gem "stimulus-rails"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
|
|
||||||
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
# 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
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
|
||||||
52
Gemfile.lock
52
Gemfile.lock
|
|
@ -79,6 +79,7 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
|
bcrypt (3.1.21)
|
||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.2)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.0.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
|
|
@ -125,6 +126,8 @@ GEM
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
|
hashie (5.1.0)
|
||||||
|
logger
|
||||||
i18n (1.14.8)
|
i18n (1.14.8)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
|
|
@ -174,6 +177,7 @@ GEM
|
||||||
minitest (6.0.1)
|
minitest (6.0.1)
|
||||||
prism (~> 1.5)
|
prism (~> 1.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
|
multi_json (1.19.1)
|
||||||
net-imap (0.6.2)
|
net-imap (0.6.2)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
|
|
@ -203,6 +207,19 @@ GEM
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.0-x86_64-linux-musl)
|
nokogiri (1.19.0-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
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)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.10.1)
|
parser (3.3.10.1)
|
||||||
|
|
@ -225,6 +242,13 @@ GEM
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.4)
|
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)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
|
|
@ -300,6 +324,7 @@ GEM
|
||||||
rubocop (>= 1.72)
|
rubocop (>= 1.72)
|
||||||
rubocop-performance (>= 1.24)
|
rubocop-performance (>= 1.24)
|
||||||
rubocop-rails (>= 2.30)
|
rubocop-rails (>= 2.30)
|
||||||
|
ruby-openid (2.9.2)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.3.0)
|
ruby-vips (2.3.0)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
|
|
@ -312,6 +337,13 @@ GEM
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.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)
|
solid_cable (3.0.12)
|
||||||
actioncable (>= 7.2)
|
actioncable (>= 7.2)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
|
|
@ -345,11 +377,13 @@ GEM
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.2.0)
|
stringio (3.2.0)
|
||||||
|
temple (0.10.4)
|
||||||
thor (1.5.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.17)
|
thruster (0.1.17)
|
||||||
thruster (0.1.17-aarch64-linux)
|
thruster (0.1.17-aarch64-linux)
|
||||||
thruster (0.1.17-arm64-darwin)
|
thruster (0.1.17-arm64-darwin)
|
||||||
thruster (0.1.17-x86_64-linux)
|
thruster (0.1.17-x86_64-linux)
|
||||||
|
tilt (2.7.0)
|
||||||
timeout (0.6.0)
|
timeout (0.6.0)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.21)
|
turbo-rails (2.0.21)
|
||||||
|
|
@ -362,6 +396,7 @@ GEM
|
||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
|
version_gem (1.1.9)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
|
|
@ -388,6 +423,7 @@ PLATFORMS
|
||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
bcrypt (~> 3.1.7)
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
bundler-audit
|
bundler-audit
|
||||||
|
|
@ -397,11 +433,13 @@ DEPENDENCIES
|
||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
kamal
|
kamal
|
||||||
|
omniauth-steam
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.2)
|
rails (~> 8.1.2)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
|
slim-rails
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
solid_queue
|
solid_queue
|
||||||
|
|
@ -428,6 +466,7 @@ CHECKSUMS
|
||||||
addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
|
addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
|
||||||
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
||||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||||
|
bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
|
||||||
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
|
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
|
||||||
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
||||||
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
|
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
|
||||||
|
|
@ -456,6 +495,7 @@ CHECKSUMS
|
||||||
ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
|
ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
|
||||||
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
|
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
|
||||||
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
|
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
|
||||||
|
hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870
|
||||||
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
|
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
|
||||||
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
|
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
|
||||||
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
|
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
|
||||||
|
|
@ -475,6 +515,7 @@ CHECKSUMS
|
||||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||||
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
|
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
|
||||||
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
|
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
|
||||||
|
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
|
||||||
net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
|
net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
|
||||||
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
|
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
|
||||||
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
|
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
|
||||||
|
|
@ -490,6 +531,9 @@ CHECKSUMS
|
||||||
nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
|
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-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c
|
||||||
nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4
|
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
|
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||||
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
|
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
|
||||||
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
|
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
|
||||||
|
|
@ -503,6 +547,8 @@ CHECKSUMS
|
||||||
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
|
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
|
||||||
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
||||||
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
|
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-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
|
||||||
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
|
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
|
||||||
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
||||||
|
|
@ -521,11 +567,14 @@ CHECKSUMS
|
||||||
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
||||||
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
|
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
|
||||||
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
|
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
|
||||||
|
ruby-openid (2.9.2) sha256=7f8e39426b9833172a79f4696bc63b66b0d2c766971919a69b3db5be400d17a4
|
||||||
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
||||||
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
|
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
|
||||||
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
|
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
|
||||||
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
||||||
selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826
|
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_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
|
||||||
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
|
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
|
||||||
solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428
|
solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428
|
||||||
|
|
@ -539,11 +588,13 @@ CHECKSUMS
|
||||||
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
|
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
|
||||||
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
|
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
|
||||||
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
||||||
|
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
|
||||||
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
||||||
thruster (0.1.17) sha256=6f3f1de43e22f0162d81cbc363f45ee42a1b8460213856c1a899cbf0d3297235
|
thruster (0.1.17) sha256=6f3f1de43e22f0162d81cbc363f45ee42a1b8460213856c1a899cbf0d3297235
|
||||||
thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba
|
thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba
|
||||||
thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec
|
thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec
|
||||||
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
|
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
|
||||||
|
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
|
||||||
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
|
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
|
||||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||||
turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7
|
turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7
|
||||||
|
|
@ -552,6 +603,7 @@ CHECKSUMS
|
||||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||||
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
||||||
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
|
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
|
||||||
|
version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4
|
||||||
web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20
|
web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20
|
||||||
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
|
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
|
||||||
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
|
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
|
||||||
|
|
|
||||||
254
README.md
254
README.md
|
|
@ -1,24 +1,252 @@
|
||||||
# README
|
# L4D Tools
|
||||||
|
|
||||||
This README would normally document whatever steps are necessary to get the
|
A Rails 8.1 application for managing Left4Dead 2 game servers with a web interface.
|
||||||
application up and running.
|
|
||||||
|
|
||||||
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]
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,279 @@
|
||||||
*
|
*
|
||||||
* Consider organizing styles into separate files for maintainability.
|
* 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module ApplicationCable
|
||||||
|
class Channel < ActionCable::Channel::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
4
app/channels/application_cable/connection.rb
Normal file
4
app/channels/application_cable/connection.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module ApplicationCable
|
||||||
|
class Connection < ActionCable::Connection::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
27
app/channels/log_channel.rb
Normal file
27
app/channels/log_channel.rb
Normal 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
|
||||||
|
|
@ -4,4 +4,23 @@ class ApplicationController < ActionController::Base
|
||||||
|
|
||||||
# Changes to the importmap will invalidate the etag for HTML responses
|
# Changes to the importmap will invalidate the etag for HTML responses
|
||||||
stale_when_importmap_changes
|
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
|
end
|
||||||
|
|
|
||||||
7
app/controllers/dashboard_controller.rb
Normal file
7
app/controllers/dashboard_controller.rb
Normal 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
|
||||||
36
app/controllers/job_logs_controller.rb
Normal file
36
app/controllers/job_logs_controller.rb
Normal 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
|
||||||
77
app/controllers/overlays_controller.rb
Normal file
77
app/controllers/overlays_controller.rb
Normal 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
|
||||||
10
app/controllers/pages_controller.rb
Normal file
10
app/controllers/pages_controller.rb
Normal 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
|
||||||
63
app/controllers/server_templates_controller.rb
Normal file
63
app/controllers/server_templates_controller.rb
Normal 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
|
||||||
85
app/controllers/servers_controller.rb
Normal file
85
app/controllers/servers_controller.rb
Normal 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
|
||||||
98
app/controllers/sessions_controller.rb
Normal file
98
app/controllers/sessions_controller.rb
Normal 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
|
||||||
2
app/helpers/job_logs_helper.rb
Normal file
2
app/helpers/job_logs_helper.rb
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
module JobLogsHelper
|
||||||
|
end
|
||||||
|
|
@ -4,4 +4,54 @@ class ApplicationJob < ActiveJob::Base
|
||||||
|
|
||||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||||
# discard_on ActiveJob::DeserializationError
|
# 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
|
end
|
||||||
|
|
|
||||||
28
app/jobs/restart_server_job.rb
Normal file
28
app/jobs/restart_server_job.rb
Normal 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
|
||||||
34
app/jobs/spawn_server_job.rb
Normal file
34
app/jobs/spawn_server_job.rb
Normal 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
|
||||||
28
app/jobs/start_server_job.rb
Normal file
28
app/jobs/start_server_job.rb
Normal 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
|
||||||
20
app/jobs/status_update_job.rb
Normal file
20
app/jobs/status_update_job.rb
Normal 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
|
||||||
27
app/jobs/stop_server_job.rb
Normal file
27
app/jobs/stop_server_job.rb
Normal 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
23
app/models/activity.rb
Normal 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
19
app/models/job_log.rb
Normal 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
39
app/models/overlay.rb
Normal 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
37
app/models/server.rb
Normal 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
|
||||||
11
app/models/server_template.rb
Normal file
11
app/models/server_template.rb
Normal 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
|
||||||
10
app/models/template_overlay.rb
Normal file
10
app/models/template_overlay.rb
Normal 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
20
app/models/user.rb
Normal 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
|
||||||
59
app/views/dashboard/index.html.slim
Normal file
59
app/views/dashboard/index.html.slim
Normal 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)
|
||||||
45
app/views/job_logs/index.html.slim
Normal file
45
app/views/job_logs/index.html.slim
Normal 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.
|
||||||
56
app/views/job_logs/show.html.slim
Normal file
56
app/views/job_logs/show.html.slim
Normal 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"
|
||||||
|
|
@ -24,6 +24,21 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 %>
|
<%= yield %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
40
app/views/layouts/application.html.slim
Normal file
40
app/views/layouts/application.html.slim
Normal 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
|
||||||
24
app/views/overlays/index.html.slim
Normal file
24
app/views/overlays/index.html.slim
Normal 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.
|
||||||
22
app/views/overlays/new.html.slim
Normal file
22
app/views/overlays/new.html.slim
Normal 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"
|
||||||
9
app/views/pages/home.html.slim
Normal file
9
app/views/pages/home.html.slim
Normal 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"
|
||||||
18
app/views/server_templates/_form.html.slim
Normal file
18
app/views/server_templates/_form.html.slim
Normal 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"
|
||||||
1
app/views/server_templates/edit.html.slim
Normal file
1
app/views/server_templates/edit.html.slim
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
= render "form"
|
||||||
22
app/views/server_templates/index.html.slim
Normal file
22
app/views/server_templates/index.html.slim
Normal 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.
|
||||||
1
app/views/server_templates/new.html.slim
Normal file
1
app/views/server_templates/new.html.slim
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
= render "form"
|
||||||
45
app/views/server_templates/show.html.slim
Normal file
45
app/views/server_templates/show.html.slim
Normal 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"
|
||||||
33
app/views/servers/index.html.slim
Normal file
33
app/views/servers/index.html.slim
Normal 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
|
||||||
26
app/views/servers/new.html.slim
Normal file
26
app/views/servers/new.html.slim
Normal 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"
|
||||||
82
app/views/servers/show.html.slim
Normal file
82
app/views/servers/show.html.slim
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,7 @@ image: l4d_tools
|
||||||
# Deploy to these servers.
|
# Deploy to these servers.
|
||||||
servers:
|
servers:
|
||||||
web:
|
web:
|
||||||
- 192.168.0.1
|
- 10.0.0.176
|
||||||
# job:
|
# job:
|
||||||
# hosts:
|
# hosts:
|
||||||
# - 192.168.0.1
|
# - 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.
|
# When you start using multiple servers, you should split out job processing to a dedicated machine.
|
||||||
SOLID_QUEUE_IN_PUMA: true
|
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)
|
# Set number of processes dedicated to Solid Queue (default: 1)
|
||||||
# JOB_CONCURRENCY: 3
|
# JOB_CONCURRENCY: 3
|
||||||
|
|
||||||
|
|
@ -70,6 +73,7 @@ aliases:
|
||||||
# Recommended to change this to a mounted volume path that is backed up off server.
|
# Recommended to change this to a mounted volume path that is backed up off server.
|
||||||
volumes:
|
volumes:
|
||||||
- "l4d_tools_storage:/rails/storage"
|
- "l4d_tools_storage:/rails/storage"
|
||||||
|
- "/opt/l4d2:/opt/l4d2"
|
||||||
|
|
||||||
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
|
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
|
||||||
# hitting 404 on in-flight requests. Combines all files from new and old
|
# hitting 404 on in-flight requests. Combines all files from new and old
|
||||||
|
|
|
||||||
3
config/initializers/application_cable.rb
Normal file
3
config/initializers/application_cable.rb
Normal 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
|
||||||
55
config/initializers/l4d_server.rb
Normal file
55
config/initializers/l4d_server.rb
Normal 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
|
||||||
2
config/initializers/omniauth.rb
Normal file
2
config/initializers/omniauth.rb
Normal 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
|
||||||
|
|
@ -9,7 +9,16 @@
|
||||||
# priority: 2
|
# priority: 2
|
||||||
# schedule: at 5am every day
|
# schedule: at 5am every day
|
||||||
|
|
||||||
|
development:
|
||||||
|
status_update:
|
||||||
|
class: StatusUpdateJob
|
||||||
|
schedule: every 30 seconds
|
||||||
|
|
||||||
production:
|
production:
|
||||||
clear_solid_queue_finished_jobs:
|
clear_solid_queue_finished_jobs:
|
||||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||||
schedule: every hour at minute 12
|
schedule: every hour at minute 12
|
||||||
|
|
||||||
|
status_update:
|
||||||
|
class: StatusUpdateJob
|
||||||
|
schedule: every 30 seconds
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Health check
|
||||||
|
|
||||||
# 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.
|
|
||||||
get "up" => "rails/health#show", as: :rails_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)
|
# Authentication
|
||||||
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
root "pages#home"
|
||||||
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
|
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 ("/")
|
# Server resources
|
||||||
# root "posts#index"
|
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
|
end
|
||||||
|
|
|
||||||
12
db/migrate/20260118162612_create_users.rb
Normal file
12
db/migrate/20260118162612_create_users.rb
Normal 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
|
||||||
15
db/migrate/20260118162614_create_overlays.rb
Normal file
15
db/migrate/20260118162614_create_overlays.rb
Normal 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
|
||||||
12
db/migrate/20260118162617_create_server_templates.rb
Normal file
12
db/migrate/20260118162617_create_server_templates.rb
Normal 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
|
||||||
14
db/migrate/20260118162619_create_template_overlays.rb
Normal file
14
db/migrate/20260118162619_create_template_overlays.rb
Normal 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
|
||||||
19
db/migrate/20260118162626_create_servers.rb
Normal file
19
db/migrate/20260118162626_create_servers.rb
Normal 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
|
||||||
15
db/migrate/20260118162630_create_activities.rb
Normal file
15
db/migrate/20260118162630_create_activities.rb
Normal 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
|
||||||
5
db/migrate/20260118180000_rename_overlay_path_to_slug.rb
Normal file
5
db/migrate/20260118180000_rename_overlay_path_to_slug.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
class RenameOverlayPathToSlug < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
rename_column :overlays, :path, :slug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
class DropConfigOptionsAndStartupParamsTables < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
drop_table :config_options
|
||||||
|
drop_table :startup_params
|
||||||
|
end
|
||||||
|
end
|
||||||
22
db/migrate/20260118200001_create_job_logs.rb
Normal file
22
db/migrate/20260118200001_create_job_logs.rb
Normal 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
111
db/schema.rb
generated
Normal 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
|
||||||
33
lib/l4d_server/config_generator.rb
Normal file
33
lib/l4d_server/config_generator.rb
Normal 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
|
||||||
47
lib/l4d_server/health_checker.rb
Normal file
47
lib/l4d_server/health_checker.rb
Normal 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
|
||||||
85
lib/l4d_server/launcher.rb
Normal file
85
lib/l4d_server/launcher.rb
Normal 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
|
||||||
117
lib/l4d_server/systemd_manager.rb
Normal file
117
lib/l4d_server/systemd_manager.rb
Normal 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
|
||||||
13
test/controllers/job_logs_controller_test.rb
Normal file
13
test/controllers/job_logs_controller_test.rb
Normal 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
23
test/fixtures/job_logs.yml
vendored
Normal 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
9
test/fixtures/users.yml
vendored
Normal 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
|
||||||
7
test/jobs/restart_server_job_test.rb
Normal file
7
test/jobs/restart_server_job_test.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class RestartServerJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/jobs/start_server_job_test.rb
Normal file
7
test/jobs/start_server_job_test.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class StartServerJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/jobs/stop_server_job_test.rb
Normal file
7
test/jobs/stop_server_job_test.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class StopServerJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/job_log_test.rb
Normal file
7
test/models/job_log_test.rb
Normal 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
7
test/models/user_test.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class UserTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue