35 Commits

Author SHA1 Message Date
Vantz Stockwell
4abf0ab889 ci(host-agent): Rust agent build pipeline on agent-v* tags -> CDN alpha channel
Some checks failed
Build Host Agent (Rust) / build (push) Failing after 3s
Test Asgard Runner / test (push) Successful in 3s
Separate tag namespace from the Go pipeline (v*.*.*) per the
blast-radius doctrine; artifacts publish to /host-agent/alpha/ and a
versioned dir, leaving /host-agent/latest/ on the Go build until
cutover. Linux = static musl, Windows = mingw (msvc/cargo-xwin stays
the local release path). Tag-vs-Cargo.toml version gate included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:09:43 -04:00
Vantz Stockwell
cea3d66cdd feat(host-agent): Rust rewrite Phase 0 — multi-instance foundation, v2 wire protocol, real telemetry
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
New corrosion-host-agent/ crate (Go companion-agent stays as behavior
reference until parity). Wire protocol v2 per COA-B: instance-scoped
subjects corrosion.{license}.{instance}.* + host-level .host.* — spec
in PROTOCOL.md, designed for the license->host->instance fleet model.

- Multi-instance TOML config in the foundation, not retrofitted
- NATS layer on the Vigilance production profile (infinite reconnect,
  capped backoff, 30s ping, 8192-msg offline buffer)
- Heartbeat with real sysinfo telemetry — Go agent shipped hardcoded
  disk/cpu placeholders; this is the panel's first true Resources data
- Connectivity prober (outbound TCP, periodic + on-demand)
- Host cmd channel (ping/probe/sysinfo), going-offline beacon,
  CancellationToken shutdown
- Live-fire verified against production NATS; artifacts: 3.7MB static
  linux-musl, 3.8MB windows .exe (static CRT)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:02:46 -04:00
Vantz Stockwell
1abe57ca40 fix(marketing): strip dead Discord invite from footer; docs: Scout tier -> sonnet[1m]
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
discord.gg/corrosion is an Unknown Invite (verified via Discord API) -
no dead community promises on the marketing site. Re-add when a real
server exists. Discord *webhook* feature copy stays; that's shipped.

AGENTS.md Scout tier haiku -> sonnet[1m] confirmed by Commander:
marginal price difference, 1m context window pays for itself on recon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:28:06 -04:00
Vantz Stockwell
a8722a7a07 fix(audit): kill fake install cmds + dead demo CTA; production fonts; scoped error boundary; admin bootstrap seed
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full-site fake-data audit findings:
- SetupWizard showed a curl|sh installer (get.corrosionmgmt.com) and a
  'corrosion-agent' binary that don't exist -> real host-agent commands
- 'View live demo' CTA on 5 marketing pages linked to a login, not a
  demo -> honest 'Sign in'
- Google Fonts @import was silently dropped from the production CSS
  bundle (mid-bundle @import) -> <link> tags in index.html; prod was
  shipping system fallback fonts
- App-root ErrorBoundary bricked the entire SPA (incl. marketing) on a
  single failed fetch until manual reload -> resets on route change +
  content-scoped boundary inside DashboardLayout so nav chrome survives
- Status page KPIs showed fake zeros while the fetch failed -> em dash
- Login lacked the forgot-password link (flow already existed end-to-end)
- AdminSeedService: fresh DB had schema but no login possible; seeds
  super-admin + license from ADMIN_* env when users table is empty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:23:44 -04:00
Vantz Stockwell
180631989a fix(panel): real auto-updating version + remove fake agent footer; rename companion -> Corrosion host agent
All checks were successful
Build Host Agent / build (push) Successful in 28s
Test Asgard Runner / test (push) Successful in 3s
Version badge: was hardcoded '1.0.8' — now single-sourced from frontend/package.json (1.0.0) via Vite define __APP_VERSION__, so it auto-updates on release. Sidebar agent footer: removed the FABRICATED 'asgard-01' host name and the fake 'Agent v1.0.8' line — now shows real server.connection data, or an honest 'No host agent connected' empty state when nothing is deployed (the operator's actual state). Renamed 'Companion agent' -> 'Corrosion host agent' across the UI (ServerView/SetupWizard/Dashboard/Plugins), the binary names (corrosion-host-agent-<os>-<arch>) + CDN path (/host-agent/), the Go Makefile build output, and the Gitea CI workflow — frontend download links and CI output now match. Marketing hero mock host names neutralized (asgard-01 -> rust-host/dune-host/conan-host). DB column names (companion_last_seen) left intact. Build green; zero 'asgard'/'1.0.8' remain in frontend/src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:03:37 -04:00
Vantz Stockwell
23decd9b08 feat(panel): per-game UI adaptation — sidebar, Server view, and dashboard transform by selected game
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Drives the panel off the active game (GameSwitcher selection) + the GameProfile registry, so each game visibly differs (not just accent color). Sidebar nav: Rust = full (uMod plugins + plugin configs); Conan/Soulmask/Dune drop uMod + plugin-configs and relabel reset (Wipe World / World Reset / Deep Desert), Dune relabels Console->Broadcast (no RCON) and is Docker-managed. ServerView: management-model badge + game-appropriate panels (Rust deploy + Oxide; Dune Docker/BattleGroup-Sietches; Conan clans/thralls/avatars/purge; Soulmask main-client cluster) with HONEST EmptyStates where no backend data exists yet. Dashboard: per-game reset terminology + stat labels. No invented routes (all map to existing router entries); no fabricated data. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:37:03 -04:00
Vantz Stockwell
8b84bba165 fix(docker): auto-build schema on a fresh DB via docker-entrypoint-initdb.d
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Root cause of 'data lost on every rebuild': nothing created the Postgres schema. TypeORM is synchronize:false, the API container runs no migration step, and there was no init mount — so a fresh pg_data volume came up with ZERO tables (empty/broken DB; the schema had only ever been loaded manually). Mount backend/migrations/*.sql into /docker-entrypoint-initdb.d so Postgres auto-applies the full schema (001..021, plain SQL) ON FIRST INIT ONLY. Existing volumes are untouched (initdb scripts run only on an empty data dir); a fresh volume now self-heals the schema. NOTE: actual row DATA still persists only while the pg_data named volume persists — 'docker compose down' keeps it across 'build --no-cache'; 'down -v' / volume prune is the only thing that wipes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:34:18 -04:00
Vantz Stockwell
9a5b93dd08 feat(api): early-access signup endpoint (POST /api/early-access)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Real @Public() NestJS endpoint persisting to the existing early_access_signups table (email + server_count), matching the schema exactly (no migration). Duplicate-email safe (pre-check + unique-constraint catch -> friendly success). Wired into app.module. Makes the marketing early-access form functional end-to-end on next API deploy. tsc/nest build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:09:34 -04:00
Vantz Stockwell
3545e6f5c8 feat(marketing): pricing, how-it-works, FAQ, roadmap, early-access pages (real content)
Five marketing sub-pages built to match the landing's design language, all real content: Pricing (4 real tiers + Fleet Block + commercial-use definition + feature-comparison table + self-service support model), How it works (one agent -> N game instances, BYOS, no-SSH), FAQ (real support/product/games/billing Q&A reflecting the self-service model), Roadmap (honest Shipped/In-progress/Planned, no fake dates), Early access (real signup form). 3 icons added (circle/send/help-circle). Visually verified via Playwright; 0 console errors. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:09:34 -04:00
Vantz Stockwell
1edaaf985d feat(marketing): rebuild landing + layout from new design (multi-game, real content)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
MarketingLayout + LandingView rebuilt from the delivered design as a multi-game platform site (was Rust-only stub): hero with per-game re-skin + panel mockup, 8-pain problem grid, agent-model shift, 4 self-themed game blueprints (Rust/Dune/Conan/Soulmask), core capabilities, wipe orchestration, built-like-infrastructure, public sites/storefront, pricing, serious-admins, final CTA, footer. REAL pricing (Hobby $9.99 / Community $19.99 / Operator $99.99 / Network $99.99 + $49.99 fleet block) + commercial-use definition + self-service support model ($125/hr prepaid blocks, 'a tool, not a managed service'). marketing.css ported (token-based). 6 icons added to the registry. No fabricated metrics/testimonials. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:52:12 -04:00
Vantz Stockwell
f2b09b281a feat(panel): GameProfile registry + real-data dashboard (remove all mock/fake data)
DashboardView now renders the REAL server from useServerStore (connection/config + live WebSocket stats) + real 24h history from /analytics/timeseries, with honest EmptyStates ('install the companion agent') when there is no data. DELETED _dashboardMock.ts (the fake 8-server fleet/feed/wipes). PlayersChart hardened: removed the DEFAULT_SERIES fallback, renders an 'awaiting telemetry' empty state instead of a fabricated curve. New gameProfiles.ts: real per-game capability/terminology/stat registry (rust/conan/soulmask/dune; dune managementModel=docker-compose), ready to wire when the backend gains a per-license game field. No fake data. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:52:12 -04:00
Vantz Stockwell
be57d2839a Merge redesign/design-system-port — full design-system re-skin of the panel
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Tokens + theming contract + 23 DS components + game-aware shell + Fleet/Solo dashboard, and every panel view re-skinned onto the design system (auth, account, server ops, operations, store, analytics, 9 plugin-config editors + Loot Builder, platform-admin, public pages). Marketing views deferred to their dedicated redesign. Includes the token-loading fix (f440fd7) verified live. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:04:13 -04:00
Vantz Stockwell
769d75d937 docs: Add lessons 22-23 — visual verification (build-green != render-correct) + Tailwind v4 nested @import barrel gotcha
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Forged during the design-system port: the whole panel rendered unstyled despite green builds because a nested @import token barrel after `@import "tailwindcss"` was dropped by Tailwind v4. Caught only by a Playwright screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:04:47 -04:00
Vantz Stockwell
f440fd7751 fix(redesign): load design tokens directly in style.css (whole panel rendered unstyled)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
The nested ./styles/corrosion.css barrel (8 @import token files) was placed after `@import "tailwindcss"`. Once Tailwind v4 expands in place, those nested @imports no longer precede all statements, so PostCSS DROPPED them (the 8 'should be written before any other statement' warnings). Result: every design token (--surface-*, --accent, --text-*, --font-brand, --space-*) was empty and the entire re-skin rendered unstyled (white bg, no surfaces/accent) despite a green build. Fix: import the 8 token files directly + contiguous in style.css. Verified live via Playwright — tokens resolve (--accent #f26622, canvas #0a0b0e) and the login renders correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:02:29 -04:00
Vantz Stockwell
29615cb4f3 feat(redesign): re-skin admin-ops/platform-admin/public views to DS (Phase D batch 4 — panel re-skin complete)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Final re-skin batch: admin ops (Console/FileManager[VueFinder preserved]/WipeCalendar/WipeHistory/Changelog/Migration), platform-admin (Dashboard/Licenses/Servers/Subscriptions/Users), public product pages (ServerInfo/StatusPage/StoreView) + PublicLayout, WarpEditor, ErrorBoundary. All logic/store/router/WebSocket/handlers preserved. Marketing views (Landing/Pricing/FAQ/HowItWorks/Roadmap/EarlyAccess + MarketingLayout) intentionally deferred to the dedicated marketing-site redesign. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:55:02 -04:00
Vantz Stockwell
376ed9a98d feat(redesign): re-skin plugin-config editors + Loot Builder to DS (Phase D batch 3)
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
10 plugin-config views (LootBuilder, RaidableBases, Teleport, Kits, Gather, AutoDoors, FurnaceSplitter, BetterChat, TimedExecute, PluginConfigs landing) + 5 child components (loot sidebar/item-editor/group-editor/item-picker, teleport PermissionGroupEditor) re-skinned onto DS components + tokens. All config logic preserved (path-traversal get/set, apply-to-server, import-from-server, CRUD, multiplier logic, per-store status derivation). Presentation-only. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:46:16 -04:00
Vantz Stockwell
b42a2d7ea7 feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:34:46 -04:00
Vantz Stockwell
560d023250 feat(redesign): re-skin auth + account views to DS (Phase D batch 1)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Auth (Login/Register/ForgotPassword/SetupWizard) + account cluster (Settings/Team/Notifications) re-skinned onto design-system components + tokens. JPEG login banner replaced with the C-gauge mark + Oxanium wordmark. All logic/store/router/handlers preserved (TOTP flow, validators, save handlers, API endpoints). Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:21:14 -04:00
Vantz Stockwell
f91ef84832 feat(redesign): design-system tokens, 23 Vue components, game-aware shell + Fleet/Solo dashboard
All checks were successful
Test Asgard Runner / test (push) Successful in 4s
Tokens ported 1:1 from the Claude Design bundle (colors/game-themes/type/spacing/elevation/motion/fonts) with the data-theme/data-game theming contract via useThemeGame (+ cc-skin-swap repaint guard). 23 design-system components reimplemented as Vue SFCs (core/forms/data/navigation/feedback/brand). DashboardLayout rebuilt as the game-aware shell (GameSwitcher, grouped nav with permission gating preserved, agent-health footer, topbar). DashboardView: Fleet + Solo with per-game GAME_FIELDS rows and the themed ECharts PlayersChart; Solo wired to the real server store, Fleet on representative data pending the multi-instance backend. All four game skins (Rust/Dune/Conan/Soulmask). vue-tsc + vite build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:12:35 -04:00
Vantz Stockwell
ef128b47d2 docs: Add lessons 20-21 — state drift + resilient routing
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Cross-pollinated from parallel instance on sister project. Adapted
to Corrosion context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:04:39 -05:00
Vantz Stockwell
1bb810f851 docs: Add lessons 18-19 to CLAUDE.md — naming drift + UI scaling
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:01:35 -05:00
Vantz Stockwell
b4d1bc8dd0 feat: Add Plugin Configs landing page — collapse 9 sidebar items to 1
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Replace individual plugin config sidebar entries with a single "Plugin Configs"
link that opens a card-based landing page. Cards show status (Active/Configured/
Not Configured), config count, and link to existing editor views. Search bar for
filtering. All existing plugin routes preserved for direct navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:29:36 -05:00
Vantz Stockwell
d15ea28e8f feat: Restructure sidebar nav into section-grouped menu
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Replaces flat 25-item navItems array with 6 labeled sections:
Dashboard, Server, Plugin Configs, Operations, Monitoring, Management.
Section headers only render when at least one item is visible to the
user's permissions. Platform Admin section restyled to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:56:39 -05:00
Vantz Stockwell
7d5966839a fix: Resolve vue-tsc -b errors in KitsView and TimedExecuteView
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
KitsView: cast v-for Items array to fix string|number index type mismatch.
TimedExecuteView: remove unused X icon import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:43:32 -05:00
Vantz Stockwell
2668014068 feat: Add RaidableBases plugin config module — DB migration, NestJS CRUD, Vue editor
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- Migration 021: raidablebases_configs table with JSONB config_data
- Entity, module, controller (7 endpoints), service with NATS deploy/import
- Frontend: 4-tab editor (General, Difficulty, NPC, Loot & Rewards)
- Pinia store, types, router route, sidebar nav with Swords icon
- Top 30 most common settings with actual RaidableBases.json key paths
- Difficulty sub-tabs for Easy/Medium/Hard/Expert/Nightmare with spawn day toggles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:20:21 -05:00
Vantz Stockwell
bb381569e3 feat: Add BetterChat + TimedExecute plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- DB migrations 017 (betterchat_configs) and 020 (timedexecute_configs) applied
- TypeORM entities matching production schema exactly
- NestJS modules with full CRUD + apply-to-server + import-from-server
- Pinia stores following teleport config pattern
- BetterChatView: Chat Groups editor with color pickers, font sizes, format strings; Settings tab with word filter, anti-flood, player tagging
- TimedExecuteView: TimerRepeat with presets, RealTime-Timer, OnConnect/OnDisconnect command lists
- Wired into app.module.ts, router, DashboardLayout nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:19:29 -05:00
Vantz Stockwell
39622de8dc feat: Add Kits + FurnaceSplitter plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
DB migrations 016 (kits_configs) and 019 (furnacesplitter_configs) applied.
Backend: NestJS modules with CRUD, apply-to-server, import-from-server.
Frontend: Pinia stores, Vue views with config editor, router + nav wiring.
Kits view: 3-tab editor (list/editor/settings), kit items with shortname/amount/skinId/container.
FurnaceSplitter view: per-furnace toggles, split count, fuel multiplier settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:19:14 -05:00
Vantz Stockwell
500dca48a5 feat: Add GatherManager + AutoDoors plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- GatherManager: 2-tab editor (Resource Rates with 1x-10x presets,
  Advanced with Pickup/Quarry/Excavator/Survey modifiers), 9 resource
  types with slider+number inputs, CRUD + deploy + import via NATS
- AutoDoors: Global settings (delay sliders, 6 toggles), 7 door type
  toggles, permission group overrides table, CRUD + deploy + import
- DB: migrations 015 (gather_configs) + 018 (autodoors_configs)
- Backend: GatherModule + AutoDoorsModule registered in app.module.ts
- Frontend: Pinia stores, Vue views, router routes, sidebar nav items
- Icons: Pickaxe (gather), DoorOpen (autodoors)
- All type checks pass: tsc + vue-tsc zero errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:17:51 -05:00
Vantz Stockwell
b542f30dcf fix: Remove unused Loader2 import and toast variable from TeleportConfigView
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Fixes Docker build failure — vue-tsc -b treats unused declarations as errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:58:58 -05:00
Vantz Stockwell
6461417b50 feat: Add one-click Oxide/uMod installer — backend + frontend
All checks were successful
Build Companion Agent / build (push) Successful in 24s
Test Asgard Runner / test (push) Successful in 3s
POST /servers/install-oxide endpoint, NATS bridge for oxide.status,
server store installOxide method, ServerView Install Oxide card with
progress tracker matching the Deploy card pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:56:59 -05:00
Vantz Stockwell
380ab2700c feat: Add Oxide/uMod installer package + wire into companion agent daemon
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
New oxide package downloads latest Oxide.Rust release from GitHub,
extracts over the server directory, and restarts the game server.
Progress published to NATS (corrosion.{license_id}.oxide.status).
Heartbeat now reports oxide_installed status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:53:27 -05:00
Vantz Stockwell
585e8aa3f7 feat: Add teleport_configs DB migration + TypeORM entity
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:16:08 -05:00
Vantz Stockwell
4d087132db feat: Add teleport config frontend — Pinia store, views, 2 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:14:59 -05:00
Vantz Stockwell
16f378eada feat: Add CorrosionTeleportGUI uMod plugin — in-game teleport CUI
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Standalone C# uMod plugin that provides a full-screen CUI teleport
interface for Rust game servers, integrating with NTeleportation.

Features:
- /tpgui chat command opens tabbed overlay (Teleport, Homes, Warps, Settings)
- 4x5 player grid with search filtering and pagination for TPR
- Home management (teleport, set, delete) via NTeleportation API
- Server warp list with teleport buttons
- Incoming TPR accept/deny popup with 30s auto-dismiss
- Settings tab showing cooldowns, limits, NTeleportation status
- Oxide-orange color scheme matching Corrosion brand

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:12:34 -05:00
Vantz Stockwell
3e1af29b38 feat: Add teleport module backend — NestJS CRUD + NATS deploy/import
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Seven endpoints for managing NTeleportation configs: list summaries,
get full config, create, update, delete, deploy to server via NATS,
and import live config from server. Follows loot module pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:11:44 -05:00
223 changed files with 33924 additions and 6935 deletions

View File

@@ -1,4 +1,4 @@
name: Build Companion Agent
name: Build Host Agent
on:
push:
@@ -26,19 +26,19 @@ jobs:
run: |
cd companion-agent
mkdir -p bin
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-linux-amd64 ./cmd/agent
chmod +x bin/corrosion-companion-linux-amd64
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-linux-amd64 ./cmd/agent
chmod +x bin/corrosion-host-agent-linux-amd64
- name: Build Windows AMD64
run: |
cd companion-agent
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-windows-amd64.exe ./cmd/agent
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-windows-amd64.exe ./cmd/agent
- name: Generate checksums
run: |
cd companion-agent/bin
sha256sum corrosion-companion-linux-amd64 > checksums.txt
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
cat checksums.txt
- name: Create Release
@@ -53,7 +53,7 @@ jobs:
RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Companion Agent ${VERSION}\", \"body\": \"Companion Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Corrosion Host Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_URL}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
@@ -68,15 +68,15 @@ jobs:
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
# Upload Windows binary
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-windows-amd64.exe"
--data-binary @companion-agent/bin/corrosion-host-agent-windows-amd64.exe \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-windows-amd64.exe"
# Upload checksums
curl -s -X POST \
@@ -89,43 +89,43 @@ jobs:
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
# Upload Linux binary to /companion/latest/
# Upload Linux binary to /host-agent/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
# Upload Windows binary to /companion/latest/
# Upload Windows binary to /host-agent/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
# Upload checksums
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/companion/latest/checksums.txt"
"${CDN_URL}/host-agent/latest/checksums.txt"
# Also upload versioned copies
VERSION=${{ steps.version.outputs.VERSION }}
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-linux-amd64"
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/companion/${VERSION}/checksums.txt"
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
echo "CDN upload complete: ${CDN_URL}/host-agent/latest/"
- name: Build Summary
run: |
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
echo "## Corrosion Host Agent Build Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY

View File

@@ -0,0 +1,115 @@
name: Build Host Agent (Rust)
# Rust agent ships on its own tag namespace (agent-v*) so it never collides
# with the legacy Go pipeline (v*.*.*). Artifacts publish to the CDN /alpha/
# channel — /host-agent/latest/ stays on the Go build until cutover.
on:
push:
tags:
- 'agent-v*'
jobs:
build:
runs-on: ubuntu-latest
env:
# Override the macOS toolchain names in corrosion-host-agent/.cargo/config.toml
# (real env beats the config [env] table).
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
CC_x86_64_unknown_linux_musl: musl-gcc
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/agent-v}" >> $GITHUB_OUTPUT
- name: Verify tag matches Cargo.toml
run: |
CARGO_VERSION=$(grep '^version' corrosion-host-agent/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "${{ steps.version.outputs.VERSION }}" != "$CARGO_VERSION" ]; then
echo "Tag agent-v${{ steps.version.outputs.VERSION }} does not match Cargo.toml version $CARGO_VERSION"
exit 1
fi
- name: Install cross toolchains
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq musl-tools gcc-mingw-w64-x86-64
rustup target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
- name: Build Linux AMD64 (static musl)
run: |
cd corrosion-host-agent
cargo build --release --target x86_64-unknown-linux-musl
mkdir -p bin
cp target/x86_64-unknown-linux-musl/release/corrosion-host-agent bin/corrosion-host-agent-linux-amd64
chmod +x bin/corrosion-host-agent-linux-amd64
- name: Build Windows AMD64 (mingw)
run: |
cd corrosion-host-agent
cargo build --release --target x86_64-pc-windows-gnu
cp target/x86_64-pc-windows-gnu/release/corrosion-host-agent.exe bin/corrosion-host-agent-windows-amd64.exe
- name: Generate checksums
run: |
cd corrosion-host-agent/bin
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
cat checksums.txt
- name: Create Release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
API_URL="${{ github.server_url }}/api/v1"
REPO="${{ github.repository }}"
VERSION="agent-v${{ steps.version.outputs.VERSION }}"
RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Rust host agent release ${VERSION}\", \"draft\": false, \"prerelease\": true}" \
"${API_URL}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @corrosion-host-agent/bin/$f \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$f"
done
- name: Upload to CDN (alpha channel)
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
VERSION="${{ steps.version.outputs.VERSION }}"
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
curl -s -X POST \
-F "file=@corrosion-host-agent/bin/$f" \
"${CDN_URL}/host-agent/alpha/$f"
curl -s -X POST \
-F "file=@corrosion-host-agent/bin/$f" \
"${CDN_URL}/host-agent/${VERSION}/$f"
done
echo "CDN upload complete: ${CDN_URL}/host-agent/alpha/"
- name: Build Summary
run: |
echo "## Corrosion Host Agent (Rust) Build Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
echo "**Channel:** alpha (latest/ untouched until cutover)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
echo "- Linux AMD64 static musl ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Windows AMD64 mingw ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY

View File

@@ -38,7 +38,7 @@
### **TYPE 1: THE SCOUT (Intelligence)**
- **Model:** haiku
- **Model:** sonnet[1m]
- **Role:** Reconnaissance, Context Mapping, Log Analysis.

View File

@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
- Multi-instance TOML config baked into the foundation (one agent supervises N game instances; rust/conan/soulmask/dune), env overrides for secrets, strict validation (subject-safe ids, reserved segments)
- NATS layer with the production-proven Vigilance profile: infinite reconnect w/ capped backoff, 30s ping, 8192-msg offline send buffer, `tls://` scheme support
- Host heartbeat with REAL telemetry via sysinfo (CPU/mem/disks/per-instance state) — the Go agent hardcoded disk=50000MB and cpu=0.0; this is the first true Resources data
- Connectivity prober (outbound TCP + latency, periodic jittered + on-demand) — first piece of the support-triage story
- Host command channel (`ping`/`probe`/`sysinfo`, request-reply), going-offline beacon, CancellationToken graceful shutdown
- Version embedding (semver + git hash + build ts) in `--version` and every heartbeat
- Verified live against production NATS: connected, heartbeats published, clean shutdown
- Deploy artifacts verified: 3.7MB fully-static linux-musl binary, 3.8MB windows .exe (static CRT, no VC++ redist needed)
**Next phases**: 1 = process-class adapter (spawn/RCON/SteamCMD/files for Rust/Conan/Soulmask) + NestJS v2 heartbeat consumer; 2 = Dune Docker adapter; 3 = signed self-update (release gate) + service install.
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
**Frontend:**
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
**Backend (NestJS):**
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
### Fixed (Safe Formatting Utilities — 2026-02-15)
**Frontend:**

View File

@@ -423,3 +423,15 @@ Things I discovered about myself building a sister platform across multiple sess
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
18. **When aggregating across N similar modules, scout for the one that doesn't match the pattern — it's always the oldest or the first-built.** The Loot module was the first plugin config module built, so it uses `fetchProfiles()`/`profiles` while the other 8 use `fetchConfigs()`/`configs`. The first implementation defines its own naming before a convention exists. Every aggregation layer (landing pages, batch operations, monitoring dashboards) will hit this drift. A 30-second recon across all N modules before writing the aggregator prevents a mid-implementation refactor.
19. **UI scaling problems are invisible when you're adding one item at a time — they only become obvious in aggregate.** Nine plugin config sidebar entries were added across multiple sessions, each one reasonable in isolation. Nobody noticed the sidebar was becoming unusable until all nine were there. When building a repeatable pattern (nav items, config modules, API endpoints), build the aggregation layer early — ideally when N hits 3 or 4 — not after it's already painful.
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.

View File

@@ -36,6 +36,15 @@ import { MigrationModule } from './modules/migration/migration.module';
import { ChangelogModule } from './modules/changelog/changelog.module';
import { FilesModule } from './modules/files/files.module';
import { LootModule } from './modules/loot/loot.module';
import { TeleportModule } from './modules/teleport/teleport.module';
import { GatherModule } from './modules/gather/gather.module';
import { AutoDoorsModule } from './modules/autodoors/autodoors.module';
import { KitsModule } from './modules/kits/kits.module';
import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module';
import { BetterChatModule } from './modules/betterchat/betterchat.module';
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
import { EarlyAccessModule } from './modules/early-access/early-access.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -107,6 +116,15 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
ChangelogModule,
FilesModule,
LootModule,
TeleportModule,
GatherModule,
AutoDoorsModule,
KitsModule,
FurnaceSplitterModule,
BetterChatModule,
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('autodoors_configs')
export class AutoDoorsConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('betterchat_configs')
export class BetterChatConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('furnacesplitter_configs')
export class FurnaceSplitterConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('gather_configs')
export class GatherConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('kits_configs')
export class KitsConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('raidablebases_configs')
export class RaidableBasesConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('teleport_configs')
export class TeleportConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('timedexecute_configs')
export class TimedExecuteConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,82 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import { User } from '../../entities/user.entity';
import { License } from '../../entities/license.entity';
/**
* Bootstraps the first admin account on a fresh database.
*
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
* ADMIN_PASSWORD are set and the users table is empty, this creates a
* super-admin user plus an active license — the same rows the register flow
* would create. It never runs against a database that already has users.
*/
@Injectable()
export class AdminSeedService implements OnApplicationBootstrap {
private readonly logger = new Logger(AdminSeedService.name);
constructor(
private readonly config: ConfigService,
@InjectRepository(User) private readonly userRepository: Repository<User>,
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
) {}
async onApplicationBootstrap(): Promise<void> {
try {
await this.seedAdminIfEmpty();
} catch (err) {
// A failed seed must not take the API down — surface it loudly and move on
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
}
}
private async seedAdminIfEmpty(): Promise<void> {
const email = this.config.get<string>('admin.email');
const password = this.config.get<string>('admin.password');
const username = this.config.get<string>('admin.username') || 'Commander';
if (!email || !password) {
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
return;
}
const userCount = await this.userRepository.count();
if (userCount > 0) {
return;
}
const password_hash = await argon2.hash(password);
const user = this.userRepository.create({
email: email.toLowerCase(),
username,
password_hash,
email_verified: true,
is_super_admin: true,
});
await this.userRepository.save(user);
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
const license = this.licenseRepository.create({
license_key: licenseKey,
owner_user_id: user.id,
status: 'active',
modules_enabled: [],
webstore_active: false,
});
await this.licenseRepository.save(license);
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
}
private generateLicenseKey(): string {
const part1 = randomBytes(2).toString('hex').toUpperCase();
const part2 = randomBytes(2).toString('hex').toUpperCase();
const part3 = randomBytes(2).toString('hex').toUpperCase();
return `CORR-${part1}-${part2}-${part3}`;
}
}

View File

@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AdminSeedService } from './admin-seed.service';
import { JwtStrategy } from './jwt.strategy';
import { User } from '../../entities/user.entity';
import { License } from '../../entities/license.entity';
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
providers: [AuthService, AdminSeedService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { AutoDoorsService } from './autodoors.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('autodoors')
@ApiBearerAuth()
@Controller('autodoors')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class AutoDoorsController {
constructor(private readonly autoDoorsService: AutoDoorsService) {}
@Get('configs')
@RequirePermission('autodoors.view')
@ApiOperation({ summary: 'List AutoDoors configs' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.autoDoorsService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('autodoors.view')
@ApiOperation({ summary: 'Get full AutoDoors config' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Create AutoDoors config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) {
return this.autoDoorsService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Update AutoDoors config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateAutoDoorsConfigDto,
) {
return this.autoDoorsService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Delete AutoDoors config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Deploy AutoDoors config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Import AutoDoors.json from server' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) {
return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AutoDoorsController } from './autodoors.controller';
import { AutoDoorsService } from './autodoors.service';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([AutoDoorsConfig])],
controllers: [AutoDoorsController],
providers: [AutoDoorsService, NatsService],
exports: [AutoDoorsService],
})
export class AutoDoorsModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
@Injectable()
export class AutoDoorsService {
private readonly logger = new Logger(AutoDoorsService.name);
constructor(
@InjectRepository(AutoDoorsConfig)
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.autoDoorsRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) {
const config = this.autoDoorsRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('AutoDoors config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write AutoDoors.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/AutoDoors.json',
content: jsonString,
},
30000,
);
// Reload AutoDoors plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload AutoDoors',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
await this.autoDoorsRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy AutoDoors config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy AutoDoors config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import AutoDoors.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read AutoDoors.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/AutoDoors.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new AutoDoors config row
const config = this.autoDoorsRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import AutoDoors config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAutoDoorsConfigDto {
@ApiProperty({ example: 'Default AutoDoors' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard auto-close settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportAutoDoorsConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateAutoDoorsConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { BetterChatService } from './betterchat.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
import { ImportBetterChatConfigDto } from './dto/import-betterchat-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('betterchat')
@ApiBearerAuth()
@Controller('betterchat')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class BetterChatController {
constructor(private readonly betterChatService: BetterChatService) {}
@Get('configs')
@RequirePermission('betterchat.view')
@ApiOperation({ summary: 'List BetterChat configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.betterChatService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('betterchat.view')
@ApiOperation({ summary: 'Get full BetterChat config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Create BetterChat config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) {
return this.betterChatService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Update BetterChat config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateBetterChatConfigDto,
) {
return this.betterChatService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Delete BetterChat config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Deploy BetterChat config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Import BetterChat.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) {
return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BetterChatController } from './betterchat.controller';
import { BetterChatService } from './betterchat.service';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([BetterChatConfig])],
controllers: [BetterChatController],
providers: [BetterChatService, NatsService],
exports: [BetterChatService],
})
export class BetterChatModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
@Injectable()
export class BetterChatService {
private readonly logger = new Logger(BetterChatService.name);
constructor(
@InjectRepository(BetterChatConfig)
private readonly repo: Repository<BetterChatConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.repo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) {
const config = this.repo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.repo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.repo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.repo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('BetterChat config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write BetterChat.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/BetterChat.json',
content: jsonString,
},
30000,
);
// Reload BetterChat plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterChat',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
await this.repo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy BetterChat config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy BetterChat config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import BetterChat.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read BetterChat.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/BetterChat.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new config row
const config = this.repo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.repo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import BetterChat config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateBetterChatConfigDto {
@ApiProperty({ example: 'Default Chat Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard BetterChat settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportBetterChatConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateBetterChatConfigDto {
@ApiPropertyOptional({ example: 'Updated Chat Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateEarlyAccessDto {
@ApiProperty({ example: 'admin@example.com' })
@IsEmail()
email: string;
@ApiPropertyOptional({ example: 'rust', description: 'Primary game interest or server count' })
@IsOptional()
@IsString()
@MaxLength(10)
server_count?: string;
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator';
import { EarlyAccessService } from './early-access.service';
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
@ApiTags('early-access')
@Controller()
export class EarlyAccessController {
constructor(private readonly earlyAccessService: EarlyAccessService) {}
@Public()
@Post('early-access')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Register for early access' })
async register(@Body() dto: CreateEarlyAccessDto) {
return this.earlyAccessService.register(dto);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
import { EarlyAccessController } from './early-access.controller';
import { EarlyAccessService } from './early-access.service';
@Module({
imports: [TypeOrmModule.forFeature([EarlyAccessSignup])],
controllers: [EarlyAccessController],
providers: [EarlyAccessService],
})
export class EarlyAccessModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
@Injectable()
export class EarlyAccessService {
private readonly logger = new Logger(EarlyAccessService.name);
constructor(
@InjectRepository(EarlyAccessSignup)
private readonly repo: Repository<EarlyAccessSignup>,
) {}
async register(dto: CreateEarlyAccessDto): Promise<{ success: true; alreadyRegistered: boolean }> {
const existing = await this.repo.findOne({ where: { email: dto.email } });
if (existing) {
// Duplicate email — return friendly success rather than a 409 that would break the UX
return { success: true, alreadyRegistered: true };
}
const signup = this.repo.create({
email: dto.email,
server_count: dto.server_count ?? 'not specified',
});
try {
await this.repo.save(signup);
} catch (err: unknown) {
// Guard against a race-condition duplicate (unique constraint violation)
const pg = err as { code?: string };
if (pg.code === '23505') {
return { success: true, alreadyRegistered: true };
}
this.logger.error('Failed to save early-access signup', err);
throw err;
}
return { success: true, alreadyRegistered: false };
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFurnaceSplitterConfigDto {
@ApiProperty({ example: 'Default FurnaceSplitter' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard furnace splitter settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportFurnaceSplitterConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateFurnaceSplitterConfigDto {
@ApiPropertyOptional({ example: 'Updated FurnaceSplitter' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { FurnaceSplitterService } from './furnacesplitter.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
import { ImportFurnaceSplitterConfigDto } from './dto/import-furnacesplitter-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('furnacesplitter')
@ApiBearerAuth()
@Controller('furnacesplitter')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class FurnaceSplitterController {
constructor(private readonly furnaceSplitterService: FurnaceSplitterService) {}
@Get('configs')
@RequirePermission('furnacesplitter.view')
@ApiOperation({ summary: 'List furnace splitter configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.furnaceSplitterService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('furnacesplitter.view')
@ApiOperation({ summary: 'Get full furnace splitter config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Create furnace splitter config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateFurnaceSplitterConfigDto) {
return this.furnaceSplitterService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Update furnace splitter config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateFurnaceSplitterConfigDto,
) {
return this.furnaceSplitterService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Delete furnace splitter config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Deploy furnace splitter config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Import FurnaceSplitter.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportFurnaceSplitterConfigDto) {
return this.furnaceSplitterService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FurnaceSplitterController } from './furnacesplitter.controller';
import { FurnaceSplitterService } from './furnacesplitter.service';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([FurnaceSplitterConfig])],
controllers: [FurnaceSplitterController],
providers: [FurnaceSplitterService, NatsService],
exports: [FurnaceSplitterService],
})
export class FurnaceSplitterModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
@Injectable()
export class FurnaceSplitterService {
private readonly logger = new Logger(FurnaceSplitterService.name);
constructor(
@InjectRepository(FurnaceSplitterConfig)
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.furnaceRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateFurnaceSplitterConfigDto) {
const config = this.furnaceRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.furnaceRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateFurnaceSplitterConfigDto) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.furnaceRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.furnaceRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('FurnaceSplitter config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write FurnaceSplitter.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/FurnaceSplitter.json',
content: jsonString,
},
30000,
);
// Reload FurnaceSplitter plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload FurnaceSplitter',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
await this.furnaceRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy furnace splitter config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy furnace splitter config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import FurnaceSplitter.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read FurnaceSplitter.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/FurnaceSplitter.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new furnace splitter config row
const config = this.furnaceRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.furnaceRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import furnace splitter config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import furnace splitter config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateGatherConfigDto {
@ApiProperty({ example: 'Default 2x Rates' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard 2x gather rates' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportGatherConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateGatherConfigDto {
@ApiPropertyOptional({ example: 'Updated Rates' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { GatherService } from './gather.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
import { ImportGatherConfigDto } from './dto/import-gather-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('gather')
@ApiBearerAuth()
@Controller('gather')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class GatherController {
constructor(private readonly gatherService: GatherService) {}
@Get('configs')
@RequirePermission('gather.view')
@ApiOperation({ summary: 'List gather configs' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.gatherService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('gather.view')
@ApiOperation({ summary: 'Get full gather config' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Create gather config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) {
return this.gatherService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Update gather config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateGatherConfigDto,
) {
return this.gatherService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Delete gather config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Deploy gather config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Import GatherManager.json from server' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) {
return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GatherController } from './gather.controller';
import { GatherService } from './gather.service';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([GatherConfig])],
controllers: [GatherController],
providers: [GatherService, NatsService],
exports: [GatherService],
})
export class GatherModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
@Injectable()
export class GatherService {
private readonly logger = new Logger(GatherService.name);
constructor(
@InjectRepository(GatherConfig)
private readonly gatherRepo: Repository<GatherConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.gatherRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateGatherConfigDto) {
const config = this.gatherRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.gatherRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.gatherRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Gather config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write GatherManager.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/GatherManager.json',
content: jsonString,
},
30000,
);
// Reload GatherManager plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload GatherManager',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
await this.gatherRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy gather config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy gather config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import GatherManager.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read GatherManager.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/GatherManager.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new gather config row
const config = this.gatherRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.gatherRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import gather config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateKitsConfigDto {
@ApiProperty({ example: 'Default Kits' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard kit configuration' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportKitsConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateKitsConfigDto {
@ApiPropertyOptional({ example: 'Updated Kits' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { KitsService } from './kits.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
import { ImportKitsConfigDto } from './dto/import-kits-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('kits')
@ApiBearerAuth()
@Controller('kits')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class KitsController {
constructor(private readonly kitsService: KitsService) {}
@Get('configs')
@RequirePermission('kits.view')
@ApiOperation({ summary: 'List kits configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.kitsService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('kits.view')
@ApiOperation({ summary: 'Get full kits config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Create kits config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateKitsConfigDto) {
return this.kitsService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Update kits config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateKitsConfigDto,
) {
return this.kitsService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Delete kits config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Deploy kits config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Import Kits.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportKitsConfigDto) {
return this.kitsService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KitsController } from './kits.controller';
import { KitsService } from './kits.service';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([KitsConfig])],
controllers: [KitsController],
providers: [KitsService, NatsService],
exports: [KitsService],
})
export class KitsModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
@Injectable()
export class KitsService {
private readonly logger = new Logger(KitsService.name);
constructor(
@InjectRepository(KitsConfig)
private readonly kitsRepo: Repository<KitsConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.kitsRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateKitsConfigDto) {
const config = this.kitsRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.kitsRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateKitsConfigDto) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.kitsRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.kitsRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Kits config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write Kits.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/Kits.json',
content: jsonString,
},
30000,
);
// Reload Kits plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload Kits',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
await this.kitsRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy kits config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy kits config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import Kits.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read Kits.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/Kits.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new kits config row
const config = this.kitsRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.kitsRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import kits config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import kits config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateRaidableBasesConfigDto {
@ApiProperty({ example: 'Default RaidableBases Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard RaidableBases settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportRaidableBasesConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateRaidableBasesConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { RaidableBasesService } from './raidablebases.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
import { ImportRaidableBasesConfigDto } from './dto/import-raidablebases-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('raidablebases')
@ApiBearerAuth()
@Controller('raidablebases')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class RaidableBasesController {
constructor(private readonly raidableBasesService: RaidableBasesService) {}
@Get('configs')
@RequirePermission('raidablebases.view')
@ApiOperation({ summary: 'List RaidableBases configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.raidableBasesService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('raidablebases.view')
@ApiOperation({ summary: 'Get full RaidableBases config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Create RaidableBases config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateRaidableBasesConfigDto) {
return this.raidableBasesService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Update RaidableBases config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateRaidableBasesConfigDto,
) {
return this.raidableBasesService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Delete RaidableBases config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Deploy RaidableBases config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Import RaidableBases.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportRaidableBasesConfigDto) {
return this.raidableBasesService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RaidableBasesController } from './raidablebases.controller';
import { RaidableBasesService } from './raidablebases.service';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([RaidableBasesConfig])],
controllers: [RaidableBasesController],
providers: [RaidableBasesService, NatsService],
exports: [RaidableBasesService],
})
export class RaidableBasesModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@Injectable()
export class RaidableBasesService {
private readonly logger = new Logger(RaidableBasesService.name);
constructor(
@InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.raidableBasesRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) {
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('RaidableBases config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write RaidableBases.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
);
// Reload RaidableBases plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
await this.raidableBasesRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy RaidableBases config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read RaidableBases.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new RaidableBases config row
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -73,4 +73,11 @@ export class ServersController {
) {
return await this.serversService.deployServer(licenseId, dto);
}
@Post('install-oxide')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
async installOxide(@CurrentTenant() licenseId: string) {
return await this.serversService.installOxide(licenseId);
}
}

View File

@@ -103,4 +103,12 @@ export class ServersService {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
}
/**
* Install Oxide/uMod via companion agent
*/
async installOxide(licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId);
return { message: 'Oxide installation started' };
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTeleportConfigDto {
@ApiProperty({ example: 'Default Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard NTeleportation settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTeleportConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTeleportConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeleportService } from './teleport.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
import { ImportTeleportConfigDto } from './dto/import-teleport-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('teleport')
@ApiBearerAuth()
@Controller('teleport')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TeleportController {
constructor(private readonly teleportService: TeleportService) {}
@Get('configs')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'List teleport configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.teleportService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'Get full teleport config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Create teleport config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
return this.teleportService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Update teleport config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTeleportConfigDto,
) {
return this.teleportService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Delete teleport config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Deploy teleport config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TeleportController } from './teleport.controller';
import { TeleportService } from './teleport.service';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TeleportConfig])],
controllers: [TeleportController],
providers: [TeleportService, NatsService],
exports: [TeleportService],
})
export class TeleportModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@Injectable()
export class TeleportService {
private readonly logger = new Logger(TeleportService.name);
constructor(
@InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.teleportRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write NTeleportation.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
);
// Reload NTeleportation plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
await this.teleportRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read NTeleportation.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new teleport config row
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTimedExecuteConfigDto {
@ApiProperty({ example: 'Default Timer Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard TimedExecute settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTimedExecuteConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTimedExecuteConfigDto {
@ApiPropertyOptional({ example: 'Updated Timer Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TimedExecuteService } from './timedexecute.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
import { ImportTimedExecuteConfigDto } from './dto/import-timedexecute-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('timedexecute')
@ApiBearerAuth()
@Controller('timedexecute')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TimedExecuteController {
constructor(private readonly timedExecuteService: TimedExecuteService) {}
@Get('configs')
@RequirePermission('timedexecute.view')
@ApiOperation({ summary: 'List TimedExecute configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.timedExecuteService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('timedexecute.view')
@ApiOperation({ summary: 'Get full TimedExecute config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Create TimedExecute config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTimedExecuteConfigDto) {
return this.timedExecuteService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Update TimedExecute config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTimedExecuteConfigDto,
) {
return this.timedExecuteService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Delete TimedExecute config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Deploy TimedExecute config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Import TimedExecute.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTimedExecuteConfigDto) {
return this.timedExecuteService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TimedExecuteController } from './timedexecute.controller';
import { TimedExecuteService } from './timedexecute.service';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TimedExecuteConfig])],
controllers: [TimedExecuteController],
providers: [TimedExecuteService, NatsService],
exports: [TimedExecuteService],
})
export class TimedExecuteModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
@Injectable()
export class TimedExecuteService {
private readonly logger = new Logger(TimedExecuteService.name);
constructor(
@InjectRepository(TimedExecuteConfig)
private readonly repo: Repository<TimedExecuteConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.repo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTimedExecuteConfigDto) {
const config = this.repo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.repo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTimedExecuteConfigDto) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.repo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.repo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('TimedExecute config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write TimedExecute.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/TimedExecute.json',
content: jsonString,
},
30000,
);
// Reload TimedExecute plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload TimedExecute',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
await this.repo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy TimedExecute config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy TimedExecute config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import TimedExecute.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read TimedExecute.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/TimedExecute.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new config row
const config = this.repo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.repo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import TimedExecute config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import TimedExecute config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'deploy_status', data);
});
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'oxide_status', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

View File

@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
timestamp: new Date().toISOString(),
});
}
/** Publish an Oxide install command to a specific license's companion agent */
async sendOxideInstallCommand(licenseId: string): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
action: 'install_oxide',
timestamp: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,12 @@
-- Teleport configuration profiles for NTeleportation integration
CREATE TABLE teleport_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_teleport_configs_license ON teleport_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS gather_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_gather_configs_license ON gather_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS kits_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_kits_configs_license ON kits_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS betterchat_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_betterchat_configs_license ON betterchat_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS autodoors_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_autodoors_configs_license ON autodoors_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS furnacesplitter_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_furnacesplitter_configs_license ON furnacesplitter_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS timedexecute_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_timedexecute_configs_license ON timedexecute_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS raidablebases_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_raidablebases_configs_license ON raidablebases_configs(license_id);

View File

@@ -1,7 +1,7 @@
.PHONY: all build build-linux build-windows clean test run
# Binary names
BINARY_NAME=corrosion-companion
BINARY_NAME=corrosion-host-agent
BINARY_LINUX=$(BINARY_NAME)-linux-amd64
BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe
@@ -66,10 +66,10 @@ run: build-local
install-service:
@echo "Installing systemd service..."
@sudo cp $(BUILD_DIR)/$(BINARY_LINUX) /usr/local/bin/$(BINARY_NAME)
@sudo cp deployment/corrosion-companion.service /etc/systemd/system/
@sudo cp deployment/corrosion-host-agent.service /etc/systemd/system/
@sudo systemctl daemon-reload
@sudo systemctl enable corrosion-companion
@echo "Service installed. Configure /etc/corrosion-companion/.env then start with: sudo systemctl start corrosion-companion"
@sudo systemctl enable corrosion-host-agent
@echo "Service installed. Configure /etc/corrosion-host-agent/.env then start with: sudo systemctl start corrosion-host-agent"
# Development helpers
dev: build-local

View File

@@ -11,6 +11,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/vigilcyber/corrosion-companion/internal/deploy"
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
"github.com/vigilcyber/corrosion-companion/internal/oxide"
"github.com/vigilcyber/corrosion-companion/internal/files"
"github.com/vigilcyber/corrosion-companion/internal/process"
"github.com/vigilcyber/corrosion-companion/internal/rcon"
@@ -32,14 +33,15 @@ type DaemonConfig struct {
// Daemon manages the companion agent's main operations
type Daemon struct {
nc *nats.Conn
cfg *DaemonConfig
gameServer *process.GameServer
fileOps *files.Operations
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
subscriptions []*nats.Subscription
nc *nats.Conn
cfg *DaemonConfig
gameServer *process.GameServer
fileOps *files.Operations
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
oxideInstaller *oxide.OxideInstaller
subscriptions []*nats.Subscription
}
// HeartbeatPayload represents the data sent in heartbeat messages
@@ -56,6 +58,7 @@ type HeartbeatPayload struct {
OS string `json:"os"`
Arch string `json:"arch"`
ServerInstalled bool `json:"server_installed"`
OxideInstalled bool `json:"oxide_installed"`
}
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
@@ -74,6 +77,15 @@ func (a *gameServerAdapter) UpdatePath(path string) {
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
}
// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter
type restartAdapter struct {
gs *process.GameServer
}
func (a *restartAdapter) Restart() error {
return a.gs.Restart()
}
// NewDaemon creates a new daemon instance
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
@@ -82,15 +94,18 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
updater := update.NewUpdater(cfg.Version)
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
restarter := &restartAdapter{gs: gameServer}
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
d := &Daemon{
nc: nc,
cfg: cfg,
gameServer: gameServer,
fileOps: fileOps,
fm: fm,
updater: updater,
deployer: deployer,
nc: nc,
cfg: cfg,
gameServer: gameServer,
fileOps: fileOps,
fm: fm,
updater: updater,
deployer: deployer,
oxideInstaller: oxideInst,
}
return d, nil
@@ -125,6 +140,11 @@ func (d *Daemon) Run(ctx context.Context) error {
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
}
// Subscribe to Oxide install commands
if err := d.subscribeOxideInstall(); err != nil {
return fmt.Errorf("failed to subscribe to oxide install commands: %w", err)
}
// Subscribe to file manager commands (VueFinder-compatible request-reply)
if err := d.subscribeFileManager(); err != nil {
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
@@ -389,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error {
return nil
}
// subscribeOxideInstall subscribes to Oxide installation commands
func (d *Daemon) subscribeOxideInstall() error {
subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID)
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
log.Println("Received Oxide install command")
// Run installation in goroutine (it's long-running)
go func() {
if err := d.oxideInstaller.Install(); err != nil {
log.Printf("Oxide installation failed: %v", err)
} else {
log.Println("Oxide installation completed successfully")
}
}()
// Immediately acknowledge the command
d.respondSuccess(msg, map[string]interface{}{
"status": "accepted",
"message": "Oxide installation started, progress will be published to oxide.status",
})
})
if err != nil {
return err
}
d.subscriptions = append(d.subscriptions, sub)
log.Printf("Subscribed to: %s", subject)
return nil
}
// handleFileOperation processes file operation requests
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
// Parse common fields
@@ -459,6 +511,7 @@ func (d *Daemon) publishHeartbeat() {
OS: runtime.GOOS,
Arch: runtime.GOARCH,
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
}
data, err := json.Marshal(payload)

View File

@@ -0,0 +1,250 @@
package oxide
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// GameServerRestarter abstracts the game server process manager so the installer
// can restart the server after extracting Oxide files.
type GameServerRestarter interface {
Restart() error
}
// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation.
type OxideInstaller struct {
nc *nats.Conn
licenseID string
installDir string
gameServer GameServerRestarter
}
// NewOxideInstaller creates a new OxideInstaller instance.
func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller {
return &OxideInstaller{
nc: nc,
licenseID: licenseID,
installDir: installDir,
gameServer: gs,
}
}
// githubRelease represents the relevant fields from the GitHub Releases API response.
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// Install performs the full Oxide installation pipeline:
// 1. Fetch latest release info from GitHub
// 2. Download the zip
// 3. Extract over {installDir}/server/
// 4. Restart the game server
func (o *OxideInstaller) Install() error {
// Stage 1: Fetch latest release
log.Printf("Oxide: fetching latest release for license %s", o.licenseID)
o.publishStatus("fetching_release", 0, "Checking latest Oxide release...")
release, err := o.fetchLatestRelease()
if err != nil {
o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error())
return fmt.Errorf("fetch release failed: %w", err)
}
if len(release.Assets) == 0 {
err := fmt.Errorf("no assets found in release %s", release.TagName)
o.publishStatus("failed", 0, "No download assets in release", err.Error())
return err
}
downloadURL := release.Assets[0].BrowserDownloadURL
version := release.TagName
log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL)
o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version))
// Stage 2: Download zip
log.Printf("Oxide: downloading %s", downloadURL)
o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version))
tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip")
if err := o.downloadFile(downloadURL, tmpPath); err != nil {
o.publishStatus("failed", 0, "Failed to download Oxide", err.Error())
return fmt.Errorf("download failed: %w", err)
}
defer os.Remove(tmpPath)
log.Printf("Oxide: download complete")
o.publishStatus("downloading", 100, "Download complete")
// Stage 3: Extract over server directory
serverDir := filepath.Join(o.installDir, "server")
log.Printf("Oxide: extracting to %s", serverDir)
o.publishStatus("installing", 0, "Extracting Oxide over server directory...")
if err := o.extractZip(tmpPath, serverDir); err != nil {
o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error())
return fmt.Errorf("extract failed: %w", err)
}
log.Printf("Oxide: extraction complete")
o.publishStatus("installing", 100, "Oxide files extracted")
// Stage 4: Restart server
log.Printf("Oxide: restarting server")
o.publishStatus("restarting", 0, "Restarting server to load Oxide...")
if err := o.gameServer.Restart(); err != nil {
o.publishStatus("failed", 0, "Server restart failed", err.Error())
return fmt.Errorf("server restart failed: %w", err)
}
log.Printf("Oxide: server restarted, installation complete")
o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version))
return nil
}
// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release.
func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest")
if err != nil {
return nil, fmt.Errorf("GitHub API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse GitHub API response: %w", err)
}
return &release, nil
}
// downloadFile downloads a URL to a local file path.
func (o *OxideInstaller) downloadFile(url, destPath string) error {
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("HTTP GET failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", destPath, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write download: %w", err)
}
return nil
}
// extractZip extracts a zip file to a destination directory, overwriting existing files.
// This is used to overlay Oxide's DLLs over the Rust server's Managed directory
// and create the oxide/ folder structure.
func (o *OxideInstaller) extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
targetPath := filepath.Join(destDir, f.Name)
// Security: prevent path traversal
if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) {
log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name)
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
}
continue
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err)
}
outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
}
}
return nil
}
// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged
// but do not fail the installation — losing a progress update is not fatal.
func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) {
subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID)
status := OxideStatus{
Stage: stage,
Progress: progress,
Message: message,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if len(errDetail) > 0 && errDetail[0] != "" {
status.Error = errDetail[0]
}
data, err := json.Marshal(status)
if err != nil {
log.Printf("Failed to marshal oxide status: %v", err)
return
}
if err := o.nc.Publish(subject, data); err != nil {
log.Printf("Failed to publish oxide status to %s: %v", subject, err)
}
}

View File

@@ -0,0 +1,31 @@
package oxide
import (
"os"
"path/filepath"
)
// OxideStatus represents a progress update published to NATS during Oxide installation.
// The frontend listens on corrosion.{license_id}.oxide.status for these messages.
type OxideStatus struct {
Stage string `json:"stage"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
}
// Valid installation stages:
// fetching_release - Querying GitHub API for latest Oxide.Rust release
// downloading - Downloading the Oxide zip file
// installing - Extracting zip over server directory
// restarting - Restarting the game server to load Oxide
// complete - Oxide installation finished successfully
// failed - Installation failed at some stage
// CheckOxideInstalled returns true if the oxide/ directory exists in the
// server installation directory, indicating that Oxide/uMod has been installed.
func CheckOxideInstalled(installDir string) bool {
_, err := os.Stat(filepath.Join(installDir, "server", "oxide"))
return err == nil
}

View File

@@ -0,0 +1,22 @@
# Corrosion Host Agent — cross-compilation configuration
#
# Deploy targets:
# Linux: x86_64-unknown-linux-musl (fully static — runs on any distro)
# Windows: x86_64-pc-windows-msvc (build via `cargo xwin build` on non-Windows)
#
# Prerequisites on macOS:
# brew install filosottile/musl-cross/musl-cross (x86_64-linux-musl-gcc)
# cargo install cargo-xwin (bundles MSVC CRT + lld-link)
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
[env]
CC_x86_64_unknown_linux_musl = "x86_64-linux-musl-gcc"
[target.x86_64-pc-windows-msvc]
linker = "lld-link"
# Statically link the MSVC CRT so the agent runs on fresh Windows installs
# without the Visual C++ Redistributable (otherwise: STATUS_DLL_NOT_FOUND on
# any machine missing VCRUNTIME140.dll — most fresh OEM images).
rustflags = ["-C", "target-feature=+crt-static"]

1
corrosion-host-agent/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2100
corrosion-host-agent/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
[package]
name = "corrosion-host-agent"
version = "2.0.0-alpha.1"
edition = "2021"
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
license = "UNLICENSED"
publish = false
[[bin]]
name = "corrosion-host-agent"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
futures = "0.3"
async-nats = "0.37"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
sysinfo = "0.33"
chrono = { version = "0.4", features = ["serde", "clock"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
anyhow = "1"
clap = { version = "4.5", features = ["derive"] }
rand = "0.8"
# Size-optimized release: single static binary living next to RAM-heavy game
# servers. Panic stays 'unwind' so a panicking task surfaces through its
# JoinHandle instead of killing the whole agent.
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true

View File

@@ -0,0 +1,143 @@
# Corrosion Wire Protocol v2
Status: **Phase 0 implemented** (host heartbeat, host commands, going-offline
beacon). Per-instance command/status subjects are reserved and specified here
for Phase 1.
## Design
One **host agent** per machine supervises **N game instances**. Subjects are
scoped license-first, then by addressee:
```
corrosion.{license_id}.host.* host-level (the agent itself)
corrosion.{license_id}.{instance_id}.* instance-level (one game server)
```
`instance_id` is a config-defined slug (`[a-z0-9_-]{1,64}`), validated at
agent start. `host` is a reserved segment and can never be an instance id.
Payloads are JSON. Every heartbeat carries `"schema": 2` so consumers can
distinguish v2 from the legacy Go companion protocol (which used
`corrosion.{license_id}.companion.heartbeat`, no schema field).
## Host-level subjects (Phase 0 — live)
### `corrosion.{license_id}.host.heartbeat` (agent → backend, publish)
Published every `heartbeat_seconds` (default 60, jittered ±20%).
```json
{
"schema": 2,
"timestamp": "2026-06-11T18:00:00Z",
"agent": {
"version": "2.0.0-alpha.1",
"commit": "a8722a7",
"os": "linux",
"arch": "x86_64",
"uptime_seconds": 86400
},
"host": {
"hostname": "asgard-01",
"cpu_percent": 12.5,
"cpu_cores": 80,
"mem_total_mb": 262144,
"mem_used_mb": 81920,
"uptime_seconds": 1209600,
"disks": [
{ "mount": "/", "total_mb": 1907729, "free_mb": 1532211 }
]
},
"instances": [
{
"id": "rust-main",
"game": "rust",
"label": "Main 2x Vanilla",
"state": "configured",
"root_disk_free_mb": 1532211
}
],
"probe": {
"timestamp": "2026-06-11T17:58:00Z",
"results": [
{ "name": "corrosion-cdn", "host": "cdn.corrosionmgmt.com", "port": 443, "ok": true, "latency_ms": 18 }
]
}
}
```
All telemetry is measured, never fabricated. Fields the agent cannot measure
are omitted (`probe` before the first probe completes, `hostname` if
unavailable).
Phase 0 instance `state` values: `configured` (root path exists),
`missing_root`. Phase 1 adds live process states: `running`, `stopped`,
`crashed`, `starting`, `updating`.
### `corrosion.{license_id}.host.cmd` (backend → agent, request-reply)
Request: `{ "func": "<name>" }`. Reply: `{ "status": "success" | "error", ... }`.
| func | Reply payload |
| --------- | -------------------------------------------------------- |
| `ping` | `version`, `commit`, `uptime_seconds` |
| `probe` | `report` — fresh ProbeReport (also cached for heartbeat) |
| `sysinfo` | `snapshot` — full heartbeat payload, collected on demand |
Unknown funcs return `status: "error"` with a message listing supported funcs.
### `corrosion.{license_id}.host.going_offline` (agent → backend, publish)
Best-effort beacon (500ms budget) on graceful shutdown so the panel can flip
the host to offline immediately instead of waiting out heartbeat staleness.
Payload: `{}`.
## Instance-level subjects (Phase 1 — reserved, not yet implemented)
### `corrosion.{license_id}.{instance_id}.cmd` (backend → agent, request-reply)
Lifecycle and control for one game instance. Planned funcs: `start`, `stop`,
`restart`, `status`, `rcon` (process-class games), `steam_update`,
`oxide_install` (rust), plus game-adapter-specific commands (Dune: docker
lifecycle, RabbitMQ bus commands, Coriolis reset).
### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish)
State-change events (started/stopped/crashed) so the panel does not wait for
the next heartbeat.
### `corrosion.{license_id}.{instance_id}.console` (agent → backend, publish)
Live console/log lines for the panel console view.
### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply)
VueFinder-style file manager ops, jailed to the instance root. Carries over
the Go agent's jailed filemanager semantics (`fm_list`, `fm_save`, ...); the
legacy UNJAILED `files.get/put/delete/list` API is retired and will not be
ported.
## Backend mapping notes (Phase 0)
- The NestJS NATS bridge subscribes `corrosion.*.host.heartbeat` and
`corrosion.*.host.going_offline`.
- Until the license→host→instance schema lands, the backend may map the host
heartbeat onto the existing single `server_connections` row per license:
`companion_last_seen` ← heartbeat arrival, `connection_status`
connected/offline, resources ← `host.cpu_percent` / `mem_*` / first disk.
Instance-level mapping activates with the fleet schema.
## Probing — scope honesty
The Phase 0 prober measures **outbound** reachability from the host (TCP
connect + latency). It cannot verify **inbound** port-forwarding (the thing
players hit). Inbound verification requires a backend-side reverse probe
service that attempts connections to the customer's public IP/ports on
request; that is specified as a Phase 1+ feature and will reuse this report
format with `direction: "inbound"`.
## Versioning
- The agent embeds semver + git hash + build timestamp (`--version`,
heartbeat `agent` block).
- Schema changes bump `schema` and are additive where possible.

View File

@@ -0,0 +1,36 @@
# Corrosion Host Agent
Rust rewrite of the Go companion agent (`companion-agent/`, retained as the
behavior reference until parity). One agent per machine supervises every game
instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening.
- **Wire protocol**: see [PROTOCOL.md](./PROTOCOL.md) (v2, instance-scoped subjects)
- **Config**: see [agent.example.toml](./agent.example.toml)
## Status — Phase 0
- [x] Multi-instance TOML config + env overrides (`CORROSION_LICENSE_ID`, `CORROSION_NATS_URL`, `CORROSION_NATS_TOKEN`)
- [x] NATS connection (infinite reconnect, capped backoff, 30s ping, offline send-buffering, `tls://` support)
- [x] Host heartbeat with real telemetry (sysinfo: CPU, memory, disks) — no fabricated values
- [x] Connectivity prober (outbound TCP, periodic + on-demand)
- [x] Host command channel (`ping`, `probe`, `sysinfo`)
- [x] Graceful shutdown (cancellation token, going-offline beacon, NATS flush)
- [ ] Phase 1: process-class game adapter (spawn/RCON/SteamCMD/files) — Rust, Conan, Soulmask
- [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin)
- [ ] Phase 3: signed self-update (enforced ed25519 — release gate), service install, supervisor split
## Build
```bash
cargo build --release # native
cargo build --release --target x86_64-unknown-linux-gnu # linux deploy target
cargo build --release --target x86_64-pc-windows-msvc # windows (cargo-xwin on non-Windows)
```
## Run
```bash
corrosion-host-agent --config ./agent.toml # foreground
corrosion-host-agent --config ./agent.toml check # validate config only
corrosion-host-agent version # semver + git hash + build ts
```

View File

@@ -0,0 +1,39 @@
# Corrosion Host Agent configuration
# Default location: /etc/corrosion/agent.toml (Linux)
# C:\ProgramData\Corrosion\agent.toml (Windows)
# Override with: corrosion-host-agent --config /path/to/agent.toml
#
# Secrets can come from the environment instead of this file:
# CORROSION_LICENSE_ID, CORROSION_NATS_URL, CORROSION_NATS_TOKEN
[agent]
license_id = "your-license-uuid"
nats_url = "nats://nats.corrosionmgmt.com:4222"
# nats_token = "set-me-or-use-CORROSION_NATS_TOKEN"
heartbeat_seconds = 60
log_level = "info"
# One agent supervises every game instance on this host.
# Each instance gets a stable id (lowercase letters, digits, '-', '_') that
# the panel uses to address it. Changing an id orphans its panel history.
[[instance]]
id = "rust-main"
game = "rust" # rust | conan | soulmask | dune
root = "/opt/rustserver"
label = "Main 2x Vanilla"
# [[instance]]
# id = "soulmask-main"
# game = "soulmask"
# root = "/opt/soulmask/main"
# label = "Cloud Mist Forest (cluster main)"
[prober]
interval_seconds = 300
# Extra outbound TCP checks beyond the built-in defaults:
# [[prober.target]]
# name = "steam-cdn"
# host = "steamcdn-a.akamaihd.net"
# port = 443

View File

@@ -0,0 +1,21 @@
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let build_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
println!("cargo:rustc-env=CORROSION_GIT_HASH={git_hash}");
println!("cargo:rustc-env=CORROSION_BUILD_TS={build_ts}");
println!("cargo:rerun-if-changed=../.git/HEAD");
}

View File

@@ -0,0 +1,16 @@
//! Shared agent handle: every subsystem task holds an `Arc<Agent>`.
use std::time::Instant;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::config::Settings;
use crate::prober::ProbeReport;
pub struct Agent {
pub cfg: Settings,
pub nats: async_nats::Client,
pub started: Instant,
pub last_probe: RwLock<Option<ProbeReport>>,
pub shutdown: CancellationToken,
}

View File

@@ -0,0 +1,58 @@
//! NATS connection layer.
//!
//! Connection parameters follow the production-proven Vigilance profile:
//! infinite reconnects with capped exponential backoff, 30s pings to detect
//! zombie TCP in ~60s, and a deep client-side send queue so telemetry buffers
//! through broker outages instead of erroring.
use anyhow::{Context, Result};
use std::time::Duration;
use crate::config::Settings;
pub async fn connect(cfg: &Settings) -> Result<async_nats::Client> {
let (url, force_tls) = normalize_url(&cfg.nats_url);
let mut opts = async_nats::ConnectOptions::new()
.name("corrosion-host-agent")
.retry_on_initial_connect()
.max_reconnects(None)
.ping_interval(Duration::from_secs(30))
.client_capacity(8192)
.reconnect_delay_callback(|attempts| {
Duration::from_millis(std::cmp::min(attempts as u64 * 100, 8_000))
})
.event_callback(|event| async move {
match event {
async_nats::Event::Disconnected => tracing::warn!("nats disconnected"),
async_nats::Event::Connected => tracing::info!("nats connected"),
other => tracing::debug!("nats event: {other}"),
}
});
if force_tls {
opts = opts.require_tls(true);
}
if let Some(token) = &cfg.nats_token {
opts = opts.token(token.clone());
}
let client = opts
.connect(&url)
.await
.with_context(|| format!("connecting to NATS at {url}"))?;
Ok(client)
}
/// Accept `tls://` / `nats+tls://` URL schemes by translating to `nats://` +
/// an explicit TLS requirement.
fn normalize_url(raw: &str) -> (String, bool) {
if let Some(rest) = raw.strip_prefix("tls://") {
(format!("nats://{rest}"), true)
} else if let Some(rest) = raw.strip_prefix("nats+tls://") {
(format!("nats://{rest}"), true)
} else {
(raw.to_string(), false)
}
}

View File

@@ -0,0 +1,186 @@
//! Agent configuration: TOML file + environment overrides.
//!
//! Multi-instance is foundational, not bolted on: one agent supervises N game
//! instances on the host, each declared as an `[[instance]]` block. Connection
//! secrets may come from env so the config file can be world-readable-ish
//! while the token is not.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// Instance ids share the NATS subject namespace with host-level segments.
const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"];
pub const SUPPORTED_GAMES: &[&str] = &["rust", "conan", "soulmask", "dune"];
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
pub agent: AgentSection,
#[serde(default, rename = "instance")]
pub instances: Vec<InstanceConfig>,
#[serde(default)]
pub prober: ProberSection,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentSection {
pub license_id: Option<String>,
pub nats_url: Option<String>,
pub nats_token: Option<String>,
#[serde(default = "default_heartbeat_seconds")]
pub heartbeat_seconds: u64,
#[serde(default = "default_log_level")]
pub log_level: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstanceConfig {
/// Short slug, unique per license: becomes a NATS subject segment.
pub id: String,
/// One of SUPPORTED_GAMES.
pub game: String,
/// Install root for this instance on the host.
pub root: PathBuf,
/// Optional human label shown in the panel.
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProberSection {
#[serde(default = "default_probe_interval")]
pub interval_seconds: u64,
/// Extra TCP targets beyond the built-in defaults.
#[serde(default, rename = "target")]
pub targets: Vec<ProbeTargetConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProbeTargetConfig {
pub name: String,
pub host: String,
pub port: u16,
}
fn default_heartbeat_seconds() -> u64 {
60
}
fn default_probe_interval() -> u64 {
300
}
fn default_log_level() -> String {
"info".to_string()
}
/// Fully-resolved settings after merging file + env. Everything required is
/// present and validated.
#[derive(Debug, Clone)]
pub struct Settings {
pub license_id: String,
pub nats_url: String,
pub nats_token: Option<String>,
pub heartbeat_seconds: u64,
pub log_level: String,
pub instances: Vec<InstanceConfig>,
pub probe_interval_seconds: u64,
pub probe_targets: Vec<ProbeTargetConfig>,
}
pub fn default_config_path() -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(r"C:\ProgramData\Corrosion\agent.toml")
}
#[cfg(not(windows))]
{
PathBuf::from("/etc/corrosion/agent.toml")
}
}
pub fn load(path: &Path) -> Result<Settings> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading config file {}", path.display()))?;
let file: ConfigFile = toml::from_str(&raw)
.with_context(|| format!("parsing config file {}", path.display()))?;
resolve(file)
}
/// Merge env overrides (env wins) and validate.
fn resolve(file: ConfigFile) -> Result<Settings> {
let license_id = std::env::var("CORROSION_LICENSE_ID")
.ok()
.filter(|v| !v.is_empty())
.or(file.agent.license_id)
.context("license_id missing: set [agent].license_id or CORROSION_LICENSE_ID")?;
let nats_url = std::env::var("CORROSION_NATS_URL")
.ok()
.filter(|v| !v.is_empty())
.or(file.agent.nats_url)
.context("nats_url missing: set [agent].nats_url or CORROSION_NATS_URL")?;
let nats_token = std::env::var("CORROSION_NATS_TOKEN")
.ok()
.filter(|v| !v.is_empty())
.or(file.agent.nats_token);
validate_subject_segment("license_id", &license_id)?;
let mut seen: HashSet<&str> = HashSet::new();
for inst in &file.instances {
validate_subject_segment("instance id", &inst.id)?;
if RESERVED_INSTANCE_IDS.contains(&inst.id.as_str()) {
bail!("instance id '{}' is reserved", inst.id);
}
if !seen.insert(inst.id.as_str()) {
bail!("duplicate instance id '{}'", inst.id);
}
if !SUPPORTED_GAMES.contains(&inst.game.as_str()) {
bail!(
"instance '{}': unsupported game '{}' (supported: {})",
inst.id,
inst.game,
SUPPORTED_GAMES.join(", ")
);
}
}
if file.agent.heartbeat_seconds < 10 {
bail!("[agent].heartbeat_seconds must be >= 10");
}
Ok(Settings {
license_id,
nats_url,
nats_token,
heartbeat_seconds: file.agent.heartbeat_seconds,
log_level: file.agent.log_level,
instances: file.instances,
probe_interval_seconds: file.prober.interval_seconds.max(30),
probe_targets: file.prober.targets,
})
}
/// NATS subject segments must not contain '.', '*', '>', whitespace, etc.
/// Keep it strict: lowercase alphanumerics plus '-' and '_', max 64 chars.
fn validate_subject_segment(what: &str, value: &str) -> Result<()> {
if value.is_empty() || value.len() > 64 {
bail!("{what} '{value}' must be 1-64 characters");
}
if !value
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
bail!("{what} '{value}' may only contain lowercase letters, digits, '-' and '_'");
}
Ok(())
}

View File

@@ -0,0 +1,115 @@
//! Host-level command handler: request-reply on `corrosion.{license}.host.cmd`.
//!
//! One subscriber; each message handled in its own task so a slow command
//! never blocks the dispatch loop. Phase 0 commands: ping, probe, sysinfo.
use futures::StreamExt;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use sysinfo::System;
use crate::agent::Agent;
use crate::prober;
use crate::subjects;
use crate::telemetry;
use crate::version;
#[derive(Debug, Deserialize)]
struct HostCommand {
func: String,
}
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
let subject = subjects::host_cmd(&agent.cfg.license_id);
let mut sub = agent.nats.subscribe(subject.clone()).await?;
tracing::info!("host command handler listening on {subject}");
let cancel = agent.shutdown.clone();
loop {
tokio::select! {
msg = sub.next() => {
match msg {
Some(msg) => {
let agent = agent.clone();
tokio::spawn(async move { handle(agent, msg).await });
}
None => {
tracing::warn!("host command subscription ended");
break;
}
}
}
_ = cancel.cancelled() => {
tracing::info!("host command handler stopping");
break;
}
}
}
Ok(())
}
async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
let Some(reply) = msg.reply.clone() else {
tracing::warn!("host command without reply subject ignored");
return;
};
let response = match serde_json::from_slice::<HostCommand>(&msg.payload) {
Ok(cmd) => dispatch(&agent, &cmd.func).await,
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
};
let bytes = match serde_json::to_vec(&response) {
Ok(b) => b,
Err(e) => {
tracing::error!("response serialize failed: {e}");
return;
}
};
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
tracing::warn!("response publish failed: {e}");
}
}
async fn dispatch(agent: &Arc<Agent>, func: &str) -> serde_json::Value {
match func {
"ping" => json!({
"status": "success",
"func": "ping",
"version": version::VERSION,
"commit": version::GIT_HASH,
"uptime_seconds": agent.started.elapsed().as_secs(),
}),
"probe" => {
let report = prober::run_probe(&agent.cfg.probe_targets).await;
*agent.last_probe.write().await = Some(report.clone());
match serde_json::to_value(&report) {
Ok(report_json) => json!({
"status": "success",
"func": "probe",
"report": report_json,
}),
Err(e) => json!({ "status": "error", "message": format!("probe serialize: {e}") }),
}
}
"sysinfo" => {
let mut sys = System::new();
sys.refresh_cpu_usage();
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let payload = telemetry::collect(agent, &mut sys).await;
match serde_json::to_value(&payload) {
Ok(snapshot) => json!({
"status": "success",
"func": "sysinfo",
"snapshot": snapshot,
}),
Err(e) => json!({ "status": "error", "message": format!("sysinfo serialize: {e}") }),
}
}
other => json!({
"status": "error",
"message": format!("unknown func '{other}' (supported: ping, probe, sysinfo)"),
}),
}
}

View File

@@ -0,0 +1,168 @@
//! Corrosion Host Agent — multi-game ops runtime.
//!
//! Phase 0: NATS connectivity, real host telemetry, multi-instance config,
//! connectivity prober, host command channel. Process control, file ops, and
//! game adapters arrive in Phase 1+ (see PROTOCOL.md).
mod agent;
mod bus;
mod config;
mod hostcmd;
mod prober;
mod subjects;
mod telemetry;
mod version;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::agent::Agent;
#[derive(Parser)]
#[command(name = "corrosion-host-agent", version = version::VERSION, about)]
struct Cli {
/// Path to agent.toml (default: /etc/corrosion/agent.toml on Linux,
/// C:\ProgramData\Corrosion\agent.toml on Windows)
#[arg(long, short = 'c')]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Validate the config file and exit.
Check,
/// Print full version (semver, git hash, build timestamp) and exit.
Version,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let config_path = cli.config.unwrap_or_else(config::default_config_path);
match cli.command {
Some(Command::Version) => {
println!("corrosion-host-agent {}", version::long());
Ok(())
}
Some(Command::Check) => {
let settings = config::load(&config_path)?;
println!(
"config ok: license {}, {} instance(s), nats {}",
settings.license_id,
settings.instances.len(),
settings.nats_url
);
Ok(())
}
None => {
let settings = config::load(&config_path)?;
init_logging(&settings.log_level);
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("building tokio runtime")?
.block_on(run(settings))
}
}
}
fn init_logging(level: &str) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
}
async fn run(settings: config::Settings) -> Result<()> {
tracing::info!(
"corrosion-host-agent {} starting: license {}, {} instance(s)",
version::long(),
settings.license_id,
settings.instances.len()
);
for inst in &settings.instances {
tracing::info!(" instance '{}' ({}) at {}", inst.id, inst.game, inst.root.display());
}
let nats = bus::connect(&settings).await?;
let agent = Arc::new(Agent {
cfg: settings,
nats,
started: Instant::now(),
last_probe: RwLock::new(None),
shutdown: CancellationToken::new(),
});
let mut handles = Vec::new();
handles.push(tokio::spawn(telemetry::run(agent.clone())));
handles.push(tokio::spawn(prober::run_loop(agent.clone())));
{
let agent = agent.clone();
handles.push(tokio::spawn(async move {
if let Err(e) = hostcmd::run(agent).await {
tracing::error!("host command handler failed: {e:#}");
}
}));
}
wait_for_shutdown_signal().await;
tracing::info!("shutdown signal received");
agent.shutdown.cancel();
// Best-effort offline beacon so the panel flips to offline immediately
// instead of waiting out the heartbeat staleness window.
let beacon = subjects::host_going_offline(&agent.cfg.license_id);
let _ = tokio::time::timeout(
Duration::from_millis(500),
agent.nats.publish(beacon, "{}".into()),
)
.await;
match tokio::time::timeout(
Duration::from_secs(10),
futures::future::join_all(handles),
)
.await
{
Ok(_) => tracing::info!("all subsystems stopped cleanly"),
Err(_) => tracing::warn!("shutdown timeout: some subsystems did not stop within 10s"),
}
let _ = agent.nats.flush().await;
tracing::info!("corrosion-host-agent stopped");
Ok(())
}
async fn wait_for_shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
tracing::error!("SIGTERM handler failed: {e}; falling back to ctrl-c only");
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}

View File

@@ -0,0 +1,121 @@
//! Connectivity prober.
//!
//! Answers "is it the box or is it the network?" before a support ticket gets
//! written. Phase 0 scope is OUTBOUND reachability: TCP connect timing from
//! the host to known endpoints. Inbound port-forward verification (the thing
//! panel users actually struggle with) requires a backend-side reverse probe
//! and is specified in PROTOCOL.md as a later phase.
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::TcpStream;
use crate::agent::Agent;
use crate::config::ProbeTargetConfig;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Serialize)]
pub struct ProbeResult {
pub name: String,
pub host: String,
pub port: u16,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProbeReport {
pub timestamp: String,
pub results: Vec<ProbeResult>,
}
/// Built-in targets every agent checks, before config extras.
fn default_targets() -> Vec<ProbeTargetConfig> {
vec![ProbeTargetConfig {
name: "corrosion-cdn".to_string(),
host: "cdn.corrosionmgmt.com".to_string(),
port: 443,
}]
}
pub async fn run_probe(extra_targets: &[ProbeTargetConfig]) -> ProbeReport {
let mut targets = default_targets();
targets.extend(extra_targets.iter().cloned());
let checks = targets.into_iter().map(|t| async move {
let started = Instant::now();
let addr = format!("{}:{}", t.host, t.port);
let outcome = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)).await;
match outcome {
Ok(Ok(_stream)) => ProbeResult {
name: t.name,
host: t.host,
port: t.port,
ok: true,
latency_ms: Some(started.elapsed().as_millis() as u64),
error: None,
},
Ok(Err(e)) => ProbeResult {
name: t.name,
host: t.host,
port: t.port,
ok: false,
latency_ms: None,
error: Some(e.to_string()),
},
Err(_) => ProbeResult {
name: t.name,
host: t.host,
port: t.port,
ok: false,
latency_ms: None,
error: Some(format!("timeout after {}s", CONNECT_TIMEOUT.as_secs())),
},
}
});
let results = futures::future::join_all(checks).await;
ProbeReport {
timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
results,
}
}
/// Periodic probe loop; results land in shared state and ride the next
/// heartbeat. Jittered interval to avoid fleet-wide synchronization.
pub async fn run_loop(agent: Arc<Agent>) {
let cancel = agent.shutdown.clone();
loop {
let report = run_probe(&agent.cfg.probe_targets).await;
let failed: Vec<&str> = report
.results
.iter()
.filter(|r| !r.ok)
.map(|r| r.name.as_str())
.collect();
if failed.is_empty() {
tracing::debug!("probe ok ({} targets)", report.results.len());
} else {
tracing::warn!("probe failures: {}", failed.join(", "));
}
*agent.last_probe.write().await = Some(report);
let jitter = rand::Rng::gen_range(&mut rand::thread_rng(), 0.8..1.2);
let interval =
Duration::from_secs_f64(agent.cfg.probe_interval_seconds as f64 * jitter);
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = cancel.cancelled() => {
tracing::info!("prober stopping");
break;
}
}
}
}

View File

@@ -0,0 +1,30 @@
//! Corrosion wire protocol v2 subject scheme (see PROTOCOL.md).
//!
//! Host-level subjects live under `corrosion.{license}.host.*`; per-instance
//! subjects under `corrosion.{license}.{instance_id}.*`. Instance ids are
//! validated at config load so they can never collide with the reserved
//! `host` segment or contain subject metacharacters.
pub fn host_heartbeat(license: &str) -> String {
format!("corrosion.{license}.host.heartbeat")
}
pub fn host_cmd(license: &str) -> String {
format!("corrosion.{license}.host.cmd")
}
pub fn host_going_offline(license: &str) -> String {
format!("corrosion.{license}.host.going_offline")
}
/// Phase 1: per-instance command channel (start/stop/restart/rcon/...).
#[allow(dead_code)]
pub fn instance_cmd(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.cmd")
}
/// Phase 1: per-instance state-change events.
#[allow(dead_code)]
pub fn instance_status(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.status")
}

View File

@@ -0,0 +1,175 @@
//! Host heartbeat: real telemetry, never fabricated.
//!
//! The Go agent shipped `disk_free_mb: 50000` and `cpu_percent: 0.0` as
//! hardcoded placeholders. This module is the first time the panel's
//! Resources view receives the truth. Anything we cannot measure is omitted
//! or null — never invented.
use chrono::{SecondsFormat, Utc};
use rand::Rng;
use serde::Serialize;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use sysinfo::{Disks, System};
use crate::agent::Agent;
use crate::prober::ProbeReport;
use crate::subjects;
use crate::version;
#[derive(Debug, Serialize)]
pub struct HeartbeatPayload {
/// Wire schema version — lets the backend distinguish v2 host heartbeats
/// from legacy Go companion heartbeats during any transition window.
pub schema: u32,
pub timestamp: String,
pub agent: AgentInfo,
pub host: HostInfo,
pub instances: Vec<InstanceInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub probe: Option<ProbeReport>,
}
#[derive(Debug, Serialize)]
pub struct AgentInfo {
pub version: String,
pub commit: String,
pub os: String,
pub arch: String,
pub uptime_seconds: u64,
}
#[derive(Debug, Serialize)]
pub struct HostInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
pub cpu_percent: f32,
pub cpu_cores: usize,
pub mem_total_mb: u64,
pub mem_used_mb: u64,
pub uptime_seconds: u64,
pub disks: Vec<DiskInfo>,
}
#[derive(Debug, Serialize)]
pub struct DiskInfo {
pub mount: String,
pub total_mb: u64,
pub free_mb: u64,
}
#[derive(Debug, Serialize)]
pub struct InstanceInfo {
pub id: String,
pub game: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// Phase 0 states: `configured` (root exists) or `missing_root`.
/// Phase 1 adds live process states (running/stopped/crashed).
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub root_disk_free_mb: Option<u64>,
}
pub async fn run(agent: Arc<Agent>) {
let cancel = agent.shutdown.clone();
let mut sys = System::new();
// CPU usage is a delta between refreshes; prime it once so the first
// heartbeat carries a real figure instead of 0.
sys.refresh_cpu_usage();
tokio::time::sleep(Duration::from_millis(250)).await;
loop {
let payload = collect(&agent, &mut sys).await;
match serde_json::to_vec(&payload) {
Ok(bytes) => {
let subject = subjects::host_heartbeat(&agent.cfg.license_id);
if let Err(e) = agent.nats.publish(subject, bytes.into()).await {
tracing::warn!("heartbeat publish failed: {e}");
} else {
tracing::debug!(
"heartbeat sent: cpu {:.1}%, {} instance(s)",
payload.host.cpu_percent,
payload.instances.len()
);
}
}
Err(e) => tracing::error!("heartbeat serialize failed: {e}"),
}
let jitter = rand::thread_rng().gen_range(0.8..1.2);
let interval = Duration::from_secs_f64(agent.cfg.heartbeat_seconds as f64 * jitter);
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = cancel.cancelled() => {
tracing::info!("telemetry stopping");
break;
}
}
}
}
pub async fn collect(agent: &Agent, sys: &mut System) -> HeartbeatPayload {
sys.refresh_cpu_usage();
sys.refresh_memory();
let disks = Disks::new_with_refreshed_list();
let disk_infos: Vec<DiskInfo> = disks
.iter()
.map(|d| DiskInfo {
mount: d.mount_point().to_string_lossy().to_string(),
total_mb: d.total_space() / 1_048_576,
free_mb: d.available_space() / 1_048_576,
})
.collect();
let instances = agent
.cfg
.instances
.iter()
.map(|inst| {
let exists = inst.root.exists();
InstanceInfo {
id: inst.id.clone(),
game: inst.game.clone(),
label: inst.label.clone(),
state: if exists { "configured" } else { "missing_root" }.to_string(),
root_disk_free_mb: disk_free_for_path(&disks, &inst.root),
}
})
.collect();
HeartbeatPayload {
schema: 2,
timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
agent: AgentInfo {
version: version::VERSION.to_string(),
commit: version::GIT_HASH.to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
uptime_seconds: agent.started.elapsed().as_secs(),
},
host: HostInfo {
hostname: System::host_name(),
cpu_percent: sys.global_cpu_usage(),
cpu_cores: sys.cpus().len(),
mem_total_mb: sys.total_memory() / 1_048_576,
mem_used_mb: sys.used_memory() / 1_048_576,
uptime_seconds: System::uptime(),
disks: disk_infos,
},
instances,
probe: agent.last_probe.read().await.clone(),
}
}
/// Free space on the disk whose mount point is the longest prefix of `path`.
fn disk_free_for_path(disks: &Disks, path: &Path) -> Option<u64> {
disks
.iter()
.filter(|d| path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.map(|d| d.available_space() / 1_048_576)
}

Some files were not shown because too many files have changed in this diff Show More