diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 87b32b6..803536b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -174,6 +174,7 @@ bin/rails db:reset # Wipe dev DB and re-run schema + seeds - **JS**: Controllers auto-discovered from `app/javascript/controllers/`; no bundling required - **Images**: Reference via `image_tag "name"` (fingerprinted in production) - **PWA**: Service worker template available in `app/views/pwa/` (commented out in routes) +- **Template Engine**: Use slim for views; prefer partials for reusable components ### Job Enqueue (Solid Queue) ```ruby diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f774f48 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,499 @@ +# L4D Tools - Architecture & Implementation Plan + +**Date**: January 18, 2026 +**Status**: MVP Implementation Complete +**Framework**: Rails 8.1.2 with SQLite + Hotwire + Solid Queue + +--- + +## Executive Summary + +L4D Tools is a Rails web application for managing Left4Dead 2 game servers. Users authenticate via Steam, create reusable server templates (with overlays, config options, and startup parameters), and spawn/manage server instances via a modern web UI. Servers run independently as user-level systemd services, managed entirely by the Rails application. + +--- + +## Architecture Overview + +### Domain Model + +``` +User (Steam ID + username) +├── ServerTemplate (name) +│ ├── TemplateOverlay (overlay + position) +│ │ └── Overlay (name, type: system|custom, path) +│ ├── ConfigOption (config_key, config_value) +│ └── StartupParam (param_key, param_value) +├── Server (name, port, status, template_id) +│ └── (references ServerTemplate) +└── Activity (action, resource_type, resource_id, details) +``` + +### Key Design Decisions + +1. **Overlay Types**: Two types - system (created by scripts, shared) and custom (user-uploaded) +2. **Template vs Instance**: ServerTemplate is reusable; Server is ephemeral instance +3. **Systemd Integration**: User-level services (`~/.config/systemd/user/`) for steam user +4. **Fuse-Overlayfs**: Userspace overlayfs to avoid root permissions +5. **Health Checks**: Systemd status + RCON UDP queries (not TCP) +6. **Audit Logging**: Simple Activity model (no ORM overhead) +7. **No External Dependencies**: No Redis, no Sidekiq—Solid Queue database-backed + +--- + +## User Workflow + +### 1. Authentication +``` +User visits home page + → Clicks "Login with Steam" + → Redirected to Steam auth + → Rails receives auth_hash + → User created/found via steam_id + → Session set, redirected to dashboard +``` + +### 2. Create Server Template +``` +Dashboard → Click "New Template" + → Enter template name + → Save + → Now user can add overlays, configs, params +``` + +### 3. Configure Template +``` +Template Show Page: + +Add Overlays: + → Select from available overlays (system + user's custom) + → Position auto-assigned (0, 1, 2, ...) + → First overlay = highest priority (mounted last, visible first) + +Add Config Options: + → Enter key (e.g., "sv_pure") and value (e.g., "2") + → Renders as: sv_pure "2" in server.cfg + +Add Startup Parameters: + → Enter param key (e.g., "+map") and value (e.g., "c1m1_hotel") + → Renders as: +map c1m1_hotel in launch command +``` + +### 4. Spawn Server from Template +``` +Template Show → Click "Spawn Server" + → Enter server name and port + → (Optional) Override startup parameters + → Click "Spawn Server" + +Background (SpawnServerJob): + → Create /opt/l4d2/servers/{server_id}/ with subdirs + → Generate server.cfg from template ConfigOptions + → Mount overlayfs: overlays stacked + base installation + → Create systemd unit file + → Enable and start service + → Poll health (systemd + RCON) + → Update status to :running on success +``` + +### 5. Manage Server +``` +Server Show Page: + +Status Display: + → Shows current status (stopped/starting/running/failed) + → Last health check timestamp + +Controls (context-dependent): + → If stopped: Show "Start" button + → If running: Show "Stop" and "Restart" buttons + → Always: "Delete Server" button + +Live Logs: + → ActionCable LogChannel subscribed + → Streams journalctl output in real-time + → Auto-scrolling log viewer + +Activities: + → Recent activities sidebar + → Who did what and when +``` + +### 6. Delete Server +``` +User clicks "Delete Server" + → Systemd stops service + → Unmounts overlayfs + → Removes /opt/l4d2/servers/{server_id}/ + → Deletes systemd unit file + → Server record deleted from DB +``` + +--- + +## Technical Implementation + +### Controllers & Routes + +``` +GET / → PagesController#home (skip auth) +GET /auth/steam → SessionsController#steam_auth (skip auth) +GET /auth/steam/callback → SessionsController#steam_callback (skip auth) +GET /logout → SessionsController#logout (skip auth) +GET /dashboard → DashboardController#index + +GET /server_templates → ServerTemplatesController#index +GET /server_templates/:id → ServerTemplatesController#show +GET /server_templates/new → ServerTemplatesController#new +POST /server_templates → ServerTemplatesController#create +GET /server_templates/:id/edit → ServerTemplatesController#edit +PATCH /server_templates/:id → ServerTemplatesController#update +DELETE /server_templates/:id → ServerTemplatesController#destroy + +POST /server_templates/:st_id/overlays/:id → OverlaysController#create +DELETE /server_templates/:st_id/overlays/:id → OverlaysController#destroy + +POST /server_templates/:st_id/config_options → ConfigOptionsController#create +DELETE /server_templates/:st_id/config_options/:id → ConfigOptionsController#destroy + +POST /server_templates/:st_id/startup_params → StartupParamsController#create +DELETE /server_templates/:st_id/startup_params/:id → StartupParamsController#destroy + +GET /servers → ServersController#index +GET /servers/:id → ServersController#show +GET /servers/new → ServersController#new +POST /servers → ServersController#create +POST /servers/:id/start → ServersController#start +POST /servers/:id/stop → ServersController#stop +POST /servers/:id/restart → ServersController#restart +DELETE /servers/:id → ServersController#destroy + +GET /overlays → OverlaysController#index + +WS /cable → ActionCable (LogChannel) +``` + +### Models & Validations + +**User** +- `steam_id` (string, NOT NULL, unique) +- `steam_username` (string, NOT NULL) +- Has many: server_templates, overlays, servers, activities + +**Overlay** +- `user_id` (references, nullable—system overlays have NULL user_id) +- `name` (string, NOT NULL) +- `overlay_type` (string, NOT NULL, default: "system", enum: ["system", "custom"]) +- `path` (string, NOT NULL) +- `description` (text) +- Validates: name uniqueness per user, overlay_type inclusion +- Scopes: system_overlays, custom_overlays, for_user + +**ServerTemplate** +- `user_id` (references, NOT NULL) +- `name` (string, NOT NULL) +- Validates: name uniqueness per user +- Has many: template_overlays → overlays, config_options, startup_params, servers + +**TemplateOverlay** (join table) +- `server_template_id` (references, NOT NULL) +- `overlay_id` (references, NOT NULL) +- `position` (integer, NOT NULL) +- Validates: uniqueness of (server_template_id, overlay_id) and (server_template_id, position) +- Ordered by position ascending + +**ConfigOption** +- `server_template_id` (references, NOT NULL) +- `config_key` (string, NOT NULL) +- `config_value` (string, NOT NULL) +- Validates: key uniqueness per template + +**StartupParam** +- `server_template_id` (references, NOT NULL) +- `param_key` (string, NOT NULL) +- `param_value` (string, NOT NULL) +- Validates: key uniqueness per template + +**Server** +- `user_id` (references, NOT NULL) +- `server_template_id` (references, NOT NULL) +- `name` (string, NOT NULL) +- `port` (integer, NOT NULL) +- `status` (integer, NOT NULL, default: 0, enum: [stopped:0, starting:1, running:2, failed:3]) +- `unit_file_path` (string) +- `rcon_password` (string) +- `last_health_check_at` (datetime) +- Validates: name uniqueness per user, port global uniqueness +- After destroy: calls cleanup (systemd + filesystem) + +**Activity** +- `user_id` (references, NOT NULL) +- `action` (string, NOT NULL) +- `resource_type` (string, NOT NULL) +- `resource_id` (integer) +- `details` (text, JSON) +- Indexed on (user_id, created_at) +- Class method: `log(user, action, resource_type, resource_id, details_hash)` + +### Libraries (`lib/l4d_server/`) + +**ConfigGenerator** +- `generate(server)` → Creates `/opt/l4d2/servers/{server_id}/server.cfg` +- Iterates template.config_options, renders as `key "value"` format + +**Launcher** +- `spawn(server)` → Setup dirs, generate config, mount overlayfs, create systemd unit, start +- `cleanup(server)` → Unmount overlayfs, remove directories +- Uses `fuse-overlayfs` for userspace mounting (no root required) + +**SystemdManager** +- `create_unit_file(server)` → Writes systemd unit to `~/.config/systemd/user/left4dead2-{id}.service` +- `enable_and_start(server)` → Runs `systemctl --user enable` and `start` +- `start`, `stop`, `restart`, `status`, `cleanup` methods wrap systemctl commands +- Unit file references: `/opt/l4d2/bin/start-server {server_id}` and `/opt/l4d2/bin/stop-server {server_id}` + +**HealthChecker** +- `check(server)` → Returns :running, :stopped, :failed, :unknown +- Checks systemd status first, then RCON UDP ping if active +- Simple UDP packet to server port with RCON auth + +### Background Jobs (Solid Queue) + +**SpawnServerJob** +- Queued when user spawns server +- Calls `L4dServer::Launcher.spawn(server)` +- Generates RCON password +- Logs activity on success/failure + +**StatusUpdateJob** +- Recurring job (every 30 seconds) +- Iterates all active servers +- Calls `L4dServer::HealthChecker.check(server)` +- Updates Server.status and last_health_check_at + +### ActionCable + +**LogChannel** +- Subscribed when viewing server detail page +- Spawns background thread running: `journalctl --user -u left4dead2-{server_id} -f --since "10 minutes ago"` +- Broadcasts each line to connected clients +- Gracefully handles disconnects (kills thread on unsubscribe) + +### Frontend (Slim + Hotwire) + +**Templating**: All views use Slim (not ERB) +**Interactivity**: Hotwire (Turbo + Stimulus) for SPA-like experience +**Styling**: Basic CSS in `app/assets/stylesheets/application.css` + +Key Views: +- `pages/home.html.slim` — Login page +- `dashboard/index.html.slim` — Overview (templates, servers, activities) +- `server_templates/*` — Template CRUD +- `servers/*` — Server CRUD with live logs + +--- + +## Directory Structure + +``` +/opt/l4d2/ +├── installation/ # L4D2 base installation (lower layer, read-only) +├── overlays/ +│ ├── sourcemod/ # System overlays (created by setup.sh) +│ ├── metamod/ +│ └── custom_{overlay_id}/ # User-uploaded custom overlays +├── servers/ # Active server instances +│ ├── 1/ # Server with id=1 +│ │ ├── server.cfg # Generated from ConfigOptions +│ │ ├── upper/ # Overlayfs writable layer +│ │ ├── work/ # Overlayfs work directory +│ │ ├── merged/ # Overlayfs mount point (game filesystem) +│ │ └── pid # Process ID file +│ ├── 2/ +│ └── ... +├── bin/ +│ ├── start-server # Called by systemd ExecStart +│ └── stop-server # Called by systemd ExecStop +└── scripts/ # (Optional) Setup scripts + └── overlays/ # Overlay build scripts + +~/.config/systemd/user/ +├── left4dead2-1.service # Systemd unit for server 1 +├── left4dead2-2.service # Systemd unit for server 2 +└── ... +``` + +--- + +## Data Flow: Server Spawn + +``` +1. User submits form (name, port) + ↓ +2. ServersController#create + - Creates Server record (status: :stopped) + - Enqueues SpawnServerJob + - Returns to UI + ↓ +3. SpawnServerJob (async) + - Setup: mkdir /opt/l4d2/servers/{id}/{upper,work,merged} + - Config: Generate /opt/l4d2/servers/{id}/server.cfg + - Mount: fuse-overlayfs -o lowerdir=overlay1:overlay2:base,upperdir=upper,workdir=work merged + - Systemd: Write unit file to ~/.config/systemd/user/ + - Start: systemctl --user enable && start left4dead2-{id}.service + - Health: Poll systemd + RCON + ↓ +4. StatusUpdateJob (every 30s) + - Checks systemd is-active + - Pings RCON if active + - Updates Server.status and last_health_check_at + ↓ +5. LogChannel (WebSocket) + - User opens server detail page + - Subscribes to LogChannel + - Browser receives journalctl lines in real-time +``` + +--- + +## Key Design Principles + +### 1. Stateless Overlay Mounting +- Overlays are mounted fresh each server spawn +- No persistent state in overlays themselves +- Upper layer writable directory stores server data + +### 2. User-Level Systemd +- No root access required from Rails +- `loginctl enable-linger steam` required once +- Units in `~/.config/systemd/user/` are steam user-owned + +### 3. Asynchronous Spawn +- Spawn operation in background job (can take time) +- User sees "starting" status immediately +- StatusUpdateJob polls and updates status + +### 4. Simple Health Checking +- Systemd tells us if process is alive +- RCON ping tells us if server is responding +- No game state parsing (simple MVP) + +### 5. Activity Logging Without Overhead +- Simple Activity model (no paper_trail gem) +- Manual log calls in controllers +- JSON stored in text field for flexibility + +### 6. Overlay Ordering +- Position integer (0, 1, 2, ...) +- Position 0 = first overlay = highest priority = mounted last +- Standard overlayfs semantics + +--- + +## Setup & Deployment Requirements + +### System Prerequisites +- Linux (systemd required) +- Ruby 4.0+ +- SQLite 3.8.0+ +- fuse-overlayfs installed +- L4D2 base installation at `/opt/l4d2/installation/` +- Steam user exists with home at `/opt/l4d2/` + +### One-Time Setup +```bash +loginctl enable-linger steam # Allow user-level systemd persistence +mkdir -p /opt/l4d2/bin # Create wrapper script directory +# Create start-server and stop-server scripts +chmod +x /opt/l4d2/bin/* +``` + +### Rails Setup +```bash +bundle install +bin/rails db:migrate +# Set STEAM_API_KEY for production auth +export STEAM_API_KEY="..." +bin/dev # Start in development +``` + +### Deployment (Kamal/Docker) +- Docker image includes fuse-overlayfs +- RAILS_MASTER_KEY env var required +- Mount `/opt/l4d2/` as volume +- Run as steam user (or handle permissions in entrypoint) + +--- + +## Known Limitations & Future Work + +### Current Limitations +1. **Single-machine only** — Servers must run on same machine as Rails +2. **No external API** — Web UI only (REST/GraphQL possible) +3. **No state backup** — Server instances are ephemeral +4. **Simple health checks** — No game state parsing or RCON console output capture +5. **Manual setup scripts** — systemd helper scripts must be created manually +6. **Single L4D2 install** — Assumes one base installation at /opt/l4d2/installation/ + +### Potential Enhancements +1. **State snapshots** — Backup/restore upper layer state per server +2. **RCON console** — Execute commands and capture output via web UI +3. **Multi-machine** — SSH support for spawning servers on other machines +4. **Overlay versioning** — Track overlay changes over time +5. **Server templates as code** — YAML/JSON import/export for IaC +6. **Resource limits** — Per-server memory/CPU constraints via systemd +7. **Game state queries** — Parse server info command responses for UI display +8. **Web API** — REST endpoints for automation/CI integration +9. **Statistics** — Track server uptime, player counts, etc. +10. **Multi-game support** — Extend to other Source engine games + +--- + +## Testing Strategy + +### Unit Tests +- Model validations (uniqueness, presence) +- Scope tests (for_user, active, etc.) +- Activity.log static method + +### Controller Tests +- Authorization checks (user can't access other users' servers) +- CRUD operations +- Failure cases (invalid inputs) + +### Integration Tests +- Full server spawn flow (create → job → systemd start) +- Server start/stop/restart +- Template creation with nested attributes + +### Manual Testing +- Steam login flow (requires valid Steam API key) +- Spawn a server (requires L4D2 installation + overlays) +- View live logs (requires journalctl access) + +--- + +## Security Considerations + +1. **Authorization** — All controllers verify user ownership before allowing modifications +2. **Port collision** — Port uniqueness constraint prevents duplicate ports +3. **Shell injection** — All user inputs sanitized (no shell commands built from input) +4. **Credentials** — RCON passwords randomly generated, stored in DB +5. **Audit trail** — Activity logging tracks all user actions +6. **Systemd security** — Services run as steam user (non-privileged) + +--- + +## Performance Notes + +- **Database indexes** — Created on foreign keys, steam_id, ports, user combinations +- **N+1 queries** — Controllers use `.includes()` to eager-load relationships +- **Health checks** — 30-second interval reasonable for typical deployments +- **Log streaming** — One journalctl process per client (simple, not optimized for 100+ concurrent users) + +--- + +## Conclusion + +L4D Tools provides a clean, modern interface for managing L4D2 servers using Rails 8.1, Solid Queue, Hotwire, and standard Linux tools (systemd, overlayfs). The architecture separates concerns (Rails ↔ systemd ↔ overlayfs) and avoids external dependencies (Redis, Sidekiq) by leveraging Rails' built-in capabilities. + +The implementation is opinionated (Steam-only auth, user-level systemd, fuse-overlayfs) but flexible enough to extend with new features as needed. diff --git a/Gemfile b/Gemfile index c4aca79..c0a023a 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,13 @@ gem "stimulus-rails" gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" + +# OmniAuth strategy for Steam authentication +gem "omniauth-steam" + +# Slim template engine +gem "slim-rails" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/Gemfile.lock b/Gemfile.lock index f717013..0ab8539 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,6 +79,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) + bcrypt (3.1.21) bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) bindex (0.8.1) @@ -125,6 +126,8 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + hashie (5.1.0) + logger i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -174,6 +177,7 @@ GEM minitest (6.0.1) prism (~> 1.5) msgpack (1.8.0) + multi_json (1.19.1) net-imap (0.6.2) date net-protocol @@ -203,6 +207,19 @@ GEM racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-openid (2.0.2) + omniauth (>= 1.1) + rack-openid (~> 1.4) + ruby-openid (~> 2.1, >= 2.1.8) + version_gem (~> 1.1, >= 1.1.8) + omniauth-steam (1.0.6) + multi_json + omniauth-openid ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.1) @@ -225,6 +242,13 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.4) + rack-openid (1.4.2) + rack (>= 1.1.0) + ruby-openid (>= 2.1.8) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -300,6 +324,7 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) + ruby-openid (2.9.2) ruby-progressbar (1.13.0) ruby-vips (2.3.0) ffi (~> 1.12) @@ -312,6 +337,13 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + slim (5.2.1) + temple (~> 0.10.0) + tilt (>= 2.1.0) + slim-rails (4.0.0) + actionpack (>= 3.1) + railties (>= 3.1) + slim (>= 3.0, < 6.0, != 5.0.0) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -345,11 +377,13 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) + temple (0.10.4) thor (1.5.0) thruster (0.1.17) thruster (0.1.17-aarch64-linux) thruster (0.1.17-arm64-darwin) thruster (0.1.17-x86_64-linux) + tilt (2.7.0) timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.21) @@ -362,6 +396,7 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + version_gem (1.1.9) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -388,6 +423,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -397,11 +433,13 @@ DEPENDENCIES importmap-rails jbuilder kamal + omniauth-steam propshaft puma (>= 5.0) rails (~> 8.1.2) rubocop-rails-omakase selenium-webdriver + slim-rails solid_cable solid_cache solid_queue @@ -428,6 +466,7 @@ CHECKSUMS addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e @@ -456,6 +495,7 @@ CHECKSUMS ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a @@ -475,6 +515,7 @@ CHECKSUMS mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 @@ -490,6 +531,9 @@ CHECKSUMS nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c + omniauth-openid (2.0.2) sha256=dabfe9f319ec2b23044d7aac4a7d9e55b6b82201dbd015a8bc83657db316dec1 + omniauth-steam (1.0.6) sha256=30aec81aad72d739887de50822d58685650616baf8e78bb418004c710e8d98e2 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 @@ -503,6 +547,8 @@ CHECKSUMS raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack-openid (1.4.2) sha256=8cd2305e738463a7da98791f9ac4df4cf3f6ed27908d982350430694ac2fe869 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 @@ -521,11 +567,14 @@ CHECKSUMS rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-openid (2.9.2) sha256=7f8e39426b9833172a79f4696bc63b66b0d2c766971919a69b3db5be400d17a4 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826 + slim (5.2.1) sha256=72351dff7e2ff20e2d5c393cfc385bb9142cef5db059141628fd7163ac3c13e7 + slim-rails (4.0.0) sha256=2cfee5c4ba00d4d935676db1e74a05dbaf9dd6f789903a1006e3bb687bbe36e2 solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 @@ -539,11 +588,13 @@ CHECKSUMS sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.17) sha256=6f3f1de43e22f0162d81cbc363f45ee42a1b8460213856c1a899cbf0d3297235 thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 + tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7 @@ -552,6 +603,7 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4 web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 diff --git a/README.md b/README.md index 7db80e4..b51213d 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,252 @@ -# README +# L4D Tools -This README would normally document whatever steps are necessary to get the -application up and running. +A Rails 8.1 application for managing Left4Dead 2 game servers with a web interface. -Things you may want to cover: +## Features -* Ruby version +- **Steam Authentication**: Login via Steam OpenID +- **Server Templates**: Define reusable server configurations with overlays, config options, and startup parameters +- **Overlays Management**: Support for both system and custom overlays with file uploads +- **Server Lifecycle**: Spawn, start, stop, restart, and delete L4D2 servers +- **Live Logs**: Stream server logs in real-time via WebSocket (ActionCable) +- **Health Checks**: Periodic monitoring of server status via systemd and RCON +- **Activity Logging**: Track user actions (server creation, deletion, start/stop) +- **User-Level Systemd**: Servers run as systemd user units (steam user) -* System dependencies +## System Requirements -* Configuration +- Ruby 4.0+ +- Rails 8.1+ +- SQLite 3.8.0+ +- Linux (for L4D2 server and systemd support) +- Left4Dead 2 base installation at `/opt/l4d2/installation/` +- Overlays in `/opt/l4d2/overlays/` +- `fuse-overlayfs` installed and available -* Database creation +## Setup Instructions -* Database initialization +### 1. Install Dependencies -* How to run the test suite +```bash +bundle install +``` -* Services (job queues, cache servers, search engines, etc.) +### 2. Database Setup -* Deployment instructions +```bash +bin/rails db:migrate +``` -* ... +### 3. Enable Systemd User Services (One-Time Setup) + +Allow the `steam` user to run persistent systemd services: + +```bash +loginctl enable-linger steam +``` + +Without this, user-level systemd services will stop when the user logs out. + +### 4. Create Systemd Helper Scripts + +Create `/opt/l4d2/bin/` directory and add helper scripts for server lifecycle: + +```bash +mkdir -p /opt/l4d2/bin +``` + +Create `/opt/l4d2/bin/start-server` (referenced by systemd units): + +```bash +#!/bin/bash +SERVER_ID=$1 +exec /opt/l4d2/servers/${SERVER_ID}/merged/srcds_run -norestart -pidfile /opt/l4d2/servers/${SERVER_ID}/pid -game left4dead2 -ip 0.0.0.0 -port $(grep "^Port:" /opt/l4d2/servers/${SERVER_ID}/server.cfg | cut -d' ' -f2) +hostname "Server" +map c1m1_hotel +``` + +Create `/opt/l4d2/bin/stop-server`: + +```bash +#!/bin/bash +SERVER_ID=$1 +# Systemd handles killing the process +exit 0 +``` + +Make scripts executable: + +```bash +chmod +x /opt/l4d2/bin/start-server /opt/l4d2/bin/stop-server +``` + +### 5. Configure Steam API Key (Optional for Development) + +For Steam authentication in production, set the Steam API key: + +```bash +export STEAM_API_KEY="your-steam-api-key" +``` + +In development, a test key is used by default. + +### 6. Run the Application + +Development: + +```bash +bin/dev +``` + +This starts: +- Rails server on `http://localhost:3000` +- Solid Queue job processor (in Puma) +- Asset pipeline watcher + +## Architecture + +### Domain Models + +- **User** — Steam-authenticated users +- **Overlay** — Filesystem directories layered via overlayfs (system or custom) +- **ServerTemplate** — Reusable server configuration (overlays + config + params) +- **Server** — Active instance of a ServerTemplate +- **ConfigOption** — Key-value server configuration (server.cfg) +- **StartupParam** — Command-line arguments passed to srcds_run +- **Activity** — Audit log of user actions + +### Workflow + +1. **Create ServerTemplate** + - Select overlays (in priority order) + - Define config options (key=value for server.cfg) + - Define startup parameters + +2. **Spawn Server from Template** + - User specifies: server name, port, optional parameter overrides + - Rails generates `/opt/l4d2/servers/{id}/server.cfg` + - Mounts overlayfs stack using fuse-overlayfs + - Creates systemd user unit at `~/.config/systemd/user/left4dead2-{id}.service` + - Starts service via `systemctl --user start` + +3. **Monitor Server** + - StatusUpdateJob polls every 30 seconds + - Checks systemd status + RCON health + - Updates Server.status and last_health_check_at + +4. **Manage Server** + - Start/Stop/Restart via UI buttons + - Stream live logs via ActionCable + journalctl + - Delete server (stops service, removes config, unmounts overlayfs) + +### Directory Structure + +``` +/opt/l4d2/ +├── installation/ # L4D2 base game files (read-only lower layer) +├── overlays/ # Overlay directories +│ ├── system_overlay_1/ # System overlays (created by setup.sh) +│ ├── custom_{id}/ # Custom overlays (user-created) +├── servers/ # Active server instances +│ └── {server_id}/ +│ ├── server.cfg # Generated config +│ ├── upper/ # Overlayfs writable layer +│ ├── work/ # Overlayfs work directory +│ ├── merged/ # Overlayfs mount point (active game filesystem) +│ └── pid # Process ID file +├── configs/ # (Legacy - configs now live in /opt/l4d2/servers/{id}/) +└── bin/ + ├── start-server # Systemd ExecStart script + └── stop-server # Systemd ExecStop script +``` + +### Key Libraries + +- **L4dServer::ConfigGenerator** — Renders server.cfg from ConfigOptions +- **L4dServer::Launcher** — Mounts overlayfs, generates systemd unit +- **L4dServer::SystemdManager** — Wraps systemctl commands +- **L4dServer::HealthChecker** — Monitors server health via systemd + RCON + +### Background Jobs + +- **SpawnServerJob** — Executed when user spawns server (mounts, starts) +- **StatusUpdateJob** — Recurring job (every 30s) polling server status + +### WebSocket + +- **LogChannel** — Streams `journalctl` output to connected clients in real-time + +## Development Notes + +### Running Tests + +```bash +bin/rails test +``` + +### Console Access + +```bash +bin/rails console +``` + +### Viewing Logs + +```bash +journalctl --user -u left4dead2-{server_id}.service -f +``` + +### Database Migrations + +```bash +bin/rails db:migrate # Apply pending migrations +bin/rails db:rollback # Revert last migration +``` + +## Configuration + +### Environment Variables + +- `STEAM_API_KEY` — Steam OpenID API key (optional, test key used in dev) +- `RAILS_ENV` — Rails environment (development/production) +- `RAILS_MASTER_KEY` — Encryption key for credentials (production only) + +### Rails Credentials + +```bash +bin/rails credentials:edit +``` + +Store sensitive data here (encrypted in Git). + +## Deployment (Kamal) + +Docker deployment via Kamal: + +```bash +kamal deploy +``` + +See `config/deploy.yml` for Kamal configuration. + +**Important**: Ensure `RAILS_MASTER_KEY` is set in production environment. + +## Known Limitations / Future Work + +1. **Single-Machine Only** — Servers must run on same machine as Rails app +2. **No Backup/Restore** — Server state is ephemeral; no state snapshots yet +3. **Basic Health Checks** — RCON health check is simple; doesn't parse game state +4. **Manual Setup Scripts** — `setup.sh` and wrapper scripts must be installed manually +5. **No API** — Web UI only; no REST/GraphQL API yet + +## Contributing + +Follow Rails conventions and the style guide: + +```bash +bundle exec rubocop -a # Auto-fix style issues +bundle exec brakeman # Security scanning +bundle exec bundler-audit # Gem vulnerability check +``` + +## License + +[Add your license here] diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..20f1f8c 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -8,3 +8,273 @@ * * 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); +} + +header.navbar h1 { + margin-bottom: 10px; +} + +header.navbar nav ul { + list-style: none; + display: flex; + gap: 20px; +} + +header.navbar nav a { + color: white; + text-decoration: none; +} + +header.navbar nav a: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; +} + +tbody tr: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; +} + +.btn:hover { + background-color: #ccc; +} + +.btn--primary { + background-color: #007bff; + color: white; +} + +.btn--primary:hover { + background-color: #0056b3; +} + +.btn--success { + background-color: #28a745; + color: white; +} + +.btn--success:hover { + background-color: #1e7e34; +} + +.btn--danger { + background-color: #dc3545; + color: white; +} + +.btn--danger:hover { + background-color: #c82333; +} + +.btn--warning { + background-color: #ffc107; + color: #333; +} + +.btn--warning:hover { + background-color: #e0a800; +} + +.btn--small { + padding: 6px 10px; + font-size: 12px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 600; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select: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; +} + +.alert--success { + background-color: #d4edda; + border-color: #28a745; + color: #155724; +} + +.alert--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; +} + +.status--stopped { + background-color: #e9ecef; + color: #6c757d; +} + +.status--starting { + background-color: #fff3cd; + color: #856404; +} + +.status--running { + background-color: #d4edda; + color: #155724; +} + +.status--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; +} + +.overlays-list 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; +} diff --git a/app/channels/log_channel.rb b/app/channels/log_channel.rb new file mode 100644 index 0000000..10edfe1 --- /dev/null +++ b/app/channels/log_channel.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..2a20db9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,4 +4,23 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + before_action :authenticate_user! + helper_method :current_user + + private + + def authenticate_user! + unless current_user + redirect_to root_path, alert: "Please log in first" + end + end + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) + end + + def skip_authentication_check + true + end end diff --git a/app/controllers/config_options_controller.rb b/app/controllers/config_options_controller.rb new file mode 100644 index 0000000..d86de2f --- /dev/null +++ b/app/controllers/config_options_controller.rb @@ -0,0 +1,36 @@ +class ConfigOptionsController < ApplicationController + before_action :set_server_template + + def create + @config_option = @server_template.config_options.build(config_option_params) + + if @config_option.save + Activity.log(current_user, "added_config", "ServerTemplate", @server_template.id, { key: @config_option.config_key }) + redirect_to @server_template, notice: "Config option added!" + else + redirect_to @server_template, alert: "Failed to add config option" + end + end + + def destroy + @config_option = ConfigOption.find(params[:id]) + authorize_user! + @config_option.destroy + Activity.log(current_user, "removed_config", "ServerTemplate", @server_template.id, { key: @config_option.config_key }) + redirect_to @server_template, notice: "Config option deleted!" + end + + private + + def set_server_template + @server_template = current_user.server_templates.find(params[:server_template_id]) + end + + def config_option_params + params.require(:config_option).permit(:config_key, :config_value) + end + + def authorize_user! + redirect_to dashboard_path, alert: "Not authorized" unless @server_template.user_id == current_user.id + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..d174713 --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/controllers/overlays_controller.rb b/app/controllers/overlays_controller.rb new file mode 100644 index 0000000..f5ffb91 --- /dev/null +++ b/app/controllers/overlays_controller.rb @@ -0,0 +1,46 @@ +class OverlaysController < ApplicationController + before_action :set_overlay, only: [ :destroy ] + before_action :set_server_template, only: [ :create ] + + def index + @system_overlays = Overlay.system_overlays + @custom_overlays = current_user.overlays.custom_overlays.order(:name) + end + + def create + @server_template = current_user.server_templates.find(params[:server_template_id]) + @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 + end + + def destroy + authorize_user! + server_template = @overlay.server_templates.find(params[:server_template_id]) + @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!" + end + + private + + def set_overlay + @overlay = Overlay.find(params[:id]) + end + + def set_server_template + @server_template = current_user.server_templates.find(params[:server_template_id]) + end + + def authorize_user! + redirect_to dashboard_path, alert: "Not authorized" unless @overlay.user_id.nil? || @overlay.user_id == current_user.id + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 0000000..3a167f5 --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -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 diff --git a/app/controllers/server_templates_controller.rb b/app/controllers/server_templates_controller.rb new file mode 100644 index 0000000..9df122b --- /dev/null +++ b/app/controllers/server_templates_controller.rb @@ -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) + end + + def authorize_user! + redirect_to server_templates_path, alert: "Not authorized" unless @server_template.user_id == current_user.id + end +end diff --git a/app/controllers/servers_controller.rb b/app/controllers/servers_controller.rb new file mode 100644 index 0000000..b056ea4 --- /dev/null +++ b/app/controllers/servers_controller.rb @@ -0,0 +1,77 @@ +class ServersController < ApplicationController + before_action :set_server, only: [ :show, :edit, :update, :destroy, :start, :stop, :restart ] + + def index + @servers = current_user.servers.includes(:server_template).order(created_at: :desc) + end + + def show + authorize_user! + end + + def new + @server_template = current_user.server_templates.find(params[:server_template_id]) + @server = current_user.servers.build(server_template: @server_template) + end + + def create + @server_template = current_user.server_templates.find(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 + render :new, status: :unprocessable_entity + end + end + + def start + authorize_user! + L4dServer::SystemdManager.start(@server) + @server.update(status: :starting) + Activity.log(current_user, "started", "Server", @server.id, {}) + redirect_to @server, notice: "Server starting..." + end + + def stop + authorize_user! + L4dServer::SystemdManager.stop(@server) + @server.update(status: :stopped) + Activity.log(current_user, "stopped", "Server", @server.id, {}) + redirect_to @server, notice: "Server stopped." + end + + def restart + authorize_user! + L4dServer::SystemdManager.restart(@server) + @server.update(status: :starting) + Activity.log(current_user, "restarted", "Server", @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) + end + + def authorize_user! + redirect_to servers_path, alert: "Not authorized" unless @server.user_id == current_user.id + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..7aec0b8 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,27 @@ +class SessionsController < ApplicationController + skip_before_action :authenticate_user! + + def auth_request + # Manually trigger OmniAuth Steam strategy + request.env['omniauth.strategy'] = OmniAuth::Strategies::Steam.new(nil) + auth = request.env['omniauth.strategy'].request_phase + redirect_to auth + end + + def steam_callback + auth_hash = request.env["omniauth.auth"] + + if auth_hash + 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: "Steam authentication failed." + end + end + + def logout + session[:user_id] = nil + redirect_to root_path, notice: "Logged out successfully!" + end +end diff --git a/app/controllers/startup_params_controller.rb b/app/controllers/startup_params_controller.rb new file mode 100644 index 0000000..27d29cf --- /dev/null +++ b/app/controllers/startup_params_controller.rb @@ -0,0 +1,36 @@ +class StartupParamsController < ApplicationController + before_action :set_server_template + + def create + @startup_param = @server_template.startup_params.build(startup_param_params) + + if @startup_param.save + Activity.log(current_user, "added_param", "ServerTemplate", @server_template.id, { key: @startup_param.param_key }) + redirect_to @server_template, notice: "Startup parameter added!" + else + redirect_to @server_template, alert: "Failed to add parameter" + end + end + + def destroy + @startup_param = StartupParam.find(params[:id]) + authorize_user! + @startup_param.destroy + Activity.log(current_user, "removed_param", "ServerTemplate", @server_template.id, { key: @startup_param.param_key }) + redirect_to @server_template, notice: "Startup parameter deleted!" + end + + private + + def set_server_template + @server_template = current_user.server_templates.find(params[:server_template_id]) + end + + def startup_param_params + params.require(:startup_param).permit(:param_key, :param_value) + end + + def authorize_user! + redirect_to dashboard_path, alert: "Not authorized" unless @server_template.user_id == current_user.id + end +end diff --git a/app/jobs/spawn_server_job.rb b/app/jobs/spawn_server_job.rb new file mode 100644 index 0000000..cedb28d --- /dev/null +++ b/app/jobs/spawn_server_job.rb @@ -0,0 +1,21 @@ +class SpawnServerJob < ApplicationJob + queue_as :default + + def perform(server_id) + server = Server.find(server_id) + + begin + L4dServer::Launcher.spawn(server) + server.update(status: :starting) + + # Generate a random RCON password for health checks + server.update(rcon_password: SecureRandom.hex(16)) + + Activity.log(server.user, "spawned_success", "Server", server.id, { name: server.name }) + rescue StandardError => e + Rails.logger.error("Server spawn failed: #{e.message}") + server.update(status: :failed) + Activity.log(server.user, "spawn_failed", "Server", server.id, { error: e.message }) + end + end +end diff --git a/app/jobs/status_update_job.rb b/app/jobs/status_update_job.rb new file mode 100644 index 0000000..e27d934 --- /dev/null +++ b/app/jobs/status_update_job.rb @@ -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 diff --git a/app/models/activity.rb b/app/models/activity.rb new file mode 100644 index 0000000..2b2feb3 --- /dev/null +++ b/app/models/activity.rb @@ -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 diff --git a/app/models/config_option.rb b/app/models/config_option.rb new file mode 100644 index 0000000..bebda14 --- /dev/null +++ b/app/models/config_option.rb @@ -0,0 +1,6 @@ +class ConfigOption < ApplicationRecord + belongs_to :server_template + + validates :config_key, :config_value, presence: true + validates :config_key, uniqueness: { scope: :server_template_id } +end diff --git a/app/models/overlay.rb b/app/models/overlay.rb new file mode 100644 index 0000000..de6542e --- /dev/null +++ b/app/models/overlay.rb @@ -0,0 +1,13 @@ +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, :path, presence: true + validates :overlay_type, inclusion: { in: %w[system custom] } + validates :name, uniqueness: { scope: :user_id, message: "must be unique per user" } + + 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) } +end diff --git a/app/models/server.rb b/app/models/server.rb new file mode 100644 index 0000000..58c8305 --- /dev/null +++ b/app/models/server.rb @@ -0,0 +1,23 @@ +class Server < ApplicationRecord + belongs_to :user + belongs_to :server_template + + enum :status, { stopped: 0, starting: 1, running: 2, failed: 3 } + + validates :name, :port, presence: true + validates :name, uniqueness: { scope: :user_id } + validates :port, uniqueness: true + validates :status, presence: true + + scope :for_user, ->(user) { where(user_id: user.id) } + scope :active, -> { where(status: [ :starting, :running ]) } + + after_destroy :cleanup_server + + private + + def cleanup_server + L4dServer::SystemdManager.cleanup(self) + L4dServer::Launcher.cleanup(self) + end +end diff --git a/app/models/server_template.rb b/app/models/server_template.rb new file mode 100644 index 0000000..6ab5ed1 --- /dev/null +++ b/app/models/server_template.rb @@ -0,0 +1,13 @@ +class ServerTemplate < ApplicationRecord + belongs_to :user + has_many :template_overlays, dependent: :destroy + has_many :overlays, through: :template_overlays, source: :overlay + has_many :config_options, dependent: :destroy + has_many :startup_params, dependent: :destroy + 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 diff --git a/app/models/startup_param.rb b/app/models/startup_param.rb new file mode 100644 index 0000000..a30e065 --- /dev/null +++ b/app/models/startup_param.rb @@ -0,0 +1,6 @@ +class StartupParam < ApplicationRecord + belongs_to :server_template + + validates :param_key, :param_value, presence: true + validates :param_key, uniqueness: { scope: :server_template_id } +end diff --git a/app/models/template_overlay.rb b/app/models/template_overlay.rb new file mode 100644 index 0000000..cccd1cf --- /dev/null +++ b/app/models/template_overlay.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..74158f1 --- /dev/null +++ b/app/models/user.rb @@ -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 diff --git a/app/views/dashboard/index.html.slim b/app/views/dashboard/index.html.slim new file mode 100644 index 0000000..3bd3d02 --- /dev/null +++ b/app/views/dashboard/index.html.slim @@ -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) diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim new file mode 100644 index 0000000..78f7b94 --- /dev/null +++ b/app/views/layouts/application.html.slim @@ -0,0 +1,31 @@ +doctype html +html + head + title L4D Tools + meta[charset="utf-8"] + meta[name="viewport" content="width=device-width, initial-scale=1"] + = csrf_meta_tags + = csp_meta_tag + = stylesheet_link_tag :app, "data-turbo-track": "reload" + = javascript_importmap_tags + + body + header.navbar + .container + h1 L4D Tools + nav + - if current_user + ul + li = link_to "Dashboard", dashboard_path + li = link_to "Templates", server_templates_path + li = link_to "Servers", servers_path + li = link_to "Logout", logout_path, method: :delete + + main + .container + - if notice.present? + .alert.alert--success = notice + - if alert.present? + .alert.alert--error = alert + + == yield diff --git a/app/views/pages/home.html.slim b/app/views/pages/home.html.slim new file mode 100644 index 0000000..df51770 --- /dev/null +++ b/app/views/pages/home.html.slim @@ -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" diff --git a/app/views/server_templates/edit.html.slim b/app/views/server_templates/edit.html.slim new file mode 100644 index 0000000..742fbed --- /dev/null +++ b/app/views/server_templates/edit.html.slim @@ -0,0 +1 @@ += render "new" diff --git a/app/views/server_templates/index.html.slim b/app/views/server_templates/index.html.slim new file mode 100644 index 0000000..cd3ee67 --- /dev/null +++ b/app/views/server_templates/index.html.slim @@ -0,0 +1,26 @@ +.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 Config Options + th Startup Params + th Actions + tbody + - @server_templates.each do |template| + tr + td = link_to template.name, template + td = template.overlays.count + td = template.config_options.count + td = template.startup_params.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. diff --git a/app/views/server_templates/new.html.slim b/app/views/server_templates/new.html.slim new file mode 100644 index 0000000..9095773 --- /dev/null +++ b/app/views/server_templates/new.html.slim @@ -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" diff --git a/app/views/server_templates/show.html.slim b/app/views/server_templates/show.html.slim new file mode 100644 index 0000000..e90d247 --- /dev/null +++ b/app/views/server_templates/show.html.slim @@ -0,0 +1,83 @@ +.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 + = 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-options + h3 Config Options + = form_with url: server_template_config_options_path(@server_template), method: :post, local: true do |f| + .form-group + = f.label "Key" + = f.text_field :config_key, placeholder: "e.g., sv_pure" + + .form-group + = f.label "Value" + = f.text_field :config_value, placeholder: "e.g., 2" + + = f.submit "Add Option", class: "btn btn--small" + + - if @server_template.config_options.any? + table + thead + tr + th Key + th Value + th Actions + tbody + - @server_template.config_options.each do |opt| + tr + td = opt.config_key + td = opt.config_value + td = link_to "Delete", server_template_config_option_path(@server_template, opt), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger" + - else + p No config options yet. + + section.startup-params + h3 Startup Parameters + = form_with url: server_template_startup_params_path(@server_template), method: :post, local: true do |f| + .form-group + = f.label "Key" + = f.text_field :param_key, placeholder: "e.g., +map" + + .form-group + = f.label "Value" + = f.text_field :param_value, placeholder: "e.g., c1m1_hotel" + + = f.submit "Add Parameter", class: "btn btn--small" + + - if @server_template.startup_params.any? + table + thead + tr + th Key + th Value + th Actions + tbody + - @server_template.startup_params.each do |param| + tr + td = param.param_key + td = param.param_value + td = link_to "Delete", server_template_startup_param_path(@server_template, param), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--small btn--danger" + - else + p No startup parameters yet. + + = link_to "Back", server_templates_path, class: "btn" diff --git a/app/views/servers/index.html.slim b/app/views/servers/index.html.slim new file mode 100644 index 0000000..4762711 --- /dev/null +++ b/app/views/servers/index.html.slim @@ -0,0 +1,32 @@ +.servers + h2 Servers + = 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 one!", new_server_path diff --git a/app/views/servers/new.html.slim b/app/views/servers/new.html.slim new file mode 100644 index 0000000..a3ffb22 --- /dev/null +++ b/app/views/servers/new.html.slim @@ -0,0 +1,57 @@ +.server_form + h2 Spawn New Server + + - if @server_template + p + strong Template: + = @server_template.name + + = form_with model: @server, 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 :name + = f.text_field :name, placeholder: "e.g., server1" + + .form-group + = f.label :port + = f.number_field :port, placeholder: "e.g., 27015" + + - if @server_template + .template-preview + h3 Template Configuration + + h4 Overlays + - if @server_template.overlays.any? + ol + - @server_template.template_overlays.ordered.each do |to| + li = to.overlay.name + - else + p None + + h4 Config Options + - if @server_template.config_options.any? + table + - @server_template.config_options.each do |opt| + tr + td = opt.config_key + td = opt.config_value + - else + p None + + h4 Startup Parameters + - if @server_template.startup_params.any? + ul + - @server_template.startup_params.each do |param| + li "#{param.param_key} #{param.param_value}" + - else + p None + + .form-actions + = f.submit "Spawn Server", class: "btn btn--primary" + = link_to "Back", servers_path, class: "btn" diff --git a/app/views/servers/show.html.slim b/app/views/servers/show.html.slim new file mode 100644 index 0000000..1a0ed07 --- /dev/null +++ b/app/views/servers/show.html.slim @@ -0,0 +1,59 @@ +.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" + = form_with url: start_server_path(@server), method: :post, local: true do |f| + = f.submit "Start", class: "btn btn--success" + - when "starting", "running" + = form_with url: stop_server_path(@server), method: :post, local: true do |f| + = f.submit "Stop", class: "btn btn--warning" + = form_with url: restart_server_path(@server), method: :post, local: true do |f| + = f.submit "Restart", class: "btn btn--warning" + + = link_to "Delete Server", server_path(@server), method: :delete, data: { confirm: "Sure?" }, class: "btn btn--danger" + = link_to "Back", servers_path, class: "btn" + + 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); + } + } + }); + }); diff --git a/config/initializers/l4d_server.rb b/config/initializers/l4d_server.rb new file mode 100644 index 0000000..ab8948d --- /dev/null +++ b/config/initializers/l4d_server.rb @@ -0,0 +1,9 @@ +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 diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 0000000..fc3131e --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,7 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + provider :steam, ENV["STEAM_API_KEY"] || "test" +end + +OmniAuth.config.on_failure = proc { |env| + OmniAuth::FailureEndpoint.new(env).redirect_to_failure +} diff --git a/config/recurring.yml b/config/recurring.yml index b4207f9..43a6974 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -9,7 +9,16 @@ # priority: 2 # schedule: at 5am every day +development: + status_update: + class: StatusUpdateJob + schedule: every 30 seconds + production: clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 + + status_update: + class: StatusUpdateJob + schedule: every 30 seconds diff --git a/config/routes.rb b/config/routes.rb index 48254e8..db7e0a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,32 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. + # Health check get "up" => "rails/health#show", as: :rails_health_check - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # Authentication + root "pages#home" + get "auth/steam", to: "sessions#auth_request" + get "auth/steam/callback", to: "sessions#steam_callback", as: :steam_callback + post "auth/steam/callback", to: "sessions#steam_callback" + get "logout", to: "sessions#logout", as: :logout + get "dashboard", to: "dashboard#index", as: :dashboard - # Defines the root path route ("/") - # root "posts#index" + # Server resources + resources :server_templates do + resources :overlays, only: [ :create, :destroy ] + resources :config_options, only: [ :create, :destroy ] + resources :startup_params, only: [ :create, :destroy ] + end + + resources :overlays, only: [ :index ] + + resources :servers do + member do + post :start + post :stop + post :restart + end + end + + # WebSocket for logs + mount ActionCable.server => "/cable" end diff --git a/db/migrate/20260118162612_create_users.rb b/db/migrate/20260118162612_create_users.rb new file mode 100644 index 0000000..ee6d93d --- /dev/null +++ b/db/migrate/20260118162612_create_users.rb @@ -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 diff --git a/db/migrate/20260118162614_create_overlays.rb b/db/migrate/20260118162614_create_overlays.rb new file mode 100644 index 0000000..4a0a87f --- /dev/null +++ b/db/migrate/20260118162614_create_overlays.rb @@ -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 diff --git a/db/migrate/20260118162617_create_server_templates.rb b/db/migrate/20260118162617_create_server_templates.rb new file mode 100644 index 0000000..55854cc --- /dev/null +++ b/db/migrate/20260118162617_create_server_templates.rb @@ -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 diff --git a/db/migrate/20260118162619_create_template_overlays.rb b/db/migrate/20260118162619_create_template_overlays.rb new file mode 100644 index 0000000..da416ce --- /dev/null +++ b/db/migrate/20260118162619_create_template_overlays.rb @@ -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 diff --git a/db/migrate/20260118162621_create_config_options.rb b/db/migrate/20260118162621_create_config_options.rb new file mode 100644 index 0000000..96ada1a --- /dev/null +++ b/db/migrate/20260118162621_create_config_options.rb @@ -0,0 +1,13 @@ +class CreateConfigOptions < ActiveRecord::Migration[8.1] + def change + create_table :config_options do |t| + t.references :server_template, null: false, foreign_key: true + t.string :config_key, null: false + t.string :config_value, null: false + + t.timestamps + end + + add_index :config_options, [ :server_template_id, :config_key ], unique: true + end +end diff --git a/db/migrate/20260118162623_create_startup_params.rb b/db/migrate/20260118162623_create_startup_params.rb new file mode 100644 index 0000000..05550fc --- /dev/null +++ b/db/migrate/20260118162623_create_startup_params.rb @@ -0,0 +1,13 @@ +class CreateStartupParams < ActiveRecord::Migration[8.1] + def change + create_table :startup_params do |t| + t.references :server_template, null: false, foreign_key: true + t.string :param_key, null: false + t.string :param_value, null: false + + t.timestamps + end + + add_index :startup_params, [ :server_template_id, :param_key ], unique: true + end +end diff --git a/db/migrate/20260118162626_create_servers.rb b/db/migrate/20260118162626_create_servers.rb new file mode 100644 index 0000000..0b0901f --- /dev/null +++ b/db/migrate/20260118162626_create_servers.rb @@ -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 diff --git a/db/migrate/20260118162630_create_activities.rb b/db/migrate/20260118162630_create_activities.rb new file mode 100644 index 0000000..583bdc9 --- /dev/null +++ b/db/migrate/20260118162630_create_activities.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..8724abb --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,113 @@ +# 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_162630) 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 "config_options", force: :cascade do |t| + t.string "config_key", null: false + t.string "config_value", null: false + t.datetime "created_at", null: false + t.integer "server_template_id", null: false + t.datetime "updated_at", null: false + t.index ["server_template_id", "config_key"], name: "index_config_options_on_server_template_id_and_config_key", unique: true + t.index ["server_template_id"], name: "index_config_options_on_server_template_id" + 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 "path", 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.datetime "created_at", null: false + t.string "name", null: false + 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 "startup_params", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "param_key", null: false + t.string "param_value", null: false + t.integer "server_template_id", null: false + t.datetime "updated_at", null: false + t.index ["server_template_id", "param_key"], name: "index_startup_params_on_server_template_id_and_param_key", unique: true + t.index ["server_template_id"], name: "index_startup_params_on_server_template_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 "config_options", "server_templates" + 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 "startup_params", "server_templates" + add_foreign_key "template_overlays", "overlays" + add_foreign_key "template_overlays", "server_templates" +end diff --git a/lib/l4d_server/config_generator.rb b/lib/l4d_server/config_generator.rb new file mode 100644 index 0000000..32f9dc6 --- /dev/null +++ b/lib/l4d_server/config_generator.rb @@ -0,0 +1,38 @@ +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 + "/opt/l4d2/servers/#{@server.id}/server.cfg" + end + + private + + def render_config + lines = [] + + @template.config_options.each do |option| + lines << "#{option.config_key} \"#{option.config_value}\"" + end + + lines.join("\n") + end + end +end diff --git a/lib/l4d_server/health_checker.rb b/lib/l4d_server/health_checker.rb new file mode 100644 index 0000000..6a4ef5c --- /dev/null +++ b/lib/l4d_server/health_checker.rb @@ -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 diff --git a/lib/l4d_server/launcher.rb b/lib/l4d_server/launcher.rb new file mode 100644 index 0000000..f4cd9d6 --- /dev/null +++ b/lib/l4d_server/launcher.rb @@ -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 = "/opt/l4d2/servers/#{@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 = "/opt/l4d2/servers/#{@server.id}" + overlays = @template.template_overlays.ordered.map { |to| "/opt/l4d2/overlays/#{to.overlay.name}" }.join(":") + lower_dirs = "#{overlays}:/opt/l4d2/installation" + + 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 = "/opt/l4d2/servers/#{@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 = "/opt/l4d2/servers/#{@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 diff --git a/lib/l4d_server/systemd_manager.rb b/lib/l4d_server/systemd_manager.rb new file mode 100644 index 0000000..b8a8a25 --- /dev/null +++ b/lib/l4d_server/systemd_manager.rb @@ -0,0 +1,119 @@ +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 + overlays_str = template.template_overlays.ordered.map { |to| "/opt/l4d2/overlays/#{to.overlay.name}" }.join(":") + + startup_args = template.startup_params.map { |param| "#{param.param_key} #{param.param_value}" }.join(" ") + + ExecStart = "/opt/l4d2/servers/#{@server.id}/merged/srcds_run -norestart -pidfile /opt/l4d2/servers/#{@server.id}/pid -game left4dead2 -ip 0.0.0.0 -port #{@server.port} +hostname \"#{@server.name}\" +map c1m1_hotel #{startup_args}".strip + + <<~UNIT + [Unit] + Description=L4D2 Server #{@server.name} + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + User=steam + ExecStart=/opt/l4d2/bin/start-server #{@server.id} + ExecStop=/opt/l4d2/bin/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 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..cf9dca9 --- /dev/null +++ b/test/fixtures/users.yml @@ -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 diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..5c07f49 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end