27 Commits

Author SHA1 Message Date
Vantz Stockwell
7fdca2cd4f chore(host-agent): bump to 2.0.0-alpha.3 (RCON + supervision + SteamCMD + file manager)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m26s
Build Host Agent (Rust) / build (push) Successful in 1m35s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:52:05 -04:00
Vantz Stockwell
18f978dde1 feat(host-agent): Phase 1c — SteamCMD update + jailed file manager
steam_update func runs SteamCMD per game (rust/conan/soulmask app-ids;
dune rejected), streaming stdout to {instance}.steam_status. Jailed
file manager on {instance}.files.cmd: list/read/write/delete/rename/
mkdir/mkfile/move/copy, all confined to instance root via two-stage
lexical-normalize + canonicalize (defeats ../ traversal AND symlink
escape — incl chained symlinks). Replaces the Go agent's UNJAILED
legacy files API (retired, not ported). 5MiB read cap.

42/42 tests green: 24 filemanager incl 7 jail-escape attempts
(dotdot, deep dotdot, absolute, symlink-inside, direct symlink,
chained symlink), 5 steamcmd app-id (cfg-gated win/linux soulmask).
Jail logic reviewed line-by-line: Path::starts_with is component-wise
(no sibling-prefix bypass), non-existent suffix components can't be
symlinks, leading .. normalizes to / and fails the prefix check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:51:46 -04:00
Vantz Stockwell
9e5e828c8d fix(docker): nginx healthcheck uses 127.0.0.1 not localhost — IPv4-only listener
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
corrosion-nginx reported (unhealthy) despite serving the panel fine:
nginx listens 0.0.0.0:80 (IPv4 only, no listen [::]:80), but
'localhost' resolves to ::1 first inside the container, so the probe
got connection-refused. Verified: 127.0.0.1:80 serves the SPA. Probe
now targets IPv4 explicitly. No nginx config change — the panel was
never broken, only the healthcheck's hostname resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:43:01 -04:00
Vantz Stockwell
fccd5c61c5 docs(claude): Lessons 24-25 — onModuleInit-before-connect dead subscriptions + resurrected-path crash
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:17:02 -04:00
Vantz Stockwell
c72a280361 fix(api): WS gateways crashed on first forwarded event — WebSocket.OPEN undefined at runtime
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 40s
CI / integration (push) Successful in 20s
Lesson 10 in the flesh: the onApplicationBootstrap fix made the NATS->
WS bridge actually deliver events for the first time, which instantly
crashed the API. esModuleInterop is off, so 'import WebSocket from ws'
compiles to ws_1.default = undefined; WebSocket.OPEN threw
'Cannot read properties of undefined' and killed the process on the
first heartbeat forward. All three WS guard sites (nats-bridge x2,
console gateway) switched to the import-agnostic instance constant
client.OPEN. Latent in every build — never hit because the bridge was
dead-on-boot until today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:11:29 -04:00
Vantz Stockwell
a3b4b5cc7d fix(api): NATS subscriptions moved to onApplicationBootstrap — they silently no-oped before connect
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 47s
CI / integration (push) Successful in 22s
Production bug caught live: provider onModuleInit order put bridge/
consumer subscription hooks BEFORE NatsService finished connecting, so
every subscribe() hit the [OFFLINE] no-op path — the WS bridge has been
dead-on-boot in every production build, and the new v2 consumer never
saw a heartbeat (server_connections stayed empty under a live agent).
onApplicationBootstrap is guaranteed to run after all module inits,
including the awaited NATS connect.

The new CI contract suite fails on exactly this class of bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:02:52 -04:00
Vantz Stockwell
4e184ca571 ci: full test gate — types, frontend build, agent tests, agent<->backend contract suite
Some checks failed
CI / backend-types (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m48s
CI / integration (push) Has been cancelled
ci.yml runs on every push to main: backend tsc, frontend vue-tsc+vite,
cargo test (cached), then an integration job with postgres:16 + nats
service containers — real migrations applied to a fresh DB, real
backend booted (admin seed provides the license), real agent binary
spawned. contract-tests/agent-backend.contract.mjs proves the entire
v2 pipeline: heartbeat shape + measured telemetry, auto-registered
server_connections row flipping connected, instance start/stop/status
round-trips with push events, and the offline beacon flipping the row
back. This is the test that could not run before a production rebuild
until now — it now runs before every push lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:59:44 -04:00
Vantz Stockwell
fde0926d52 feat(host-agent): Phase 1b RCON — WebRCON (rust) + Source RCON (conan/soulmask)
rcon func on the instance command channel: WebSocket JSON WebRCON with
Identifier correlation (skips chat/log noise frames) and full Valve
Source RCON over TCP (auth, exec, multi-packet reassembly via empty
probe, 1MiB cap). Protocol inferred from game, explicit kind override
in [instance.rcon]. Always 127.0.0.1 — agent is co-located.

Hardening from review: WebRCON password never interpolated into error
contexts/logs (redacted URL); probe-tolerant termination — a quiet
period after received data ends the response for servers that don't
echo the probe (Soulmask conformance unverified), so data is never
discarded on probe timeout.

13/13 tests green incl. mock Source-RCON server (auth/multi-packet/
errors) and mock WebRCON server (noise-frame skipping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:53:52 -04:00
Vantz Stockwell
4d99c9d99d feat(frontend): validate persisted session on app boot
A stale or revoked token previously rendered the full panel chrome and
only collapsed on the first API call. App boot now calls /auth/me
through useApi (401 -> refresh -> logout already handled there); user
profile refreshes on success, and non-auth failures (network, 5xx)
never log the user out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:49:21 -04:00
Vantz Stockwell
b8f0ccba3c fix(frontend): env-driven marketing host detection
Exact-match on 'corrosionmgmt.com' meant www. or any staging host
silently served the panel instead of the marketing site. Hosts now come
from VITE_MARKETING_HOSTS (comma-separated, defaults cover bare + www).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:47:15 -04:00
Vantz Stockwell
068a476f39 feat(host-agent): Phase 1a process supervision — instance start/stop/restart/status + push state events
Per-instance ProcessSupervisor: tokio child spawn with proper arg list
(fixes Go's naive space-splitting), graceful SIGTERM with 30s budget
then force kill, monitor task classifying ordered-stop vs crash (exit
code captured), watch-channel state observable everywhere. Instance cmd
channel live on corrosion.{license}.{instance}.cmd (start/stop/restart/
status) with state events pushed on {instance}.status (keep-latest
semantics, documented). Heartbeats now carry live process state +
uptime per instance. Crate restructured lib+bin for integration tests.

Verified: 5 integration tests with real OS processes (lifecycle, crash
exit-code, restart recovery, unmanaged rejection, clean spawn failure)
+ live-NATS contract test (request-reply roundtrips, double-start
rejection, push events, heartbeat state) — all green.

Known limitation (documented): no PID adoption yet — agent restart
orphans a running game process to 'stopped' until panel restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:44:24 -04:00
Vantz Stockwell
f706c3c47e docs(claude): host-agent reality — active Rust crate, tag scheme, runner container truth, command corrections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:37:37 -04:00
Vantz Stockwell
4c9c322c29 feat(seo): per-route titles + meta descriptions; ci: honest runner test
Every page previously titled 'Corrosion Management' with zero meta -
marketing invisible to search and link previews. Router afterEach now
sets title/description/og per route (no new deps); marketing pages get
real content-backed descriptions, panel views mechanical titles.
index.html carries defaults for pre-JS crawlers. Verified in-browser
per page via Playwright.

test-runner.yml: per-tool presence checks instead of green-lighting
missing toolchains; workflow_dispatch instead of every push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:35:58 -04:00
Vantz Stockwell
47fa72763c feat(api): host-agent protocol v2 consumer — heartbeat persistence, auto-register, staleness sweep
Nothing persisted agent heartbeats before: companion_last_seen was
written once at setup and connection_status stayed 'connected' forever.
HostAgentConsumerService now consumes corrosion.*.host.heartbeat
(updates last_seen + status, auto-creates the bare_metal connection row
on first contact), host.going_offline (graceful offline), and sweeps
connections offline after 180s of heartbeat silence. License-existence
tenant validation with caching per NATS-consumer doctrine. WS bridge
forwards host_heartbeat/host_going_offline to the panel.

Contract-verified against production NATS with the backend's own nats
lib: v2 subjects, schema 2, real telemetry, offline beacon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:35:58 -04:00
Vantz Stockwell
b455bf9f14 ci(host-agent): bootstrap Rust in the runner container; roll to alpha.2
All checks were successful
Build Host Agent (Rust) / build (push) Successful in 1m29s
Test Asgard Runner / test (push) Successful in 3s
Asgard runner executes jobs in bare node:20-bullseye (no Rust, no sudo)
- install rustup + musl/mingw cross toolchains per-run, same pattern as
setup-go in the Go pipeline. agent-v2.0.0-alpha.1 predates this fix;
forward-only doctrine: version rolls to alpha.2 rather than re-pushing
the tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:15:36 -04:00
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
78 changed files with 11646 additions and 1668 deletions

View File

@@ -42,3 +42,6 @@ FRONTEND_URL=http://localhost:5174
# Frontend (Vite — must be prefixed with VITE_)
VITE_PANEL_URL=https://panel.corrosionmgmt.com
# Hostnames that serve the marketing site (comma-separated); all other hosts get the panel
VITE_MARKETING_HOSTS=corrosionmgmt.com,www.corrosionmgmt.com

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,120 @@
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
# The Asgard runner executes jobs in a bare node:20-bullseye container
# (no Rust, no sudo, runs as root) — bootstrap the toolchain per-run,
# same pattern as actions/setup-go in the Go pipeline.
- name: Install Rust + cross toolchains
run: |
apt-get update -qq
apt-get install -y -qq build-essential musl-tools gcc-mingw-w64-x86-64 curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
"$HOME/.cargo/bin/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

122
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,122 @@
name: CI
# Test gate for every push to main. The deploy story: main must be green here
# before the stack is rebuilt (deploy workflow enforces it once SSH transport
# secrets land). Jobs run in the runner's bare node:20-bullseye container —
# toolchains bootstrap per-run.
on:
push:
branches: [main]
pull_request:
jobs:
backend-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Type-check NestJS backend
run: |
cd backend-nest
npm ci --no-audit --no-fund 2>&1 | tail -2
npx tsc --noEmit
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build frontend (vue-tsc gate + vite)
run: |
cd frontend
npm ci --no-audit --no-fund 2>&1 | tail -2
npm run build
agent-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
corrosion-host-agent/target
key: cargo-${{ hashFiles('corrosion-host-agent/Cargo.lock') }}
- name: Install Rust
run: |
apt-get update -qq && apt-get install -y -qq build-essential curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Test agent
run: |
cd corrosion-host-agent
cargo test
- name: Upload agent binary for integration
uses: actions/upload-artifact@v3
with:
name: agent-debug
path: corrosion-host-agent/target/debug/corrosion-host-agent
integration:
runs-on: ubuntu-latest
needs: agent-tests
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: corrosion
POSTGRES_PASSWORD: citest
POSTGRES_DB: corrosion
nats:
image: nats:2.10-alpine
steps:
- uses: actions/checkout@v4
- name: Download agent binary
uses: actions/download-artifact@v3
with:
name: agent-debug
path: agent-bin
- name: Apply migrations to fresh DB
run: |
apt-get update -qq && apt-get install -y -qq postgresql-client
until PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -c 'SELECT 1' >/dev/null 2>&1; do sleep 1; done
for f in $(ls backend/migrations/*.sql | sort); do
echo "applying $f"
PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -v ON_ERROR_STOP=1 -q -f "$f"
done
- name: Build + boot backend
run: |
cd backend-nest
npm ci --no-audit --no-fund 2>&1 | tail -2
npm run build
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
NATS_URL=nats://nats:4222 \
JWT_SECRET=ci-secret ENCRYPTION_KEY=ci-encryption-key \
ADMIN_EMAIL=ci@corrosion.test ADMIN_PASSWORD=ci-password-123 ADMIN_USERNAME=CI \
nohup node dist/main.js > /tmp/backend.log 2>&1 &
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{}' || true)
[ "$code" = "400" ] && echo "backend up" && exit 0
sleep 2
done
echo "backend failed to come up"; cat /tmp/backend.log; exit 1
- name: Run agent↔backend contract suite
run: |
chmod +x agent-bin/corrosion-host-agent
LICENSE_ID=$(PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -t -A -c 'SELECT id FROM licenses LIMIT 1')
echo "license under test: $LICENSE_ID"
[ -n "$LICENSE_ID" ] || { echo "admin seed did not create a license"; cat /tmp/backend.log; exit 1; }
LICENSE_ID="$LICENSE_ID" \
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
NATS_URL=nats://nats:4222 \
AGENT_BIN=$PWD/agent-bin/corrosion-host-agent \
node contract-tests/agent-backend.contract.mjs
- name: Backend log on failure
if: failure()
run: cat /tmp/backend.log || true

View File

@@ -1,5 +1,6 @@
name: Test Asgard Runner
on: [push]
# On-demand only — no reason to spin a container on every push.
on: [workflow_dispatch]
jobs:
test:
@@ -17,8 +18,15 @@ jobs:
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
echo "==========================================="
echo "Go: $(go version)"
echo "Rust: $(rustc --version)"
echo "Docker: $(docker --version)"
# Jobs run in a bare node:20-bullseye container: toolchains are NOT
# preinstalled — workflows must bootstrap them (setup-go, rustup).
# Report presence honestly instead of green-lighting a missing tool.
for tool in go rustc docker node; do
if command -v "$tool" >/dev/null 2>&1; then
echo "$tool: $($tool --version 2>&1 | head -1)"
else
echo "$tool: NOT PRESENT (workflows must install per-run)"
fi
done
echo "==========================================="
echo "✅ Asgard runner is OPERATIONAL"
echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run"

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,50 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11)
**Backend (NestJS):**
- `HostAgentConsumerService` (new) — consumes wire protocol v2: `corrosion.*.host.heartbeat` updates `companion_last_seen` + `connection_status='connected'` (auto-registers the connection row on first contact); `host.going_offline` flips offline; a 60s staleness sweep marks hosts offline after 180s of silence. Previously NOTHING persisted heartbeats — `connection_status` was set once at setup and never changed again. Tenant-validated (UUID + license existence, cached) per NATS-consumer doctrine
- `NatsBridgeService` — bridges `host_heartbeat` / `host_going_offline` events to the panel WebSocket
- Verified by contract test: real agent → production NATS → captured with the backend's own `nats` lib under the real license; subjects, schema 2, real telemetry, offline beacon all confirmed
**Frontend:**
- Per-route document titles + meta descriptions (router `afterEach`, no new deps): six marketing pages get real titles/descriptions/OG tags (previously every page was "Corrosion Management" with zero meta — invisible to search and link previews); panel views get mechanical "{View} — Corrosion" titles
**CI:**
- `test-runner.yml` — honest per-tool presence checks (was printing "OPERATIONAL" while every toolchain probe failed); on-demand trigger instead of every push
### 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

@@ -55,7 +55,12 @@ frontend/ # Vue 3 + TypeScript
package.json
vite.config.ts # Proxies /api to :3000
companion-agent/ # Go binary for bare metal servers
corrosion-host-agent/ # Rust host agent (ACTIVE) — multi-game ops runtime
src/ # main, config, bus (NATS), telemetry, prober, hostcmd
PROTOCOL.md # Wire protocol v2 spec (instance-scoped subjects)
agent.example.toml # Multi-instance config reference
companion-agent/ # Go binary (LEGACY — behavior reference until Rust parity)
cmd/agent/ # main.go entry point
internal/ # Core agent logic (nats, commands, process)
Makefile # Build for Linux/Windows
@@ -91,14 +96,16 @@ cd backend-nest && npx tsc --noEmit # Type-check without building
# Frontend
cd frontend && npm run dev # Vite dev server (port 5174)
cd frontend && npm run build # Production build → dist/
cd frontend && npm run lint # ESLint
cd frontend && npm run type-check # TypeScript checking (vue-tsc)
cd frontend && npm run build # vue-tsc -b && vite build (type-check included; no separate lint/type-check scripts exist)
# Companion Agent (Go)
# Host Agent (Rust — ACTIVE)
cd corrosion-host-agent && cargo check # Fast validation
cd corrosion-host-agent && cargo build --release --target x86_64-unknown-linux-musl # Static Linux binary
cd corrosion-host-agent && cargo xwin build --release --target x86_64-pc-windows-msvc # Windows (local)
# CI: push tag agent-vX.Y.Z (must match Cargo.toml version) → Asgard builds → CDN /host-agent/alpha/
# Companion Agent (Go — LEGACY, behavior reference until Rust parity)
cd companion-agent && make build # Build for current platform
cd companion-agent && make linux # Cross-compile for Linux
cd companion-agent && make windows # Cross-compile for Windows
# Docker (from docker/ directory — Commander ALWAYS builds with --no-cache)
docker compose build --no-cache && docker compose up -d # Full rebuild + start
@@ -374,7 +381,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
- Treat every change as production deployment (`corrosionmgmt.com`)
- Document why, not just what, in commits and CHANGELOG
- **Always commit and push when done touching code — never ask, never wait for permission**
- **Tag companion agent builds when Go code in `companion-agent/` is modified** — increment from latest tag (currently v1.0.3), push tag to trigger CI build + CDN upload
- **Tag agent builds when agent code is modified** — Rust agent: `agent-vX.Y.Z` (must match `corrosion-host-agent/Cargo.toml`; CI publishes to CDN `/host-agent/alpha/`, while `/latest/` stays on the Go build until cutover). Legacy Go agent: `vX.Y.Z`. Tags roll FORWARD only — never reuse or re-push a tag; cut the next version
- **The Asgard CI runner executes jobs in a bare `node:20-bullseye` container** — no Rust/Go/Docker/sudo preinstalled; workflows must bootstrap toolchains per-run (setup-go, rustup via curl)
## Development Notes
@@ -435,3 +443,7 @@ Things I discovered about myself building a sister platform across multiple sess
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.
24. **`onModuleInit` runs before async `onModuleInit` of dependencies completes — register NATS/external subscriptions in `onApplicationBootstrap`.** `NatsService.onModuleInit` connects to NATS (async); `NatsBridgeService`/`HostAgentConsumerService` registered their subscriptions in their own `onModuleInit`, which fired while the connection was still null — so every `subscribe()` hit the `[OFFLINE]` no-op path and the WS bridge was dead-on-boot in *every* production build, silently. Nest guarantees `onApplicationBootstrap` runs only after all module init (including the awaited connect) finishes. Anything that depends on another provider's async startup belongs in bootstrap, not init. The tell: a subscription that "should be there" but the handler never fires and there's no error — trace the *startup ordering*, not the handler.
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.

View File

@@ -44,10 +44,14 @@ import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter
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';
import { NatsBridgeService } from './services/nats-bridge.service';
import { HostAgentConsumerService } from './services/host-agent-consumer.service';
import { ServerConnection } from './entities/server-connection.entity';
import { License } from './entities/license.entity';
import { SteamService } from './services/steam.service';
// Gateway
@@ -90,6 +94,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
// Scheduler
ScheduleModule.forRoot(),
// Repositories for app-level shared services (host-agent consumer)
TypeOrmModule.forFeature([ServerConnection, License]),
// Feature Modules
AuthModule,
UsersModule,
@@ -123,6 +130,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
BetterChatModule,
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)
@@ -132,6 +140,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
// Shared services
NatsService,
NatsBridgeService,
HostAgentConsumerService,
SteamService,
// WebSocket gateway

View File

@@ -71,7 +71,10 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
// Subscribe to NATS events for this license
const listener = (event: string, data: unknown) => {
if (client.readyState === WebSocket.OPEN) {
// client.OPEN (instance constant) — NOT WebSocket.OPEN: with
// esModuleInterop off, the default `ws` import is undefined at
// runtime, so the static crashes. The instance constant is safe.
if (client.readyState === client.OPEN) {
client.send(JSON.stringify({
type: 'event',
license_id: payload.license_id,

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

@@ -108,7 +108,9 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
const message = JSON.stringify({ event, data });
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
// client.OPEN, not WebSocket.OPEN — esModuleInterop is off so the
// default `ws` import is undefined at runtime (would crash on forward).
if (client.readyState === client.OPEN) {
client.send(message);
}
}

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,154 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NatsService } from './nats.service';
import { ServerConnection } from '../entities/server-connection.entity';
import { License } from '../entities/license.entity';
/**
* Consumes Corrosion wire protocol v2 host-agent subjects
* (corrosion-host-agent/PROTOCOL.md) and keeps server_connections truthful.
*
* Before this service existed, NOTHING persisted agent heartbeats:
* companion_last_seen was written once at setup and connection_status stayed
* 'connected' forever. Now: heartbeat -> last_seen + connected (row
* auto-created on first contact), going_offline beacon -> offline, and a
* staleness sweep marks hosts offline when heartbeats stop arriving.
*/
@Injectable()
export class HostAgentConsumerService implements OnApplicationBootstrap {
private readonly logger = new Logger(HostAgentConsumerService.name);
/** licenseId -> cache expiry epoch-ms. Positive = exists, absent = unknown. */
private knownLicenses = new Map<string, number>();
/** Unknown/garbage license ids we already warned about (anti log-spam). */
private warnedUnknown = new Set<string>();
private static readonly UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
private static readonly LICENSE_CACHE_TTL_MS = 5 * 60_000;
/** 3x the agent's default 60s heartbeat (which jitters to max 72s). */
private static readonly OFFLINE_AFTER_MS = 180_000;
constructor(
private readonly nats: NatsService,
@InjectRepository(ServerConnection)
private readonly connectionRepository: Repository<ServerConnection>,
@InjectRepository(License)
private readonly licenseRepository: Repository<License>,
) {}
// Bootstrap, not module-init: subscriptions registered before NatsService
// finished connecting silently no-op (see NatsBridgeService note).
onApplicationBootstrap() {
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
void this.onHeartbeat(licenseId).catch((err) =>
this.logger.error(`heartbeat handling failed for ${licenseId}: ${err.message}`, err.stack),
);
void data; // payload telemetry is bridged to the browser; persistence here is liveness only
});
this.nats.subscribe('corrosion.*.host.going_offline', (_data, subject) => {
const licenseId = subject.split('.')[1];
void this.onGoingOffline(licenseId).catch((err) =>
this.logger.error(`going_offline handling failed for ${licenseId}: ${err.message}`, err.stack),
);
});
this.logger.log('Host agent (protocol v2) consumer subscriptions initialized');
}
private async onHeartbeat(licenseId: string): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
const now = new Date();
const existing = await this.connectionRepository.findOne({
where: { license_id: licenseId },
});
if (existing) {
await this.connectionRepository.update(
{ id: existing.id },
{ companion_last_seen: now, connection_status: 'connected', updated_at: now },
);
if (existing.connection_status !== 'connected') {
this.logger.log(`host agent for license ${licenseId} is back online`);
}
} else {
// First contact from a host agent: auto-register the connection so the
// panel lights up without a manual setup step.
await this.connectionRepository.save(
this.connectionRepository.create({
license_id: licenseId,
connection_type: 'bare_metal',
connection_status: 'connected',
companion_last_seen: now,
}),
);
this.logger.log(`host agent registered for license ${licenseId} (first heartbeat)`);
}
}
private async onGoingOffline(licenseId: string): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
await this.connectionRepository.update(
{ license_id: licenseId },
{ connection_status: 'offline', updated_at: new Date() },
);
this.logger.log(`host agent for license ${licenseId} went offline (graceful beacon)`);
}
/**
* Heartbeats stopping must flip the panel to offline — an agent that
* crashes or loses network never sends the goodbye beacon.
*/
@Interval(60_000)
async sweepStaleConnections(): Promise<void> {
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
const result = await this.connectionRepository
.createQueryBuilder()
.update(ServerConnection)
.set({ connection_status: 'offline', updated_at: () => 'NOW()' })
.where('connection_status = :connected', { connected: 'connected' })
.andWhere('companion_last_seen IS NOT NULL')
.andWhere('companion_last_seen < :threshold', { threshold })
.execute();
if (result.affected) {
this.logger.warn(`marked ${result.affected} stale host connection(s) offline`);
}
}
/**
* Tenant validation: the subject segment must be a real license UUID.
* NATS consumers must never write rows for subjects an arbitrary publisher
* invented. Existence is cached to avoid a query per heartbeat.
*/
private async isValidTenant(licenseId: string): Promise<boolean> {
if (!HostAgentConsumerService.UUID_RE.test(licenseId)) {
this.warnUnknownOnce(licenseId, 'not a UUID');
return false;
}
const cachedUntil = this.knownLicenses.get(licenseId);
if (cachedUntil && cachedUntil > Date.now()) return true;
const exists = await this.licenseRepository.exist({ where: { id: licenseId } });
if (!exists) {
this.warnUnknownOnce(licenseId, 'no such license');
return false;
}
this.knownLicenses.set(licenseId, Date.now() + HostAgentConsumerService.LICENSE_CACHE_TTL_MS);
return true;
}
private warnUnknownOnce(licenseId: string, reason: string): void {
if (this.warnedUnknown.has(licenseId)) return;
this.warnedUnknown.add(licenseId);
this.logger.warn(`ignoring host-agent traffic for invalid license '${licenseId}' (${reason})`);
}
}

View File

@@ -1,3 +1,4 @@
export { NatsService } from './nats.service';
export { NatsBridgeService } from './nats-bridge.service';
export { HostAgentConsumerService } from './host-agent-consumer.service';
export { SteamService } from './steam.service';

View File

@@ -1,14 +1,19 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { Injectable, OnApplicationBootstrap, Logger } from '@nestjs/common';
import { NatsService } from './nats.service';
@Injectable()
export class NatsBridgeService implements OnModuleInit {
export class NatsBridgeService implements OnApplicationBootstrap {
private readonly logger = new Logger(NatsBridgeService.name);
private listeners: Map<string, Set<(event: string, data: unknown) => void>> = new Map();
constructor(private nats: NatsService) {}
onModuleInit() {
// Subscriptions MUST happen in onApplicationBootstrap, not onModuleInit:
// provider onModuleInit order is not guaranteed, and these hooks once ran
// before NatsService connected — every subscribe() silently no-oped and the
// WS bridge was dead from boot. Bootstrap runs after ALL module inits
// (including the awaited NATS connect) complete.
onApplicationBootstrap() {
this.nats.subscribe('corrosion.*.companion.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'heartbeat', data);
@@ -44,6 +49,17 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'oxide_status', data);
});
// Wire protocol v2 (corrosion-host-agent) — host-level telemetry
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'host_heartbeat', data);
});
this.nats.subscribe('corrosion.*.host.going_offline', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'host_going_offline', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

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

@@ -0,0 +1,152 @@
// Full-pipeline contract test: Rust host agent → NATS → NestJS consumer → Postgres.
//
// Proves the wire protocol v2 chain end to end against a REAL backend and DB:
// 1. agent heartbeat arrives with schema 2 + measured telemetry
// 2. backend auto-registers the server_connections row and marks it connected
// 3. instance command channel round-trips (start/status/stop) with push events
// 4. graceful agent shutdown publishes the offline beacon and the row flips offline
//
// Required env:
// LICENSE_ID — existing license uuid (CI: from the admin seed)
// DATABASE_URL — postgres connection string for assertions
// NATS_URL — broker both agent and backend use (default nats://localhost:4222)
// AGENT_BIN — path to the corrosion-host-agent binary
//
// Uses the backend's own node_modules (nats, pg) so the client libs under test
// are exactly what production runs.
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(join(repoRoot, 'backend-nest', 'node_modules', 'x.js'));
const { connect, StringCodec } = require('nats');
const { Client: PgClient } = require('pg');
const LICENSE = process.env.LICENSE_ID;
const NATS_URL = process.env.NATS_URL ?? 'nats://localhost:4222';
const DATABASE_URL = process.env.DATABASE_URL;
const AGENT_BIN = process.env.AGENT_BIN ?? join(repoRoot, 'corrosion-host-agent', 'target', 'debug', 'corrosion-host-agent');
if (!LICENSE || !DATABASE_URL) {
console.error('LICENSE_ID and DATABASE_URL are required');
process.exit(2);
}
const sc = StringCodec();
const errs = [];
const check = (cond, msg) => { if (!cond) errs.push(msg); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollDb(pg, predicate, label, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const { rows } = await pg.query(
'SELECT connection_type, connection_status, companion_last_seen FROM server_connections WHERE license_id = $1',
[LICENSE],
);
if (predicate(rows)) return rows;
if (Date.now() > deadline) {
errs.push(`${label}: timeout after ${timeoutMs}ms — rows: ${JSON.stringify(rows)}`);
return rows;
}
await sleep(1000);
}
}
const main = async () => {
const pg = new PgClient({ connectionString: DATABASE_URL });
await pg.connect();
const nc = await connect({ servers: NATS_URL });
const heartbeats = [];
const statusEvents = [];
(async () => { for await (const m of nc.subscribe(`corrosion.${LICENSE}.host.heartbeat`)) heartbeats.push(JSON.parse(sc.decode(m.data))); })();
(async () => { for await (const m of nc.subscribe(`corrosion.${LICENSE}.ci-instance.status`)) statusEvents.push(JSON.parse(sc.decode(m.data))); })();
// --- spawn the real agent ---
const dir = mkdtempSync(join(tmpdir(), 'cha-contract-'));
const cfgPath = join(dir, 'agent.toml');
writeFileSync(cfgPath, `
[agent]
license_id = "${LICENSE}"
nats_url = "${NATS_URL}"
heartbeat_seconds = 10
log_level = "info"
[[instance]]
id = "ci-instance"
game = "rust"
root = "/tmp"
label = "Contract CI"
executable = "/bin/sleep"
args = ["300"]
`);
const agent = spawn(AGENT_BIN, ['--config', cfgPath], { stdio: ['ignore', 'inherit', 'inherit'] });
const agentExited = new Promise((r) => agent.on('exit', r));
// --- 1. heartbeat shape + real telemetry ---
const hbDeadline = Date.now() + 20_000;
while (heartbeats.length === 0 && Date.now() < hbDeadline) await sleep(500);
check(heartbeats.length > 0, 'no heartbeat within 20s');
if (heartbeats.length) {
const hb = heartbeats[0];
check(hb.schema === 2, `schema != 2: ${hb.schema}`);
check(typeof hb.host?.cpu_percent === 'number', 'missing host.cpu_percent');
check(hb.host?.mem_total_mb > 0, 'mem_total_mb not measured');
check(Array.isArray(hb.host?.disks) && hb.host.disks.length > 0, 'no disks reported');
check(hb.instances?.[0]?.id === 'ci-instance', 'instance missing from heartbeat');
check(!!hb.agent?.version && !!hb.agent?.commit, 'agent version/commit missing');
}
// --- 2. backend auto-registers + connects ---
const rows = await pollDb(pg, (r) => r.length === 1 && r[0].connection_status === 'connected', 'auto-register connected');
if (rows.length === 1) {
check(rows[0].connection_type === 'bare_metal', `connection_type: ${rows[0].connection_type}`);
check(rows[0].companion_last_seen !== null, 'companion_last_seen not set');
}
// --- 3. instance command channel ---
const cmd = async (payload) =>
JSON.parse(sc.decode((await nc.request(`corrosion.${LICENSE}.ci-instance.cmd`, sc.encode(JSON.stringify(payload)), { timeout: 8000 })).data));
const st0 = await cmd({ func: 'status' });
check(st0.state?.state === 'stopped', `initial state: ${JSON.stringify(st0.state)}`);
const start = await cmd({ func: 'start' });
check(start.status === 'success', `start: ${JSON.stringify(start)}`);
await sleep(1000);
const st1 = await cmd({ func: 'status' });
check(st1.state?.state === 'running', `post-start state: ${JSON.stringify(st1.state)}`);
check((await cmd({ func: 'start' })).status === 'error', 'double start must error');
check((await cmd({ func: 'bogus' })).status === 'error', 'unknown func must error');
const stop = await cmd({ func: 'stop' });
check(stop.status === 'success', `stop: ${JSON.stringify(stop)}`);
await sleep(1000);
const seq = statusEvents.map((e) => e.event?.state);
check(seq.includes('running') && seq.includes('stopped'), `status events incomplete: ${seq.join(',')}`);
// --- 4. graceful shutdown → offline beacon → DB flips offline ---
agent.kill('SIGTERM');
await Promise.race([agentExited, sleep(8000)]);
await pollDb(pg, (r) => r.length === 1 && r[0].connection_status === 'offline', 'beacon offline', 20_000);
await nc.close();
await pg.end();
if (errs.length) {
console.error('\nCONTRACT FAIL:');
errs.forEach((e) => console.error(' -', e));
process.exit(1);
}
console.log('\nCONTRACT PASS: heartbeat shape, auto-register, connected/offline lifecycle, instance command channel, push events');
process.exit(0);
};
main().catch((e) => {
console.error('contract test crashed:', e);
process.exit(1);
});

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
[package]
name = "corrosion-host-agent"
version = "2.0.0-alpha.3"
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"
tokio-tungstenite = "0.24"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
# 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,186 @@
# Corrosion Wire Protocol v2
Status: **Phase 0 + Phase 1 process control implemented** (host heartbeat,
host commands, going-offline beacon, per-instance start/stop/restart/status
with push state events). RCON, SteamCMD, file ops, and game adapters are
specified but not yet implemented.
## 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).
Instance `state` values — process-managed (an `executable` is configured):
`running`, `stopped`, `starting`, `stopping`, `crashed`; unmanaged
(telemetry-only): `configured` (root exists), `missing_root`. Each instance
also reports `uptime_seconds` (0 unless running).
### `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
### `corrosion.{license_id}.{instance_id}.cmd` (backend → agent, request-reply) — LIVE
Lifecycle and control for one game instance.
Implemented funcs: `start`, `stop` (graceful with 30s budget, then force
kill), `restart`, `status` (returns `state` + `uptime_seconds`), and
`rcon``{ "func": "rcon", "command": "<console command>" }` returns
`{ "status": "success", "output": <server response> }`. Protocol per game:
WebRCON (WebSocket JSON) for rust, Source RCON (Valve TCP) for
conan/soulmask; explicit `kind` override available in the instance's
`[instance.rcon]` config. Always targets 127.0.0.1 (agent is co-located).
Errors reply `{ "status": "error", "message": ... }` — including start on an
unmanaged instance, double start, missing rcon config, and unknown funcs.
Also implemented: `steam_update``{ "func": "steam_update" }` runs
SteamCMD for the instance's game (app ids: rust 258550, conan 443030,
soulmask 3017310/3017300; dune rejects — Docker images, no SteamCMD),
streaming progress lines to `corrosion.{license}.{instance}.steam_status`
and replying on completion.
Planned funcs: `oxide_install` (rust), plus game-adapter-specific
commands (Dune: docker lifecycle, RabbitMQ bus commands, Coriolis reset).
### `corrosion.{license_id}.{instance_id}.steam_status` (agent → backend, publish) — LIVE
Per-line SteamCMD stdout during a `steam_update`, so the panel can show
live update progress. Payload: `{ "timestamp", "instance_id", "line" }`.
### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply) — LIVE
Jailed file manager, confined to the instance `root` (two-stage check:
lexical normalize + canonicalize, defeating `../` traversal and symlink
escape). Request `{ "op": "list|read|write|delete|rename|mkdir|mkfile|move|copy",
"path": "rel/path", "dest"?, "content"?, "name"? }`; reply
`{ "status": "success", "data": ... }` or `{ "status": "error", "message": ... }`.
`read` caps at 5 MiB. Replaces the Go agent's UNJAILED legacy files API,
which is retired and will not be ported.
### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish) — LIVE
State-change events so the panel does not wait for the next heartbeat.
Payload: `{ "timestamp", "instance_id", "event": { "state": ..., "exit_code"? } }`.
Semantics: **keep-latest state sync**, not a lossless transition ledger —
near-instant transient states (e.g. `starting` when spawn succeeds
immediately) may coalesce into the following state. Consumers should treat
each event as "current state is now X".
Known Phase 1 limitation: the supervisor does not yet persist/adopt PIDs — if
the agent itself restarts while a game server is running, the game process
survives but reports `stopped` until restarted through the panel. PID
adoption is queued with the service-install work.
### `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,40 @@
# 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)
- [x] Phase 1a: process supervision — per-instance start/stop/restart/status over
`{instance}.cmd` request-reply, push state events on `{instance}.status`,
crash detection with exit codes, live state in heartbeats
(integration-tested with real processes + live-NATS contract test)
- [ ] Phase 1b: RCON trait (WebRCON rust / TCP conan+soulmask), SteamCMD, jailed file manager
- [ ] 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,66 @@
# 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"
# RCON lets the panel send console commands to the running server.
# For rust the protocol is WebRCON (WebSocket JSON); for conan/soulmask it is
# Source RCON (Valve TCP binary). `kind` is optional — it is inferred from
# the game name when absent.
#
# The [instance.rcon] sub-table MUST immediately follow the [[instance]] entry
# it belongs to (standard TOML array-of-tables scoping rule).
[instance.rcon]
port = 28016
password = "changeme"
# kind = "webrcon" # explicit override; omit to infer from game
# [[instance]]
# id = "soulmask-main"
# game = "soulmask"
# root = "/opt/soulmask/main"
# label = "Cloud Mist Forest (cluster main)"
#
# [instance.rcon]
# port = 19000
# password = "changeme"
# # kind = "source" # inferred automatically for soulmask
# SteamCMD update settings — optional sub-table for any instance.
# Absent = defaults: steamcmd binary resolved via PATH, validate = false.
#
# [instance.steamcmd]
# steamcmd_path = "/opt/steamcmd/steamcmd.sh" # omit to use PATH
# validate = true # enable file-hash check pass
#
# Dune instances do not use SteamCMD (Docker images); the steam_update func
# will return a clear error if invoked on a dune instance.
[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,22 @@
//! Shared agent handle: every subsystem task holds an `Arc<Agent>`.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::config::Settings;
use crate::process::ProcessSupervisor;
use crate::prober::ProbeReport;
pub struct Agent {
pub cfg: Settings,
pub nats: async_nats::Client,
pub started: Instant,
pub last_probe: RwLock<Option<ProbeReport>>,
/// One supervisor per instance (unmanaged instances included — they
/// report `unmanaged` state and reject process commands).
pub supervisors: HashMap<String, Arc<ProcessSupervisor>>,
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,220 @@
//! 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};
use crate::rcon::RconConfig;
use crate::steamcmd::SteamcmdConfig;
/// 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>,
/// Game server executable. Relative paths resolve against `root`.
/// Absent = unmanaged instance (telemetry only, no process control).
#[serde(default)]
pub executable: Option<PathBuf>,
/// Arguments as a proper list — no shell splitting, quoted values survive.
#[serde(default)]
pub args: Vec<String>,
/// Working directory for the process. Defaults to the executable's directory.
#[serde(default)]
pub working_dir: Option<PathBuf>,
/// RCON connection settings for this instance. Absent = rcon unavailable.
/// Protocol defaults to WebRcon for rust, Source for conan/soulmask.
#[serde(default)]
pub rcon: Option<RconConfig>,
/// SteamCMD update settings. Absent = defaults apply (steamcmd on PATH,
/// validate = false).
#[serde(default)]
pub steamcmd: Option<SteamcmdConfig>,
}
impl InstanceConfig {
/// Absolute executable path, if this instance is process-managed.
pub fn resolved_executable(&self) -> Option<PathBuf> {
self.executable.as_ref().map(|exe| {
if exe.is_absolute() {
exe.clone()
} else {
self.root.join(exe)
}
})
}
}
#[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,527 @@
//! Jailed file manager for game-server install directories.
//!
//! Every path operation is confined to the instance `root` — the directory
//! declared as `root` in `[[instance]]` config. A two-stage check (lexical
//! Clean + `std::fs::canonicalize`) prevents both `../..` traversals and
//! symlink-based escapes: even if an attacker plants a symlink inside the root
//! that points outside it, `canonicalize` resolves the target and the prefix
//! check catches the escape.
//!
//! The NATS request/reply contract mirrors the Go companion agent's jailed file
//! manager (see `companion-agent/internal/filemanager/`) but uses a simpler
//! flat JSON envelope rather than the VueFinder storage-path protocol — the
//! Rust agent is the replacement, and the panel's backend talks to whichever
//! agent is present.
//!
//! Subject: `corrosion.{license}.{instance}.files.cmd`
//! Request: `{"op":"list"|"read"|"write"|"delete"|"rename"|"mkdir"|"mkfile"|"move"|"copy",
//! "path":"rel/path", "dest"?:"...", "content"?:"...", "name"?:"..."}`
//! Response: `{"status":"success","data":...}` or `{"status":"error","message":"..."}`
use anyhow::{bail, Context};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Maximum size for a `read` operation (5 MiB). Larger files must be
/// transferred through a dedicated download endpoint, not the file manager.
const MAX_READ_SIZE: u64 = 5 * 1024 * 1024;
// ---------------------------------------------------------------------------
// Wire types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct FileRequest {
pub op: String,
/// Relative path within the instance root (the "subject" of the operation).
#[serde(default)]
pub path: String,
/// Destination for `rename`, `move`, `copy` — relative to instance root.
#[serde(default)]
pub dest: Option<String>,
/// Text content for `write`.
#[serde(default)]
pub content: Option<String>,
/// Bare filename for `mkdir` and `mkfile`.
#[serde(default)]
pub name: Option<String>,
}
/// A single directory entry returned by `list`.
#[derive(Debug, Serialize)]
pub struct FileEntry {
pub name: String,
/// Path relative to the instance root, using forward slashes.
pub path: String,
pub is_dir: bool,
/// File size in bytes. Zero for directories.
pub size: u64,
/// RFC 3339 modification timestamp.
pub modified: String,
}
// ---------------------------------------------------------------------------
// Jail helper — the security core of this module
// ---------------------------------------------------------------------------
/// Resolve `rel` against `root`, then canonicalize to reject any form of
/// escape including `../..` traversals and symlinks that point outside root.
///
/// For paths that do not yet exist (e.g. write targets), we canonicalize the
/// nearest existing ancestor and then re-join the remaining components, which
/// are lexically-clean because they went through `std::path::Path` building.
///
/// Returns the absolute, canonicalized path if it is within `root`.
pub fn jail(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
// Canonicalize root once to get a stable prefix for comparison.
// We do this on every call rather than caching so the function stays
// pure and testable without Agent state.
let canon_root = fs::canonicalize(root)
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
// Build the candidate absolute path. We use Path joining so that an
// absolute `rel` (e.g. "/etc/passwd") replaces the root entirely — we
// detect and reject that case immediately.
let candidate = if rel.is_empty() || rel == "." {
root.to_path_buf()
} else {
let rel_path = Path::new(rel);
if rel_path.is_absolute() {
bail!(
"absolute path '{}' is not allowed; supply a path relative to the instance root",
rel
);
}
root.join(rel_path)
};
// Normalize lexically first (removes `..` / `.` without filesystem access).
// This is a defence-in-depth step; the authoritative check is below.
let lexical = normalize_lexical(&candidate);
// Canonicalize: resolve symlinks and `..` via the kernel.
// For a not-yet-existing path we walk up to the nearest existing ancestor.
let canon = canonicalize_lenient(&lexical)?;
// Authoritative prefix check: the resolved path must be equal to or a
// child of the canonicalized root.
if canon != canon_root && !canon.starts_with(&canon_root) {
bail!(
"path '{}' resolves to '{}' which is outside the instance root '{}'",
rel,
canon.display(),
canon_root.display()
);
}
Ok(canon)
}
/// Canonicalize a path that may not fully exist yet by walking up to the
/// nearest existing ancestor, canonicalizing it, then re-joining the remaining
/// (lexically-clean) suffix.
fn canonicalize_lenient(path: &Path) -> anyhow::Result<PathBuf> {
// Fast path: path already exists.
if let Ok(c) = fs::canonicalize(path) {
return Ok(c);
}
// Walk up until we find an ancestor that exists.
let mut existing = path.to_path_buf();
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
loop {
match fs::canonicalize(&existing) {
Ok(canon) => {
// Re-attach the non-existing suffix.
let mut result = canon;
for component in suffix.iter().rev() {
result = result.join(component);
}
return Ok(result);
}
Err(_) => {
let file_name = match existing.file_name() {
Some(n) => n.to_os_string(),
None => bail!("cannot resolve path '{}'", path.display()),
};
suffix.push(file_name);
existing = match existing.parent() {
Some(p) => p.to_path_buf(),
None => bail!("cannot resolve path '{}'", path.display()),
};
}
}
}
}
/// Lexically normalize a path (remove `.` and `..` components) without
/// touching the filesystem. This mirrors `filepath.Clean` in Go.
fn normalize_lexical(path: &Path) -> PathBuf {
let mut components: Vec<std::path::Component> = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
// Only pop a normal component — we cannot pop a root prefix.
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
components.pop();
} else {
components.push(component);
}
}
other => components.push(other),
}
}
components.iter().collect()
}
// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------
/// List the contents of a directory. Returns an entry per item, sorted
/// (directories first, then files, both alphabetical).
pub fn list(root: &Path, rel: &str) -> anyhow::Result<Vec<FileEntry>> {
let abs = jail(root, rel)?;
// Use the canonicalized root as the prefix for relative path computation so
// that symlinked root paths (e.g. macOS /var → /private/var) don't cause
// strip_prefix to fail and fall back to leaking the absolute path.
let canon_root = fs::canonicalize(root)
.with_context(|| format!("canonicalize root '{}'", root.display()))?;
let rd = fs::read_dir(&abs)
.with_context(|| format!("read_dir '{}'", abs.display()))?;
let mut entries: Vec<FileEntry> = Vec::new();
for item in rd {
let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?;
let meta = item.metadata().with_context(|| format!("stat '{}'", item.path().display()))?;
let name = item.file_name().to_string_lossy().into_owned();
let is_dir = meta.is_dir();
let size = if is_dir { 0 } else { meta.len() };
// Build the relative path from the canonicalized root.
let entry_abs = item.path();
let entry_rel = entry_abs
.strip_prefix(&canon_root)
.unwrap_or(&entry_abs)
.to_string_lossy()
.replace('\\', "/");
let modified = meta
.modified()
.ok()
.map(|t| {
let dt: DateTime<Utc> = t.into();
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
})
.unwrap_or_default();
entries.push(FileEntry { name, path: entry_rel, is_dir, size, modified });
}
// Stable sort: dirs first, then alphabetical within each group.
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
/// Read a text file. Capped at `MAX_READ_SIZE` bytes.
pub fn read(root: &Path, rel: &str) -> anyhow::Result<String> {
let abs = jail(root, rel)?;
let meta = fs::metadata(&abs)
.with_context(|| format!("stat '{}'", abs.display()))?;
if meta.is_dir() {
bail!("'{}' is a directory, not a file", rel);
}
if meta.len() > MAX_READ_SIZE {
bail!(
"file '{}' is {} bytes which exceeds the {} byte read limit",
rel,
meta.len(),
MAX_READ_SIZE
);
}
fs::read_to_string(&abs).with_context(|| format!("read '{}'", abs.display()))
}
/// Write (create or overwrite) a file. Parent directories are created as
/// needed.
pub fn write(root: &Path, rel: &str, content: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
fs::write(&abs, content.as_bytes())
.with_context(|| format!("write '{}'", abs.display()))
}
/// Delete a file or directory tree.
pub fn delete(root: &Path, rel: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
let meta = fs::metadata(&abs)
.with_context(|| format!("stat '{}'", abs.display()))?;
if meta.is_dir() {
fs::remove_dir_all(&abs).with_context(|| format!("remove_dir_all '{}'", abs.display()))
} else {
fs::remove_file(&abs).with_context(|| format!("remove_file '{}'", abs.display()))
}
}
/// Rename/move `rel` to a new bare name (`new_name`) within the same parent.
/// `new_name` must not contain path separators.
pub fn rename(root: &Path, rel: &str, new_name: &str) -> anyhow::Result<()> {
if new_name.is_empty() || new_name == "." || new_name == ".." {
bail!("new_name '{}' is not a valid filename", new_name);
}
if new_name.contains('/') || new_name.contains('\\') {
bail!("new_name '{}' must not contain path separators", new_name);
}
let src_abs = jail(root, rel)?;
// Construct the destination relative path by replacing the filename part
// of `rel` with `new_name`. This keeps everything in relative-path space
// so we never hand an absolute path to `jail`.
let src_rel = Path::new(rel);
let dest_rel = match src_rel.parent() {
Some(parent) if parent != Path::new("") => {
parent.join(new_name).to_string_lossy().replace('\\', "/")
}
_ => new_name.to_string(),
};
let dest_abs = jail(root, &dest_rel)?;
fs::rename(&src_abs, &dest_abs)
.with_context(|| format!("rename '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Create a directory (and any missing parents) at `rel`.
pub fn mkdir(root: &Path, rel: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
fs::create_dir_all(&abs).with_context(|| format!("mkdir '{}'", abs.display()))
}
/// Create an empty file at `rel`. Fails if it already exists.
pub fn mkfile(root: &Path, rel: &str) -> anyhow::Result<()> {
let abs = jail(root, rel)?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
let _ = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&abs)
.with_context(|| format!("mkfile '{}'", abs.display()))?;
Ok(())
}
/// Move `src` to `dest` (both relative to root).
pub fn move_path(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
let src_abs = jail(root, src)?;
let dest_abs = jail(root, dest)?;
if let Some(parent) = dest_abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
fs::rename(&src_abs, &dest_abs).or_else(|_| {
// Cross-device move: copy then delete.
copy_recursive(&src_abs, &dest_abs)?;
fs::remove_dir_all(&src_abs)
.with_context(|| format!("remove source '{}' after cross-device move", src_abs.display()))
}).with_context(|| format!("move '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Copy `src` to `dest` (both relative to root).
pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
let src_abs = jail(root, src)?;
let dest_abs = jail(root, dest)?;
if let Some(parent) = dest_abs.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all '{}'", parent.display()))?;
}
copy_recursive(&src_abs, &dest_abs)
.with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Recursive copy helper (mirrors Go's `copyRecursive`).
fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> {
let meta = fs::metadata(src)
.with_context(|| format!("stat source '{}'", src.display()))?;
if meta.is_dir() {
fs::create_dir_all(dest)
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;
for entry in fs::read_dir(src)
.with_context(|| format!("read_dir '{}'", src.display()))?
{
let entry = entry?;
copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
}
} else {
fs::copy(src, dest)
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// NATS request dispatch
// ---------------------------------------------------------------------------
/// Dispatch a `FileRequest` against `root` and return a JSON `serde_json::Value`
/// ready for the NATS reply.
pub fn dispatch(root: &Path, req: &FileRequest) -> serde_json::Value {
use serde_json::json;
let result = match req.op.as_str() {
"list" => {
list(root, &req.path).map(|entries| json!({ "entries": entries }))
}
"read" => {
read(root, &req.path).map(|content| json!({ "content": content }))
}
"write" => {
let content = req.content.as_deref().unwrap_or("");
write(root, &req.path, content).map(|_| json!(null))
}
"delete" => {
delete(root, &req.path).map(|_| json!(null))
}
"rename" => {
let new_name = req.name.as_deref().unwrap_or("");
rename(root, &req.path, new_name).map(|_| json!(null))
}
"mkdir" => {
mkdir(root, &req.path).map(|_| json!(null))
}
"mkfile" => {
mkfile(root, &req.path).map(|_| json!(null))
}
"move" => {
let dest = req.dest.as_deref().unwrap_or("");
move_path(root, &req.path, dest).map(|_| json!(null))
}
"copy" => {
let dest = req.dest.as_deref().unwrap_or("");
copy(root, &req.path, dest).map(|_| json!(null))
}
other => Err(anyhow::anyhow!(
"unknown op '{}' (supported: list, read, write, delete, rename, mkdir, mkfile, move, copy)",
other
)),
};
match result {
Ok(data) => json!({ "status": "success", "data": data }),
Err(e) => {
tracing::warn!("filemanager op='{}' path='{}': {e:#}", req.op, req.path);
json!({ "status": "error", "message": format!("{e:#}") })
}
}
}
/// Subscribe to `corrosion.{license}.{instance}.files.cmd` and serve file
/// manager requests for `instance_id` jailed to `root`.
///
/// This function runs until the agent's cancellation token fires or the NATS
/// subscription ends. It is spawned once per instance in `main.rs`.
pub async fn run(
agent: std::sync::Arc<crate::agent::Agent>,
instance_id: String,
root: PathBuf,
) -> anyhow::Result<()> {
use futures::StreamExt;
let subject = crate::subjects::instance_files_cmd(&agent.cfg.license_id, &instance_id);
let mut sub = agent.nats.subscribe(subject.clone()).await?;
tracing::info!("file manager handler listening on {subject}");
let cancel = agent.shutdown.clone();
loop {
tokio::select! {
msg = sub.next() => {
match msg {
Some(msg) => {
let agent = agent.clone();
let root = root.clone();
let instance_id = instance_id.clone();
tokio::spawn(async move { handle(agent, &instance_id, &root, msg).await });
}
None => {
tracing::warn!("file manager subscription ended for '{instance_id}'");
break;
}
}
}
_ = cancel.cancelled() => {
tracing::info!("file manager handler stopping for '{instance_id}'");
break;
}
}
}
Ok(())
}
async fn handle(
agent: std::sync::Arc<crate::agent::Agent>,
instance_id: &str,
root: &Path,
msg: async_nats::Message,
) {
let Some(reply) = msg.reply.clone() else {
tracing::warn!("file manager message without reply subject ignored (instance '{instance_id}')");
return;
};
let response = match serde_json::from_slice::<FileRequest>(&msg.payload) {
Ok(req) => {
// Blocking fs calls — offload from the async executor.
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || dispatch(&root, &req))
.await
.unwrap_or_else(|e| {
serde_json::json!({ "status": "error", "message": format!("internal error: {e}") })
})
}
Err(e) => {
serde_json::json!({ "status": "error", "message": format!("invalid request payload: {e}") })
}
};
let bytes = match serde_json::to_vec(&response) {
Ok(b) => b,
Err(e) => {
tracing::error!("file manager response serialize failed: {e}");
return;
}
};
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
tracing::warn!("file manager response publish failed: {e}");
}
}

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,276 @@
//! Per-instance command channel + state-change events.
//!
//! Each process-managed instance gets a request-reply subscriber on
//! `corrosion.{license}.{instance_id}.cmd` (funcs: start/stop/restart/status/rcon)
//! and a publisher task that pushes every supervisor state change to
//! `corrosion.{license}.{instance_id}.status` — the panel sees crashes when
//! they happen, not when the next heartbeat ambles in.
use chrono::{SecondsFormat, Utc};
use futures::StreamExt;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use crate::agent::Agent;
use crate::process::ProcessSupervisor;
use crate::subjects;
use crate::steamcmd;
#[derive(Debug, Deserialize)]
struct InstanceCommand {
func: String,
/// Payload for funcs that carry a text argument (e.g. rcon).
#[serde(default)]
command: Option<String>,
}
/// Forward every supervisor state change as a status event.
pub async fn publish_state_changes(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>) {
let subject = subjects::instance_status(&agent.cfg.license_id, &sup.instance_id);
let mut rx = sup.watch_state();
let cancel = agent.shutdown.clone();
loop {
tokio::select! {
changed = rx.changed() => {
if changed.is_err() {
break;
}
let state = rx.borrow().clone();
let event = json!({
"timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
"instance_id": sup.instance_id,
"event": state,
});
match serde_json::to_vec(&event) {
Ok(bytes) => {
if let Err(e) = agent.nats.publish(subject.clone(), bytes.into()).await {
tracing::warn!("status publish failed for '{}': {e}", sup.instance_id);
}
}
Err(e) => tracing::error!("status serialize failed: {e}"),
}
}
_ = cancel.cancelled() => break,
}
}
}
/// Request-reply command handler for one instance.
pub async fn run(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>) -> anyhow::Result<()> {
let subject = subjects::instance_cmd(&agent.cfg.license_id, &sup.instance_id);
let mut sub = agent.nats.subscribe(subject.clone()).await?;
tracing::info!("instance command handler listening on {subject}");
let cancel = agent.shutdown.clone();
loop {
tokio::select! {
msg = sub.next() => {
match msg {
Some(msg) => {
let agent = agent.clone();
let sup = sup.clone();
tokio::spawn(async move { handle(agent, sup, msg).await });
}
None => {
tracing::warn!("instance command subscription ended for '{}'", sup.instance_id);
break;
}
}
}
_ = cancel.cancelled() => {
tracing::info!("instance command handler stopping for '{}'", sup.instance_id);
break;
}
}
}
Ok(())
}
async fn handle(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>, msg: async_nats::Message) {
let Some(reply) = msg.reply.clone() else {
tracing::warn!("instance command without reply subject ignored");
return;
};
let response = match serde_json::from_slice::<InstanceCommand>(&msg.payload) {
Ok(cmd) => dispatch(&agent, &sup, &cmd).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>,
sup: &Arc<ProcessSupervisor>,
cmd: &InstanceCommand,
) -> serde_json::Value {
let func = cmd.func.as_str();
let outcome = match func {
"start" => sup.start().await.map(|_| "starting"),
"stop" => sup.stop().await.map(|_| "stopped"),
"restart" => sup.restart().await.map(|_| "restarted"),
"status" => {
return json!({
"status": "success",
"func": "status",
"instance_id": sup.instance_id,
"state": sup.state(),
"uptime_seconds": sup.uptime_seconds().await,
});
}
"rcon" => {
// Look up the InstanceConfig for this supervisor so we can access
// rcon settings and the game name without changing the supervisor's
// data model.
let inst_cfg = agent
.cfg
.instances
.iter()
.find(|i| i.id == sup.instance_id);
let rcon_cfg = inst_cfg.and_then(|i| i.rcon.as_ref());
let Some(rcon_cfg) = rcon_cfg else {
return json!({
"status": "error",
"func": "rcon",
"instance_id": sup.instance_id,
"message": format!("instance '{}' has no rcon configured", sup.instance_id),
});
};
let Some(command) = cmd.command.as_deref() else {
return json!({
"status": "error",
"func": "rcon",
"instance_id": sup.instance_id,
"message": "rcon func requires a 'command' field",
});
};
let game = inst_cfg.map(|i| i.game.as_str()).unwrap_or("rust");
return match crate::rcon::send_command(rcon_cfg, game, command).await {
Ok(output) => json!({
"status": "success",
"func": "rcon",
"instance_id": sup.instance_id,
"output": output,
}),
Err(e) => json!({
"status": "error",
"func": "rcon",
"instance_id": sup.instance_id,
"message": format!("{e:#}"),
}),
};
}
"steam_update" => {
// Look up instance config for game name, root, and optional steamcmd
// settings. The supervisor only carries process-control state, not
// the full config, so we reach into agent.cfg.instances here as the
// rcon dispatch does.
let inst_cfg = agent.cfg.instances.iter().find(|i| i.id == sup.instance_id);
let Some(inst_cfg) = inst_cfg else {
return json!({
"status": "error",
"func": "steam_update",
"instance_id": sup.instance_id,
"message": format!("no config found for instance '{}'", sup.instance_id),
});
};
let game = inst_cfg.game.as_str();
let root = inst_cfg.root.clone();
// Resolve steamcmd path and validate flag from config or use defaults.
let (steamcmd_path, validate) = match inst_cfg.steamcmd.as_ref() {
Some(s) => {
let path = s
.steamcmd_path
.as_ref()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "steamcmd".to_string());
(path, s.validate)
}
None => ("steamcmd".to_string(), false),
};
let license = agent.cfg.license_id.clone();
let instance_id = sup.instance_id.clone();
let nats = agent.nats.clone();
// Publish each progress line to the steam_status subject.
let on_progress = move |line: &str| {
let subject = subjects::instance_steam_status(&license, &instance_id);
let event = json!({
"timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
"instance_id": instance_id,
"line": line,
});
match serde_json::to_vec(&event) {
Ok(bytes) => {
// Fire-and-forget; the async publish is non-blocking on
// the caller side. We create a mini-runtime task via
// a oneshot since on_progress is Fn (not async).
let nats = nats.clone();
tokio::spawn(async move {
if let Err(e) = nats.publish(subject, bytes.into()).await {
tracing::warn!("steam_status publish failed: {e}");
}
});
}
Err(e) => tracing::error!("steam_status serialize failed: {e}"),
}
};
return match steamcmd::update(game, &root, &steamcmd_path, validate, on_progress).await {
Ok(()) => json!({
"status": "success",
"func": "steam_update",
"instance_id": sup.instance_id,
}),
Err(e) => json!({
"status": "error",
"func": "steam_update",
"instance_id": sup.instance_id,
"message": format!("{e:#}"),
}),
};
}
other => {
return json!({
"status": "error",
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"),
});
}
};
match outcome {
Ok(result) => json!({
"status": "success",
"func": func,
"instance_id": sup.instance_id,
"result": result,
"state": sup.state(),
}),
Err(e) => json!({
"status": "error",
"func": func,
"instance_id": sup.instance_id,
"message": format!("{e:#}"),
}),
}
}

View File

@@ -0,0 +1,16 @@
//! Corrosion Host Agent library surface — modules are public so integration
//! tests can drive subsystems (notably the process supervisor) directly.
pub mod agent;
pub mod bus;
pub mod config;
pub mod filemanager;
pub mod hostcmd;
pub mod instancecmd;
pub mod prober;
pub mod process;
pub mod rcon;
pub mod steamcmd;
pub mod subjects;
pub mod telemetry;
pub mod version;

View File

@@ -0,0 +1,204 @@
//! 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).
use corrosion_host_agent::{
agent, bus, config, filemanager, hostcmd, instancecmd, prober, process, subjects, telemetry,
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 supervisors = settings
.instances
.iter()
.map(|inst| (inst.id.clone(), process::ProcessSupervisor::new(inst)))
.collect();
let agent = Arc::new(Agent {
cfg: settings,
nats,
started: Instant::now(),
last_probe: RwLock::new(None),
supervisors,
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:#}");
}
}));
}
for (instance_id, sup) in &agent.supervisors {
{
let agent = agent.clone();
let sup = sup.clone();
handles.push(tokio::spawn(async move {
if let Err(e) = instancecmd::run(agent, sup).await {
tracing::error!("instance command handler failed: {e:#}");
}
}));
}
handles.push(tokio::spawn(instancecmd::publish_state_changes(
agent.clone(),
sup.clone(),
)));
// File manager: one handler task per instance, jailed to root.
{
let agent = agent.clone();
let inst_cfg = agent
.cfg
.instances
.iter()
.find(|i| &i.id == instance_id)
.cloned();
if let Some(cfg) = inst_cfg {
let id = instance_id.clone();
handles.push(tokio::spawn(async move {
if let Err(e) = filemanager::run(agent, id, cfg.root).await {
tracing::error!("file manager 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,278 @@
//! Per-instance game-server process supervision.
//!
//! One `ProcessSupervisor` per process-managed instance. Lifecycle mirrors the
//! proven Go agent behavior — graceful SIGTERM with a 30s budget before force
//! kill, a monitor task that reaps the child and records crash-vs-stop — with
//! two fixes the Go version needed: args are a proper list (no naive space
//! splitting), and every state change is observable through a watch channel
//! so the panel gets push events instead of waiting for the next heartbeat.
use anyhow::{bail, Context, Result};
use serde::Serialize;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::process::{Child, Command};
use tokio::sync::{watch, Mutex};
use crate::config::InstanceConfig;
const GRACEFUL_STOP_BUDGET: Duration = Duration::from_secs(30);
const RESTART_PAUSE: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum InstanceState {
/// Not process-managed (no executable configured).
Unmanaged,
Stopped,
Starting,
Running,
Stopping,
/// Process exited without a stop request.
Crashed {
#[serde(skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
},
}
impl InstanceState {
pub fn as_label(&self) -> &'static str {
match self {
InstanceState::Unmanaged => "unmanaged",
InstanceState::Stopped => "stopped",
InstanceState::Starting => "starting",
InstanceState::Running => "running",
InstanceState::Stopping => "stopping",
InstanceState::Crashed { .. } => "crashed",
}
}
}
struct Inner {
child: Option<Child>,
started_at: Option<Instant>,
/// True while a stop was requested — the monitor uses it to distinguish
/// an ordered shutdown from a crash.
stop_requested: bool,
}
pub struct ProcessSupervisor {
pub instance_id: String,
executable: Option<PathBuf>,
args: Vec<String>,
working_dir: Option<PathBuf>,
inner: Mutex<Inner>,
state_tx: watch::Sender<InstanceState>,
}
impl ProcessSupervisor {
pub fn new(cfg: &InstanceConfig) -> Arc<Self> {
let executable = cfg.resolved_executable();
let initial = if executable.is_some() {
InstanceState::Stopped
} else {
InstanceState::Unmanaged
};
let (state_tx, _) = watch::channel(initial);
Arc::new(Self {
instance_id: cfg.id.clone(),
executable,
args: cfg.args.clone(),
working_dir: cfg.working_dir.clone(),
inner: Mutex::new(Inner {
child: None,
started_at: None,
stop_requested: false,
}),
state_tx,
})
}
pub fn state(&self) -> InstanceState {
self.state_tx.borrow().clone()
}
pub fn watch_state(&self) -> watch::Receiver<InstanceState> {
self.state_tx.subscribe()
}
pub async fn uptime_seconds(&self) -> u64 {
let inner = self.inner.lock().await;
match (&*self.state_tx.borrow(), inner.started_at) {
(InstanceState::Running, Some(t)) => t.elapsed().as_secs(),
_ => 0,
}
}
pub async fn start(self: &Arc<Self>) -> Result<()> {
let Some(exe) = self.executable.clone() else {
bail!("instance '{}' has no executable configured", self.instance_id);
};
if !exe.exists() {
bail!("executable not found: {}", exe.display());
}
let mut inner = self.inner.lock().await;
if matches!(*self.state_tx.borrow(), InstanceState::Running | InstanceState::Starting) {
bail!("instance '{}' is already running", self.instance_id);
}
self.set_state(InstanceState::Starting);
let workdir = self
.working_dir
.clone()
.or_else(|| exe.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."));
let child = Command::new(&exe)
.args(&self.args)
.current_dir(&workdir)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("spawning {}", exe.display()))?;
let pid = child.id();
inner.child = Some(child);
inner.started_at = Some(Instant::now());
inner.stop_requested = false;
drop(inner);
self.set_state(InstanceState::Running);
tracing::info!(
"instance '{}' started: {} (pid {:?})",
self.instance_id,
exe.display(),
pid
);
// Monitor: reap the child and classify the exit.
let sup = Arc::clone(self);
tokio::spawn(async move { sup.monitor().await });
Ok(())
}
async fn monitor(self: Arc<Self>) {
// Take a waiter without holding the lock across the whole child
// lifetime: Child::wait needs &mut, so the child stays in inner and
// we poll it.
loop {
let status = {
let mut inner = self.inner.lock().await;
let Some(child) = inner.child.as_mut() else { return };
match child.try_wait() {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
tracing::error!("instance '{}' wait failed: {e}", self.instance_id);
return;
}
}
};
match status {
Some(status) => {
let mut inner = self.inner.lock().await;
inner.child = None;
inner.started_at = None;
let ordered = inner.stop_requested;
inner.stop_requested = false;
drop(inner);
if ordered {
self.set_state(InstanceState::Stopped);
tracing::info!("instance '{}' stopped ({status})", self.instance_id);
} else {
let exit_code = status.code();
self.set_state(InstanceState::Crashed { exit_code });
tracing::warn!(
"instance '{}' exited unexpectedly ({status}) — marked crashed",
self.instance_id
);
}
return;
}
None => tokio::time::sleep(Duration::from_millis(500)).await,
}
}
}
pub async fn stop(self: &Arc<Self>) -> Result<()> {
let mut inner = self.inner.lock().await;
if inner.child.is_none() {
bail!("instance '{}' is not running", self.instance_id);
}
inner.stop_requested = true;
self.set_state(InstanceState::Stopping);
let child = inner.child.as_mut().expect("checked above");
// Graceful first: SIGTERM on unix; Windows has no SIGTERM equivalent
// for console processes, so it goes straight to kill there.
#[cfg(unix)]
if let Some(pid) = child.id() {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
#[cfg(not(unix))]
{
let _ = child.start_kill();
}
drop(inner);
// Wait for the monitor to observe the exit; force kill on budget.
let mut rx = self.watch_state();
let deadline = tokio::time::timeout(GRACEFUL_STOP_BUDGET, async {
loop {
if matches!(*rx.borrow(), InstanceState::Stopped) {
return;
}
if rx.changed().await.is_err() {
return;
}
}
})
.await;
if deadline.is_err() {
tracing::warn!(
"instance '{}' ignored SIGTERM for {}s — force killing",
self.instance_id,
GRACEFUL_STOP_BUDGET.as_secs()
);
let mut inner = self.inner.lock().await;
if let Some(child) = inner.child.as_mut() {
let _ = child.start_kill();
}
drop(inner);
let mut rx = self.watch_state();
let _ = tokio::time::timeout(Duration::from_secs(5), async {
while !matches!(*rx.borrow(), InstanceState::Stopped) {
if rx.changed().await.is_err() {
break;
}
}
})
.await;
}
Ok(())
}
pub async fn restart(self: &Arc<Self>) -> Result<()> {
if !matches!(*self.state_tx.borrow(), InstanceState::Stopped | InstanceState::Crashed { .. } | InstanceState::Unmanaged) {
self.stop().await?;
}
tokio::time::sleep(RESTART_PAUSE).await;
self.start().await
}
fn set_state(&self, state: InstanceState) {
// send_replace never fails even with zero receivers.
let _ = self.state_tx.send_replace(state);
}
}

View File

@@ -0,0 +1,320 @@
//! RCON client: game-server remote-console over WebRCON (Rust) or Source RCON (Conan/Soulmask).
//!
//! The agent runs co-located with the game server, so every connection targets
//! 127.0.0.1 — no TLS is needed and latency is sub-millisecond. Two protocols
//! are supported because the Rust game ships its own WebSocket-based WebRCON
//! while Conan Exiles and Soulmask use the Valve Source RCON wire format over
//! plain TCP.
//!
//! The protocol selection is explicit in the config (`kind`) but can be inferred
//! from the game name when absent — callers supply the `game` field they already
//! have in `InstanceConfig`.
use anyhow::{bail, Context, Result};
use futures::{SinkExt, StreamExt};
use rand::Rng;
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
/// WebRCON is the Facepunch WebSocket protocol (Rust game).
/// Source RCON is the Valve wire protocol used by Conan Exiles and Soulmask.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RconKind {
WebRcon,
Source,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RconConfig {
/// Protocol override. When absent the kind is resolved from `game`.
#[serde(default)]
pub kind: Option<RconKind>,
pub port: u16,
pub password: String,
}
impl RconConfig {
/// Resolve the concrete protocol, falling back to a per-game default when
/// `kind` is not set. rust → WebRcon; conan + soulmask → Source.
pub fn resolved_kind(&self, game: &str) -> RconKind {
if let Some(k) = self.kind {
return k;
}
match game {
"conan" | "soulmask" => RconKind::Source,
// rust is the primary game; anything unknown defaults to WebRcon
// — operators can always override with an explicit `kind`.
_ => RconKind::WebRcon,
}
}
}
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
/// Send `command` to the game server and return its text response.
///
/// The agent runs on the same host as the game server, so the target address
/// is always 127.0.0.1:{port}. Connection and response deadlines are fixed at
/// 5 s and 10 s respectively — enough headroom for a loaded server while still
/// catching hung connections quickly.
pub async fn send_command(cfg: &RconConfig, game: &str, command: &str) -> Result<String> {
match cfg.resolved_kind(game) {
RconKind::WebRcon => webrcon_exec(cfg, command).await,
RconKind::Source => source_rcon_exec(cfg, command).await,
}
}
// ---------------------------------------------------------------------------
// WebRCON (Rust game) — WebSocket JSON protocol
// ---------------------------------------------------------------------------
/// WebRCON request/response envelope. The server also emits chat/log frames
/// on this socket with Identifier == 0; those are skipped.
#[derive(serde::Serialize)]
struct WebRconRequest<'a> {
#[serde(rename = "Identifier")]
identifier: i32,
#[serde(rename = "Message")]
message: &'a str,
#[serde(rename = "Name")]
name: &'static str,
}
#[derive(serde::Deserialize)]
struct WebRconResponse {
#[serde(rename = "Identifier")]
identifier: i32,
#[serde(rename = "Message")]
message: String,
}
async fn webrcon_exec(cfg: &RconConfig, command: &str) -> Result<String> {
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as WsMsg;
// The Rust game server embeds the password in the WebSocket URL path —
// never interpolate the real URL into errors or logs.
let url = format!("ws://127.0.0.1:{}/{}", cfg.port, cfg.password);
let redacted = format!("ws://127.0.0.1:{}/<redacted>", cfg.port);
// Wrap the entire connection + exchange in the connect timeout — we want
// the timeout to cover TCP handshake + WS upgrade, not just the send.
let (mut ws, _) = timeout(CONNECT_TIMEOUT, connect_async(&url))
.await
.context("connect timeout")?
.with_context(|| format!("WebRCON connect to {redacted}"))?;
// Use a random positive i32 so correlation is unambiguous even when
// multiple callers share a port (future concurrency).
let id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
let req = WebRconRequest { identifier: id, message: command, name: "Corrosion" };
let payload = serde_json::to_string(&req).context("serialize WebRCON request")?;
ws.send(WsMsg::Text(payload))
.await
.context("send WebRCON command")?;
tracing::debug!("WebRCON sent id={id} command={command:?}");
// Read frames until we see our Identifier — skip chat/log noise (id 0 or
// any other value that isn't ours).
let result = timeout(RESPONSE_TIMEOUT, async {
loop {
match ws.next().await {
Some(Ok(WsMsg::Text(text))) => {
match serde_json::from_str::<WebRconResponse>(&text) {
Ok(resp) if resp.identifier == id => return Ok(resp.message),
Ok(_) => {
// Not our response (chat, log, another caller's frame).
tracing::trace!("WebRCON skipping frame with different Identifier");
continue;
}
Err(e) => {
tracing::trace!("WebRCON non-JSON frame ignored: {e}");
continue;
}
}
}
Some(Ok(WsMsg::Close(_))) => bail!("WebRCON server closed connection"),
Some(Ok(_)) => continue, // binary/ping/pong — skip
Some(Err(e)) => return Err(anyhow::anyhow!(e).context("WebRCON read error")),
None => bail!("WebRCON stream ended without response"),
}
}
})
.await
.context("WebRCON response timeout")??;
// Close cleanly; a send error here is cosmetic — we already have our data.
let _ = ws.close(None).await;
Ok(result)
}
// ---------------------------------------------------------------------------
// Source RCON (Conan Exiles, Soulmask) — Valve TCP binary protocol
//
// Packet layout (all fields little-endian):
// i32 size — byte count of the remaining packet (id + type + body + 2 nulls)
// i32 id — caller-chosen correlation id; auth failure returns -1
// i32 type — 0=RESPONSE_VALUE, 2=EXECCOMMAND/AUTH_RESPONSE, 3=AUTH
// [u8] body — UTF-8 command or response text
// u8 0x00 — body null terminator
// u8 0x00 — padding null terminator
//
// Multi-packet handling: after sending the command we also send an empty
// RESPONSE_VALUE probe with a distinct id. We collect all RESPONSE_VALUE
// packets belonging to the command id and stop when we receive the probe's
// response. This is the standard technique specified in the Valve wiki.
// ---------------------------------------------------------------------------
const RCON_TYPE_AUTH: i32 = 3;
const RCON_TYPE_AUTH_RESPONSE: i32 = 2;
const RCON_TYPE_EXECCOMMAND: i32 = 2;
const RCON_TYPE_RESPONSE_VALUE: i32 = 0;
/// Maximum accumulated response body (guards against misbehaving servers).
const MAX_RESPONSE_BYTES: usize = 1024 * 1024; // 1 MiB
async fn source_rcon_exec(cfg: &RconConfig, command: &str) -> Result<String> {
let addr = format!("127.0.0.1:{}", cfg.port);
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))
.await
.context("connect timeout")?
.with_context(|| format!("Source RCON connect to {addr}"))?;
let mut stream = stream;
// --- Auth ---
let auth_id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
send_packet(&mut stream, auth_id, RCON_TYPE_AUTH, cfg.password.as_bytes()).await?;
// The server sends two responses to AUTH: first an empty RESPONSE_VALUE,
// then an AUTH_RESPONSE. We skip the first and read until AUTH_RESPONSE.
timeout(RESPONSE_TIMEOUT, async {
loop {
let (id, ptype, _body) = recv_packet(&mut stream).await?;
if ptype == RCON_TYPE_AUTH_RESPONSE {
if id == -1 {
bail!("Source RCON auth failed: wrong password");
}
tracing::debug!("Source RCON authenticated (id={id})");
return Ok(());
}
// Skip the empty RESPONSE_VALUE that precedes AUTH_RESPONSE.
}
#[allow(unreachable_code)]
Ok::<(), anyhow::Error>(())
})
.await
.context("Source RCON auth timeout")??;
// --- Command ---
let cmd_id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
// Probe id must differ from cmd_id.
let probe_id: i32 = loop {
let id: i32 = rand::thread_rng().gen_range(1..=i32::MAX);
if id != cmd_id {
break id;
}
};
send_packet(&mut stream, cmd_id, RCON_TYPE_EXECCOMMAND, command.as_bytes()).await?;
// Empty RESPONSE_VALUE probe — the server echoes it after processing the
// preceding command, signalling end-of-response.
send_packet(&mut stream, probe_id, RCON_TYPE_RESPONSE_VALUE, b"").await?;
// Not every server is probe-conformant (Soulmask unverified): once we hold
// response data, a short per-read quiet period also terminates — never
// discard a response we already received just because the probe echo
// didn't come back.
const QUIET_PERIOD: Duration = Duration::from_millis(1500);
let response = timeout(RESPONSE_TIMEOUT, async {
let mut body_accum: Vec<u8> = Vec::new();
loop {
let next = if body_accum.is_empty() {
recv_packet(&mut stream).await.map(Some)
} else {
match timeout(QUIET_PERIOD, recv_packet(&mut stream)).await {
Ok(res) => res.map(Some),
Err(_elapsed) => Ok(None), // quiet after data — done
}
};
let Some((id, ptype, body)) = next? else {
break;
};
if ptype != RCON_TYPE_RESPONSE_VALUE {
continue; // unexpected packet type — skip
}
if id == probe_id {
// Probe echoed back — all command response packets have arrived.
break;
}
if id == cmd_id {
if body_accum.len() + body.len() > MAX_RESPONSE_BYTES {
bail!("Source RCON response exceeded {MAX_RESPONSE_BYTES} bytes");
}
body_accum.extend_from_slice(&body);
}
// Skip packets with other ids (shouldn't happen but be defensive).
}
Ok::<Vec<u8>, anyhow::Error>(body_accum)
})
.await
.context("Source RCON response timeout")??;
String::from_utf8(response).context("Source RCON response is not valid UTF-8")
}
/// Write a Source RCON packet to the stream.
async fn send_packet(stream: &mut TcpStream, id: i32, ptype: i32, body: &[u8]) -> Result<()> {
// size = id(4) + type(4) + body(n) + 2 null terminators
let size = (4 + 4 + body.len() + 2) as i32;
let mut buf: Vec<u8> = Vec::with_capacity(4 + size as usize);
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&id.to_le_bytes());
buf.extend_from_slice(&ptype.to_le_bytes());
buf.extend_from_slice(body);
buf.push(0x00);
buf.push(0x00);
stream.write_all(&buf).await.context("Source RCON write")?;
Ok(())
}
/// Read one Source RCON packet; returns (id, type, body).
async fn recv_packet(stream: &mut TcpStream) -> Result<(i32, i32, Vec<u8>)> {
let mut size_buf = [0u8; 4];
stream
.read_exact(&mut size_buf)
.await
.context("Source RCON read size")?;
let size = i32::from_le_bytes(size_buf) as usize;
// Minimum packet: id(4) + type(4) + 2 null terminators = 10 bytes.
if size < 10 {
bail!("Source RCON: malformed packet (size={size})");
}
if size > MAX_RESPONSE_BYTES + 16 {
bail!("Source RCON: packet too large ({size} bytes)");
}
let mut payload = vec![0u8; size];
stream
.read_exact(&mut payload)
.await
.context("Source RCON read payload")?;
let id = i32::from_le_bytes(payload[0..4].try_into().unwrap());
let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap());
// Body is everything between the two fields and the two trailing nulls.
let body_end = size.saturating_sub(2); // strip 2 null terminators
let body = payload[8..body_end].to_vec();
Ok((id, ptype, body))
}

View File

@@ -0,0 +1,126 @@
//! SteamCMD update integration for process-managed game instances.
//!
//! Wraps the `steamcmd` binary to perform an `+app_update` for a given game
//! instance, streaming stdout lines to a caller-supplied progress callback so
//! the panel can display live update output. The agent already runs a task per
//! command in a separate `tokio::spawn`, so the blocking-until-done semantics
//! here are intentional — the NATS reply is sent only when SteamCMD exits.
//!
//! Dune is Docker-image-based and explicitly has no SteamCMD integration — any
//! attempt to invoke `update` on a Dune instance returns a clear error rather
//! than a silent no-op.
use std::path::Path;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
/// Return the Steam app ID for a given game name, or `None` for Dune (Docker).
///
/// Soulmask returns the Windows or Linux server app ID depending on the compile
/// target so this function is `#[cfg]`-gated at the platform level.
pub fn app_id_for_game(game: &str) -> Option<u32> {
match game {
"rust" => Some(258550),
"conan" => Some(443030),
"soulmask" => {
#[cfg(windows)]
{
Some(3017310)
}
#[cfg(not(windows))]
{
Some(3017300)
}
}
// Dune uses Docker images — SteamCMD has no role here.
"dune" => None,
_ => None,
}
}
/// Configuration controlling SteamCMD behaviour for one instance.
/// Serialised as `[instance.steamcmd]` in agent.toml.
#[derive(Debug, Clone, serde::Deserialize, Default)]
pub struct SteamcmdConfig {
/// Absolute or relative path to the `steamcmd` binary.
/// Defaults to `"steamcmd"` (resolved via `PATH`) when absent.
#[serde(default)]
pub steamcmd_path: Option<std::path::PathBuf>,
/// Whether to pass `validate` to `+app_update`. Adds a file-hash check
/// pass that catches corruption at the cost of a longer update time.
#[serde(default)]
pub validate: bool,
}
/// Run a SteamCMD update for `game` into `install_dir`.
///
/// - `steamcmd_path`: path to the binary (or `"steamcmd"` to use PATH).
/// - `validate`: appends `validate` to the `+app_update` call.
/// - `on_progress`: receives each stdout line as it arrives so callers can
/// forward progress to the panel in real time.
///
/// Returns `Ok(())` on a zero exit code, otherwise an error describing the
/// failure. Dune is rejected before any process is spawned.
pub async fn update(
game: &str,
install_dir: &Path,
steamcmd_path: &str,
validate: bool,
on_progress: impl Fn(&str),
) -> anyhow::Result<()> {
use anyhow::Context;
let app_id = app_id_for_game(game).ok_or_else(|| {
anyhow::anyhow!(
"dune uses Docker images, not SteamCMD — cannot run app_update for game '{game}'"
)
})?;
let install_dir_str = install_dir
.to_str()
.with_context(|| format!("install_dir '{}' is not valid UTF-8", install_dir.display()))?;
let mut args: Vec<String> = vec![
"+force_install_dir".to_string(),
install_dir_str.to_string(),
"+login".to_string(),
"anonymous".to_string(),
"+app_update".to_string(),
app_id.to_string(),
];
if validate {
args.push("validate".to_string());
}
args.push("+quit".to_string());
tracing::info!(
"steamcmd: starting update for game={game} app_id={app_id} install_dir={} validate={validate}",
install_dir.display()
);
let mut child = Command::new(steamcmd_path)
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| format!("spawning steamcmd binary '{steamcmd_path}'"))?;
let stdout = child.stdout.take().expect("stdout was piped");
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await.context("reading steamcmd stdout")? {
tracing::debug!("steamcmd: {line}");
on_progress(&line);
}
let status = child.wait().await.context("waiting for steamcmd to exit")?;
if status.success() {
tracing::info!("steamcmd: update completed successfully for game={game}");
Ok(())
} else {
let code = status.code().unwrap_or(-1);
anyhow::bail!("steamcmd exited with non-zero status {code} for game={game}")
}
}

View File

@@ -0,0 +1,39 @@
//! 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")
}
/// Per-instance command channel (start/stop/restart/status; rcon et al. to come).
pub fn instance_cmd(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.cmd")
}
/// Per-instance state-change events.
pub fn instance_status(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.status")
}
/// Per-instance SteamCMD progress stream. Lines from `steamcmd` stdout are
/// published here so the panel can display live update output.
pub fn instance_steam_status(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.steam_status")
}
/// Per-instance file manager command channel (request-reply).
pub fn instance_files_cmd(license: &str, instance: &str) -> String {
format!("corrosion.{license}.{instance}.files.cmd")
}

View File

@@ -0,0 +1,185 @@
//! 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>,
/// Process-managed: running/stopped/starting/stopping/crashed.
/// Unmanaged (no executable configured): configured/missing_root.
pub state: String,
pub uptime_seconds: u64,
#[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 mut instances = Vec::with_capacity(agent.cfg.instances.len());
for inst in &agent.cfg.instances {
let (state, uptime_seconds) = match agent.supervisors.get(&inst.id) {
Some(sup) if !matches!(sup.state(), crate::process::InstanceState::Unmanaged) => {
(sup.state().as_label().to_string(), sup.uptime_seconds().await)
}
_ => {
let exists = inst.root.exists();
(
if exists { "configured" } else { "missing_root" }.to_string(),
0,
)
}
};
instances.push(InstanceInfo {
id: inst.id.clone(),
game: inst.game.clone(),
label: inst.label.clone(),
state,
uptime_seconds,
root_disk_free_mb: disk_free_for_path(&disks, &inst.root),
});
}
let instances = instances;
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)
}

View File

@@ -0,0 +1,10 @@
//! Build-time identity, embedded so every heartbeat and `--version` can state
//! exactly what is running.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GIT_HASH: &str = env!("CORROSION_GIT_HASH");
pub const BUILD_TS: &str = env!("CORROSION_BUILD_TS");
pub fn long() -> String {
format!("{VERSION} ({GIT_HASH}, built {BUILD_TS})")
}

View File

@@ -0,0 +1,405 @@
//! Integration tests for the jailed file manager.
//!
//! Each test runs in a real tempdir on the host filesystem. The jail-escape
//! tests are the security-critical section: any path that resolves outside the
//! instance root MUST be rejected regardless of how the escape is attempted.
//!
//! Coverage:
//! - Functional: list, write, read roundtrip, mkdir, rename, delete
//! - Security: dotdot traversal, absolute path injection, symlink escape
//! (POSIX symlinks only — `#[cfg(unix)]`)
use corrosion_host_agent::filemanager;
use std::path::Path;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Create a temporary directory and return its path. The directory is
/// automatically cleaned up when the `TempDir` is dropped.
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
// ---------------------------------------------------------------------------
// Functional tests
// ---------------------------------------------------------------------------
#[test]
fn write_read_roundtrip() {
let dir = tempdir();
let root = dir.path();
let content = "hello from the file manager\nline 2\n";
filemanager::write(root, "test.txt", content).expect("write should succeed");
let got = filemanager::read(root, "test.txt").expect("read should succeed");
assert_eq!(got, content);
}
#[test]
fn list_returns_written_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "server.cfg", "hostname MyServer\n").expect("write");
let entries = filemanager::list(root, "").expect("list root");
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"server.cfg"), "expected 'server.cfg' in listing, got {names:?}");
}
#[test]
fn list_empty_root_is_empty() {
let dir = tempdir();
let entries = filemanager::list(dir.path(), "").expect("list empty root");
assert!(entries.is_empty(), "fresh tempdir should have no entries");
}
#[test]
fn mkdir_creates_directory() {
let dir = tempdir();
let root = dir.path();
filemanager::mkdir(root, "cfg/custom").expect("mkdir should succeed");
assert!(root.join("cfg/custom").is_dir(), "directory should exist after mkdir");
}
#[test]
fn mkdir_creates_nested_dirs() {
let dir = tempdir();
let root = dir.path();
filemanager::mkdir(root, "a/b/c/d").expect("mkdir nested");
assert!(root.join("a/b/c/d").is_dir());
}
#[test]
fn write_creates_parent_dirs() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "subdir/deep/file.txt", "data").expect("write with auto-mkdir");
let content = filemanager::read(root, "subdir/deep/file.txt").expect("read");
assert_eq!(content, "data");
}
#[test]
fn rename_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "old.txt", "content").expect("write");
filemanager::rename(root, "old.txt", "new.txt").expect("rename");
assert!(!root.join("old.txt").exists(), "old.txt should be gone");
assert!(root.join("new.txt").exists(), "new.txt should exist");
let content = filemanager::read(root, "new.txt").expect("read renamed");
assert_eq!(content, "content");
}
#[test]
fn rename_rejects_separator_in_new_name() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "file.txt", "data").expect("write");
let err = filemanager::rename(root, "file.txt", "subdir/escape.txt")
.expect_err("rename with path separator must fail");
assert!(
err.to_string().contains("separator"),
"error should mention separator: {err}"
);
}
#[test]
fn delete_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "todelete.txt", "bye").expect("write");
assert!(root.join("todelete.txt").exists());
filemanager::delete(root, "todelete.txt").expect("delete");
assert!(!root.join("todelete.txt").exists());
}
#[test]
fn delete_directory_recursive() {
let dir = tempdir();
let root = dir.path();
filemanager::mkdir(root, "tree/sub").expect("mkdir");
filemanager::write(root, "tree/sub/file.txt", "x").expect("write");
assert!(root.join("tree").is_dir());
filemanager::delete(root, "tree").expect("delete tree");
assert!(!root.join("tree").exists(), "directory tree should be deleted");
}
#[test]
fn mkfile_creates_empty_file() {
let dir = tempdir();
let root = dir.path();
filemanager::mkfile(root, "empty.txt").expect("mkfile");
let content = filemanager::read(root, "empty.txt").expect("read empty file");
assert_eq!(content, "");
}
#[test]
fn copy_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "source.txt", "original").expect("write source");
filemanager::copy(root, "source.txt", "dest.txt").expect("copy");
let src = filemanager::read(root, "source.txt").expect("read source after copy");
let dst = filemanager::read(root, "dest.txt").expect("read destination");
assert_eq!(src, "original");
assert_eq!(dst, "original");
}
#[test]
fn move_file() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "moveme.txt", "payload").expect("write");
filemanager::move_path(root, "moveme.txt", "moved.txt").expect("move");
assert!(!root.join("moveme.txt").exists(), "source should be gone");
let content = filemanager::read(root, "moved.txt").expect("read after move");
assert_eq!(content, "payload");
}
#[test]
fn list_entry_fields_are_populated() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "check.txt", "abcde").expect("write");
filemanager::mkdir(root, "subdir").expect("mkdir");
let entries = filemanager::list(root, "").expect("list");
// Dirs sort before files.
let dir_entry = entries.iter().find(|e| e.name == "subdir").expect("subdir entry");
assert!(dir_entry.is_dir);
assert_eq!(dir_entry.size, 0);
assert!(!dir_entry.modified.is_empty(), "modified should be set");
let file_entry = entries.iter().find(|e| e.name == "check.txt").expect("file entry");
assert!(!file_entry.is_dir);
assert_eq!(file_entry.size, 5, "size should match byte count");
// path should be relative and use forward slashes.
assert!(!file_entry.path.starts_with('/'), "path should be relative");
assert!(!file_entry.path.contains('\\'), "path should use forward slashes");
}
// ---------------------------------------------------------------------------
// Security: jail-escape tests
// CRITICAL — these are the whole point of the jail abstraction.
// ---------------------------------------------------------------------------
/// `../../etc/passwd` must never resolve outside the instance root.
#[test]
fn jail_rejects_dotdot_traversal() {
let dir = tempdir();
let root = dir.path();
let err = filemanager::read(root, "../../etc/passwd")
.expect_err("dotdot traversal must be rejected");
// Verify the error is security-related and not just "file not found".
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention jail escape for dotdot traversal, got: {msg}"
);
}
/// A deeply nested `../` chain must also be stopped.
#[test]
fn jail_rejects_deep_dotdot_traversal() {
let dir = tempdir();
let root = dir.path();
let err = filemanager::read(root, "a/b/c/../../../../../../../../etc/shadow")
.expect_err("deep dotdot traversal must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape") || msg.contains("absolute"),
"error should mention jail escape for deep traversal, got: {msg}"
);
}
/// An absolute path (e.g. `/etc/passwd`) must be rejected immediately — it
/// completely bypasses relative joining and should never be accepted.
#[test]
fn jail_rejects_absolute_path() {
let dir = tempdir();
let root = dir.path();
let err = filemanager::read(root, "/etc/passwd")
.expect_err("absolute path must be rejected");
let msg = err.to_string();
assert!(
msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention the absolute-path rejection, got: {msg}"
);
}
/// An absolute path to a Windows-style location must also be rejected.
#[test]
fn jail_rejects_absolute_windows_style_path() {
let dir = tempdir();
let root = dir.path();
// On POSIX this is just treated as an absolute path starting with `/`.
// The test is intentionally platform-portable: any absolute path is bad.
let err = filemanager::read(root, "/tmp/evil")
.expect_err("absolute /tmp/evil must be rejected");
let msg = err.to_string();
assert!(
msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"got: {msg}"
);
}
/// A symlink inside the root that points to a path outside the root must not
/// be followed. This is the critical symlink-escape vector.
#[cfg(unix)]
#[test]
fn jail_rejects_symlink_escape() {
let dir = tempdir();
let root = dir.path();
// Create a directory outside the root to be the symlink target.
let outside = tempdir();
let outside_file = outside.path().join("secret.txt");
std::fs::write(&outside_file, "secret data").expect("write outside file");
// Plant a symlink inside the root pointing to the outside directory.
let link_path = root.join("evil_link");
std::os::unix::fs::symlink(outside.path(), &link_path)
.expect("create symlink inside root");
// Attempt to read through the symlink.
let err = filemanager::read(root, "evil_link/secret.txt")
.expect_err("symlink escape must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention jail escape for symlink traversal, got: {msg}"
);
}
/// A symlink directly inside the root pointing to a file outside must be
/// rejected even when the path looks like a normal relative reference.
#[cfg(unix)]
#[test]
fn jail_rejects_symlink_pointing_directly_outside() {
let dir = tempdir();
let root = dir.path();
// Symlink to /etc/passwd itself (or any outside path that exists or not).
let link_path = root.join("passwd_link");
std::os::unix::fs::symlink(Path::new("/etc/passwd"), &link_path)
.expect("create symlink to /etc/passwd");
let err = filemanager::read(root, "passwd_link")
.expect_err("direct symlink outside root must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"error should mention jail escape, got: {msg}"
);
}
/// A symlink chain (symlink → symlink → outside) must also be caught.
#[cfg(unix)]
#[test]
fn jail_rejects_chained_symlink_escape() {
let dir = tempdir();
let root = dir.path();
let outside = tempdir();
// Chain: root/link1 → root/link2 → outside/
let link2_path = root.join("link2");
std::os::unix::fs::symlink(outside.path(), &link2_path)
.expect("create link2");
let link1_path = root.join("link1");
std::os::unix::fs::symlink(&link2_path, &link1_path)
.expect("create link1");
let err = filemanager::read(root, "link1")
.expect_err("chained symlink escape must be rejected");
let msg = err.to_string();
assert!(
msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"),
"chained symlink should be caught, got: {msg}"
);
}
// ---------------------------------------------------------------------------
// Dispatch layer tests
// ---------------------------------------------------------------------------
#[test]
fn dispatch_list_returns_success() {
let dir = tempdir();
let root = dir.path();
filemanager::write(root, "a.txt", "a").expect("write");
let req = filemanager::FileRequest {
op: "list".to_string(),
path: String::new(),
dest: None,
content: None,
name: None,
};
let resp = filemanager::dispatch(root, &req);
assert_eq!(resp["status"], "success");
assert!(resp["data"]["entries"].is_array());
}
#[test]
fn dispatch_unknown_op_returns_error() {
let dir = tempdir();
let req = filemanager::FileRequest {
op: "explode".to_string(),
path: String::new(),
dest: None,
content: None,
name: None,
};
let resp = filemanager::dispatch(dir.path(), &req);
assert_eq!(resp["status"], "error");
assert!(resp["message"].as_str().unwrap().contains("unknown op"));
}
#[test]
fn dispatch_escape_attempt_returns_error_not_panic() {
let dir = tempdir();
let req = filemanager::FileRequest {
op: "read".to_string(),
path: "../../etc/passwd".to_string(),
dest: None,
content: None,
name: None,
};
let resp = filemanager::dispatch(dir.path(), &req);
// Must return an error response, not panic or expose the file.
assert_eq!(resp["status"], "error", "escape attempt should return error status");
assert!(
resp["message"].as_str().is_some(),
"error response must have a message"
);
}

View File

@@ -0,0 +1,353 @@
//! RCON integration tests using in-process mock servers.
//!
//! Real OS sockets on ephemeral ports — no mocking framework. Each test
//! binds a listener, spawns a task that speaks the expected protocol, then
//! exercises `rcon::send_command` and asserts on the result. Tests are
//! unix-only because the musl cross-compile target and the CI runner are both
//! Linux; the production use case is also Linux-only (game servers don't run
//! on macOS or Windows in production).
//!
//! We use `#[cfg(unix)]` to keep parity with the supervisor integration tests.
#![cfg(unix)]
use corrosion_host_agent::rcon::{RconConfig, RconKind};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
// ---------------------------------------------------------------------------
// Source RCON helpers — duplicate the wire-format encode/decode locally so
// the tests own the mock server without depending on the production code path.
// ---------------------------------------------------------------------------
/// Build a Source RCON packet: [size(4LE) | id(4LE) | type(4LE) | body | 0x00 0x00]
fn encode_packet(id: i32, ptype: i32, body: &[u8]) -> Vec<u8> {
let size = (4 + 4 + body.len() + 2) as i32;
let mut out = Vec::with_capacity(4 + size as usize);
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&id.to_le_bytes());
out.extend_from_slice(&ptype.to_le_bytes());
out.extend_from_slice(body);
out.push(0x00);
out.push(0x00);
out
}
/// Read one Source RCON packet from a TcpStream.
async fn read_packet(stream: &mut TcpStream) -> (i32, i32, Vec<u8>) {
let mut size_buf = [0u8; 4];
stream.read_exact(&mut size_buf).await.unwrap();
let size = i32::from_le_bytes(size_buf) as usize;
let mut payload = vec![0u8; size];
stream.read_exact(&mut payload).await.unwrap();
let id = i32::from_le_bytes(payload[0..4].try_into().unwrap());
let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap());
let body_end = size.saturating_sub(2);
let body = payload[8..body_end].to_vec();
(id, ptype, body)
}
const SOURCE_TYPE_AUTH: i32 = 3;
const SOURCE_TYPE_AUTH_RESPONSE: i32 = 2;
const SOURCE_TYPE_EXECCOMMAND: i32 = 2;
const SOURCE_TYPE_RESPONSE_VALUE: i32 = 0;
// ---------------------------------------------------------------------------
// Mock Source RCON server
// ---------------------------------------------------------------------------
/// Run a Source RCON server that accepts password "goodpw", rejects others,
/// and responds to the first EXECCOMMAND with `response_body`.
///
/// If `split_at` is Some(n) the body is split: the first `n` bytes arrive in
/// one RESPONSE_VALUE packet and the remainder in a second — testing multi-
/// packet reassembly.
async fn run_source_mock(
mut stream: TcpStream,
accept_password: &str,
command_response: &[u8],
split_at: Option<usize>,
) {
// --- Auth phase ---
let (auth_id, ptype, body) = read_packet(&mut stream).await;
assert_eq!(ptype, SOURCE_TYPE_AUTH, "expected AUTH packet");
let password = String::from_utf8_lossy(&body);
if password != accept_password {
// Send empty RESPONSE_VALUE then AUTH_RESPONSE with id = -1 (failure).
let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
stream.write_all(&empty).await.unwrap();
let fail = encode_packet(-1, SOURCE_TYPE_AUTH_RESPONSE, b"");
stream.write_all(&fail).await.unwrap();
return;
}
// Success: empty RESPONSE_VALUE then AUTH_RESPONSE with the auth id.
let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
stream.write_all(&empty).await.unwrap();
let ok = encode_packet(auth_id, SOURCE_TYPE_AUTH_RESPONSE, b"");
stream.write_all(&ok).await.unwrap();
// --- Command phase ---
let (cmd_id, cmd_ptype, _cmd_body) = read_packet(&mut stream).await;
assert_eq!(cmd_ptype, SOURCE_TYPE_EXECCOMMAND, "expected EXECCOMMAND");
// Read the probe packet (empty RESPONSE_VALUE with a different id).
let (probe_id, probe_ptype, _) = read_packet(&mut stream).await;
assert_eq!(probe_ptype, SOURCE_TYPE_RESPONSE_VALUE, "expected probe packet");
// Send the command response, optionally split across two packets.
if let Some(n) = split_at {
let (part1, part2) = command_response.split_at(n.min(command_response.len()));
let p1 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part1);
stream.write_all(&p1).await.unwrap();
let p2 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part2);
stream.write_all(&p2).await.unwrap();
} else {
let p = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, command_response);
stream.write_all(&p).await.unwrap();
}
// Echo the probe to signal end-of-response.
let probe_echo = encode_packet(probe_id, SOURCE_TYPE_RESPONSE_VALUE, b"");
stream.write_all(&probe_echo).await.unwrap();
}
// ---------------------------------------------------------------------------
// Source RCON tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn source_rcon_auth_and_exec_returns_response() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
run_source_mock(stream, "goodpw", b"Hello from server", None).await;
});
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
let result = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
.await
.expect("command should succeed");
assert_eq!(result, "Hello from server");
}
#[tokio::test]
async fn source_rcon_wrong_password_returns_auth_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
run_source_mock(stream, "goodpw", b"should not see this", None).await;
});
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "wrongpw".to_string() };
let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
.await
.expect_err("wrong password should fail");
assert!(
err.to_string().to_lowercase().contains("auth"),
"error should mention auth failure, got: {err}"
);
}
#[tokio::test]
async fn source_rcon_multi_packet_response_concatenated() {
// Build a body large enough to split meaningfully across two packets.
// Use repeating ASCII so the result is valid UTF-8 and easy to verify.
// 200 'A's then 200 'B's = 400 bytes, split at 200.
let body: Vec<u8> = std::iter::repeat_n(b'A', 200)
.chain(std::iter::repeat_n(b'B', 200))
.collect();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let body_clone = body.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
run_source_mock(stream, "goodpw", &body_clone, Some(200)).await;
});
let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
let result = corrosion_host_agent::rcon::send_command(&cfg, "soulmask", "showplayers")
.await
.expect("multi-packet command should succeed");
let expected = String::from_utf8(body).unwrap();
assert_eq!(result, expected, "full body should be concatenated across both packets");
}
#[tokio::test]
async fn source_rcon_connect_timeout_to_unreachable_port() {
// Bind a listener but never accept — the connection will time out during
// the RCON auth phase because nothing is reading from the socket.
// We use a port that is bound (so TCP connect itself succeeds) but then
// the mock simply drops the stream, forcing a read error, which should
// surface as an error (not a panic or hang).
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
// Accept the TCP connection but immediately drop it — simulates a port
// that accepts but never speaks RCON.
tokio::spawn(async move {
let (_stream, _) = listener.accept().await.unwrap();
// _stream dropped here — EOF on the client's read
});
let cfg =
RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() };
let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status")
.await
.expect_err("closed connection should fail");
// We just need it to fail and not hang; error message varies by OS.
let _ = err;
}
// ---------------------------------------------------------------------------
// WebRCON mock server
// ---------------------------------------------------------------------------
/// Run a WebRCON mock: send one noise frame (Identifier 0), then respond to
/// the first real request with the given output.
async fn run_webrcon_mock(stream: tokio::net::TcpStream, output: &str) {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message as WsMsg;
let mut ws = accept_async(stream).await.expect("WS handshake failed");
// Send noise (chat frame, Identifier 0) before the real request arrives.
let noise = serde_json::json!({
"Identifier": 0,
"Message": "Player X joined",
"Name": "Server",
"Type": "Chat"
});
ws.send(WsMsg::Text(noise.to_string()))
.await
.unwrap();
// Read the command request.
let msg = ws.next().await.unwrap().unwrap();
let text = match msg {
WsMsg::Text(t) => t,
other => panic!("expected Text frame, got {other:?}"),
};
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
let req_id = req["Identifier"].as_i64().unwrap() as i32;
// Reply with the same Identifier so the client correlates correctly.
let reply = serde_json::json!({
"Identifier": req_id,
"Message": output,
"Type": "Generic",
});
ws.send(WsMsg::Text(reply.to_string())).await.unwrap();
}
// ---------------------------------------------------------------------------
// WebRCON tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn webrcon_skips_noise_and_returns_correct_message() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
run_webrcon_mock(stream, "Players: 42/100").await;
});
// Password is embedded in the URL path — any non-empty string works with
// our mock.
let cfg = RconConfig {
kind: Some(RconKind::WebRcon),
port,
password: "testpw".to_string(),
};
let result = corrosion_host_agent::rcon::send_command(&cfg, "rust", "playercount")
.await
.expect("WebRCON command should succeed");
assert_eq!(result, "Players: 42/100");
}
// ---------------------------------------------------------------------------
// TOML parsing test — pins [[instance]] + [instance.rcon] sub-table syntax
// ---------------------------------------------------------------------------
#[test]
fn toml_instance_with_rcon_parses_correctly() {
let toml = r#"
[agent]
license_id = "test-license"
nats_url = "nats://localhost:4222"
[[instance]]
id = "rust-main"
game = "rust"
root = "/opt/rustserver"
[instance.rcon]
port = 28016
password = "secretpassword"
kind = "webrcon"
"#;
let cfg: corrosion_host_agent::config::ConfigFile =
toml::from_str(toml).expect("TOML should parse");
assert_eq!(cfg.instances.len(), 1);
let inst = &cfg.instances[0];
assert_eq!(inst.id, "rust-main");
let rcon = inst.rcon.as_ref().expect("rcon should be present");
assert_eq!(rcon.port, 28016);
assert_eq!(rcon.password, "secretpassword");
assert_eq!(rcon.kind, Some(corrosion_host_agent::rcon::RconKind::WebRcon));
}
#[test]
fn toml_instance_without_rcon_defaults_to_none() {
let toml = r#"
[agent]
license_id = "test-license"
nats_url = "nats://localhost:4222"
[[instance]]
id = "conan-main"
game = "conan"
root = "/opt/conan"
"#;
let cfg: corrosion_host_agent::config::ConfigFile =
toml::from_str(toml).expect("TOML should parse");
assert!(cfg.instances[0].rcon.is_none(), "absent rcon should be None");
}
#[test]
fn resolved_kind_infers_from_game_name() {
use corrosion_host_agent::rcon::{RconConfig, RconKind};
let cfg_no_kind = RconConfig { kind: None, port: 28016, password: "x".to_string() };
assert_eq!(cfg_no_kind.resolved_kind("rust"), RconKind::WebRcon);
assert_eq!(cfg_no_kind.resolved_kind("conan"), RconKind::Source);
assert_eq!(cfg_no_kind.resolved_kind("soulmask"), RconKind::Source);
assert_eq!(cfg_no_kind.resolved_kind("dune"), RconKind::WebRcon); // fallback
// Explicit kind always wins.
let cfg_source = RconConfig { kind: Some(RconKind::Source), ..cfg_no_kind.clone() };
assert_eq!(cfg_source.resolved_kind("rust"), RconKind::Source);
let cfg_webrcon = RconConfig { kind: Some(RconKind::WebRcon), ..cfg_no_kind };
assert_eq!(cfg_webrcon.resolved_kind("conan"), RconKind::WebRcon);
}

View File

@@ -0,0 +1,45 @@
//! Unit tests for the SteamCMD module.
//!
//! Tests cover app ID resolution for all four supported games, including the
//! platform-specific Soulmask split, and verify that Dune correctly returns
//! `None` (it uses Docker images, not SteamCMD).
use corrosion_host_agent::steamcmd::app_id_for_game;
#[test]
fn rust_has_correct_app_id() {
assert_eq!(app_id_for_game("rust"), Some(258550));
}
#[test]
fn conan_has_correct_app_id() {
assert_eq!(app_id_for_game("conan"), Some(443030));
}
/// Soulmask returns the Windows server app ID on Windows builds, the Linux
/// dedicated server app ID on all other targets.
#[test]
#[cfg(windows)]
fn soulmask_windows_app_id() {
assert_eq!(app_id_for_game("soulmask"), Some(3017310));
}
#[test]
#[cfg(not(windows))]
fn soulmask_linux_app_id() {
assert_eq!(app_id_for_game("soulmask"), Some(3017300));
}
/// Dune uses Docker images — SteamCMD integration is explicitly unsupported.
#[test]
fn dune_has_no_app_id() {
assert_eq!(app_id_for_game("dune"), None);
}
/// Unknown games also produce None; callers should treat this the same as
/// Dune (no SteamCMD support).
#[test]
fn unknown_game_returns_none() {
assert_eq!(app_id_for_game("minecraft"), None);
assert_eq!(app_id_for_game(""), None);
}

View File

@@ -0,0 +1,109 @@
//! Process supervisor integration tests using real OS processes.
//! Unix-only test doubles (/bin/sleep, /bin/sh) — the supervisor logic under
//! test is platform-shared; Windows-specific stop semantics get covered when
//! the Windows service work lands.
#![cfg(unix)]
use std::path::PathBuf;
use std::time::Duration;
use corrosion_host_agent::config::InstanceConfig;
use corrosion_host_agent::process::{InstanceState, ProcessSupervisor};
fn managed_instance(executable: &str, args: &[&str]) -> InstanceConfig {
InstanceConfig {
id: "test-instance".to_string(),
game: "rust".to_string(),
root: PathBuf::from("/tmp"),
label: None,
executable: Some(PathBuf::from(executable)),
args: args.iter().map(|s| s.to_string()).collect(),
working_dir: None,
rcon: None,
steamcmd: None,
}
}
async fn wait_for_state(
sup: &std::sync::Arc<ProcessSupervisor>,
want: fn(&InstanceState) -> bool,
budget: Duration,
) -> InstanceState {
let deadline = tokio::time::Instant::now() + budget;
loop {
let state = sup.state();
if want(&state) {
return state;
}
if tokio::time::Instant::now() > deadline {
panic!("timed out waiting for state; last = {state:?}");
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[tokio::test]
async fn start_status_stop_lifecycle() {
let sup = ProcessSupervisor::new(&managed_instance("/bin/sleep", &["300"]));
assert_eq!(sup.state(), InstanceState::Stopped);
sup.start().await.expect("start should succeed");
assert_eq!(sup.state(), InstanceState::Running);
tokio::time::sleep(Duration::from_millis(1100)).await;
assert!(sup.uptime_seconds().await >= 1, "uptime should advance");
// Double-start must be rejected while running.
assert!(sup.start().await.is_err(), "double start must fail");
sup.stop().await.expect("stop should succeed");
let state = wait_for_state(&sup, |s| matches!(s, InstanceState::Stopped), Duration::from_secs(5)).await;
assert_eq!(state, InstanceState::Stopped);
assert_eq!(sup.uptime_seconds().await, 0);
}
#[tokio::test]
async fn unexpected_exit_is_crashed_with_code() {
let sup = ProcessSupervisor::new(&managed_instance("/bin/sh", &["-c", "sleep 0.2; exit 7"]));
sup.start().await.expect("start should succeed");
let state = wait_for_state(
&sup,
|s| matches!(s, InstanceState::Crashed { .. }),
Duration::from_secs(5),
)
.await;
assert_eq!(state, InstanceState::Crashed { exit_code: Some(7) });
}
#[tokio::test]
async fn restart_from_crashed_recovers() {
let sup = ProcessSupervisor::new(&managed_instance("/bin/sh", &["-c", "exit 1"]));
sup.start().await.expect("start should succeed");
wait_for_state(&sup, |s| matches!(s, InstanceState::Crashed { .. }), Duration::from_secs(5)).await;
// Restart from crashed must work (panel "Restart" after a crash).
// Use a long-lived command this time by replacing the supervisor — the
// command is fixed per supervisor, so emulate via a fresh one.
let sup2 = ProcessSupervisor::new(&managed_instance("/bin/sleep", &["300"]));
sup2.restart().await.expect("restart from stopped should start");
assert_eq!(sup2.state(), InstanceState::Running);
sup2.stop().await.expect("cleanup stop");
}
#[tokio::test]
async fn unmanaged_instance_rejects_process_commands() {
let mut cfg = managed_instance("/bin/sleep", &["300"]);
cfg.executable = None;
let sup = ProcessSupervisor::new(&cfg);
assert_eq!(sup.state(), InstanceState::Unmanaged);
assert!(sup.start().await.is_err(), "unmanaged start must fail");
assert!(sup.stop().await.is_err(), "unmanaged stop must fail");
}
#[tokio::test]
async fn missing_executable_fails_cleanly() {
let sup = ProcessSupervisor::new(&managed_instance("/nonexistent/bin/gameserver", &[]));
let err = sup.start().await.expect_err("must fail");
assert!(err.to_string().contains("not found"), "error should say not found: {err}");
assert_eq!(sup.state(), InstanceState::Stopped, "failed start must not leave Starting state");
}

View File

@@ -8,6 +8,13 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:-corrosion_dev}
volumes:
- pg_data:/var/lib/postgresql/data
# Auto-build the schema on a FRESH database. Postgres runs these ONLY when
# the data dir is empty (first boot or after a volume reset), so it never
# touches an existing volume — it just makes a fresh DB self-heal: the full
# schema is applied in order from the sqlx migrations (001..NNN), then the
# API's bootstrap seeds the admin. Rebuilds (with the volume kept) are a
# no-op here; the data persists. Only `down -v` / volume prune loses data.
- ../backend/migrations:/docker-entrypoint-initdb.d:ro
ports:
- "8101:5432"
healthcheck:
@@ -80,7 +87,10 @@ services:
api:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:80/ || exit 1"]
# 127.0.0.1, not localhost: nginx listens IPv4-only (0.0.0.0:80) but
# `localhost` resolves to ::1 first inside the container → the probe hit
# nothing and reported unhealthy while the panel served fine on IPv4.
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3

View File

@@ -9,6 +9,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<title>Corrosion Management</title>
<meta name="description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
<meta property="og:title" content="Corrosion — Game Server Operations for Self-Hosted Communities" />
<meta property="og:description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
<!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules
that land mid-file after concatenation, silently shipping system fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap"
/>
<script>
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
so the design-system tokens paint with the right skin from frame one. */

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,7 +1,14 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import ToastNotification from '@/components/ToastNotification.vue'
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import { useAuthStore } from '@/stores/auth'
// Validate any persisted session against the API on boot — a stale token
// should bounce to login immediately, not after the first failed call.
const auth = useAuthStore()
onMounted(() => { void auth.validateSession() })
</script>
<template>

1
frontend/src/app-version.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __APP_VERSION__: string

View File

@@ -1,8 +1,15 @@
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import { ref, watch, onErrorCaptured } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import Button from '@/components/ds/core/Button.vue'
withDefaults(defineProps<{
/** 'screen' fills the viewport (app root); 'content' fills its container (inside layout chrome) */
variant?: 'screen' | 'content'
}>(), { variant: 'screen' })
const route = useRoute()
const hasError = ref(false)
const errorMessage = ref('')
@@ -13,6 +20,12 @@ onErrorCaptured((err) => {
return false
})
// A failed view must not brick navigation — clear the error when the route changes
watch(() => route.fullPath, () => {
hasError.value = false
errorMessage.value = ''
})
function retry() {
hasError.value = false
errorMessage.value = ''
@@ -21,7 +34,7 @@ function retry() {
</script>
<template>
<div v-if="hasError" class="eb-screen">
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
<div class="eb-card">
<div class="eb-icon-wrap">
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
@@ -44,6 +57,11 @@ function retry() {
padding: var(--space-6);
}
.eb-screen--content {
min-height: 60vh;
background: transparent;
}
.eb-card {
background: var(--surface-base);
box-shadow: var(--ring-default), var(--shadow-md);

View File

@@ -23,6 +23,9 @@ import {
Ban, Flag,
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
Pencil, Save, ShoppingBag, Target, User,
// Marketing site additions
Route, Timer, Megaphone, DatabaseBackup, Store, Undo2,
Circle, Send, HelpCircle,
} from 'lucide-vue-next'
const props = withDefaults(
@@ -58,6 +61,10 @@ const registry: Record<string, Component> = {
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
target: Target, user: User,
// Marketing site additions
route: Route, timer: Timer, megaphone: Megaphone,
'database-backup': DatabaseBackup, store: Store, 'undo-2': Undo2,
circle: Circle, send: Send, 'help-circle': HelpCircle,
}
const cmp = computed<Component | null>(() => registry[props.name] ?? null)

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
/**
* PlayersChart — themed ECharts area chart of players online.
* Reads the live design tokens (--accent etc.) from CSS so it matches the
* active theme/game, and re-renders when data-game / data-theme flip on <html>.
*
* Requires real `data` — there is NO fallback series. When `data` is absent
* or empty, an "awaiting telemetry" placeholder is shown instead of the chart.
* This is intentional: fabricated curves mislead operators.
*
* Reads live design tokens (--accent etc.) from CSS so it matches the active
* theme/game, and re-renders when data-game / data-theme flip on <html>.
*/
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import * as echarts from 'echarts'
const props = withDefaults(
@@ -12,29 +17,26 @@ const props = withDefaults(
{ height: 200, max: 200 },
)
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
const el = useTemplateRef<HTMLDivElement>('el')
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | null = null
let mo: MutationObserver | null = null
const DEFAULT_SERIES = [
60, 52, 44, 38, 33, 30, 34, 46, 62, 78, 92, 104,
118, 126, 131, 138, 142, 151, 168, 182, 176, 150, 112, 84,
]
function cssVar(name: string, node?: HTMLElement): string {
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
}
function render(): void {
if (!chart || !el.value) return
if (!chart || !el.value || !hasData.value) return
const node = el.value
const accent = cssVar('--accent', node) || '#f26622'
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
const text = cssVar('--text-tertiary', node) || '#767d89'
const mono = 'JetBrains Mono, monospace'
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
const series = props.data ?? DEFAULT_SERIES
const series = props.data as number[]
chart.setOption({
animationDuration: 700,
@@ -77,6 +79,7 @@ function render(): void {
onMounted(() => {
if (!el.value) return
if (!hasData.value) return // empty-state slot renders instead
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
render()
ro = new ResizeObserver(() => chart?.resize())
@@ -94,5 +97,33 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="el" :style="{ width: '100%', height: height + 'px' }" />
<!-- Real data: render the ECharts canvas -->
<div v-if="hasData" ref="el" :style="{ width: '100%', height: height + 'px' }" />
<!-- No data: honest empty state never show a fabricated curve -->
<div
v-else
class="pc-empty"
:style="{ height: height + 'px' }"
>
<svg class="pc-empty__icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span class="pc-empty__label">Awaiting telemetry</span>
<span class="pc-empty__sub">Player data will appear once the server connects and reports stats</span>
</div>
</template>
<style scoped>
.pc-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
color: var(--text-muted);
}
.pc-empty__icon { margin-bottom: 4px; opacity: 0.5; }
.pc-empty__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
.pc-empty__sub { font-size: var(--text-xs); color: var(--text-muted); max-width: 280px; text-align: center; line-height: 1.5; }
</style>

View File

@@ -1,15 +1,20 @@
<script setup lang="ts">
/**
* DashboardLayout — game-aware app shell (Phase C redesign).
* Replaces the old Tailwind-only sidebar with the DS component set.
* Preserves: navSections, permission gating, super-admin section, logout, RouterView.
* Adds: GameSwitcher, Logo, DS NavItem, agent-health footer, topbar w/ search + theme toggle.
* Nav is driven by GAME_PROFILES[activeGame].nav — switching the GameSwitcher
* visibly changes nav items, labels, and sections per game.
* Preserves: permission gating, super-admin section, logout, mobile sidebar,
* GameSwitcher, agent-health footer, topbar.
*/
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useServerStore } from '@/stores/server'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
import { safeDate } from '@/utils/formatters'
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import Logo from '@/components/ds/brand/Logo.vue'
import Badge from '@/components/ds/core/Badge.vue'
import StatusDot from '@/components/ds/core/StatusDot.vue'
@@ -33,7 +38,7 @@ const sidebarOpen = ref(false)
function closeSidebar() { sidebarOpen.value = false }
// ---- App version ----
const APP_VERSION = '1.0.8'
const APP_VERSION = __APP_VERSION__
// ---- Game switcher ----
const GAME_OPTIONS: GameOption[] = [
@@ -53,61 +58,15 @@ function onActiveGame(val: string) {
setActiveGame(val as ActiveGame)
}
// ---- Navigation ----
type NavItemDef = { name: string; path: string; icon: string; permission: string | null }
type NavSection = { label: string; items: NavItemDef[] }
const navSections: NavSection[] = [
{
label: '',
items: [
{ name: 'Dashboard', path: '/', icon: 'layout-dashboard', permission: null },
],
},
{
label: 'Server',
items: [
{ name: 'Server', path: '/server', icon: 'server', permission: 'server.view' },
{ name: 'Console', path: '/console', icon: 'terminal', permission: 'console.view' },
{ name: 'Players', path: '/players', icon: 'users', permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: 'puzzle', permission: 'plugins.view' },
{ name: 'File manager', path: '/files', icon: 'folder-open', permission: 'files.view' },
],
},
{
label: 'Plugin configs',
items: [
{ name: 'Plugin configs', path: '/plugin-configs', icon: 'puzzle', permission: null },
],
},
{
label: 'Operations',
items: [
{ name: 'Wipe manager', path: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: 'map', permission: 'maps.view' },
{ name: 'Schedules', path: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' },
],
},
{
label: 'Monitoring',
items: [
{ name: 'Chat log', path: '/chat', icon: 'message-square', permission: 'chat.view' },
{ name: 'Analytics', path: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' },
{ name: 'Alerts', path: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' },
{ name: 'Notifications', path: '/notifications', icon: 'bell', permission: 'notifications.view' },
],
},
{
label: 'Management',
items: [
{ name: 'Team', path: '/team', icon: 'users', permission: null },
{ name: 'Store', path: '/store/config', icon: 'shopping-cart', permission: 'store.view' },
{ name: 'Modules', path: '/modules', icon: 'layers', permission: 'modules.view' },
{ name: 'Changelog', path: '/changelog', icon: 'file-text', permission: 'changelog.view' },
{ name: 'Settings', path: '/settings', icon: 'settings', permission: 'settings.view' },
],
},
]
// ---- Navigation — driven by the game profile registry ----
/**
* For 'all', fall back to rust (superset nav). For a specific game, look up
* its profile. noUncheckedIndexedAccess-safe: always ?? GAME_PROFILES.rust.
*/
const activeNavSections = computed<NavSection[]>(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
return (useGameProfile(game)).nav
})
const adminNavItems = [
{ name: 'Admin home', path: '/admin', icon: 'shield' },
@@ -137,6 +96,8 @@ function hasVisibleItems(section: NavSection): boolean {
}
// ---- Agent health ----
const hasAgent = computed(() => server.connection !== null)
const agentTone = computed(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'online' as const
@@ -149,18 +110,23 @@ const agentLabel = computed(() => {
if (cs === 'degraded') return 'Degraded'
return 'Offline'
})
const agentName = computed(() => {
const ip = server.connection?.server_ip
return ip ?? 'asgard-01'
const agentName = computed(() => server.connection?.server_ip ?? 'Host agent')
const agentMetaLine = computed(() => {
const cs = server.connection?.connection_status
let line = cs === 'connected' ? 'Connected' : server.connection?.companion_last_seen
? `Last seen ${safeDate(server.connection.companion_last_seen)}`
: 'Awaiting first heartbeat'
if (server.stats) {
line += ` · ${server.stats.player_count}/${server.stats.max_players} players`
}
return line
})
// ---- Topbar ----
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
const userName = computed(() => auth.user?.username ?? '')
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
// ---- Import computed from vue (missed above) ----
import { computed } from 'vue'
</script>
<template>
@@ -197,20 +163,20 @@ import { computed } from 'vue'
/>
</div>
<!-- Navigation -->
<!-- Navigation sections driven by GAME_PROFILES[activeGame].nav -->
<nav class="side__nav">
<template v-for="section in navSections" :key="section.label">
<template v-for="section in activeNavSections" :key="section.label">
<template v-if="hasVisibleItems(section)">
<div class="side__sec">
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
<NavItem
v-for="item in section.items"
v-show="canShowNavItem(item)"
:key="item.path"
:key="item.route"
:icon="item.icon"
:label="item.name"
:active="isActive(item.path)"
@click="navigate(item.path)"
:label="item.label"
:active="isActive(item.route)"
@click="navigate(item.route)"
/>
</div>
</template>
@@ -230,18 +196,24 @@ import { computed } from 'vue'
</div>
</nav>
<!-- Agent health footer -->
<!-- Host agent footer -->
<div class="side__foot">
<div class="agent">
<!-- Connected: real IP + status badge + meta line -->
<div v-if="hasAgent" class="agent">
<div class="agent__row">
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
<span class="agent__name">{{ agentName }}</span>
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
</div>
<div class="agent__meta">
Agent v{{ APP_VERSION }}
<template v-if="server.stats"> · {{ server.stats.player_count }}/{{ server.stats.max_players }} players</template>
<div class="agent__meta">{{ agentMetaLine }}</div>
</div>
<!-- Not connected: honest empty state -->
<div v-else class="agent agent--empty">
<div class="agent__row">
<StatusDot tone="offline" />
<span class="agent__name agent__name--muted">No host agent connected</span>
</div>
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
</div>
<!-- User / logout row -->
<div class="side__user">
@@ -313,9 +285,11 @@ import { computed } from 'vue'
</div>
</header>
<!-- Page content -->
<!-- Page content boundary keeps sidebar/topbar alive when a view fails -->
<main class="app__content">
<RouterView />
<ErrorBoundary variant="content">
<RouterView />
</ErrorBoundary>
</main>
</div>
</div>
@@ -419,6 +393,13 @@ body { margin: 0; overflow: hidden; }
padding-left: 16px;
}
.agent--empty { opacity: 0.7; }
.agent__name--muted {
color: var(--text-tertiary);
font-style: italic;
}
.side__user {
display: flex;
align-items: center;

View File

@@ -1,76 +1,79 @@
<script setup lang="ts">
import { RouterView, RouterLink } from 'vue-router'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
import '@/styles/marketing.css'
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
</script>
<template>
<div class="min-h-screen bg-neutral-950 flex flex-col">
<!-- Navigation -->
<nav class="border-b border-neutral-800 bg-neutral-950/80 backdrop-blur-sm sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<RouterLink :to="{ name: 'landing' }" class="flex items-center gap-3">
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
<span class="text-lg font-bold text-neutral-100">Corrosion</span>
<div>
<!-- Nav -->
<nav class="mkt-nav">
<div class="wrap mkt-nav__in">
<RouterLink :to="{ name: 'landing' }" class="brand">
<span class="mark"><CorrosionMark :size="26" /></span>
<b>Corrosion</b>
</RouterLink>
<div class="hidden md:flex items-center gap-6">
<RouterLink :to="{ name: 'how-it-works' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">How It Works</RouterLink>
<RouterLink :to="{ name: 'pricing' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Pricing</RouterLink>
<RouterLink :to="{ name: 'roadmap' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Roadmap</RouterLink>
<RouterLink :to="{ name: 'faq' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">FAQ</RouterLink>
<div class="mkt-nav__links">
<RouterLink :to="{ name: 'landing' }" class="scroll-link">Features</RouterLink>
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
<RouterLink :to="{ name: 'how-it-works' }">How it works</RouterLink>
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
</div>
<div class="flex items-center gap-3">
<a :href="panelUrl + '/login'" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Sign In</a>
<a :href="panelUrl + '/register'" class="px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">Get Started</a>
<div class="mkt-nav__cta">
<a class="mkt-nav__signin" :href="panelUrl + '/login'">Sign in</a>
<RouterLink class="btn btn--primary btn--sm" :to="{ name: 'early-access' }">
Early access
</RouterLink>
</div>
</div>
</nav>
<!-- Page content -->
<main class="flex-1">
<RouterView />
</main>
<RouterView />
<!-- Footer -->
<footer class="border-t border-neutral-800 py-12">
<div class="max-w-6xl mx-auto px-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
<div>
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Product</h4>
<div class="space-y-2">
<RouterLink :to="{ name: 'how-it-works' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">How It Works</RouterLink>
<RouterLink :to="{ name: 'pricing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Pricing</RouterLink>
<RouterLink :to="{ name: 'roadmap' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Roadmap</RouterLink>
</div>
<footer class="mkt-footer">
<div class="wrap">
<div class="footer__cols">
<div class="footer__brand">
<RouterLink :to="{ name: 'landing' }" class="brand">
<span class="mark"><CorrosionMark :size="24" /></span>
<b>Corrosion</b>
</RouterLink>
<p>Game server operations for self-hosted communities.</p>
</div>
<div>
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Support</h4>
<div class="space-y-2">
<RouterLink :to="{ name: 'faq' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">FAQ</RouterLink>
<a href="https://discord.gg/corrosion" target="_blank" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Discord</a>
</div>
<div class="footer__col">
<h5>Product</h5>
<RouterLink :to="{ name: 'landing' }">Supported games</RouterLink>
<RouterLink :to="{ name: 'landing' }">Features</RouterLink>
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
</div>
<div>
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Company</h4>
<div class="space-y-2">
<RouterLink :to="{ name: 'landing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">About</RouterLink>
<RouterLink to="/status" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Status</RouterLink>
</div>
<div class="footer__col">
<h5>Games</h5>
<RouterLink :to="{ name: 'landing' }">Rust</RouterLink>
<RouterLink :to="{ name: 'landing' }">Dune: Awakening</RouterLink>
<RouterLink :to="{ name: 'landing' }">Soulmask</RouterLink>
<RouterLink :to="{ name: 'landing' }">Conan Exiles</RouterLink>
</div>
<div>
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Legal</h4>
<div class="space-y-2">
<span class="block text-sm text-neutral-600">Terms of Service</span>
<span class="block text-sm text-neutral-600">Privacy Policy</span>
</div>
<div class="footer__col">
<h5>Support</h5>
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
<RouterLink to="/status">Status</RouterLink>
</div>
<div class="footer__col">
<h5>Company</h5>
<RouterLink :to="{ name: 'landing' }">About</RouterLink>
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
<a href="mailto:support@corrosionmgmt.com">Contact</a>
</div>
</div>
<div class="border-t border-neutral-800 pt-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
<span class="text-sm text-neutral-600">&copy; 2026 Corrosion. All rights reserved.</span>
</div>
<span class="text-xs text-neutral-700">The Control Plane for Rust Servers.</span>
<div class="footer__bar">
<span>&copy; 2026 Corrosion. All rights reserved.</span>
<span>One control plane. Every game.</span>
</div>
</div>
</footer>

View File

@@ -0,0 +1,342 @@
/**
* gameProfiles.ts — Source of truth for per-game UI adaptation.
*
* Every game-specific label, terminology, Steam app ID, management model,
* stat field list, AND sidebar nav lives here. The dashboard, server cards,
* wipe manager, sidebar, and any future multi-game surface should key off this
* registry — never hard-code game-specific strings in components.
*
* Backend status: the backend has NO game field on licenses yet. Today every
* license is implicitly Rust. This registry is ready: when the backend adds a
* `game` column to `licenses` (or `server_config`), the frontend only needs to
* read that field and call `useGameProfile(id)` — no component changes required.
*
* To add a new game: add a GameId union member and a corresponding entry in
* GAME_PROFILES. Nothing else changes.
*/
// ---------------------------------------------------------------------------
// Nav structure — drives the per-game sidebar
// ---------------------------------------------------------------------------
/** A single sidebar nav item. route must be an existing panel route path. */
export interface NavItemDef {
label: string
route: string
icon: string
/** Permission key required to show this item (e.g. 'plugins.view'). Null = always visible. */
permission: string | null
}
/** A labelled section grouping nav items in the sidebar. */
export interface NavSection {
/** Section heading (eyebrow text). Empty string = no heading. */
label: string
items: NavItemDef[]
}
// ---------------------------------------------------------------------------
// Union types — exhaustive, never widen to string
// ---------------------------------------------------------------------------
/** Every supported game identifier. */
export type GameId = 'rust' | 'conan' | 'soulmask' | 'dune'
/** How the server process is managed. */
export type ManagementModel = 'process+rcon' | 'docker-compose'
/** Mod ecosystem the game uses. */
export type ModSystem = 'umod' | 'workshop' | 'none'
/** Primary console / remote-admin interface. */
export type ConsoleType = 'rcon' | 'rcon+ingame' | 'rcon+gm' | 'rabbitmq'
/**
* How a "reset" is performed — each value maps to a distinct wipe code path.
* Pipe-delimited strings intentionally encode composite operations.
*/
export type ResetModel =
| 'map-bp-wipe'
| 'wipe-world-structures+decay'
| 'worlddb-delete+decay'
| 'deep-desert-coriolis-seed'
/** Cross-server or character-sharing mechanism. */
export type ClusteringModel = 'none' | 'character-transfer' | 'main-client' | 'battlegroup'
// ---------------------------------------------------------------------------
// GameProfile shape
// ---------------------------------------------------------------------------
export interface GameTerminology {
/** What the operator calls a reset / wipe. */
reset: string
/** What the operator calls plugins / mods (null if no mod system). */
mods: string | null
/** What the operator calls a player group / faction. */
group: string
}
export interface GamePorts {
game: number
query: number
rcon: number
cluster?: number
}
export interface GameProfile {
/** Human-readable game name. */
label: string
/** CSS design-token key — maps to data-game attr and --accent token. */
accent: string
managementModel: ManagementModel
steamAppId: number | { windows: number; linux: number }
/** Default ports (game-specific defaults; operator can override). */
ports?: GamePorts
mods: ModSystem
console: ConsoleType
resetModel: ResetModel
clustering: ClusteringModel
/** Available map names, if the game ships with named maps. */
maps?: string[]
terminology: GameTerminology
/** Notable game-specific mechanics that affect server administration. */
special?: string[]
/**
* Stat field labels shown on server cards and the dashboard.
* First entry is always Players; subsequent entries are game-specific.
*/
statFields: [string, string, string]
/**
* Per-game sidebar navigation. Ordered list of sections, each with items.
* Items MUST use only existing panel routes (see router/index.ts).
* The sidebar renders exactly these sections for the active game.
*/
nav: NavSection[]
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Shared nav building blocks — reused across game nav definitions
// ---------------------------------------------------------------------------
const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null }
const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' }
const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' }
const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' }
const NAV_PLUGINS: NavItemDef = { label: 'Plugins (uMod)', route: '/plugins', icon: 'puzzle', permission: 'plugins.view' }
const NAV_FILES: NavItemDef = { label: 'File manager', route: '/files', icon: 'folder-open', permission: 'files.view' }
const NAV_PLUGIN_CONFIGS: NavItemDef = { label: 'Plugin configs', route: '/plugin-configs', icon: 'sliders', permission: null }
const NAV_SCHEDULES: NavItemDef = { label: 'Schedules', route: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' }
const NAV_CHAT: NavItemDef = { label: 'Chat log', route: '/chat', icon: 'message-square', permission: 'chat.view' }
const NAV_ANALYTICS: NavItemDef = { label: 'Analytics', route: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' }
const NAV_ALERTS: NavItemDef = { label: 'Alerts', route: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' }
const NAV_NOTIFICATIONS: NavItemDef = { label: 'Notifications', route: '/notifications', icon: 'bell', permission: 'notifications.view' }
const NAV_TEAM: NavItemDef = { label: 'Team', route: '/team', icon: 'users', permission: null }
const NAV_STORE: NavItemDef = { label: 'Store', route: '/store/config', icon: 'shopping-cart', permission: 'store.view' }
const NAV_MODULES: NavItemDef = { label: 'Modules', route: '/modules', icon: 'layers', permission: 'modules.view' }
const NAV_CHANGELOG: NavItemDef = { label: 'Changelog', route: '/changelog', icon: 'file-text', permission: 'changelog.view' }
const NAV_SETTINGS: NavItemDef = { label: 'Settings', route: '/settings', icon: 'settings', permission: 'settings.view' }
const NAV_MAPS: NavItemDef = { label: 'Maps', route: '/maps', icon: 'map', permission: 'maps.view' }
/** Full Rust / 'all' nav — superset used as fallback. */
const RUST_NAV: NavSection[] = [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
},
{ label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] },
{
label: 'Operations',
items: [
{ label: 'Wipe', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
NAV_MAPS,
NAV_SCHEDULES,
],
},
{
label: 'Monitoring',
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS, NAV_NOTIFICATIONS],
},
{
label: 'Management',
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
},
]
export const GAME_PROFILES: Record<GameId, GameProfile> = {
rust: {
label: 'Rust',
accent: 'rust',
managementModel: 'process+rcon',
steamAppId: 258550,
mods: 'umod',
console: 'rcon',
resetModel: 'map-bp-wipe',
clustering: 'none',
terminology: {
reset: 'Wipe',
mods: 'Plugins',
group: 'Team',
},
statFields: ['Players', 'uMod', 'Wipe'],
nav: RUST_NAV,
},
conan: {
label: 'Conan Exiles',
accent: 'conan',
managementModel: 'process+rcon',
steamAppId: 443030,
ports: { game: 7777, query: 27015, rcon: 25575 },
mods: 'workshop',
console: 'rcon+ingame',
// Player progress persists across world wipes — only structures are cleared.
resetModel: 'wipe-world-structures+decay',
clustering: 'character-transfer',
maps: ['Exiled Lands', 'Isle of Siptah'],
terminology: {
reset: 'Wipe World',
mods: 'Mods',
group: 'Clan',
},
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
statFields: ['Players', 'Clans', 'Purge'],
nav: [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
// Conan: no uMod/Oxide; has RCON console, maps, players, files
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
},
{
label: 'Operations',
items: [
{ label: 'Wipe World', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
NAV_MAPS,
NAV_SCHEDULES,
],
},
{
label: 'Monitoring',
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS, NAV_NOTIFICATIONS],
},
{
label: 'Management',
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
},
],
},
soulmask: {
label: 'Soulmask',
accent: 'soulmask',
managementModel: 'process+rcon',
// Different Steam app IDs per OS (uncommon — store this explicitly).
steamAppId: { windows: 3017310, linux: 3017300 },
ports: { game: 8777, query: 27015, rcon: 19000, cluster: 20000 },
mods: 'workshop',
console: 'rcon+gm',
resetModel: 'worlddb-delete+decay',
clustering: 'main-client',
maps: ['Cloud Mist Forest', 'Shifting Sands'],
terminology: {
reset: 'World Reset',
mods: 'Workshop Mods',
group: 'Tribe',
},
special: ['Cluster', 'Tribes'],
statFields: ['Players', 'Tribe', 'Mask'],
nav: [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
// Soulmask: no uMod/Oxide; has RCON+GM console, players, files
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
},
{
label: 'Operations',
items: [
{ label: 'World Reset', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
NAV_SCHEDULES,
],
},
{
label: 'Monitoring',
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS],
},
{
label: 'Management',
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
},
],
},
dune: {
label: 'Dune: Awakening',
accent: 'dune',
managementModel: 'docker-compose',
steamAppId: 4754530,
mods: 'none',
// Dune uses RabbitMQ for its admin messaging — not a standard RCON port.
console: 'rabbitmq',
resetModel: 'deep-desert-coriolis-seed',
clustering: 'battlegroup',
terminology: {
reset: 'Deep Desert reset',
mods: null,
group: 'Guild',
},
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
statFields: ['Players', 'Sietches', 'Control'],
nav: [
{ label: '', items: [NAV_DASHBOARD] },
{
label: 'Server',
// Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins
items: [
NAV_SERVER,
{ label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' },
NAV_PLAYERS,
NAV_FILES,
],
},
{
label: 'Operations',
items: [
{ label: 'Deep Desert', route: '/wipes', icon: 'wind', permission: 'wipes.view' },
NAV_SCHEDULES,
],
},
{
label: 'Monitoring',
items: [NAV_ANALYTICS, NAV_ALERTS],
},
{
label: 'Management',
items: [NAV_TEAM, NAV_STORE, NAV_CHANGELOG, NAV_SETTINGS],
},
],
},
} as const
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
/**
* Returns the GameProfile for the given id, falling back to Rust if the id is
* unknown (forward-compatibility: unknown games show Rust defaults until their
* profile is added).
*
* @example
* const profile = useGameProfile('rust')
* console.log(profile.terminology.reset) // 'Wipe'
*/
export function useGameProfile(id: string): GameProfile {
return (GAME_PROFILES as Record<string, GameProfile>)[id] ?? GAME_PROFILES.rust
}

View File

@@ -1,11 +1,28 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
// Extend vue-router's RouteMeta so title/description are typed throughout
declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
requiresAuth?: boolean
guest?: boolean
superAdmin?: boolean
}
}
// ---------------------------------------------------------------------------
// Domain detection — runs once at module load
// Env-driven so www./staging hosts route correctly; an exact-match literal
// here once meant any non-canonical marketing host silently got the panel.
// ---------------------------------------------------------------------------
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
const isMarketingDomain = hostname === 'corrosionmgmt.com'
const marketingHosts = (import.meta.env.VITE_MARKETING_HOSTS ?? 'corrosionmgmt.com,www.corrosionmgmt.com')
.split(',')
.map((h: string) => h.trim().toLowerCase())
.filter(Boolean)
const isMarketingDomain = marketingHosts.includes(hostname.toLowerCase())
// ---------------------------------------------------------------------------
// Marketing page children — shared between both domain route sets
@@ -15,31 +32,55 @@ const marketingChildren: RouteRecordRaw[] = [
path: '',
name: 'landing',
component: () => import('@/views/marketing/LandingView.vue'),
meta: {
title: 'Corrosion — Game Server Operations for Self-Hosted Communities',
description: 'Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server.',
},
},
{
path: 'pricing',
name: 'pricing',
component: () => import('@/views/marketing/PricingView.vue'),
meta: {
title: 'Pricing — Corrosion',
description: 'Plans from $9.99/mo (Hobby, 15 servers) to Network ($99.99+/mo, 50+ servers). Non-commercial and commercial tiers. No hosting fees — bring your own server.',
},
},
{
path: 'how-it-works',
name: 'how-it-works',
component: () => import('@/views/marketing/HowItWorksView.vue'),
meta: {
title: 'How It Works — Corrosion',
description: 'Install one host agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
},
},
{
path: 'faq',
name: 'faq',
component: () => import('@/views/marketing/FaqView.vue'),
meta: {
title: 'FAQ — Corrosion',
description: 'Honest answers: Corrosion is self-service (BYOS, no hosting). Support is docs + community; 1:1 at $125/hr. Supports Rust, Dune, Conan Exiles, Soulmask.',
},
},
{
path: 'roadmap',
name: 'roadmap',
component: () => import('@/views/marketing/RoadmapView.vue'),
meta: {
title: 'Roadmap — Corrosion',
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game blueprints. Planned: API access, integrations.',
},
},
{
path: 'early-access',
name: 'early-access',
component: () => import('@/views/marketing/EarlyAccessView.vue'),
meta: {
title: 'Early Access — Corrosion',
description: 'Join the early access list. Get full control plane access — wipe automation, plugin management, real-time console — and lock in launch pricing.',
},
},
]
@@ -53,25 +94,25 @@ const panelRoutes: RouteRecordRaw[] = [
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { guest: true },
meta: { guest: true, title: 'Sign in — Corrosion' },
},
{
path: '/register',
name: 'register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { guest: true },
meta: { guest: true, title: 'Create account — Corrosion' },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: { guest: true },
meta: { guest: true, title: 'Reset password — Corrosion' },
},
{
path: '/setup',
name: 'setup-wizard',
component: () => import('@/views/auth/SetupWizardView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
},
// Admin dashboard routes (with sidebar layout)
@@ -84,217 +125,254 @@ const panelRoutes: RouteRecordRaw[] = [
path: '',
name: 'dashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: { title: 'Dashboard — Corrosion' },
},
{
path: 'server',
name: 'server',
component: () => import('@/views/admin/ServerView.vue'),
meta: { title: 'Server — Corrosion' },
},
{
path: 'console',
name: 'console',
component: () => import('@/views/admin/ConsoleView.vue'),
meta: { title: 'Console — Corrosion' },
},
{
path: 'players',
name: 'players',
component: () => import('@/views/admin/PlayersView.vue'),
meta: { title: 'Players — Corrosion' },
},
{
path: 'plugins',
name: 'plugins',
component: () => import('@/views/admin/PluginsView.vue'),
meta: { title: 'Plugins — Corrosion' },
},
{
path: 'files',
name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'),
meta: { title: 'Files — Corrosion' },
},
{
path: 'plugin-configs',
name: 'plugin-configs',
component: () => import('@/views/admin/PluginConfigsView.vue'),
meta: { title: 'Plugin Configs — Corrosion' },
},
{
path: 'loot-builder',
name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'),
meta: { title: 'Loot Builder — Corrosion' },
},
{
path: 'teleport-config',
name: 'teleport-config',
component: () => import('@/views/admin/TeleportConfigView.vue'),
meta: { title: 'Teleport Config — Corrosion' },
},
{
path: 'gather-manager',
name: 'gather-manager',
component: () => import('@/views/admin/GatherManagerView.vue'),
meta: { title: 'Gather Manager — Corrosion' },
},
{
path: 'autodoors',
name: 'autodoors',
component: () => import('@/views/admin/AutoDoorsView.vue'),
meta: { title: 'Auto Doors — Corrosion' },
},
{
path: 'kits',
name: 'kits-config',
component: () => import('@/views/admin/KitsView.vue'),
meta: { title: 'Kits — Corrosion' },
},
{
path: 'furnace-splitter',
name: 'furnace-splitter',
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
meta: { title: 'Furnace Splitter — Corrosion' },
},
{
path: 'better-chat',
name: 'better-chat',
component: () => import('@/views/admin/BetterChatView.vue'),
meta: { title: 'Better Chat — Corrosion' },
},
{
path: 'timed-execute',
name: 'timed-execute',
component: () => import('@/views/admin/TimedExecuteView.vue'),
meta: { title: 'Timed Execute — Corrosion' },
},
{
path: 'raidable-bases',
name: 'raidable-bases',
component: () => import('@/views/admin/RaidableBasesView.vue'),
meta: { title: 'Raidable Bases — Corrosion' },
},
{
path: 'wipes',
name: 'wipes',
component: () => import('@/views/admin/WipesView.vue'),
meta: { title: 'Wipes — Corrosion' },
},
{
path: 'wipes/profiles',
name: 'wipe-profiles',
component: () => import('@/views/admin/WipeProfilesView.vue'),
meta: { title: 'Wipe Profiles — Corrosion' },
},
{
path: 'wipes/calendar',
name: 'wipe-calendar',
component: () => import('@/views/admin/WipeCalendarView.vue'),
meta: { title: 'Wipe Calendar — Corrosion' },
},
{
path: 'wipes/history',
name: 'wipe-history',
component: () => import('@/views/admin/WipeHistoryView.vue'),
meta: { title: 'Wipe History — Corrosion' },
},
{
path: 'wipes/analytics',
name: 'wipe-analytics',
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
meta: { title: 'Wipe Analytics — Corrosion' },
},
{
path: 'maps',
name: 'maps',
component: () => import('@/views/admin/MapsView.vue'),
meta: { title: 'Maps — Corrosion' },
},
{
path: 'maps/analytics',
name: 'map-analytics',
component: () => import('@/views/admin/MapAnalyticsView.vue'),
meta: { title: 'Map Analytics — Corrosion' },
},
{
path: 'chat',
name: 'chat',
component: () => import('@/views/admin/ChatLogView.vue'),
meta: { title: 'Chat Log — Corrosion' },
},
{
path: 'analytics',
name: 'analytics',
component: () => import('@/views/admin/AnalyticsView.vue'),
meta: { title: 'Analytics — Corrosion' },
},
{
path: 'retention',
name: 'retention',
component: () => import('@/views/admin/PlayerRetentionView.vue'),
meta: { title: 'Player Retention — Corrosion' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/admin/NotificationsView.vue'),
meta: { title: 'Notifications — Corrosion' },
},
{
path: 'team',
name: 'team',
component: () => import('@/views/admin/TeamView.vue'),
meta: { title: 'Team — Corrosion' },
},
{
path: 'store/config',
name: 'store-config',
component: () => import('@/views/admin/StoreConfigView.vue'),
meta: { title: 'Store Config — Corrosion' },
},
{
path: 'store/items',
name: 'store-items',
component: () => import('@/views/admin/StoreItemsView.vue'),
meta: { title: 'Store Items — Corrosion' },
},
{
path: 'store/revenue',
name: 'store-revenue',
component: () => import('@/views/admin/StoreRevenueView.vue'),
meta: { title: 'Store Revenue — Corrosion' },
},
{
path: 'modules',
name: 'modules',
component: () => import('@/views/admin/ModuleStoreView.vue'),
meta: { title: 'Modules — Corrosion' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/admin/SettingsView.vue'),
meta: { title: 'Settings — Corrosion' },
},
{
path: 'schedules',
name: 'schedules',
component: () => import('@/views/admin/SchedulesView.vue'),
meta: { title: 'Schedules — Corrosion' },
},
{
path: 'migration',
name: 'migration',
component: () => import('@/views/admin/MigrationView.vue'),
meta: { title: 'Migration — Corrosion' },
},
{
path: 'changelog',
name: 'changelog',
component: () => import('@/views/admin/ChangelogView.vue'),
meta: { title: 'Changelog — Corrosion' },
},
{
path: 'alerts',
name: 'alerts',
component: () => import('@/views/admin/AlertsView.vue'),
meta: { title: 'Alerts — Corrosion' },
},
// Platform Admin views (super-admin only)
{
path: 'admin',
name: 'platform-admin',
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
meta: { superAdmin: true },
meta: { superAdmin: true, title: 'Admin — Corrosion' },
},
{
path: 'admin/licenses',
name: 'platform-licenses',
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
meta: { superAdmin: true },
meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
},
{
path: 'admin/subscriptions',
name: 'platform-subscriptions',
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
meta: { superAdmin: true },
meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
},
{
path: 'admin/users',
name: 'platform-users',
component: () => import('@/views/platform-admin/AdminUsers.vue'),
meta: { superAdmin: true },
meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
},
{
path: 'admin/servers',
name: 'platform-servers',
component: () => import('@/views/platform-admin/AdminServers.vue'),
meta: { superAdmin: true },
meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' },
},
],
},
@@ -329,6 +407,7 @@ const panelRoutes: RouteRecordRaw[] = [
path: '/status',
name: 'status',
component: () => import('@/views/public/StatusPageView.vue'),
meta: { title: 'Status — Corrosion' },
},
// Catch-all
@@ -366,6 +445,7 @@ const marketingRoutes: RouteRecordRaw[] = [
path: '/status',
name: 'status',
component: () => import('@/views/public/StatusPageView.vue'),
meta: { title: 'Status — Corrosion' },
},
// Catch-all: unknown routes → landing page
@@ -383,6 +463,38 @@ const router = createRouter({
routes: isMarketingDomain ? marketingRoutes : panelRoutes,
})
// ---------------------------------------------------------------------------
// Document title + meta description/OG update on every navigation
// ---------------------------------------------------------------------------
function setOrClearMeta(selector: string, attr: string, value: string): void {
let el = document.querySelector<HTMLMetaElement>(selector)
if (!el) {
el = document.createElement('meta')
// Parse the selector to set the right attribute (name="..." or property="...")
const nameMatch = selector.match(/\[name="([^"]+)"\]/)
const propMatch = selector.match(/\[property="([^"]+)"\]/)
if (nameMatch?.[1]) el.setAttribute('name', nameMatch[1])
if (propMatch?.[1]) el.setAttribute('property', propMatch[1])
document.head.appendChild(el)
}
el.setAttribute(attr, value)
}
router.afterEach((to) => {
// Title
document.title = to.meta.title ?? 'Corrosion Management'
// Description
const desc = to.meta.description ?? ''
setOrClearMeta('meta[name="description"]', 'content', desc)
// OG title
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Corrosion Management')
// OG description
setOrClearMeta('meta[property="og:description"]', 'content', desc)
})
// Auth guard — only meaningful on panel domain (marketing has no requiresAuth routes)
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()

View File

@@ -58,6 +58,27 @@ export const useAuthStore = defineStore('auth', () => {
permissions.value = {}
}
/**
* Validate the persisted session against the API on app boot. Without this,
* a stale/revoked token renders the full panel chrome and only collapses on
* the first real API call. useApi's 401 path (refresh → retry → logout)
* does the heavy lifting; any non-auth failure (network, 5xx) keeps the
* session — never log users out because the API blipped.
* Dynamic import avoids a static auth-store ↔ useApi module cycle.
*/
async function validateSession(): Promise<void> {
if (!accessToken.value) return
try {
const { useApi } = await import('@/composables/useApi')
const me = await useApi().get<Partial<User>>('/auth/me')
if (user.value && me && typeof me === 'object') {
user.value = { ...user.value, ...me }
}
} catch {
// 401 → refresh → logout/redirect already handled inside useApi.
}
}
function hasModule(moduleSlug: string): boolean {
return license.value?.modules_enabled?.includes(moduleSlug) ?? false
}
@@ -92,6 +113,7 @@ export const useAuthStore = defineStore('auth', () => {
setAuth,
setLicense,
logout,
validateSession,
hasModule,
hasPermission,
}

View File

@@ -0,0 +1,846 @@
/* ============================================================
Corrosion — Marketing site styles
Consumes the design-system tokens already loaded globally
via frontend/src/style.css (tokens/fonts → colors → etc.).
Class names match the design kit exactly.
============================================================ */
.wrap { max-width: 1140px; margin: 0 auto; padding: 0 32px; }
section { position: relative; }
.eyebrow {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: var(--tracking-caps);
text-transform: uppercase;
color: var(--accent-text);
}
h2.title {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-4xl);
letter-spacing: 0.01em;
text-align: center;
margin: 0;
line-height: 1.08;
}
.lead {
text-align: center;
color: var(--text-secondary);
font-size: var(--text-lg);
margin: 16px auto 0;
max-width: 660px;
line-height: 1.5;
}
.accent { color: var(--accent-text); }
.mark { display: inline-block; color: var(--accent); }
.mark svg { width: 100%; height: 100%; display: block; }
/* ---- Buttons ---- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 46px;
padding: 0 22px;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-weight: 600;
font-size: var(--text-base);
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
text-decoration: none;
white-space: nowrap;
}
.btn:active { transform: translateY(1px); }
.btn--primary {
background: var(--accent);
color: var(--accent-contrast);
}
.btn--primary:hover { background: var(--accent-hover); }
.btn--ghost {
background: var(--surface-raised-2);
color: var(--text-primary);
box-shadow: var(--ring-default);
}
.btn--ghost:hover { background: var(--surface-active); }
.btn--sm { height: 36px; padding: 0 14px; font-size: var(--text-sm); }
.btn--lg { height: 52px; padding: 0 28px; font-size: var(--text-md); }
/* ---- Nav ---- */
.mkt-nav {
position: sticky;
top: 0;
z-index: 50;
height: var(--topbar-h);
display: flex;
align-items: center;
background: color-mix(in srgb, var(--surface-canvas) 84%, transparent);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
.mkt-nav__in { display: flex; align-items: center; gap: 24px; width: 100%; }
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.brand .mark { width: 26px; height: 26px; }
.brand b {
font-family: var(--font-brand);
font-weight: 800;
font-size: 18px;
letter-spacing: 0.01em;
color: var(--text-primary);
}
.mkt-nav__links { display: flex; gap: 24px; margin-left: 14px; }
.mkt-nav__links a {
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: 500;
transition: color var(--dur-fast);
text-decoration: none;
}
.mkt-nav__links a:hover { color: var(--text-primary); }
.mkt-nav__cta { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.mkt-nav__signin {
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
transition: color var(--dur-fast);
}
.mkt-nav__signin:hover { color: var(--text-primary); }
/* ---- Hero ---- */
.hero { overflow: hidden; border-bottom: 1px solid var(--border-subtle); }
.hero__atmo {
position: absolute;
inset: 0;
z-index: 0;
transition: background var(--dur-slower) var(--ease-standard);
background:
radial-gradient(120% 80% at 50% -10%, var(--atmo-haze), transparent 55%),
radial-gradient(70% 50% at 85% 110%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 60%),
linear-gradient(180deg, var(--atmo-1), var(--surface-canvas) 72%);
}
.hero__grain {
position: absolute;
inset: 0;
z-index: 0;
opacity: .5;
mix-blend-mode: overlay;
background-image: radial-gradient(rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 3px 3px;
}
.hero__grid {
position: absolute;
inset: 0;
z-index: 0;
opacity: .32;
-webkit-mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
background-image:
linear-gradient(var(--border-subtle) 1px, transparent 1px),
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
background-size: 46px 46px;
}
.hero__in {
position: relative;
z-index: 1;
text-align: center;
padding: 74px 0 88px;
}
.hero__mark {
width: 72px;
height: 72px;
margin: 0 auto 22px;
color: var(--accent);
filter: drop-shadow(0 0 26px var(--accent-glow));
transition: color var(--dur-slow);
}
.hero h1 {
font-family: var(--font-brand);
font-weight: 800;
font-size: var(--text-6xl);
line-height: 1.04;
letter-spacing: 0.005em;
margin: 0;
}
.hero h1 .accent { display: block; }
.hero__sub {
color: var(--text-secondary);
font-size: var(--text-lg);
margin: 22px auto 0;
max-width: 640px;
line-height: 1.55;
}
.hero__cta {
display: flex;
gap: 14px;
justify-content: center;
margin-top: 30px;
}
.hero__games {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 34px;
flex-wrap: wrap;
}
.gpill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 16px;
border-radius: var(--radius-pill);
background: var(--surface-raised);
box-shadow: var(--ring-default);
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: none;
transition: var(--transition-colors);
}
.gpill[data-on="true"] {
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.hero__foot {
margin-top: 16px;
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: .04em;
color: var(--text-muted);
}
.notpill {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
height: 28px;
padding: 0 13px;
border-radius: var(--radius-pill);
background: var(--surface-raised);
box-shadow: var(--ring-default);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.notpill b { color: var(--accent-text); }
/* ---- Panel mockup ---- */
.mock {
position: relative;
z-index: 1;
max-width: 1000px;
margin: 54px auto 0;
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--surface-base);
box-shadow: 0 50px 130px -34px rgba(0,0,0,.85), var(--ring-default);
}
.mock__bar {
height: 40px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 14px;
background: var(--surface-raised);
border-bottom: 1px solid var(--border-subtle);
}
.mock__dots { display: flex; gap: 7px; }
.mock__dots span {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--surface-active);
display: inline-block;
}
.mock__url {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-tertiary);
background: var(--surface-inset);
padding: 5px 12px;
border-radius: var(--radius-pill);
}
.mock__body {
display: grid;
grid-template-columns: 188px 1fr;
min-height: 316px;
text-align: left;
}
.mock__side {
border-right: 1px solid var(--border-subtle);
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 7px;
}
.mock__brand {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
padding: 0 4px;
}
.mock__brand .mark { width: 18px; height: 18px; }
.mock__brand b {
font-family: var(--font-brand);
font-weight: 800;
font-size: 13px;
}
.mock__gs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--surface-inset);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
margin-bottom: 8px;
}
.mock__gs span {
flex: 1;
height: 24px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.mock__gs .on {
background: var(--surface-raised-2);
box-shadow: var(--ring-default);
color: var(--accent);
}
.mock__nav {
display: flex;
align-items: center;
gap: 9px;
height: 28px;
padding: 0 9px;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
font-size: 12px;
}
.mock__nav.on { background: var(--accent-soft); color: var(--accent-text); }
.mock__main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.mock__kpis { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; }
.mock__kpi {
background: var(--surface-raised);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
padding: 10px 12px;
}
.mock__kpi .l { font-size: 10px; color: var(--text-tertiary); }
.mock__kpi .v {
font-family: var(--font-mono);
font-weight: 600;
font-size: 19px;
color: var(--text-primary);
margin-top: 3px;
}
.mock__kpi .v small { color: var(--text-muted); font-size: 12px; }
.mock__row {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface-raised);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
padding: 9px 12px;
position: relative;
overflow: hidden;
}
.mock__row::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--accent);
}
.mock__row .g {
width: 22px;
height: 22px;
flex: none;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: var(--accent-soft);
}
.mock__row .nm { flex: 1; font-size: 12px; font-weight: 600; }
.mock__row .nm small {
display: block;
font-family: var(--font-mono);
font-weight: 400;
font-size: 10px;
color: var(--text-muted);
}
.mock__row .st {
font-family: var(--font-mono);
font-size: 10px;
color: var(--status-online);
display: inline-flex;
align-items: center;
gap: 5px;
}
.mock__row .st b {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--status-online);
display: inline-block;
}
/* ---- Section spacing ---- */
.sec { padding: 88px 0; border-bottom: 1px solid var(--border-subtle); }
.sec__head { text-align: center; margin-bottom: 48px; }
.sec__head .eyebrow { display: block; margin-bottom: 12px; }
/* ---- Problem cards ---- */
.pain {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 12px;
max-width: 1000px;
margin: 0 auto;
}
.pain__item {
display: flex;
align-items: center;
gap: 11px;
padding: 16px;
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
font-size: var(--text-sm);
color: var(--text-primary);
}
.pain__x {
width: 24px;
height: 24px;
flex: none;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: var(--status-offline-soft);
color: var(--status-offline);
}
.closing {
text-align: center;
margin: 40px auto 0;
max-width: 720px;
font-size: var(--text-xl);
font-weight: 600;
line-height: 1.4;
}
/* ---- Steps ---- */
.steps {
display: grid;
grid-template-columns: repeat(3,1fr);
gap: 16px;
max-width: 900px;
margin: 0 auto;
}
.step {
padding: 28px 24px;
text-align: center;
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--ring-default);
}
.step__n {
width: 38px;
height: 38px;
margin: 0 auto 16px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: var(--text-lg);
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.step b { font-size: var(--text-md); font-weight: 600; }
.step p { color: var(--text-tertiary); font-size: var(--text-sm); margin: 8px 0 0; }
.nots {
display: flex;
gap: 26px;
justify-content: center;
margin-top: 34px;
flex-wrap: wrap;
}
.nots span {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-tertiary);
font-size: var(--text-sm);
font-family: var(--font-mono);
}
/* ---- Blueprints (game cards) ---- */
.blueprints {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
max-width: 1000px;
margin: 0 auto;
}
.bp {
position: relative;
overflow: hidden;
padding: 24px;
border-radius: var(--radius-xl);
background:
radial-gradient(120% 90% at 100% 0%, var(--atmo-haze), transparent 55%),
linear-gradient(160deg, color-mix(in srgb, var(--atmo-1) 80%, transparent), var(--surface-base) 70%);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.bp__head { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.bp__ic {
width: 40px;
height: 40px;
flex: none;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.bp__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
.bp__accent {
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-text);
text-transform: uppercase;
letter-spacing: .08em;
}
.bp__role { font-size: var(--text-sm); font-weight: 600; color: var(--text-secondary); margin: 10px 0 14px; }
.bp__list { display: flex; flex-direction: column; gap: 8px; }
.bp__list div { display: flex; align-items: center; gap: 9px; font-size: var(--text-sm); color: var(--text-secondary); }
/* ---- Capabilities (3 col) ---- */
.caps {
display: grid;
grid-template-columns: repeat(3,1fr);
gap: 30px;
max-width: 1000px;
margin: 0 auto;
}
.caps__col > .eyebrow { display: block; margin-bottom: 8px; }
.feat { display: flex; gap: 12px; padding: 14px 0; border-top: 1px solid var(--border-subtle); }
.feat:first-of-type { border-top: 0; }
.feat__ic {
width: 32px;
height: 32px;
flex: none;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.feat b { font-size: var(--text-sm); font-weight: 600; }
/* ---- Pipeline ---- */
.pipe {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
flex-wrap: wrap;
max-width: 1000px;
margin: 0 auto;
}
.pchip {
height: 38px;
padding: 0 15px;
display: inline-flex;
align-items: center;
border-radius: var(--radius-md);
background: var(--surface-raised-2);
box-shadow: var(--ring-default);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
}
.pchip--last {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.stack-lines { display: flex; flex-direction: column; gap: 8px; align-items: center; margin-top: 32px; }
.stack-lines span { color: var(--text-tertiary); font-size: var(--text-md); }
.stack-lines .hi { color: var(--accent-text); font-weight: 600; }
/* ---- Infra ---- */
.infra {
display: grid;
grid-template-columns: repeat(5,1fr);
gap: 12px;
max-width: 1040px;
margin: 0 auto;
}
.icard {
padding: 20px 16px;
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
}
.icard__ic {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
margin-bottom: 12px;
}
.icard b { font-size: var(--text-sm); font-weight: 600; display: block; }
.icard p { margin: 5px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; }
.techrow {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 30px;
flex-wrap: wrap;
}
.techrow span {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
padding: 6px 12px;
border-radius: var(--radius-pill);
box-shadow: var(--ring-default);
}
/* ---- Store ---- */
.chips {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
max-width: 880px;
margin: 0 auto;
}
.chip-card {
display: flex;
align-items: center;
gap: 9px;
padding: 14px 18px;
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
font-weight: 600;
font-size: var(--text-sm);
}
.chip-card--accent {
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
background: var(--accent-soft);
}
/* ---- Pricing ---- */
.pricing {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 14px;
max-width: 1040px;
margin: 0 auto;
align-items: stretch;
}
.plan {
display: flex;
flex-direction: column;
padding: 24px 22px;
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--ring-default);
}
.plan--feature {
box-shadow: inset 0 0 0 1px var(--accent-border), var(--glow-accent-sm);
background: linear-gradient(180deg, var(--accent-soft), var(--surface-base) 40%);
}
.plan__tag {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--accent-text);
margin-bottom: 10px;
height: 14px;
}
.plan__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
.plan__price {
font-family: var(--font-mono);
font-weight: 600;
font-size: var(--text-3xl);
margin: 12px 0 2px;
letter-spacing: -0.02em;
}
.plan__price small { font-size: var(--text-sm); color: var(--text-muted); font-weight: 400; }
.plan__scope { font-size: var(--text-sm); color: var(--text-tertiary); min-height: 40px; }
.plan .btn { margin-top: 18px; width: 100%; justify-content: center; }
.fleetblock {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
max-width: 1040px;
margin: 14px auto 0;
padding: 16px 22px;
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
flex-wrap: wrap;
}
.fleetblock b { font-family: var(--font-brand); font-weight: 700; }
.fleetblock .p { font-family: var(--font-mono); color: var(--accent-text); font-weight: 600; }
.fleetblock span { color: var(--text-tertiary); font-size: var(--text-sm); }
.commercial {
max-width: 760px;
margin: 26px auto 0;
text-align: center;
color: var(--text-muted);
font-size: var(--text-xs);
line-height: 1.6;
}
.commercial b { color: var(--text-secondary); }
/* ---- Support block (below pricing) ---- */
.support-note {
max-width: 760px;
margin: 20px auto 0;
text-align: center;
color: var(--text-muted);
font-size: var(--text-xs);
line-height: 1.6;
}
.support-note b { color: var(--text-secondary); }
/* ---- Admins ---- */
.admins {
display: flex;
flex-direction: column;
gap: 11px;
align-items: center;
max-width: 560px;
margin: 0 auto;
}
.admins span {
display: flex;
align-items: center;
gap: 11px;
font-size: var(--text-lg);
color: var(--text-secondary);
}
/* ---- Final CTA ---- */
.finalcta {
position: relative;
overflow: hidden;
text-align: center;
padding: 104px 0;
border-bottom: 1px solid var(--border-subtle);
}
.finalcta__atmo {
position: absolute;
inset: 0;
z-index: 0;
background: radial-gradient(60% 100% at 50% 100%, var(--atmo-haze), transparent 60%);
}
.finalcta__in { position: relative; z-index: 1; }
.finalcta h2 {
font-family: var(--font-brand);
font-weight: 800;
font-size: var(--text-5xl);
margin: 0 0 28px;
line-height: 1.05;
}
.finalcta .cta-row { display: flex; gap: 14px; justify-content: center; }
/* ---- Footer ---- */
.mkt-footer { padding: 56px 0 40px; }
.footer__cols { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr; gap: 24px; }
.footer__brand .mark { width: 24px; height: 24px; }
.footer__brand p {
color: var(--text-tertiary);
font-size: var(--text-sm);
margin: 12px 0 0;
max-width: 230px;
}
.footer__col h5 {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--text-muted);
margin: 0 0 14px;
font-family: var(--font-mono);
}
.footer__col a {
display: block;
color: var(--text-secondary);
font-size: var(--text-sm);
margin-bottom: 9px;
text-decoration: none;
transition: color var(--dur-fast);
}
.footer__col a:hover { color: var(--text-primary); }
.footer__bar {
display: flex;
justify-content: space-between;
margin-top: 44px;
padding-top: 22px;
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: var(--text-xs);
}
/* ---- Scroll reveal ---- */
@media (prefers-reduced-motion: no-preference) {
.reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity .6s var(--ease-out), transform .6s var(--ease-out);
}
.reveal.in { opacity: 1; transform: none; }
}
/* ---- Responsive ---- */
@media (max-width: 980px) {
.pain { grid-template-columns: 1fr 1fr; }
.steps, .caps, .blueprints, .pricing { grid-template-columns: 1fr; }
.infra { grid-template-columns: 1fr 1fr; }
.footer__cols { grid-template-columns: 1fr 1fr; }
.mock__body { grid-template-columns: 1fr; }
.mock__side { display: none; }
.hero h1 { font-size: var(--text-5xl); }
.mkt-nav__links { display: none; }
}

View File

@@ -4,13 +4,14 @@
JetBrains Mono — console, data, IDs, telemetry
Oxanium — brand wordmark + marketing display (game-ops flavor)
------------------------------------------------------------
NOTE: Loaded from Google Fonts CDN. If you want these self-
hosted (offline), send the woff2 files and these @imports
become @font-face rules.
NOTE: The Google Fonts stylesheet is loaded via <link> tags in
index.html — NOT @import here. A CSS @import that ends up
mid-bundle after concatenation is silently dropped by the
optimizer (fonts never load in production). If you want these
self-hosted (offline), send the woff2 files and they become
@font-face rules here.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
:root {
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;

View File

@@ -1,136 +1,97 @@
<script setup lang="ts">
/**
* DashboardView — Fleet / Solo dashboard.
* Fleet: multi-game server cockpit (representative mock data — pending multi-instance backend).
* Solo: single-server detail wired to the real useServerStore where data exists.
* DashboardView — Single-server cockpit wired entirely to real data.
*
* View toggle (Fleet / Solo) lives inside the page so the shell (DashboardLayout) stays clean.
* Routing stays at path '/', no new routes added.
* Architecture:
* - useServerStore → connection + config + live stats (WebSocket updateStats)
* - useApi → /analytics/timeseries for 24h player history (PlayersChart)
* - useGameProfile → per-game labels/terminology (defaults to 'rust' today)
* - useWebSocket → subscribes to console_output and server_stats events
*
* Empty states:
* - No connection record → "No server connected" EmptyState with CTA to /server
* - Connection exists but stats absent → meters show '—', chart shows awaiting telemetry
* - No upcoming wipe schedules → honest empty state in the wipes panel
*
* No fabricated data anywhere in this file.
* The fleet/multi-server view has been removed — the current backend is
* single-server-per-license. When the backend supports multiple servers per
* license, restore a fleet tab wired to real data.
*/
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useServerStore } from '@/stores/server'
import { useWipeStore } from '@/stores/wipe'
import { useApi } from '@/composables/useApi'
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
import { useGameProfile } from '@/config/gameProfiles'
import { useThemeGame } from '@/composables/useThemeGame'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import ServerCard from '@/components/ds/data/ServerCard.vue'
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
import ConsoleLineDS from '@/components/ds/data/ConsoleLine.vue'
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
import Input from '@/components/ds/forms/Input.vue'
import Switch from '@/components/ds/forms/Switch.vue'
import {
MOCK_SERVERS, MOCK_FEED, MOCK_WIPES, buildStats,
type MockServer, type GameKey,
} from './_dashboardMock'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import type { TimeseriesData, WipeSchedule } from '@/types'
import { safeDate } from '@/utils/formatters'
// ---- Stores / composables ----
// ---------------------------------------------------------------------------
// Stores / composables
// ---------------------------------------------------------------------------
const server = useServerStore()
const wipeStore = useWipeStore()
const router = useRouter()
const api = useApi()
const { activeGame } = useThemeGame()
// ---- View toggle ----
const VIEW_KEY = 'cc-dash-view'
const view = ref<'fleet' | 'solo'>((localStorage.getItem(VIEW_KEY) as 'fleet' | 'solo') ?? 'fleet')
function setView(v: string) {
view.value = v as 'fleet' | 'solo'
localStorage.setItem(VIEW_KEY, v)
}
const viewItems = [
{ value: 'fleet', label: 'Fleet', icon: 'layout-grid' },
{ value: 'solo', label: 'Solo', icon: 'square-dashed' },
]
// ---- Fleet: filter servers by activeGame ----
const serverStatus = ref<'all' | 'online' | 'offline'>('all')
const statusItems = computed(() => [
{ value: 'all', label: 'All', count: inGame.value.length },
{ value: 'online', label: 'Running', count: inGame.value.filter((s) => s.status !== 'offline').length },
{ value: 'offline', label: 'Stopped', count: inGame.value.filter((s) => s.status === 'offline').length },
])
const GAME_LABEL: Record<string, string> = { rust: 'Rust', dune: 'Dune', conan: 'Conan Exiles', soulmask: 'Soulmask' }
const inGame = computed<MockServer[]>(() =>
activeGame.value === 'all'
? MOCK_SERVERS
: MOCK_SERVERS.filter((s) => s.game === (activeGame.value as GameKey)),
)
const shownServers = computed<MockServer[]>(() => {
const sv = serverStatus.value
return inGame.value.filter((s) => {
if (sv === 'all') return true
if (sv === 'online') return s.status !== 'offline'
return s.status === 'offline'
})
// Profile follows the GameSwitcher selection. 'all' falls back to rust (neutral house skin).
// When the backend adds a `game` field on licenses, swap activeGame for server.config?.game.
const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
return useGameProfile(game)
})
// ---- Fleet KPIs ----
const runningCount = computed(() => inGame.value.filter((s) => s.status !== 'offline').length)
const playersCur = computed(() => inGame.value.reduce((a, s) => a + (s.players?.cur ?? 0), 0))
const playersMax = computed(() => inGame.value.reduce((a, s) => a + (s.players?.max ?? 0), 0))
const cpuValues = computed(() => inGame.value.filter((s) => s.cpu != null).map((s) => s.cpu as number))
const avgCpu = computed<string>(() =>
cpuValues.value.length
? String(Math.round(cpuValues.value.reduce((a, b) => a + b, 0) / cpuValues.value.length))
: '—',
)
// ---------------------------------------------------------------------------
// Derived server state — all real, no fallbacks to fabricated values
// ---------------------------------------------------------------------------
const scopeLabel = computed(() =>
activeGame.value === 'all'
? 'Fleet overview'
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} fleet`,
)
const hasConnection = computed(() => server.connection !== null)
const isConnected = computed(() => server.connection?.connection_status === 'connected')
const fleetTitle = computed(() => {
if (activeGame.value === 'all') {
const games = new Set(MOCK_SERVERS.map((s) => s.game)).size
return `${MOCK_SERVERS.length} servers · ${games} games`
}
const n = inGame.value.length
const label = GAME_LABEL[activeGame.value as string] ?? activeGame.value
return `${n} ${label} server${n === 1 ? '' : 's'}`
const soloName = computed(() => server.config?.server_name ?? null)
const soloPlayers = computed(() => server.stats?.player_count ?? null)
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? null)
const soloFps = computed(() => server.stats?.fps ?? null)
// Memory: store gives memory_usage_mb; max must come from agent telemetry.
// We do NOT hard-code a "representative" max — show raw MB and no percentage
// until the agent reports a known max.
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? null)
const soloRamPct = computed(() => {
// ServerStats has no ram_max field — we cannot compute a real percentage.
// Return null; ResourceMeter and StatCard will show '—'.
return null
})
const soloRamSub = computed(() => {
const mb = soloRamMb.value
if (mb === null) return null
return `${(mb / 1024).toFixed(1)} GB used`
})
const chartSubtitle = computed(() =>
activeGame.value === 'all'
? 'All servers · last 24 hours'
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} servers · last 24 hours`,
)
// CPU: not in ServerStats today. Show null — never fabricate.
const soloCpu = computed(() => null as number | null)
// ---- Chart period toggle ----
const chartPeriod = ref('24h')
const periodItems = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
]
// ---- Solo: real store data + representative fallbacks ----
const soloName = computed(() => server.config?.server_name ?? 'Main · 2x Vanilla')
const soloPlayers = computed(() => server.stats?.player_count ?? 0)
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? 200)
const soloFps = computed(() => server.stats?.fps ?? 59.8)
// Memory: store gives memory_usage_mb (no max), use 8192 MB representative max for %
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? 0)
const soloRamPct = computed(() => soloRamMb.value > 0 ? Math.round((soloRamMb.value / 8192) * 100) : 68)
const soloRamSub = computed(() => soloRamMb.value > 0 ? `${(soloRamMb.value / 1024).toFixed(1)} / 8 GB` : '5.4 / 8 GB')
// CPU: not in ServerStats; use representative value
const soloCpuPct = 41
// Status badge derived from connection_status
const soloStatus = computed<'online' | 'offline' | 'starting' | 'wiping'>(() => {
const soloStatus = computed<'online' | 'offline' | 'starting'>(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'online'
if (cs === 'degraded') return 'starting'
return 'offline'
})
const soloStatusTone = computed<'online' | 'offline' | 'starting' | 'warn'>(() => {
const soloStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
if (soloStatus.value === 'online') return 'online'
if (soloStatus.value === 'starting') return 'warn'
return 'offline'
@@ -140,217 +101,282 @@ const soloStatusLabel = computed(() => {
if (soloStatus.value === 'starting') return 'Degraded'
return 'Offline'
})
const soloRegion = computed(() => {
const ip = server.connection?.server_ip
return ip ? 'Bare metal' : 'US-East'
})
const soloIp = computed(() => {
const ip = server.connection?.server_ip
const port = server.connection?.game_port ?? server.connection?.server_port
if (ip && port) return `${ip}:${port}`
return '89.142.0.7:28015'
if (ip) return ip
return null
})
const soloUptime = computed(() => {
const sec = server.stats?.uptime_seconds ?? 0
if (sec === 0) return '—'
if (sec === 0) return null
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
return `${d}d ${h}h`
if (d > 0) return `${d}d ${h}h`
return `${h}h`
})
// Representative plugin list (uMod plugin state not in backend store)
const pluginStates = ref([
{ name: 'RaidableBases', ver: '2.7.4', on: true },
{ name: 'Kits', ver: '4.3.1', on: true },
{ name: 'CorrosionTeleportGUI', ver: '1.2.0', on: true },
{ name: 'Economics', ver: '3.9.6', on: true },
{ name: 'ZLevels Remastered', ver: '3.2.0', on: false },
])
// ---------------------------------------------------------------------------
// Players chart — real 24h timeseries from /analytics/timeseries
// ---------------------------------------------------------------------------
const chartData = ref<number[] | null>(null)
const chartLoading = ref(false)
async function loadChartData() {
chartLoading.value = true
try {
const ts = await api.get<TimeseriesData>('/analytics/timeseries?range=24&granularity=hourly')
chartData.value = ts.player_count.length > 0 ? ts.player_count : null
} catch {
// API unavailable or no data yet — chart will show "awaiting telemetry"
chartData.value = null
} finally {
chartLoading.value = false
}
}
// ---------------------------------------------------------------------------
// Wipe schedules — real data from wipeStore
// ---------------------------------------------------------------------------
const nextWipe = computed<WipeSchedule | null>(() => {
const schedules = wipeStore.schedules.filter((s) => s.is_active && s.next_scheduled_run)
if (schedules.length === 0) return null
return schedules.slice().sort((a, b) => {
const at = a.next_scheduled_run ? new Date(a.next_scheduled_run).getTime() : Infinity
const bt = b.next_scheduled_run ? new Date(b.next_scheduled_run).getTime() : Infinity
return at - bt
})[0] ?? null
})
const nextWipeLabel = computed(() => {
const w = nextWipe.value
if (!w?.next_scheduled_run) return null
return safeDate(w.next_scheduled_run)
})
const nextWipeType = computed(() => {
const w = nextWipe.value
if (!w) return null
const t = w.wipe_type
if (t === 'full') return `Full ${profile.value.terminology.reset}`
if (t === 'blueprint') return 'Blueprint wipe'
return `Map ${profile.value.terminology.reset}`
})
// ---------------------------------------------------------------------------
// Console lines — real WebSocket events only
// ---------------------------------------------------------------------------
interface ConsoleLine {
time: string
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
who?: string
msg: string
}
const consoleLines = ref<ConsoleLine[]>([])
const MAX_CONSOLE_LINES = 100
function now(): string {
return new Date().toLocaleTimeString('en-US', { hour12: false })
}
function handleWsMessage(msg: WebSocketMessage) {
if (msg.type !== 'event') return
// Live server stats
if (msg.event === 'server_stats' && msg.data) {
server.updateStats(msg.data)
return
}
// Console output lines
if (msg.event === 'console_output') {
const text = msg.data?.line ?? msg.data?.output ?? msg.raw ?? ''
if (!text) return
consoleLines.value.push({
time: now(),
level: 'info',
msg: String(text),
})
if (consoleLines.value.length > MAX_CONSOLE_LINES) {
consoleLines.value.splice(0, consoleLines.value.length - MAX_CONSOLE_LINES)
}
}
}
// ---------------------------------------------------------------------------
// Console input
// ---------------------------------------------------------------------------
const consoleInput = ref('')
function sendConsoleCommand() {
if (!consoleInput.value.trim()) return
server.sendCommand(consoleInput.value.trim()).catch(() => {})
const cmd = consoleInput.value.trim()
if (!cmd) return
consoleLines.value.push({ time: now(), level: 'cmd', who: 'admin', msg: cmd })
server.sendCommand(cmd).catch(() => {})
consoleInput.value = ''
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let unsubscribe: (() => void) | null = null
onMounted(async () => {
await server.fetchServer()
await wipeStore.fetchSchedules()
await loadChartData()
const ws = useWebSocket()
unsubscribe = ws.subscribe(handleWsMessage)
})
onUnmounted(() => {
unsubscribe?.()
})
// Navigation helpers
function navConsole() { router.push('/console') }
function navWipes() { router.push('/wipes') }
// ---- Lifecycle ----
onMounted(() => {
server.fetchServer()
})
function navWipes() { router.push('/wipes') }
function navServer() { router.push('/server') }
</script>
<template>
<div class="dash">
<!-- ===== FLEET VIEW ===== -->
<template v-if="view === 'fleet'">
<!-- Page head -->
<!-- ===== NO CONNECTION: honest empty state ===== -->
<template v-if="!server.isLoading && !hasConnection">
<div class="page__head">
<div>
<div class="t-eyebrow">{{ scopeLabel }}</div>
<h1 class="page__title">{{ fleetTitle }}</h1>
</div>
<div class="page__actions">
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
<Button variant="secondary" size="sm" icon="download">Export</Button>
<Button size="sm" icon="rocket">Deploy server</Button>
</div>
</div>
<!-- KPIs -->
<div class="dash__kpis">
<StatCard icon="server" label="Servers running" :value="String(runningCount)" :unit="'/' + inGame.length" delta="+1" note="today" />
<StatCard icon="users" label="Players online" :value="String(playersCur)" :unit="'/' + playersMax" delta="+38" note="since wipe" />
<StatCard icon="cpu" :label="activeGame === 'all' ? 'Fleet CPU' : 'Avg CPU'" :value="avgCpu" :unit="avgCpu === '—' ? '' : '%'" note="reporting agents" />
<StatCard icon="server-cog" label="Agent nodes" value="2" unit="/2" note="all reporting" />
</div>
<!-- Main grid -->
<div class="dash__grid">
<!-- Left column -->
<div class="dash__col">
<!-- Players chart panel themed ECharts -->
<Panel title="Players online" :subtitle="chartSubtitle">
<template #actions>
<Tabs v-model="chartPeriod" :items="periodItems" />
</template>
<PlayersChart :height="200" :max="200" />
</Panel>
<!-- Servers list -->
<Panel :flush-body="true" title="Servers">
<template #actions>
<Tabs v-model="serverStatus" :items="statusItems" />
</template>
<div class="server__list">
<ServerCard
v-for="(s, i) in shownServers"
:key="i"
:game="s.game"
:game-icon="s.gameIcon"
:name="s.name"
:region="s.region"
:map="s.map"
:version="s.version"
:status="s.status"
:players="s.players"
:cpu="s.cpu"
:ram="s.ram"
:ram-sub="s.ramSub"
:ip="s.ip"
:stats="buildStats(s)"
/>
<div v-if="shownServers.length === 0" class="server__empty">
No servers match the current filter.
</div>
</div>
</Panel>
</div>
<!-- Right sidebar column -->
<div class="dash__col dash__col--side">
<!-- Live activity -->
<Panel :flush-body="true" title="Live activity">
<template #actions>
<Badge tone="online" :dot="true" :pulse="true">Live</Badge>
</template>
<div class="feed">
<ConsoleLine
v-for="(f, i) in MOCK_FEED"
:key="i"
:time="f.time"
:level="f.level"
:who="f.who"
>{{ f.msg }}</ConsoleLine>
</div>
</Panel>
<!-- Upcoming wipes -->
<Panel title="Upcoming wipes">
<div class="wipes">
<div
v-for="(w, i) in MOCK_WIPES"
:key="i"
class="wipe"
:data-game="w.game"
>
<div class="wipe__dot" />
<div class="wipe__body">
<div class="wipe__name">{{ w.name }}</div>
<div class="wipe__when">{{ w.when }}</div>
</div>
<Badge :tone="w.tone" size="md">{{ w.label }}</Badge>
</div>
</div>
</Panel>
<div class="t-eyebrow">Dashboard</div>
<h1 class="page__title">Server cockpit</h1>
</div>
</div>
<Panel>
<EmptyState
icon="server"
title="No server connected"
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
>
<template #action>
<Button icon="server" @click="navServer">Set up server</Button>
</template>
</EmptyState>
</Panel>
</template>
<!-- ===== SOLO VIEW ===== -->
<template v-else>
<!-- ===== SERVER COCKPIT ===== -->
<template v-else-if="hasConnection">
<!-- Page head -->
<div class="page__head">
<div class="solo-id">
<div class="solo-id__chip">
<Icon name="box" :size="21" :stroke-width="2" />
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div>
<div class="solo-id__name">
{{ soloName }}
{{ soloName ?? 'Server' }}
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
</div>
<div class="solo-id__meta">
{{ soloRegion }} · {{ soloIp }}
<span v-if="soloUptime !== '—'"> · up {{ soloUptime }}</span>
<template v-if="soloIp">{{ soloIp }}</template>
<template v-else>No IP registered</template>
<template v-if="soloUptime"> · up {{ soloUptime }}</template>
</div>
</div>
</div>
<div class="page__actions">
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
</div>
</div>
<!-- KPIs -->
<!-- KPIs game profile drives stat labels; null values show '' -->
<div class="dash__kpis">
<StatCard icon="users" label="Players online" :value="String(soloPlayers)" :unit="'/' + soloMaxPlayers" delta="+12" note="since wipe" />
<StatCard icon="cpu" label="CPU" :value="String(soloCpuPct)" unit="%" delta="3%" delta-dir="down" note="agent · representative" />
<StatCard icon="memory-stick" label="Memory" :value="String(soloRamPct)" unit="%" :note="soloRamSub" />
<StatCard icon="gauge" label="Server FPS" :value="String(soloFps)" delta-dir="flat" note="stable" />
<StatCard
icon="users"
:label="(profile.statFields[0] ?? 'Players') + ' online'"
:value="soloPlayers !== null ? String(soloPlayers) : '—'"
:unit="soloMaxPlayers !== null ? '/' + soloMaxPlayers : ''"
note="live via agent"
/>
<StatCard
icon="cpu"
label="CPU"
:value="soloCpu !== null ? String(soloCpu) : '—'"
:unit="soloCpu !== null ? '%' : ''"
note="agent telemetry"
/>
<StatCard
icon="memory-stick"
label="Memory"
:value="soloRamMb !== null ? (soloRamMb / 1024).toFixed(1) : '—'"
:unit="soloRamMb !== null ? 'GB' : ''"
:note="soloRamSub ?? 'agent telemetry'"
/>
<StatCard
icon="gauge"
label="Server FPS"
:value="soloFps !== null ? String(soloFps) : '—'"
:unit="soloFps !== null ? 'fps' : ''"
note="live via agent"
/>
</div>
<!-- Solo grid -->
<!-- Main grid -->
<div class="dash__grid">
<!-- Left column -->
<div class="dash__col">
<!-- Players chart themed ECharts -->
<Panel title="Players online" :subtitle="soloName + ' · last 24 hours'">
<PlayersChart :height="196" :max="soloMaxPlayers" />
<!-- Players chart real 24h data or honest empty state -->
<Panel
title="Players online"
:subtitle="(soloName ?? 'Server') + ' · last 24 hours'"
>
<div v-if="chartLoading" class="chart-loading">Loading telemetry</div>
<PlayersChart
v-else
:height="196"
:max="soloMaxPlayers ?? 200"
:data="chartData ?? undefined"
/>
</Panel>
<!-- Console panel -->
<!-- Console real WebSocket lines only -->
<Panel :flush-body="true" title="Console">
<template #actions>
<Badge tone="online" :dot="true" :pulse="soloStatus === 'online'">Live</Badge>
<Badge
:tone="isConnected ? 'online' : 'offline'"
:dot="true"
:pulse="isConnected"
>{{ isConnected ? 'Live' : 'Disconnected' }}</Badge>
</template>
<div class="feed feed--solo">
<ConsoleLine
v-for="(f, i) in MOCK_FEED"
:key="i"
:time="f.time"
:level="f.level"
:who="f.who"
>{{ f.msg }}</ConsoleLine>
<template v-if="consoleLines.length > 0">
<ConsoleLineDS
v-for="(line, i) in consoleLines"
:key="i"
:time="line.time"
:level="line.level"
:who="line.who"
>{{ line.msg }}</ConsoleLineDS>
</template>
<div v-else class="feed__empty">
<span v-if="isConnected">Waiting for output try sending a command below</span>
<span v-else>Console offline server is not connected</span>
</div>
</div>
<div class="console-bar">
<span class="console-bar__prompt">&gt;</span>
<Input
@@ -358,58 +384,88 @@ onMounted(() => {
:mono="true"
size="sm"
placeholder="say, kick, ban, oxide.reload …"
:disabled="!isConnected"
style="flex: 1"
@keydown.enter="sendConsoleCommand"
/>
<Button size="sm" variant="secondary" icon="corner-down-left" @click="sendConsoleCommand">Send</Button>
<Button
size="sm"
variant="secondary"
icon="corner-down-left"
:disabled="!isConnected"
@click="sendConsoleCommand"
>Send</Button>
</div>
</Panel>
</div>
<!-- Right sidebar -->
<div class="dash__col dash__col--side">
<!-- Resources -->
<Panel title="Resources" subtitle="Companion agent telemetry">
<!-- Resources real stats from agent; null = '—' -->
<Panel title="Resources" subtitle="Host agent telemetry">
<div class="solo-meters">
<ResourceMeter label="CPU" :value="soloCpuPct" sub="representative" />
<ResourceMeter label="Memory" :value="soloRamPct" :sub="soloRamSub" />
<ResourceMeter label="Disk" :value="64" sub="representative" />
<ResourceMeter
label="CPU"
:value="soloCpu ?? 0"
:sub="soloCpu !== null ? soloCpu + '%' : 'awaiting telemetry'"
/>
<ResourceMeter
label="Memory"
:value="soloRamPct ?? 0"
:sub="soloRamSub ?? 'awaiting telemetry'"
/>
</div>
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
Resource metrics arrive via the host agent heartbeat.
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
Agent setup
</Button>
</div>
</Panel>
<!-- Plugins -->
<Panel :flush-body="true" title="Plugins" subtitle="uMod / Oxide">
<template #actions>
<Button size="sm" variant="ghost" icon="plus" @click="router.push('/plugins')">Add</Button>
</template>
<div class="plugs">
<div
v-for="(p, i) in pluginStates"
:key="i"
class="plug"
>
<div class="plug__id">
<span class="plug__name">{{ p.name }}</span>
<span class="plug__ver">{{ p.ver }}</span>
</div>
<Switch v-model="p.on" size="sm" />
</div>
</div>
</Panel>
<!-- Next wipe -->
<Panel title="Next wipe">
<div class="solo-wipe">
<!-- Next wipe/reset title follows game terminology -->
<Panel :title="'Next ' + profile.terminology.reset.toLowerCase()">
<div v-if="nextWipe" class="solo-wipe">
<div>
<div class="solo-wipe__when">Thu · 18:00 UTC</div>
<div class="solo-wipe__sub">representative configure in wipe manager</div>
<div class="solo-wipe__type">{{ nextWipeType }}</div>
<div class="solo-wipe__when">{{ nextWipeLabel }}</div>
<div class="solo-wipe__name">{{ nextWipe.schedule_name }}</div>
</div>
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
</div>
<EmptyState
v-else
icon="calendar"
:title="'No ' + profile.terminology.reset.toLowerCase() + ' scheduled'"
:description="'Configure automatic ' + profile.terminology.reset.toLowerCase() + 's in the wipe manager.'"
>
<template #action>
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">
Open wipe manager
</Button>
</template>
</EmptyState>
</Panel>
</div>
</div>
</template>
<!-- Loading state -->
<template v-else>
<div class="page__head">
<div>
<div class="t-eyebrow">Dashboard</div>
<h1 class="page__title">Server cockpit</h1>
</div>
</div>
<Panel>
<div class="dash-loading">Loading server data</div>
</Panel>
</template>
</div>
</template>
@@ -431,23 +487,6 @@ onMounted(() => {
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
/* ---------- Servers list ---------- */
.server__list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
.server__empty { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--text-muted); text-align: center; padding: 24px; }
/* ---------- Live feed ---------- */
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
.feed--solo { max-height: 230px; }
/* ---------- Upcoming wipes ---------- */
.wipes { display: flex; flex-direction: column; gap: 4px; }
.wipe { display: flex; align-items: center; gap: 11px; padding: 9px 6px; border-radius: var(--radius-md); }
.wipe:hover { background: var(--surface-hover); }
.wipe__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: 0 0 10px -1px var(--accent-glow); }
.wipe__body { flex: 1; min-width: 0; }
.wipe__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wipe__when { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
/* ---------- Solo identity header ---------- */
.solo-id { display: flex; align-items: center; gap: 13px; }
.solo-id__chip {
@@ -463,6 +502,21 @@ onMounted(() => {
}
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
/* ---------- Chart loading ---------- */
.chart-loading {
display: flex; align-items: center; justify-content: center;
height: 196px; font-size: var(--text-sm); color: var(--text-muted);
}
/* ---------- Console feed ---------- */
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
.feed--solo { max-height: 230px; }
.feed__empty {
display: flex; align-items: center; justify-content: center;
height: 100px; font-size: var(--text-sm); color: var(--text-muted);
font-style: italic;
}
/* ---------- Console bar ---------- */
.console-bar {
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
@@ -472,24 +526,28 @@ onMounted(() => {
/* ---------- Resources ---------- */
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
/* ---------- Plugin list ---------- */
.plugs { display: flex; flex-direction: column; }
.plug { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
.plug:last-child { border-bottom: 0; }
.plug__id { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
.plug__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plug__ver { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
.meters-note {
margin-top: 14px; font-size: var(--text-xs); color: var(--text-muted);
border-top: 1px solid var(--border-subtle); padding-top: 12px;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.meters-cta { margin-left: auto; }
/* ---------- Next wipe ---------- */
.solo-wipe { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
.solo-wipe__sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
.solo-wipe { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.solo-wipe__type { font-size: var(--text-xs); color: var(--text-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 3px; }
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.solo-wipe__name { font-size: var(--text-xs); color: var(--text-muted); margin-top: 2px; }
/* ---------- Loading ---------- */
.dash-loading {
display: flex; align-items: center; justify-content: center;
padding: 60px; font-size: var(--text-sm); color: var(--text-muted);
}
/* ---------- Responsive ---------- */
@media (max-width: 1180px) {
.dash__grid { grid-template-columns: 1fr; }
.server__list { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.dash__kpis { grid-template-columns: repeat(2, 1fr); }

View File

@@ -485,7 +485,7 @@ onMounted(() => {
</Panel>
<Alert tone="info">
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
The plugin will be registered in your plugin list immediately. Your host agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</Alert>
</div>

View File

@@ -3,6 +3,8 @@ import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
import Panel from '@/components/ds/data/Panel.vue'
@@ -11,6 +13,7 @@ import Badge from '@/components/ds/core/Badge.vue'
import StatusDot from '@/components/ds/core/StatusDot.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Input from '@/components/ds/forms/Input.vue'
import Switch from '@/components/ds/forms/Switch.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
@@ -18,6 +21,39 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const auth = useAuthStore()
const toast = useToastStore()
const { activeGame } = useThemeGame()
// Profile follows the GameSwitcher. 'all' defaults to rust (neutral house skin).
const profile = computed(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
return useGameProfile(game)
})
// Game-specific derived flags
const isRust = computed(() => profile.value.mods === 'umod')
const hasPluginSystem = computed(() => profile.value.mods === 'umod')
const isDockerManaged = computed(() => profile.value.managementModel === 'docker-compose')
// Management model human label for the identity badge
const managementModelLabel = computed(() => {
const m = profile.value.managementModel
const c = profile.value.console
if (m === 'docker-compose') {
return profile.value.clustering === 'battlegroup' ? 'Docker · BattleGroup' : 'Docker · Compose'
}
if (c === 'rcon+ingame') return 'Process · RCON + In-game'
if (c === 'rcon+gm') return 'Process · RCON + GM'
return 'Process · RCON'
})
// Clustering section label per game
const clusterLabel = computed(() => {
const cl = profile.value.clustering
if (cl === 'battlegroup') return 'BattleGroups & Sietches'
if (cl === 'main-client') return 'Cluster'
if (cl === 'character-transfer') return 'Clans & Character Transfer'
return ''
})
const editMode = ref(false)
const saving = ref(false)
@@ -64,22 +100,22 @@ const agentLastSeenLabel = computed(() => {
const licenseKey = computed(() => auth.license?.license_key || 'YOUR-LICENSE-KEY')
const linuxCommands = computed(() => `# Download the agent
curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64
chmod +x corrosion-companion-linux-amd64
curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64
chmod +x corrosion-host-agent-linux-amd64
# Start with your license key
export LICENSE_ID="${licenseKey.value}"
export NATS_URL="nats://nats.corrosionmgmt.com:4222"
./corrosion-companion-linux-amd64`)
./corrosion-host-agent-linux-amd64`)
const windowsCommands = computed(() => `# Requires PowerShell (not Command Prompt)
# Download the agent
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe" -OutFile "corrosion-companion-windows-amd64.exe"
Invoke-WebRequest -Uri "https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe" -OutFile "corrosion-host-agent-windows-amd64.exe"
# Start with your license key
$env:LICENSE_ID="${licenseKey.value}"
$env:NATS_URL="nats://nats.corrosionmgmt.com:4222"
.\\corrosion-companion-windows-amd64.exe`)
.\\corrosion-host-agent-windows-amd64.exe`)
async function copySetupCommands() {
try {
@@ -278,17 +314,18 @@ onMounted(async () => {
<template>
<div class="sv">
<!-- Page head -->
<!-- Page head game-aware identity -->
<div class="sv__head">
<div class="sv__head-id">
<div class="sv__head-chip">
<Icon name="server" :size="20" :stroke-width="2" />
</div>
<div>
<div class="t-eyebrow">Server management</div>
<div class="t-eyebrow">{{ profile.label }} · Server management</div>
<h1 class="sv__title">Server</h1>
</div>
</div>
<Badge tone="neutral" :mono="true" class="sv__model-badge">{{ managementModelLabel }}</Badge>
</div>
<!-- Connection -->
@@ -350,8 +387,8 @@ onMounted(async () => {
</div>
</Panel>
<!-- Companion agent -->
<Panel title="Companion agent" subtitle="Bare-metal server management binary">
<!-- Host agent -->
<Panel title="Host agent" subtitle="Bare-metal server management binary">
<template #actions>
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
{{ isAgentConnected ? 'Active' : 'Inactive' }}
@@ -380,20 +417,20 @@ onMounted(async () => {
<!-- Download -->
<div class="sv__section-head">
<Icon name="download" :size="14" />
<span>Download companion agent</span>
<span>Download host agent</span>
</div>
<div class="sv__downloads sv__mb">
<a
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64"
download="corrosion-companion-linux-amd64"
href="https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64"
download="corrosion-host-agent-linux-amd64"
class="sv__dl-link"
>
<Icon name="download" :size="15" />
Linux (amd64)
</a>
<a
href="https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"
download="corrosion-companion-windows-amd64.exe"
href="https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
download="corrosion-host-agent-windows-amd64.exe"
class="sv__dl-link"
>
<Icon name="download" :size="15" />
@@ -424,28 +461,28 @@ onMounted(async () => {
<!-- Linux commands -->
<div v-if="setupTab === 'linux'" class="sv__codeblock">
<p class="sv__cmt"># Download the agent</p>
<p>curl -LO https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-linux-amd64</p>
<p>chmod +x corrosion-companion-linux-amd64</p>
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p>chmod +x corrosion-host-agent-linux-amd64</p>
<p class="sv__cmt sv__mt">&#x23; Start with your license key</p>
<p>export LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
<p>export NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>./corrosion-companion-linux-amd64</p>
<p>./corrosion-host-agent-linux-amd64</p>
</div>
<!-- Windows commands -->
<div v-if="setupTab === 'windows'" class="sv__codeblock">
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
<p class="sv__cmt"># Download the agent</p>
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/companion/latest/corrosion-companion-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-companion-windows-amd64.exe"</span></p>
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
<p class="sv__cmt sv__mt">&#x23; Start with your license key</p>
<p>$env:LICENSE_ID=<span class="sv__accent">"{{ licenseKey }}"</span></p>
<p>$env:NATS_URL=<span class="sv__accent">"nats://nats.corrosionmgmt.com:4222"</span></p>
<p>.\corrosion-companion-windows-amd64.exe</p>
<p>.\corrosion-host-agent-windows-amd64.exe</p>
</div>
</Panel>
<!-- Deploy Rust Server -->
<Panel title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
<!-- Deploy Server Rust only (SteamCMD path). Other games use docker-compose or external tooling. -->
<Panel v-if="isRust" title="Deploy Rust server" subtitle="One-click: SteamCMD, download, configure, start">
<template #title-append>
<Icon name="rocket" :size="15" />
</template>
@@ -560,8 +597,28 @@ onMounted(async () => {
</div>
</Panel>
<!-- Install Oxide / uMod -->
<Panel title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
<!-- Non-Rust: Docker-managed server note -->
<Panel
v-if="isDockerManaged"
:title="profile.label + ' server deployment'"
subtitle="Managed via Docker Compose"
>
<template #title-append>
<Icon name="box" :size="15" />
</template>
<EmptyState
icon="box"
title="Docker-managed deployment"
:description="profile.label + ' servers are managed via Docker Compose. Connect the host agent on your Docker host to enable lifecycle management.'"
>
<template #action>
<Badge tone="info">Docker · Compose</Badge>
</template>
</EmptyState>
</Panel>
<!-- Install Oxide / uMod — Rust only -->
<Panel v-if="hasPluginSystem" title="Install Oxide / uMod" subtitle="Required for all plugins including CorrosionCompanion">
<template #title-append>
<Icon name="puzzle" :size="15" />
</template>
@@ -611,6 +668,79 @@ onMounted(async () => {
</div>
</Panel>
<!-- Workshop Mods info — Conan / Soulmask (Steam Workshop, no install step needed) -->
<Panel
v-else-if="profile.mods === 'workshop'"
:title="(profile.terminology.mods ?? 'Workshop Mods')"
:subtitle="profile.label + ' uses Steam Workshop — no manual install step required'"
>
<template #title-append>
<Icon name="layers" :size="15" />
</template>
<EmptyState
icon="layers"
:title="profile.label + ' mod management'"
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Corrosion install step needed.'"
/>
</Panel>
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel
v-if="profile.accent === 'conan'"
title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers"
>
<div class="sv__concept-grid">
<div class="sv__concept">
<Icon name="users" :size="16" />
<div>
<div class="sv__concept-label">Clans</div>
<div class="sv__concept-desc">Player factions. Clan management via in-game admin panel or RCON.</div>
</div>
</div>
<div class="sv__concept">
<Icon name="zap" :size="16" />
<div>
<div class="sv__concept-label">Thralls &amp; Avatars</div>
<div class="sv__concept-desc">Server-controlled NPCs and deity summons. Purge cycle managed via server settings.</div>
</div>
</div>
<div class="sv__concept">
<Icon name="shield" :size="16" />
<div>
<div class="sv__concept-label">Purge</div>
<div class="sv__concept-desc">NPC raid events targeting player bases. Enable / tune via server config.</div>
</div>
</div>
</div>
</Panel>
<!-- Soulmask clustering section -->
<Panel
v-if="profile.clustering === 'main-client'"
:title="clusterLabel"
subtitle="Main-client cluster topology for Soulmask"
>
<EmptyState
icon="network"
title="Cluster management coming soon"
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires the host agent.'"
/>
</Panel>
<!-- Dune BattleGroup / Sietches section -->
<Panel
v-if="profile.clustering === 'battlegroup'"
title="BattleGroups &amp; Sietches"
subtitle="Dune: Awakening server cluster topology"
>
<EmptyState
icon="map"
title="Sietch management requires a connected Dune host"
description="Connect the host agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
/>
</Panel>
<!-- Configuration -->
<Panel title="Configuration">
<template #actions>
@@ -708,8 +838,13 @@ onMounted(async () => {
</div>
<div class="sv__toggle-row">
<div class="sv__toggle-body">
<div class="sv__toggle-label">Auto-update on force wipe</div>
<div class="sv__toggle-sub">Update when Facepunch pushes</div>
<div class="sv__toggle-label">
<!-- Rust: "force wipe" is a Facepunch concept. Others: plain "auto-update" -->
{{ isRust ? 'Auto-update on force wipe' : 'Auto-update on patch' }}
</div>
<div class="sv__toggle-sub">
{{ isRust ? 'Update when Facepunch pushes' : 'Update when the developer pushes a patch' }}
</div>
</div>
<Switch
:model-value="server.config?.auto_update_on_force_wipe ?? false"
@@ -717,7 +852,8 @@ onMounted(async () => {
@update:model-value="toggleAutomation('auto_update_on_force_wipe')"
/>
</div>
<div class="sv__toggle-row">
<!-- Rust-only: force wipe eligibility is a Facepunch concept -->
<div v-if="isRust" class="sv__toggle-row">
<div class="sv__toggle-body">
<div class="sv__toggle-label">Force wipe eligible</div>
<div class="sv__toggle-sub">Server participates in force wipes</div>
@@ -848,4 +984,19 @@ onMounted(async () => {
.sv__toggle-row:first-child { padding-top: 0; }
.sv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.sv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
/* Management model badge in page head */
.sv__model-badge { align-self: center; }
/* Game concept cards (Conan Exiles special features) */
.sv__concept-grid { display: flex; flex-direction: column; gap: 14px; }
.sv__concept {
display: flex; align-items: flex-start; gap: 12px;
padding: 12px 14px;
background: var(--surface-raised); border-radius: var(--radius-md);
box-shadow: var(--ring-default);
color: var(--accent);
}
.sv__concept-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.sv__concept-desc { font-size: var(--text-xs); color: var(--text-tertiary); line-height: 1.5; }
</style>

View File

@@ -1,159 +0,0 @@
/**
* Dashboard mock data — representative placeholder pending multi-instance backend.
* Current backend is single-server-per-license; the fleet view is a forward-looking
* surface that will bind to a multi-instance API. All data here is static and clearly
* labeled so it is never confused for real tenant data.
*
* Per-game fields are isolated by game key — a Dune row NEVER receives a Rust field
* like `umod`, and vice-versa. See GAME_FIELDS for the row-field contract.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ServerStatus = 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
export type GameKey = 'rust' | 'dune' | 'conan' | 'soulmask'
export interface MockServer {
game: GameKey
gameIcon: string
name: string
region: string
map: string
version: string
status: ServerStatus
players: { cur: number; max: number }
cpu?: number
ram?: number
ramSub?: string
ip: string
// Rust-only
umod?: string
wipe?: string
// Dune-only
sietches?: string
control?: string
// Conan-only
clans?: string
purge?: string
// Soulmask-only
tribe?: string
mask?: string
}
export interface MockFeedLine {
time: string
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
who?: string
msg: string
}
export interface MockWipe {
game: GameKey
name: string
when: string
tone: 'wiping' | 'starting' | 'warn' | 'online'
label: string
}
export interface StatItem {
label: string
value: string | number
}
// ---------------------------------------------------------------------------
// Fleet server roster
// ---------------------------------------------------------------------------
export const MOCK_SERVERS: MockServer[] = [
{
game: 'rust', gameIcon: 'box', name: 'Main · 2x Vanilla', region: 'US-East',
map: 'Procedural 4500', version: 'v2024.12', status: 'online',
players: { cur: 142, max: 200 }, cpu: 41, ram: 68, ramSub: '5.4 / 8 GB',
ip: '89.142.0.7:28015', umod: '14', wipe: '2d',
},
{
game: 'rust', gameIcon: 'box', name: '5x Modded · Build', region: 'US-East',
map: 'Barren 3000', version: 'v2024.12', status: 'online',
players: { cur: 38, max: 100 }, ip: '89.142.0.7:28017', umod: '27', wipe: '2d',
},
{
game: 'rust', gameIcon: 'box', name: 'Hardcore · Solo/Duo', region: 'US-West',
map: 'Procedural 3500', version: 'v2024.12', status: 'wiping',
players: { cur: 0, max: 80 }, cpu: 8, ram: 30, ramSub: '2.4 / 8 GB',
ip: '74.91.3.2:28015', umod: '9', wipe: 'now',
},
{
game: 'dune', gameIcon: 'sun', name: 'Arrakis · Hardcore', region: 'EU-Frankfurt',
map: 'Hagga Basin', version: 'v0.9.4', status: 'online',
players: { cur: 54, max: 60 }, cpu: 63, ram: 74, ramSub: '11.8 / 16 GB',
ip: '51.83.12.4:7777', sietches: '3', control: '62%',
},
{
game: 'dune', gameIcon: 'sun', name: 'Deep Desert · PvP', region: 'EU-Frankfurt',
map: 'Deep Desert', version: 'v0.9.4', status: 'starting',
players: { cur: 0, max: 40 }, ip: '51.83.12.4:7779', sietches: '0', control: '—',
},
{
game: 'dune', gameIcon: 'sun', name: 'Sietch · Roleplay', region: 'SG-Singapore',
map: 'Hagga Basin', version: 'v0.9.4', status: 'offline',
players: { cur: 0, max: 50 }, ip: '139.99.4.8:7777', sietches: '5', control: '—',
},
{
game: 'conan', gameIcon: 'swords', name: 'Exiled Lands · PvP-C', region: 'US-East',
map: 'Exiled Lands', version: 'v3.0.5', status: 'online',
players: { cur: 32, max: 40 }, cpu: 48, ram: 60, ramSub: '9.6 / 16 GB',
ip: '89.142.0.7:7777', clans: '7', purge: 'Tier 4',
},
{
game: 'soulmask', gameIcon: 'drama', name: 'Sienna Plateau · PvE', region: 'EU-Frankfurt',
map: 'Sienna Plateau', version: 'v1.4', status: 'online',
players: { cur: 18, max: 30 }, cpu: 35, ram: 52, ramSub: '8.3 / 16 GB',
ip: '51.83.12.4:8777', tribe: '4', mask: 'Jaguar',
},
]
// ---------------------------------------------------------------------------
// Per-game stat field sets — never share slots across games
// ---------------------------------------------------------------------------
function pl(s: MockServer): string {
return `${s.players.cur} / ${s.players.max}`
}
export const GAME_FIELDS: Record<GameKey, (s: MockServer) => StatItem[]> = {
rust: (s) => [{ label: 'Players', value: pl(s) }, { label: 'uMod', value: s.umod ?? '—' }, { label: 'Wipe', value: s.wipe ?? '—' }],
dune: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Sietches', value: s.sietches ?? '—' }, { label: 'Control', value: s.control ?? '—' }],
conan: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Clans', value: s.clans ?? '—' }, { label: 'Purge', value: s.purge ?? '—' }],
soulmask: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Tribe', value: s.tribe ?? '—' }, { label: 'Mask', value: s.mask ?? '—' }],
}
export function buildStats(s: MockServer): StatItem[] {
const fn = GAME_FIELDS[s.game] ?? GAME_FIELDS.rust
return fn(s)
}
// ---------------------------------------------------------------------------
// Live activity feed
// ---------------------------------------------------------------------------
export const MOCK_FEED: MockFeedLine[] = [
{ time: '18:42:07', level: 'connect', who: 'ShadowFox', msg: 'connected — 89.142.0.7' },
{ time: '18:41:55', level: 'cmd', who: 'admin', msg: 'oxide.grant group default kits.use' },
{ time: '18:41:30', level: 'kill', who: 'ironMaiden', msg: 'was killed by Scorpion (AK-47, 84m)' },
{ time: '18:40:12', level: 'warn', msg: '5x Modded agent reconnected — telemetry resuming' },
{ time: '18:39:48', level: 'chat', who: 'BlightWalker:', msg: 'anyone selling sulfur?' },
{ time: '18:38:02', level: 'info', msg: 'RaidableBases spawned Tier-3 at G14' },
{ time: '18:36:51', level: 'connect', who: 'Vex', msg: 'connected — 51.83.12.4' },
]
// ---------------------------------------------------------------------------
// Upcoming wipes
// ---------------------------------------------------------------------------
export const MOCK_WIPES: MockWipe[] = [
{ game: 'rust', name: 'Main · 2x Vanilla', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map + BP' },
{ game: 'rust', name: '5x Modded · Build', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map only' },
{ game: 'dune', name: 'Deep Desert · PvP', when: 'Sun · 12:00 UTC', tone: 'starting', label: 'Deep Desert' },
]

View File

@@ -191,6 +191,8 @@ function handleBackToLogin() {
<p v-if="!showTotpInput" class="auth-footer">
No account?
<router-link to="/register" class="auth-footer__link">Create one</router-link>
·
<router-link to="/forgot-password" class="auth-footer__link">Forgot password?</router-link>
</p>
</div>
</div>

View File

@@ -35,7 +35,7 @@ function syncPorts() {
}
const connectionTypes = [
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Companion Agent' },
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Corrosion host agent' },
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
]
@@ -183,7 +183,7 @@ async function completeSetup() {
</form>
</div>
<!-- Step 2: Companion agent install -->
<!-- Step 2: Corrosion host agent install -->
<div v-if="step === 2" class="setup-card">
<div class="setup-card__head setup-card__head--center">
<div class="setup-icon">
@@ -191,19 +191,22 @@ async function completeSetup() {
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
</svg>
</div>
<h1 class="setup-card__title">Install the Companion Agent</h1>
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
</div>
<div class="setup-code">
<p class="setup-code__comment"># Download and install the Companion Agent</p>
<p class="setup-code__cmd">curl -sSL https://get.corrosionmgmt.com | sh</p>
<p class="setup-code__comment setup-code__comment--mt"># Start the agent with your license key</p>
<p class="setup-code__cmd">corrosion-agent start --key {{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}</p>
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
<p class="setup-code__cmd">export LICENSE_ID="{{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}"</p>
<p class="setup-code__cmd">export NATS_URL="nats://nats.corrosionmgmt.com:4222"</p>
<p class="setup-code__cmd">./corrosion-host-agent-linux-amd64</p>
</div>
<p class="setup-hint">
The agent auto-registers with your panel. You can also use the uMod plugin for lightweight integration.
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
</p>
<div class="setup-actions">
@@ -235,7 +238,7 @@ async function completeSetup() {
</svg>
</div>
<h1 class="setup-card__title">You're all set</h1>
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your Rust server.</p>
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your game server.</p>
<Button
type="button"
:loading="isLoading"

View File

@@ -1,16 +1,39 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
/**
* EarlyAccess signup page.
*
* Backend endpoint: POST /api/early-access
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
* migration adding a game_interest column.
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
// ---------- Email capture ----------
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// Form state
const email = ref('')
const serverCount = ref('')
const name = ref('')
const gameInterest = ref('')
const submitting = ref(false)
const submitted = ref(false)
const errorMsg = ref('')
async function handleSubmit() {
if (!email.value || !serverCount.value) return
const GAME_OPTIONS = [
{ value: 'rust', label: 'Rust' },
{ value: 'dune', label: 'Dune: Awakening' },
{ value: 'conan', label: 'Conan Exiles' },
{ value: 'soulmask', label: 'Soulmask' },
{ value: 'multiple', label: 'Multiple games' },
]
async function handleSubmit(): Promise<void> {
if (!email.value) return
errorMsg.value = ''
submitting.value = true
try {
@@ -19,12 +42,13 @@ async function handleSubmit() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.value,
server_count: serverCount.value,
// server_count column stores game interest (varchar 10) — no dedicated name column in DB
server_count: gameInterest.value || 'not specified',
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ message: 'Something went wrong' }))
throw new Error(data.message || `HTTP ${res.status}`)
throw new Error((data as { message?: string }).message ?? `HTTP ${res.status}`)
}
submitted.value = true
} catch (err: unknown) {
@@ -34,291 +58,393 @@ async function handleSubmit() {
}
}
// ---------- Demo panels ----------
const panels = [
{ label: 'Dashboard', icon: LayoutDashboard, desc: 'Server overview, player count, uptime, and alerts at a glance.' },
{ label: 'Wipe Scheduler', icon: RefreshCw, desc: 'Visual wipe timeline with pre-wipe backup, map rotation, and health verification.' },
{ label: 'Plugin Config', icon: Zap, desc: 'Edit plugin settings from your browser. No JSON. No SFTP.' },
{ label: 'Player Management', icon: Users, desc: 'Online players, session tracking, kick/ban controls, and playtime history.' },
{ label: 'Console', icon: Terminal, desc: 'Real-time RCON console with timestamped, color-coded output.' },
]
// Scroll-reveal
let io: IntersectionObserver | null = null
// ---------- Roadmap voting ----------
interface VoteItem {
id: string
label: string
votes: number
voted: boolean
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
const voteItems = ref<VoteItem[]>([
{ id: 'analytics', label: 'Analytics & Retention Insights', votes: 47, voted: false },
{ id: 'webstore', label: 'Integrated Webstore', votes: 38, voted: false },
{ id: 'modules', label: 'Module Marketplace', votes: 31, voted: false },
{ id: 'discord', label: 'Discord Bot Integration', votes: 28, voted: false },
{ id: 'hosting', label: 'Hosting Provider API', votes: 19, voted: false },
])
function vote(item: VoteItem) {
if (item.voted) return
item.votes++
item.voted = true
}
const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.votes, 0))
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
<span class="inline-block px-4 py-1.5 bg-green-500/10 border border-green-500/20 rounded-full text-green-400 text-sm font-medium mb-6">
Early Access Is Now Open
</span>
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4 tracking-tight">
Wipe Night Just Got<br />
<span class="text-oxide-500">A Lot Easier.</span>
</h1>
<p class="text-lg text-neutral-400 max-w-xl mx-auto mb-10">
Corrosion is live in limited early access. Install once. Automate everything. Never SSH again.
</p>
<div class="flex items-center justify-center gap-4">
<a href="#join" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors">
Claim Your Spot
</a>
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors">
View Demo Architecture
</a>
</div>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/8 rounded-full blur-3xl pointer-events-none" />
</section>
<span class="eyebrow">Early access</span>
<h1 style="font-size:var(--text-5xl)">
Take control of your servers.
<span class="accent">Starting now.</span>
</h1>
<p class="hero__sub">
Corrosion is in early access. Join the list to be notified when your access opens.
No spam. No fabricated scarcity.
</p>
</div>
</section>
<!-- Early Access Live Banner -->
<section class="py-12 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<div class="inline-flex items-center gap-3 px-6 py-4 bg-green-500/10 border border-green-500/20 rounded-2xl">
<div class="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shrink-0" />
<p class="text-green-300 font-semibold text-lg">Early Access is now live founding admin spots are limited.</p>
</div>
<p class="text-neutral-500 text-sm mt-4">
Sign up below to lock in founding pricing before spots run out.
<!-- WHAT YOU GET -->
<section class="sec" id="access">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">What early access means</span>
<h2 class="title">Real access to a real platform.</h2>
<p class="lead">
Early access is not a waitlist gimmick. It is how we manage onboarding while the
platform stabilizes. You get the full Corrosion control plane one tier at a time
as capacity opens.
</p>
</div>
</section>
<!-- What Early Access Means -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">What Early Access Means</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Shield class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Limited Founding Licenses</p>
<p class="text-xs text-neutral-500 mt-1">2550 spots</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<MessageCircle class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Direct Founder Discord</p>
<p class="text-xs text-neutral-500 mt-1">Private channel access</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Star class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Influence the Roadmap</p>
<p class="text-xs text-neutral-500 mt-1">Vote on features</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Clock class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Lifetime Pricing Lock</p>
<p class="text-xs text-neutral-500 mt-1">Never pay more</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Full control plane</b>
<p>Agent, panel, wipes, console, plugins, schedules all of it. Not a trimmed preview.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
<b>Pricing you can lock in</b>
<p>Early access pricing is the live pricing. No bait-and-switch after launch.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Direct feedback channel</b>
<p>Early access operators have a direct line for platform bug reports and feature input.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="box" :size="16" /></div>
<b>Rust-first</b>
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="users" :size="16" /></div>
<b>RBAC team access</b>
<p>Add your admin team from day one. Fine-grained permission roles are built in.</p>
</div>
</div>
</section>
</div>
</section>
<!-- Email Capture -->
<section id="join" class="py-16 border-t border-neutral-800">
<div class="max-w-md mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Claim Your Founding Spot</h2>
<p class="text-neutral-400 text-center mb-8">Early access is open now. Spots are limited lock in founding pricing today.</p>
<div v-if="submitted" class="bg-green-500/10 border border-green-500/20 rounded-xl p-8 text-center">
<Check class="w-10 h-10 text-green-400 mx-auto mb-3" />
<h3 class="text-lg font-semibold text-neutral-100 mb-1">You're in.</h3>
<p class="text-sm text-neutral-400">We'll be in touch shortly with your access details.</p>
<!-- SIGNUP FORM -->
<section class="sec" id="join">
<div class="wrap">
<div class="ea-form-wrap reveal">
<!-- Success state -->
<div v-if="submitted" class="ea-success">
<div class="ea-success__ic">
<Icon name="check" :size="28" />
</div>
<h2 class="ea-success__title">You are on the list.</h2>
<p class="ea-success__body">
We will reach out when your access slot opens. In the meantime, read the
<RouterLink :to="{ name: 'how-it-works' }" class="ea-link">how it works</RouterLink>
guide or review the
<RouterLink :to="{ name: 'faq' }" class="ea-link">FAQ</RouterLink>.
</p>
<RouterLink class="btn btn--ghost" :to="{ name: 'landing' }">
Back to home
</RouterLink>
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
<!-- Form state -->
<form v-else @submit.prevent="handleSubmit" class="ea-form">
<div class="ea-form__head">
<h2>Join the early access list</h2>
<p>Required: email address. Everything else is optional but helps us prioritise.</p>
</div>
<!-- Error banner -->
<div v-if="errorMsg" class="ea-error">
<Icon name="triangle-alert" :size="15" />
{{ errorMsg }}
</div>
<div>
<label for="ea-email" class="block text-sm font-medium text-neutral-400 mb-1.5">Email</label>
<!-- Email (required) -->
<div class="ea-field">
<label class="ea-field__label" for="ea-email">
Email address <span class="ea-field__req">*</span>
</label>
<input
id="ea-email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="admin@example.com"
class="w-full px-3 py-2.5 bg-neutral-900 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
class="ea-input"
/>
</div>
<div>
<label for="ea-servers" class="block text-sm font-medium text-neutral-400 mb-1.5">How many servers do you run?</label>
<div class="grid grid-cols-3 gap-3">
<!-- Name (optional) -->
<div class="ea-field">
<label class="ea-field__label" for="ea-name">
Your name <span class="ea-field__optional">(optional)</span>
</label>
<input
id="ea-name"
v-model="name"
type="text"
autocomplete="name"
placeholder="Server admin name or handle"
class="ea-input"
/>
</div>
<!-- Game interest (optional) -->
<div class="ea-field">
<label class="ea-field__label">
Primary game interest <span class="ea-field__optional">(optional)</span>
</label>
<div class="ea-pills">
<button
v-for="option in ['1', '2-3', '4+']"
:key="option"
v-for="opt in GAME_OPTIONS"
:key="opt.value"
type="button"
@click="serverCount = option"
class="py-2.5 text-sm font-medium rounded-lg border transition-colors"
:class="serverCount === option
? 'bg-oxide-500/15 border-oxide-500/40 text-oxide-400'
: 'bg-neutral-900 border-neutral-700 text-neutral-400 hover:border-neutral-600'"
class="ea-pill"
:class="{ 'ea-pill--on': gameInterest === opt.value }"
@click="gameInterest = gameInterest === opt.value ? '' : opt.value"
>
{{ option }}
{{ opt.label }}
</button>
</div>
</div>
<button
type="submit"
:disabled="submitting || !email || !serverCount"
class="w-full py-3 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
class="btn btn--primary btn--lg ea-submit"
:disabled="submitting || !email"
>
{{ submitting ? 'Submitting...' : 'Join Early Access' }}
<Icon v-if="submitting" name="loader" :size="16" />
<Icon v-else name="send" :size="16" />
{{ submitting ? 'Submitting…' : 'Join early access' }}
</button>
<p class="ea-privacy">
We store your email to contact you when access opens. We do not sell or share it.
No newsletters unless you opt in separately.
</p>
</form>
</div>
</section>
</div>
</section>
<!-- Founding Admin Program -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<span class="inline-block px-3 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-full text-oxide-400 text-xs font-semibold uppercase tracking-wider mb-4">
Limited to 25 Servers
</span>
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Founding Admin Program</h2>
<p class="text-neutral-400 mb-8">
The first 25 servers to run Corrosion receive:
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Star class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Founding Admin Role</p>
<p class="text-xs text-neutral-500 mt-1">Discord badge</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Clock class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Permanent Early Pricing</p>
<p class="text-xs text-neutral-500 mt-1">Locked forever</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Users class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Public Recognition</p>
<p class="text-xs text-neutral-500 mt-1">Featured server</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<MessageCircle class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Direct Feature Input</p>
<p class="text-xs text-neutral-500 mt-1">Private channel</p>
</div>
<!-- HOW IT WORKS TEASER -->
<section class="sec" id="teaser">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">How it works</span>
<h2 class="title">Install the agent. Never SSH again.</h2>
</div>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the host agent</b>
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
</div>
<div class="step">
<div class="step__n">2</div>
<b>Agent connects to Corrosion</b>
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
</div>
<div class="step">
<div class="step__n">3</div>
<b>Manage from the browser</b>
<p>Console, wipes, plugins, schedules, file manager, player management all at panel.corrosionmgmt.com.</p>
</div>
</div>
</section>
<!-- Demo Dashboard Preview -->
<section id="demo" class="py-16 border-t border-neutral-800">
<div class="max-w-5xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Preview of Corrosion 1.0 Dashboard</h2>
<p class="text-neutral-500 text-center mb-10">Screenshots will replace these frames at launch.</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="panel in panels"
:key="panel.label"
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-neutral-700 transition-colors"
>
<div class="h-36 bg-neutral-800/50 flex items-center justify-center border-b border-neutral-800">
<component :is="panel.icon" class="w-10 h-10 text-neutral-700" />
</div>
<div class="p-4">
<h3 class="text-sm font-semibold text-neutral-200 mb-1">{{ panel.label }}</h3>
<p class="text-xs text-neutral-500 leading-relaxed">{{ panel.desc }}</p>
</div>
</div>
</div>
<div class="closing reveal">
<RouterLink :to="{ name: 'how-it-works' }" class="btn btn--ghost btn--lg">
<Icon name="chevron-right" :size="17" />Read the full walkthrough
</RouterLink>
</div>
</section>
</div>
</section>
<!-- Roadmap Voting -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-2xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">What Should We Build Next?</h2>
<p class="text-neutral-500 text-center mb-8">Vote on the features that matter most to you.</p>
<div class="space-y-3">
<button
v-for="item in voteItems"
:key="item.id"
@click="vote(item)"
class="w-full flex items-center gap-4 p-4 bg-neutral-900 border rounded-xl transition-colors text-left"
:class="item.voted ? 'border-oxide-500/30' : 'border-neutral-800 hover:border-neutral-700'"
>
<div class="flex-1">
<p class="text-sm font-medium" :class="item.voted ? 'text-oxide-400' : 'text-neutral-200'">{{ item.label }}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div class="w-24 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="item.voted ? 'bg-oxide-500' : 'bg-neutral-600'"
:style="{ width: `${totalVotes ? (item.votes / totalVotes) * 100 : 0}%` }"
/>
</div>
<span class="text-xs font-medium tabular-nums w-8 text-right" :class="item.voted ? 'text-oxide-400' : 'text-neutral-500'">
{{ item.votes }}
</span>
</div>
</button>
</div>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Ready to stop babysitting<br>your servers?</h2>
<div class="cta-row">
<a href="#join" class="btn btn--primary btn--lg">
Sign up above
</a>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</section>
<!-- Timeline -->
<section class="py-16 border-t border-neutral-800">
<div class="max-w-2xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">Launch Timeline</h2>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<Check class="w-4 h-4 text-green-400" />
</div>
<div>
<p class="text-sm font-medium text-neutral-200">Week 1 Closed Beta Stabilization</p>
<p class="text-xs text-neutral-500 mt-0.5">Core platform hardening and testing.</p>
</div>
</div>
<div class="ml-4 w-px h-4 bg-neutral-800" />
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<Check class="w-4 h-4 text-green-400" />
</div>
<div>
<p class="text-sm font-medium text-neutral-200">Week 2 Early Access Open</p>
<p class="text-xs text-neutral-500 mt-0.5">Founding Admin licenses are live claim yours now.</p>
</div>
</div>
<div class="ml-4 w-px h-4 bg-neutral-800" />
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-neutral-800 border border-neutral-700 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<ChevronRight class="w-4 h-4 text-neutral-500" />
</div>
<div>
<p class="text-sm font-medium text-neutral-400">Public Release</p>
<p class="text-xs text-neutral-500 mt-0.5">Shortly after early access stabilization.</p>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
</template>
<style scoped>
/* Form wrapper */
.ea-form-wrap {
max-width: 520px;
margin: 0 auto;
}
/* Success state */
.ea-success {
text-align: center;
padding: 48px 32px;
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.ea-success__ic {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
margin: 0 auto 18px;
}
.ea-success__title {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-2xl);
margin: 0 0 10px;
}
.ea-success__body {
color: var(--text-tertiary);
font-size: var(--text-sm);
line-height: 1.6;
margin: 0 0 24px;
}
.ea-link {
color: var(--accent-text);
text-decoration: none;
font-weight: 600;
}
.ea-link:hover { text-decoration: underline; }
/* Form */
.ea-form {
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--ring-default);
padding: 36px 32px;
display: flex;
flex-direction: column;
gap: 22px;
}
.ea-form__head h2 {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-2xl);
margin: 0 0 6px;
}
.ea-form__head p {
color: var(--text-tertiary);
font-size: var(--text-sm);
margin: 0;
}
/* Error banner */
.ea-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: color-mix(in srgb, var(--status-offline) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-offline) 25%, transparent);
border-radius: var(--radius-md);
color: var(--status-offline);
font-size: var(--text-sm);
}
/* Fields */
.ea-field { display: flex; flex-direction: column; gap: 7px; }
.ea-field__label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-secondary);
}
.ea-field__req { color: var(--status-offline); }
.ea-field__optional { color: var(--text-muted); font-weight: 400; }
.ea-input {
height: 42px;
padding: 0 13px;
background: var(--surface-inset);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-sm);
font-family: var(--font-sans);
transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
outline: none;
}
.ea-input::placeholder { color: var(--text-muted); }
.ea-input:focus {
border-color: var(--accent-border);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Game pills */
.ea-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ea-pill {
height: 34px;
padding: 0 14px;
border-radius: var(--radius-pill);
background: var(--surface-raised);
box-shadow: var(--ring-default);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
border: none;
transition: var(--transition-colors);
}
.ea-pill--on {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
/* Submit */
.ea-submit {
width: 100%;
justify-content: center;
}
.ea-submit:disabled { opacity: 0.55; cursor: not-allowed; }
/* Privacy note */
.ea-privacy {
font-size: var(--text-xs);
color: var(--text-muted);
text-align: center;
line-height: 1.5;
margin: 0;
}
</style>

View File

@@ -1,101 +1,353 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
interface FaqItem {
question: string
answer: string
}
const faqs: FaqItem[] = [
interface FaqGroup {
label: string
icon: string
items: FaqItem[]
}
const groups: FaqGroup[] = [
{
question: 'Do I need to open any firewall ports?',
answer: 'No. All connections are outbound from your server to Corrosion\'s cloud. No inbound ports required.',
label: 'Support',
icon: 'help-circle',
items: [
{
question: 'Do you provide direct support?',
answer:
'Corrosion is a self-service tool. Every plan includes documentation, community forum access, diagnostics, and structured platform bug reports. We do not provide 1:1 setup assistance, Discord DMs, video calls, server administration, hosting-provider troubleshooting, firewall configuration, mod installation, or emergency wipe-day support.',
},
{
question: 'What if Corrosion itself is broken?',
answer:
'Platform bugs and agent issues go through structured bug reports in the panel. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
},
{
question: 'Do you manage my server for me?',
answer:
'No. Corrosion provides the panel, the agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
},
{
question: 'Is hands-on help available?',
answer:
'Yes — separately. Direct 1:1 support is available at $125/hour, prepaid in 1-hour blocks. This is billed time with a human, not a support tier. It is available to any customer who needs it.',
},
{
question: 'What does community support include?',
answer:
'Documentation (setup guides, architecture reference, troubleshooting walkthroughs), a community forum for operator-to-operator knowledge sharing, in-panel diagnostics (agent health, log access), and a structured bug report system for platform issues.',
},
],
},
{
question: 'Does Corrosion replace my hosting panel (AMP / Pterodactyl)?',
answer: 'No. Corrosion integrates with them via API or companion agent. Your existing panel remains intact.',
label: 'Product',
icon: 'server',
items: [
{
question: 'Do I need my own server?',
answer:
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, the agent, and the panel.',
},
{
question: 'Does Corrosion host my game server for me?',
answer:
'No. Corrosion is not a hosting provider. It is a management layer that runs on top of a server you already own or rent. If you need hosting, you need a separate hosting provider.',
},
{
question: 'Do I need to open inbound firewall ports for Corrosion?',
answer:
'No. The host agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
},
{
question: 'Does Corrosion replace AMP or Pterodactyl?',
answer:
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.',
},
{
question: 'What happens if Corrosion goes offline?',
answer:
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If the panel or cloud is unreachable, your players are unaffected.',
},
{
question: 'Can multiple admins manage the same server?',
answer:
'Yes. Role-based access control (RBAC) is built in. You can grant team members specific permissions — from full admin access down to read-only viewer — without sharing credentials.',
},
{
question: 'What OS does the agent run on?',
answer:
'Both Windows and Linux are supported for the host agent. The agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
},
{
question: 'Is my data isolated from other customers?',
answer:
'Yes. All data is scoped by license ID at the database level. No server, config, or player data is shared across tenant boundaries.',
},
],
},
{
question: 'What happens if Corrosion goes offline?',
answer: 'Your Rust server continues running normally. Corrosion does not proxy gameplay traffic.',
label: 'Games',
icon: 'box',
items: [
{
question: 'Which games are supported?',
answer:
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built blueprint — not a generic template — that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
},
{
question: 'Does Corrosion support Rust plugin management?',
answer:
'Yes. Corrosion integrates with uMod (Oxide) for Rust. You can browse the plugin registry, install plugins, manage configuration profiles, and push config changes to the server — all from the browser.',
},
{
question: 'Can I run multiple game types on the same host machine?',
answer:
'Yes. A single host agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
},
{
question: 'Does Corrosion handle Rust wipes?',
answer:
'Yes. Rust wipes are a first-class feature: map wipes, blueprint wipes, and full wipes. Wipes run as verified, logged sequences — pre-warning, backup, stop, update, map rotation, restart, health check, announce. Rollback is available when supported.',
},
],
},
{
question: 'Is my data shared with other servers?',
answer: 'No. All data is isolated by license ID. Multi-tenant database queries are scoped per license.',
},
{
question: 'What if a wipe fails?',
answer: 'Corrosion can automatically retry and optionally roll back using the pre-wipe backup.',
},
{
question: 'Does this work on bare metal?',
answer: 'Yes. Use the Companion Agent — no SSH required after initial setup.',
},
{
question: 'Can I manage multiple admins?',
answer: 'Yes. Multi-Admin Role-Based Access Control is built in. Grant granular permissions per team member.',
},
{
question: 'Is this beginner friendly?',
answer: 'Yes. If you can install a uMod plugin, you can use Corrosion.',
},
{
question: 'Does this replace Tebex?',
answer: 'Corrosion includes an optional integrated store (Phase 5 roadmap), but does not require Tebex.',
},
{
question: 'How is licensing handled?',
answer: 'One license per server. License validation occurs on plugin startup and periodically.',
label: 'Billing',
icon: 'credit-card',
items: [
{
question: 'What counts as commercial use?',
answer:
'Commercial use includes monetized communities, paid access, VIP slots, donation-funded servers, sponsorship-supported servers, hosting providers, or managing servers for others. Hobby and Community plans are non-commercial only. Operator and Network plans permit commercial use.',
},
{
question: 'What is the Fleet Block on the Network plan?',
answer:
'The Network plan base includes 50 server instances at $99.99/mo. Each additional Fleet Block adds 50 more server slots at $49.99/mo. Stack as many Fleet Blocks as your operation requires.',
},
{
question: 'Can I upgrade my plan?',
answer:
'Yes. You can upgrade at any time. Pricing is prorated from the upgrade date.',
},
{
question: 'Is there a free trial?',
answer:
'Corrosion is currently in early access. Join the early access list to be notified when access opens.',
},
{
question: 'Are there annual billing discounts?',
answer:
'Not at this time. All plans are billed monthly.',
},
],
},
]
const openIndex = ref<number | null>(null)
const openKey = ref<string | null>(null)
function toggle(index: number) {
openIndex.value = openIndex.value === index ? null : index
function toggle(key: string): void {
openKey.value = openKey.value === key ? null : key
}
function itemKey(groupLabel: string, idx: number): string {
return `${groupLabel}-${idx}`
}
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Frequently Asked Questions</h1>
<p class="text-lg text-neutral-400">Everything you need to know about Corrosion.</p>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
</section>
<span class="eyebrow">FAQ</span>
<h1 style="font-size:var(--text-5xl)">
Honest answers.
<span class="accent">No marketing fluff.</span>
</h1>
<p class="hero__sub">
Common questions about support, the product, supported games, and billing answered
plainly.
</p>
</div>
</section>
<!-- FAQ Accordion -->
<section class="pb-20">
<div class="max-w-3xl mx-auto px-6">
<div class="space-y-3">
<!-- FAQ GROUPS -->
<section class="sec" id="faq">
<div class="wrap">
<div
v-for="group in groups"
:key="group.label"
class="faq-group reveal"
>
<div class="faq-group__head">
<span class="faq-group__ic">
<Icon :name="group.icon" :size="16" />
</span>
<span class="eyebrow">{{ group.label }}</span>
</div>
<div class="faq-list">
<div
v-for="(faq, index) in faqs"
:key="index"
class="bg-neutral-900 border rounded-xl overflow-hidden transition-colors"
:class="openIndex === index ? 'border-oxide-500/30' : 'border-neutral-800'"
v-for="(item, idx) in group.items"
:key="idx"
class="faq-item"
:class="{ 'faq-item--open': openKey === itemKey(group.label, idx) }"
>
<button
@click="toggle(index)"
class="w-full flex items-center justify-between p-6 text-left"
class="faq-item__q"
@click="toggle(itemKey(group.label, idx))"
>
<span class="text-neutral-100 font-medium pr-4">{{ faq.question }}</span>
<ChevronDown
class="w-5 h-5 text-neutral-500 shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': openIndex === index }"
/>
<span>{{ item.question }}</span>
<span class="faq-item__chevron">
<Icon
name="chevron-down"
:size="16"
:class="{ 'faq-item__chevron--open': openKey === itemKey(group.label, idx) }"
/>
</span>
</button>
<div
v-if="openIndex === index"
class="px-6 pb-6 -mt-2"
v-if="openKey === itemKey(group.label, idx)"
class="faq-item__a"
>
<p class="text-neutral-400 leading-relaxed">{{ faq.answer }}</p>
{{ item.answer }}
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
<!-- SUPPORT CTA -->
<section class="sec" id="support-cta" style="border-bottom:none">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Still have questions?</span>
<h2 class="title">Check the docs or join the community</h2>
<p class="lead">
The documentation covers setup, architecture, troubleshooting, and every supported
game. The community forum is where operators share configs, ask questions, and help
each other.
</p>
</div>
<div class="hero__cta reveal">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<RouterLink class="btn btn--ghost btn--lg" :to="{ name: 'pricing' }">
<Icon name="credit-card" :size="17" />View pricing
</RouterLink>
</div>
</div>
</section>
</template>
<style scoped>
.faq-group {
margin-bottom: 48px;
}
.faq-group__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.faq-group__ic {
width: 28px;
height: 28px;
flex: none;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.faq-list {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 860px;
margin: 0 auto;
}
.faq-item {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
overflow: hidden;
transition: box-shadow var(--dur-fast);
}
.faq-item--open {
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.faq-item__q {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
transition: color var(--dur-fast);
}
.faq-item__q:hover { color: var(--accent-text); }
.faq-item__chevron {
flex: none;
color: var(--text-muted);
transition: color var(--dur-fast);
}
.faq-item__chevron--open {
transform: rotate(180deg);
color: var(--accent-text);
}
.faq-item__a {
padding: 0 20px 18px;
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: 1.65;
}
</style>

View File

@@ -1,150 +1,358 @@
<script setup lang="ts">
import { Download, Globe, Wifi, LayoutDashboard, ArrowDown } from 'lucide-vue-next'
import { onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">How Corrosion Works</h1>
<p class="text-lg text-neutral-400">
Corrosion connects your Rust server to a hosted control plane securely, outbound-only.
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
<span class="eyebrow">How it works</span>
<h1 style="font-size:var(--text-5xl)">
One agent.
<span class="accent">Every game. No SSH.</span>
</h1>
<p class="hero__sub">
Install the host agent once on your Windows or Linux machine. Corrosion connects
securely, outbound-only. You manage every game instance from the browser.
</p>
</div>
</section>
<!-- THE MODEL: 3-STEP OVERVIEW -->
<section class="sec" id="model">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">The agent model</span>
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
<p class="lead">
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
agent runs on that machine and bridges your game instances to Corrosion's control
plane — securely, without opening inbound firewall ports.
</p>
</div>
</section>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the host agent</b>
<p>
Download the Corrosion agent binary from your dashboard. Run it on any Windows
or Linux host. One agent per machine — it manages every game instance you assign
to it.
</p>
</div>
<div class="step">
<div class="step__n">2</div>
<b>It connects to Corrosion</b>
<p>
The agent makes a single outbound NATS connection to Corrosion's cloud. No
inbound ports. No open panels. No SSH required after initial setup.
</p>
</div>
<div class="step">
<div class="step__n">3</div>
<b>Deploy and manage from the browser</b>
<p>
Create game instances, run wipes, manage plugins, schedule maintenance, and
monitor players all from the Corrosion panel at panel.corrosionmgmt.com.
</p>
</div>
</div>
<div class="nots reveal">
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No open inbound ports</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No permanent SSH sessions</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No config file spelunking</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />Windows and Linux supported</span>
</div>
</div>
</section>
<!-- Steps -->
<section class="pb-20">
<div class="max-w-3xl mx-auto px-6">
<div class="space-y-2">
<!-- Step 1 -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
<Download class="w-6 h-6 text-oxide-500" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 1</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Install the Plugin</h3>
<p class="text-neutral-400">
Drop the Corrosion plugin into <code class="px-2 py-0.5 bg-neutral-800 rounded text-oxide-300 text-sm">oxide/plugins</code>.
That's it. No dependencies, no config files to create.
</p>
</div>
<!-- MULTI-GAME RUNTIME -->
<section class="sec" id="multi-game">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Multi-game host runtime</span>
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
<p class="lead">
The host agent is not a per-game process. It is a general-purpose ops runtime. One
agent on a single machine can supervise multiple game server processes across
different games each with its own configuration, lifecycle, and wipe schedule.
</p>
</div>
<div class="blueprints reveal">
<div class="bp" data-game="rust">
<div class="bp__head">
<span class="bp__ic"><Icon name="box" :size="21" /></span>
<div>
<div class="bp__name">Rust</div>
<div class="bp__accent">Oxide Orange</div>
</div>
</div>
<div class="flex justify-center py-1">
<ArrowDown class="w-5 h-5 text-neutral-700" />
<div class="bp__role">uMod / Oxide plugin ecosystem</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Map / BP / full wipe scheduling</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Plugin config profiles from the browser</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Wipe-day backup before every change</div>
</div>
<!-- Step 2 -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
<Globe class="w-6 h-6 text-oxide-500" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 2</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Register Online</h3>
<p class="text-neutral-400">
Activate your license and configure your server. Set your hostname, game port, and admin preferences.
</p>
</div>
</div>
<div class="bp" data-game="dune">
<div class="bp__head">
<span class="bp__ic"><Icon name="sun" :size="21" /></span>
<div>
<div class="bp__name">Dune: Awakening</div>
<div class="bp__accent">Spice Amber</div>
</div>
</div>
<div class="flex justify-center py-1">
<ArrowDown class="w-5 h-5 text-neutral-700" />
<div class="bp__role">Battlegroup orchestration</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Deep Desert wipe scheduling</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Procedural map regeneration</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Battlegroup lifecycle controls</div>
</div>
<!-- Step 3 -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/10 border border-oxide-500/20 rounded-xl flex items-center justify-center shrink-0">
<Wifi class="w-6 h-6 text-oxide-500" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 3</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Secure Outbound Connection</h3>
<p class="text-neutral-400">
Your server connects securely to Corrosion's NATS cluster. No inbound firewall rules required. Your server initiates all connections.
</p>
</div>
</div>
<div class="bp" data-game="soulmask">
<div class="bp__head">
<span class="bp__ic"><Icon name="drama" :size="21" /></span>
<div>
<div class="bp__name">Soulmask</div>
<div class="bp__accent">Ritual Jade</div>
</div>
</div>
<div class="flex justify-center py-1">
<ArrowDown class="w-5 h-5 text-neutral-700" />
<div class="bp__role">Linked-world cluster deployment</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />One-click linked-server clusters</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Port and config automation</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Cluster health monitoring</div>
</div>
</div>
<div class="bp" data-game="conan">
<div class="bp__head">
<span class="bp__ic"><Icon name="swords" :size="21" /></span>
<div>
<div class="bp__name">Conan Exiles</div>
<div class="bp__accent">Hyborian Bronze</div>
</div>
</div>
<div class="bp__role">Persistent world management</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Mod management + world backups</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Purge, decay, and event tracking</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Scheduled maintenance workflows</div>
</div>
</div>
</div>
</div>
</section>
<!-- Step 4 -->
<div class="bg-neutral-900 border border-oxide-500/30 rounded-xl p-8">
<div class="flex items-start gap-6">
<div class="w-12 h-12 bg-oxide-500/15 border border-oxide-500/30 rounded-xl flex items-center justify-center shrink-0">
<LayoutDashboard class="w-6 h-6 text-oxide-400" />
</div>
<div>
<div class="flex items-center gap-3 mb-2">
<span class="text-xs font-bold text-oxide-400 uppercase tracking-wider">Step 4</span>
</div>
<h3 class="text-xl font-bold text-neutral-100 mb-2">Full Orchestration</h3>
<p class="text-neutral-400 mb-4">From the dashboard, you can:</p>
<ul class="space-y-2">
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Execute console commands
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Configure plugins
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Schedule wipes
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Monitor performance
</li>
<li class="text-neutral-300 text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-oxide-500 rounded-full shrink-0" />
Automate Steam updates
</li>
</ul>
</div>
<!-- WIPE AND MAINTENANCE AUTOMATION -->
<section class="sec" id="automation">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Wipe and maintenance automation</span>
<h2 class="title">Self-service workflows,<br>not manual processes</h2>
<p class="lead">
Every wipe, update, and maintenance window runs as a verified, logged sequence.
Pre-warning announcements, pre-wipe backups, health checks after restart, and
rollback capability when things go wrong.
</p>
</div>
<div class="pipe reveal">
<span class="pchip">Pre-warning</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Backup</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Stop services</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Update / rotate</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Restart</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Health check</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip pchip--last">Announce complete</span>
</div>
<div class="stack-lines reveal">
<span>Every operation is logged. Every step is verified.</span>
<span class="hi">Rollback is one click away when supported.</span>
</div>
</div>
</section>
<!-- ARCHITECTURE: HOW CONNECTIVITY WORKS -->
<section class="sec" id="connectivity">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Connectivity model</span>
<h2 class="title">Outbound-only. No exposed panel.</h2>
<p class="lead">
The host agent establishes one secure NATS connection to Corrosion's cloud. All
commands flow through that channel. Your machine never needs to accept inbound
connections from the internet.
</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="server" :size="16" /></div>
<b>Your host machine</b>
<p>Windows or Linux. Bare metal, VPS, or dedicated. You own it and run it.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Corrosion agent</b>
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
<b>Outbound NATS channel</b>
<p>One secure, outbound-only connection. No open ports. No SSH tunnels.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
<b>Corrosion cloud</b>
<p>Hosted control plane. Multi-tenant isolation. Every command is license-scoped.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
<b>Your browser</b>
<p>The panel at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
</div>
</div>
<div class="techrow reveal">
<span>Go host agent</span>
<span>NATS JetStream</span>
<span>NestJS API</span>
<span>PostgreSQL</span>
<span>Outbound-only connectivity</span>
</div>
</div>
</section>
<!-- HOST REQUIREMENTS -->
<section class="sec" id="requirements">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Host requirements</span>
<h2 class="title">What you need to get started</h2>
</div>
<div class="caps reveal" style="max-width:760px">
<div class="caps__col">
<span class="eyebrow">Your machine</span>
<div class="feat">
<span class="feat__ic"><Icon name="server" :size="16" /></span>
<div>
<b>Windows or Linux host</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
VPS, dedicated server, or bare metal. You supply it; Corrosion manages it.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="hard-drive" :size="16" /></span>
<div>
<b>Enough CPU and RAM for your game</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Corrosion's agent is lightweight. Your game server determines the actual
hardware requirement.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="wifi" :size="16" /></span>
<div>
<b>Outbound internet access</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
The agent connects out; your game server's player ports stay open as they
always have been.
</p>
</div>
</div>
</div>
<div class="caps__col">
<span class="eyebrow">From Corrosion</span>
<div class="feat">
<span class="feat__ic"><Icon name="download" :size="16" /></span>
<div>
<b>Agent binary (Windows or Linux)</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Downloaded from your dashboard. No manual build. No dependency management.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="key" :size="16" /></span>
<div>
<b>Your license key</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Issued when you register. The agent uses it to authenticate to the cloud.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
<div>
<b>The panel</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Everything else — console, wipes, schedules, players — lives at
panel.corrosionmgmt.com.
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</section>
<!-- Architecture Diagram -->
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<h2 class="text-2xl font-bold text-neutral-100 mb-10">Architecture Overview</h2>
<div class="space-y-3">
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Rust Server</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Corrosion Plugin</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-oxide-500/10 border border-oxide-500/30 rounded-lg text-oxide-400 font-medium">Secure NATS Messaging</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-oxide-500/10 border border-oxide-500/30 rounded-lg text-oxide-400 font-medium">Corrosion Cloud</div>
<div><ArrowDown class="w-5 h-5 text-neutral-600 mx-auto" /></div>
<div class="inline-block px-6 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-medium">Dashboard + Store + Analytics</div>
</div>
<p class="text-neutral-500 mt-10">
Corrosion does not proxy gameplay traffic. It orchestrates operations.
</p>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Install the agent.<br>Never SSH again.</h2>
<div class="cta-row">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</section>
</div>
</div>
</section>
</template>

View File

@@ -1,288 +1,680 @@
<script setup lang="ts">
import { Shield, Zap, RefreshCw, Terminal, Users, Wifi, Server, ChevronRight } from 'lucide-vue-next'
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
import Icon from '@/components/ds/core/Icon.vue'
import { useThemeGame, type Game } from '@/composables/useThemeGame'
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
const { setGame } = useThemeGame()
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// ---- Game pill data ----
interface GameDef {
key: Game
label: string
icon: string
}
const GAMES: GameDef[] = [
{ key: 'rust', label: 'Rust', icon: 'box' },
{ key: 'dune', label: 'Dune: Awakening', icon: 'sun' },
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
]
const activeGame = ref<Game>('rust')
const userPicked = ref(false)
let rotateTimer: ReturnType<typeof setInterval> | null = null
let idx = 0
function pickGame(g: Game): void {
userPicked.value = true
activeGame.value = g
setGame(g)
}
function heroIsVisible(): boolean {
const hero = document.querySelector('.hero')
if (!hero) return false
return hero.getBoundingClientRect().bottom >= 140
}
function startRotation(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
rotateTimer = setInterval(() => {
if (userPicked.value || !heroIsVisible()) return
idx = (idx + 1) % GAMES.length
const next = GAMES[idx]
if (next) {
activeGame.value = next.key
setGame(next.key)
}
}, 3400)
}
// ---- Scroll-reveal via IntersectionObserver ----
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => {
// Set initial game on html — stays in sync with the global composable
setGame('rust')
activeGame.value = 'rust'
startRotation()
initReveal()
})
onUnmounted(() => {
if (rotateTimer !== null) clearInterval(rotateTimer)
io?.disconnect()
})
// Mock sidebar game switcher active key mirrors activeGame
const mockActiveGame = activeGame
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div class="max-w-6xl mx-auto px-6 pt-20 pb-24 text-center">
<h1 class="text-5xl md:text-6xl font-bold text-neutral-100 mb-6 tracking-tight">
The Control Plane<br />
<span class="text-oxide-500">for Rust Servers</span>
</h1>
<p class="text-xl text-neutral-400 max-w-2xl mx-auto mb-10">
Deploy once. Automate everything. Never SSH again.
</p>
<div class="flex items-center justify-center gap-4">
<a :href="panelUrl + '/register'" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors text-lg">
Buy License
</a>
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors text-lg">
View Live Demo
</a>
</div>
<!-- HERO -->
<section class="hero">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in">
<div class="hero__mark">
<CorrosionMark :size="72" />
</div>
<!-- Gradient glow -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/10 rounded-full blur-3xl pointer-events-none" />
</section>
<!-- The Problem -->
<section class="py-20 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6">
<h2 class="text-3xl font-bold text-neutral-100 mb-8 text-center">The Problem</h2>
<p class="text-neutral-400 text-lg mb-8 text-center">Running a Rust server today means:</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto mb-10">
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<span class="text-red-400 mt-0.5">&#x2717;</span>
<span class="text-neutral-300">Editing JSON configs over SFTP</span>
</div>
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<span class="text-red-400 mt-0.5">&#x2717;</span>
<span class="text-neutral-300">Babysitting wipes</span>
</div>
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<span class="text-red-400 mt-0.5">&#x2717;</span>
<span class="text-neutral-300">Manually installing and updating plugins</span>
</div>
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<span class="text-red-400 mt-0.5">&#x2717;</span>
<span class="text-neutral-300">Restarting servers blindly</span>
</div>
<div class="flex items-start gap-3 p-4 bg-neutral-900 border border-neutral-800 rounded-lg md:col-span-2 md:max-w-sm md:mx-auto">
<span class="text-red-400 mt-0.5">&#x2717;</span>
<span class="text-neutral-300">Staying online when something crashes</span>
</div>
</div>
<p class="text-center text-lg text-neutral-300 font-medium">
Rust servers deserve <span class="text-oxide-400">orchestration</span> not babysitting.
</p>
<div class="notpill">
<b>Not hosting.</b>&nbsp;Not a generic panel. Self-hosted, agent-based.
</div>
</section>
<!-- The Shift -->
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6 text-center">
<h2 class="text-3xl font-bold text-neutral-100 mb-8">The Shift</h2>
<p class="text-lg text-neutral-400 mb-10">
Corrosion moves Rust server administration to a unified cloud control plane.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
<span class="text-oxide-400 text-xl font-bold">1</span>
</div>
<p class="text-neutral-200 font-medium">Install one plugin</p>
</div>
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
<span class="text-oxide-400 text-xl font-bold">2</span>
</div>
<p class="text-neutral-200 font-medium">Register online</p>
</div>
<div class="p-6 bg-neutral-900 border border-neutral-800 rounded-lg">
<div class="w-10 h-10 bg-oxide-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
<span class="text-oxide-400 text-xl font-bold">3</span>
</div>
<p class="text-neutral-200 font-medium">Manage everything from your browser</p>
</div>
</div>
<div class="flex flex-col md:flex-row items-center justify-center gap-4 text-neutral-500">
<span>No open firewall ports.</span>
<span class="hidden md:inline">&middot;</span>
<span>No manual file editing.</span>
<span class="hidden md:inline">&middot;</span>
<span>No SSH required.</span>
</div>
</div>
</section>
<!-- Core Capabilities -->
<section class="py-20 border-t border-neutral-800">
<div class="max-w-6xl mx-auto px-6">
<h2 class="text-3xl font-bold text-neutral-100 mb-12 text-center">Core Capabilities</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Operational Control -->
<div>
<h3 class="text-lg font-semibold text-oxide-400 mb-6 uppercase tracking-wider">Operational Control</h3>
<div class="space-y-4">
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<RefreshCw class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Auto-Wiper with Rollback</p>
<p class="text-sm text-neutral-500 mt-1">Full wipe sequences with health verification</p>
</div>
</div>
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<Terminal class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Real-Time Console + Player Control</p>
<p class="text-sm text-neutral-500 mt-1">Execute commands from your browser</p>
</div>
</div>
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<Zap class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Web-Based Plugin Configuration</p>
<p class="text-sm text-neutral-500 mt-1">No more JSON editing over SFTP</p>
</div>
</div>
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<Server class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Automated Steam Updates</p>
<p class="text-sm text-neutral-500 mt-1">Stay current without manual intervention</p>
</div>
</div>
</div>
</div>
<!-- Infrastructure & Scale -->
<div>
<h3 class="text-lg font-semibold text-oxide-400 mb-6 uppercase tracking-wider">Infrastructure & Scale</h3>
<div class="space-y-4">
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<Shield class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Companion Agent No SSH Required</p>
<p class="text-sm text-neutral-500 mt-1">Outbound-only secure connections</p>
</div>
</div>
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<Users class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Multi-Admin Role-Based Access Control</p>
<p class="text-sm text-neutral-500 mt-1">Scale your team without losing order</p>
</div>
</div>
<div class="flex items-start gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<Wifi class="w-5 h-5 text-oxide-500 mt-0.5 shrink-0" />
<div>
<p class="text-neutral-200 font-medium">Zero Inbound Ports Required</p>
<p class="text-sm text-neutral-500 mt-1">Your server initiates all connections</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Wipe Orchestration -->
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6">
<h2 class="text-3xl font-bold text-neutral-100 mb-4 text-center">Wipe Orchestration</h2>
<p class="text-lg text-neutral-400 mb-10 text-center">
Wipes aren't just "delete map and restart."
</p>
<div class="flex flex-wrap items-center justify-center gap-3 mb-10">
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Pre-Wipe</span>
<ChevronRight class="w-4 h-4 text-neutral-600" />
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Backup</span>
<ChevronRight class="w-4 h-4 text-neutral-600" />
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Map Rotation</span>
<ChevronRight class="w-4 h-4 text-neutral-600" />
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Steam Update</span>
<ChevronRight class="w-4 h-4 text-neutral-600" />
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Restart</span>
<ChevronRight class="w-4 h-4 text-neutral-600" />
<span class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-300">Health Check</span>
<ChevronRight class="w-4 h-4 text-neutral-600" />
<span class="px-4 py-2 bg-oxide-500/15 border border-oxide-500/30 rounded-lg text-sm text-oxide-400">Rollback</span>
</div>
<div class="flex flex-col md:flex-row items-center justify-center gap-6 text-neutral-400">
<span>Every wipe is logged.</span>
<span>Every step is verified.</span>
<span class="text-oxide-400 font-medium">Rollback is one click away.</span>
</div>
</div>
</section>
<!-- Built Like Infrastructure -->
<section class="py-20 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6 text-center">
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Built Like Infrastructure</h2>
<p class="text-lg text-neutral-400 mb-10">
Corrosion isn't a UI wrapper. It's a hosted SaaS platform built with:
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-oxide-400 font-semibold mb-1">NestJS</p>
<p class="text-xs text-neutral-500">TypeScript backend</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-oxide-400 font-semibold mb-1">NATS</p>
<p class="text-xs text-neutral-500">JetStream messaging</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-oxide-400 font-semibold mb-1">PostgreSQL</p>
<p class="text-xs text-neutral-500">Multi-tenant isolation</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-oxide-400 font-semibold mb-1">Outbound-Only</p>
<p class="text-xs text-neutral-500">Secure connections</p>
</div>
</div>
<div class="flex flex-col items-center gap-2 text-neutral-500 text-sm">
<span>Every server is scoped by license.</span>
<span>Every command is namespaced.</span>
<span>Every tenant is isolated.</span>
</div>
</div>
</section>
<!-- Public Server Sites -->
<section class="py-20 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6 text-center">
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Public Server Sites & Storefront</h2>
<p class="text-lg text-neutral-400 mb-10">Each license includes:</p>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-200">Public server page</p>
</div>
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-200">Wipe countdown</p>
</div>
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-200">Live player count</p>
</div>
<div class="p-4 bg-neutral-900 border border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-200">Plugin/mod list</p>
</div>
<div class="p-4 bg-neutral-900 border border-oxide-500/30 rounded-lg">
<p class="text-sm text-oxide-400">Integrated webstore</p>
</div>
</div>
<p class="text-neutral-400">Monetize your server without third-party complexity.</p>
</div>
</section>
<!-- For Serious Admins -->
<section class="py-20 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<h2 class="text-3xl font-bold text-neutral-100 mb-8">For Serious Admins</h2>
<p class="text-lg text-neutral-400 mb-8">If you:</p>
<div class="space-y-3 max-w-md mx-auto mb-10">
<p class="text-neutral-300 text-lg">Run scheduled wipes</p>
<p class="text-neutral-300 text-lg">Care about uptime</p>
<p class="text-neutral-300 text-lg">Want crash recovery</p>
<p class="text-neutral-300 text-lg">Want automation</p>
<p class="text-neutral-300 text-lg">Manage multiple admins</p>
</div>
<p class="text-xl text-oxide-400 font-semibold">Corrosion was built for you.</p>
</div>
</section>
<!-- CTA -->
<section class="py-24 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<p class="text-2xl text-neutral-400 mb-2">Stop babysitting your server.</p>
<p class="text-3xl font-bold text-neutral-100 mb-10">Start orchestrating it.</p>
<a :href="panelUrl + '/register'" class="inline-block px-10 py-4 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors text-lg">
Get Corrosion
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
<p class="hero__sub">
Corrosion is a management panel for self-hosted survival game servers. Deploy servers, automate
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
multi-server worlds from one command center.
</p>
<div class="hero__cta">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</section>
</div>
<!-- Game pills -->
<div class="hero__games">
<button
v-for="g in GAMES"
:key="g.key"
class="gpill"
:data-on="String(activeGame === g.key)"
@click="pickGame(g.key)"
>
<Icon :name="g.icon" :size="15" />
<span>{{ g.label }}</span>
</button>
</div>
</div>
<!-- Panel mockup -->
<div class="wrap">
<div class="mock">
<div class="mock__bar">
<div class="mock__dots">
<span /><span /><span />
</div>
<div class="mock__url">panel.corrosionmgmt.com / fleet</div>
</div>
<div class="mock__body">
<aside class="mock__side">
<div class="mock__brand">
<span class="mark"><CorrosionMark :size="18" /></span>
<b>Corrosion</b>
</div>
<div class="mock__gs">
<span :class="{ on: mockActiveGame === 'rust' }">
<Icon name="box" :size="13" />
</span>
<span :class="{ on: mockActiveGame === 'dune' }">
<Icon name="sun" :size="13" />
</span>
<span :class="{ on: mockActiveGame === 'soulmask' }">
<Icon name="drama" :size="13" />
</span>
<span :class="{ on: mockActiveGame === 'conan' }">
<Icon name="swords" :size="13" />
</span>
</div>
<div class="mock__nav on"><Icon name="layout-dashboard" :size="14" />Dashboard</div>
<div class="mock__nav"><Icon name="server" :size="14" />Servers</div>
<div class="mock__nav"><Icon name="terminal" :size="14" />Console</div>
<div class="mock__nav"><Icon name="trash-2" :size="14" />Wipes</div>
<div class="mock__nav"><Icon name="cpu" :size="14" />Agents</div>
</aside>
<main class="mock__main">
<div class="mock__kpis">
<div class="mock__kpi">
<div class="l">Servers running</div>
<div class="v">5<small>/6</small></div>
</div>
<div class="mock__kpi">
<div class="l">Players online</div>
<div class="v">234</div>
</div>
<div class="mock__kpi">
<div class="l">Agent nodes</div>
<div class="v">2<small>/2</small></div>
</div>
</div>
<div class="mock__row">
<span class="g"><Icon name="box" :size="13" /></span>
<span class="nm">
Main · 2x Vanilla
<small>rust-host · rust</small>
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row">
<span class="g"><Icon name="sun" :size="13" /></span>
<span class="nm">
Arrakis · Hardcore
<small>dune-host · dune</small>
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row">
<span class="g"><Icon name="swords" :size="13" /></span>
<span class="nm">
Exiled Lands · PvP-C
<small>conan-host · conan</small>
</span>
<span class="st"><b />online</span>
</div>
</main>
</div>
</div>
</div>
<div class="wrap" style="text-align:center">
<div class="hero__foot">
One host agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
Windows &amp; Linux hosts
</div>
</div>
<div style="height:80px" />
</section>
<!-- PROBLEM -->
<section class="sec" id="problem">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">The problem</span>
<h2 class="title">Game servers were never supposed<br>to be babysitting duty</h2>
</div>
<div class="pain reveal">
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Editing configs over SFTP
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Manually updating mods &amp; plugins
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Guessing when a server crashed
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Running wipe day by hand
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Juggling Discord bots &amp; cron tasks
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Linking multi-server clusters manually
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Managing admins without real permissions
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Explaining downtime to players
</div>
</div>
<p class="closing reveal">
Your community sees the server. You deal with the chaos.<br>
<span class="accent">Corrosion gives you the control plane.</span>
</p>
</div>
</section>
<!-- SHIFT -->
<section class="sec" id="shift">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">The shift</span>
<h2 class="title">Drop in the agent.<br>Take control from the panel.</h2>
<p class="lead">
One lightweight host agent runs on your machine and manages every game instance you assign
to it an outbound-only ops runtime, not an exposed panel.
</p>
</div>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the Corrosion Agent</b>
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
</div>
<div class="step">
<div class="step__n">2</div>
<b>Register your server or fleet</b>
<p>Connect one server, a cluster, or multiple game worlds on the same box.</p>
</div>
<div class="step">
<div class="step__n">3</div>
<b>Manage from the browser</b>
<p>Console, files, schedules, wipes, plugins, mods, players, backups, metrics.</p>
</div>
</div>
<div class="nots reveal">
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No open inbound ports</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No constant SSH sessions</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No config spelunking</span>
<span><Icon name="check" :size="16" style="color:var(--status-online)" />No fragile scripts</span>
</div>
<p class="closing reveal" style="font-size:var(--text-lg)">
You provide the machine.
<span class="accent">Corrosion provides the control plane.</span>
</p>
</div>
</section>
<!-- BLUEPRINTS / SUPPORTED GAMES -->
<section class="sec" id="blueprints">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Supported games</span>
<h2 class="title">Game-aware blueprints,<br>not generic templates</h2>
<p class="lead">
Every game has a different operational reality. Corrosion models each one as an operations
blueprint Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
worlds.
</p>
</div>
<div class="blueprints reveal">
<!-- Rust card sets its own game scope via inline style on the surrounding element.
The token system resolves var(--accent) from data-game on <html> (set globally by
useThemeGame). Cards carry a data-game attr for future per-card scoping if desired. -->
<div class="bp" data-game="rust">
<div class="bp__head">
<span class="bp__ic"><Icon name="box" :size="21" /></span>
<div>
<div class="bp__name">Rust</div>
<div class="bp__accent">Oxide Orange</div>
</div>
</div>
<div class="bp__role">Modded server operations</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />uMod / Oxide plugin browsing</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Plugin config profiles</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Map / blueprint / full wipes</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Wipe schedules &amp; map library</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Player &amp; admin workflows</div>
</div>
</div>
<div class="bp" data-game="dune">
<div class="bp__head">
<span class="bp__ic"><Icon name="sun" :size="21" /></span>
<div>
<div class="bp__name">Dune: Awakening</div>
<div class="bp__accent">Spice Amber</div>
</div>
</div>
<div class="bp__role">Battlegroup orchestration</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Deep Desert wipe scheduling</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Procedural map regeneration</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Service health checks</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Backups before maintenance</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Battlegroup lifecycle controls</div>
</div>
</div>
<div class="bp" data-game="soulmask">
<div class="bp__head">
<span class="bp__ic"><Icon name="drama" :size="21" /></span>
<div>
<div class="bp__name">Soulmask</div>
<div class="bp__accent">Ritual Jade</div>
</div>
</div>
<div class="bp__role">Linked-world cluster deployment</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />One-click linked-server clusters</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Linked map validation</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Port &amp; config automation</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Cluster health</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Scheduled maintenance</div>
</div>
</div>
<div class="bp" data-game="conan">
<div class="bp__head">
<span class="bp__ic"><Icon name="swords" :size="21" /></span>
<div>
<div class="bp__name">Conan Exiles</div>
<div class="bp__accent">Hyborian Bronze</div>
</div>
</div>
<div class="bp__role">Persistent world management</div>
<div class="bp__list">
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Mod &amp; server management</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Clan &amp; player visibility</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Purge, decay &amp; event tracking</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />Backup &amp; restart scheduling</div>
<div><Icon name="check" :size="15" style="color:var(--accent-text);flex:none" />World maintenance workflows</div>
</div>
</div>
</div>
</div>
</section>
<!-- CAPABILITIES -->
<section class="sec" id="caps">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Core capabilities</span>
<h2 class="title">Everything an operator needs</h2>
</div>
<div class="caps reveal">
<div class="caps__col">
<span class="eyebrow">Operations</span>
<div class="feat">
<span class="feat__ic"><Icon name="power" :size="16" /></span>
<b>Server lifecycle control</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="terminal" :size="16" /></span>
<b>Real-time console</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="users" :size="16" /></span>
<b>Player visibility</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="folder-open" :size="16" /></span>
<b>File manager</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="calendar-clock" :size="16" /></span>
<b>Scheduled tasks &amp; restart windows</b>
</div>
</div>
<div class="caps__col">
<span class="eyebrow">Automation</span>
<div class="feat">
<span class="feat__ic"><Icon name="refresh-cw" :size="16" /></span>
<b>Wipe orchestration</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="database-backup" :size="16" /></span>
<b>Backup-before-change</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="download" :size="16" /></span>
<b>SteamCMD / game updates</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="bell" :size="16" /></span>
<b>Discord / status announcements</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
<b>Health checks &amp; rollback</b>
</div>
</div>
<div class="caps__col">
<span class="eyebrow">Game systems</span>
<div class="feat">
<span class="feat__ic"><Icon name="puzzle" :size="16" /></span>
<b>Rust plugins &amp; configs</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="sun" :size="16" /></span>
<b>Dune: Awakening battlegroups</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="drama" :size="16" /></span>
<b>Soulmask clusters</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="swords" :size="16" /></span>
<b>Conan mods &amp; events</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="store" :size="16" /></span>
<b>Public pages &amp; storefront</b>
</div>
</div>
</div>
</div>
</section>
<!-- WIPE & MAINTENANCE ORCHESTRATION -->
<section class="sec" id="wipe">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Wipe &amp; maintenance orchestration</span>
<h2 class="title">Wipes should be workflows,<br>not rituals</h2>
<p class="lead">
Rust map / BP / full wipes. Dune: Awakening Deep Desert wipes. Soulmask &amp; Conan
maintenance and event resets all as verified, logged sequences.
</p>
</div>
<div class="pipe reveal">
<span class="pchip">Pre-warning</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Backup</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Stop services</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Update</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Rotate map / config</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Restart</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip">Health check</span>
<Icon name="chevron-right" :size="15" style="color:var(--text-muted)" />
<span class="pchip pchip--last">Announce complete</span>
</div>
<div class="stack-lines reveal">
<span>Every operation is logged.</span>
<span>Every step is verified.</span>
<span class="hi">Rollback is one click away when supported.</span>
</div>
</div>
</section>
<!-- BUILT LIKE INFRASTRUCTURE -->
<section class="sec" id="platform">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Built like infrastructure</span>
<h2 class="title">Not a skin over SSH</h2>
<p class="lead">
A hosted control plane plus a host agent with tenant isolation, command namespacing,
health reporting, and outbound-only connectivity.
</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Agent-based control</b>
<p>Your host connects to Corrosion. No exposed management panel required.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
<b>Tenant isolated</b>
<p>Every license, server, and command is scoped.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="route" :size="16" /></div>
<b>Command namespaced</b>
<p>Server actions are routed intentionally, not sprayed blindly.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
<b>Event-driven</b>
<p>NATS-powered messaging keeps agents and panel in sync.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
<b>Observable</b>
<p>Health, metrics, task history, and agent status all visible.</p>
</div>
</div>
<div class="techrow reveal">
<span>NestJS</span>
<span>NATS JetStream</span>
<span>PostgreSQL</span>
<span>Go host agent</span>
<span>Outbound-only</span>
</div>
</div>
</section>
<!-- PUBLIC SITES & STOREFRONT -->
<section class="sec" id="store">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Public server sites &amp; storefront</span>
<h2 class="title">Give your players a home base</h2>
<p class="lead">
Publish a server page with live status, wipe countdowns, player counts, plugin / mod lists,
announcements, and optional storefront support.
</p>
</div>
<div class="chips reveal">
<div class="chip-card"><Icon name="globe" :size="16" style="color:var(--accent-text)" />Public server page</div>
<div class="chip-card"><Icon name="users" :size="16" style="color:var(--accent-text)" />Live player count</div>
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div>
</div>
<p
class="closing reveal"
style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500"
>
Operate the server. Inform the players. Monetize without duct tape.
</p>
</div>
</section>
<!-- PRICING -->
<section class="sec" id="pricing">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Pricing</span>
<h2 class="title">Scale from one server to a fleet</h2>
</div>
<div class="pricing reveal">
<div class="plan">
<div class="plan__tag" />
<div class="plan__name">Hobby</div>
<div class="plan__price">$9.99<small>/mo</small></div>
<div class="plan__scope">15 non-commercial servers.</div>
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
</div>
<div class="plan">
<div class="plan__tag" />
<div class="plan__name">Community</div>
<div class="plan__price">$19.99<small>/mo</small></div>
<div class="plan__scope">610 non-commercial servers.</div>
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
</div>
<div class="plan plan--feature">
<div class="plan__tag">Most popular</div>
<div class="plan__name">Operator</div>
<div class="plan__price">$99.99<small>/mo</small></div>
<div class="plan__scope">Commercial use, or up to 50 servers.</div>
<RouterLink class="btn btn--primary" :to="{ name: 'early-access' }">Get Operator</RouterLink>
</div>
<div class="plan">
<div class="plan__tag" />
<div class="plan__name">Network</div>
<div class="plan__price">$99.99<small>/mo</small></div>
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
</div>
</div>
<div class="fleetblock reveal">
<b>Fleet Block</b>
<span class="p">+$49.99/mo</span>
<span>each additional 50 servers stack as many as your network needs.</span>
</div>
<p class="commercial reveal">
<b>Commercial use</b> includes monetized communities, paid access, VIP slots, donations,
sponsorship-supported servers, hosting providers, or managing servers for others.
</p>
<p class="support-note reveal">
Community support is included with every plan (documentation, community forum, diagnostics,
structured bug reports).
<b>Direct 1:1 support</b> is available separately at $125/hour in prepaid 1-hour blocks.
Corrosion is a tool, not a managed service.
</p>
</div>
</section>
<!-- FOR SERIOUS ADMINS -->
<section class="sec" id="admins">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">For serious admins</span>
<h2 class="title">Built for admins<br>who are done babysitting</h2>
</div>
<div class="admins reveal">
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />You run more than a toy server.</span>
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Your players expect uptime.</span>
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Wipe day needs a plan.</span>
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Mods and plugins need control.</span>
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Admin access needs boundaries.</span>
<span><Icon name="chevron-right" :size="18" style="color:var(--accent-text)" />Your community deserves better than guesswork.</span>
</div>
<p class="closing reveal accent">Stop babysitting your server. Start orchestrating it.</p>
</div>
</section>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Ready to run your servers<br>like an operation?</h2>
<div class="cta-row">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</div>
</section>
</template>

View File

@@ -1,129 +1,429 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Check } from 'lucide-vue-next'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
interface PlanFeature {
text: string
}
interface Plan {
name: string
price: string
period: string
scope: string
tag: string
featured: boolean
cta: string
ctaVariant: 'primary' | 'ghost'
features: PlanFeature[]
}
const plans: Plan[] = [
{
name: 'Hobby',
price: '$9.99',
period: '/mo',
scope: '15 servers · non-commercial use',
tag: '',
featured: false,
cta: 'Join early access',
ctaVariant: 'ghost',
features: [
{ text: 'Up to 5 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
{ text: 'Community support' },
],
},
{
name: 'Community',
price: '$19.99',
period: '/mo',
scope: '610 servers · non-commercial use',
tag: '',
featured: false,
cta: 'Join early access',
ctaVariant: 'ghost',
features: [
{ text: 'Up to 10 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
{ text: 'Community support' },
],
},
{
name: 'Operator',
price: '$99.99',
period: '/mo',
scope: 'Commercial use · or 1150 servers',
tag: 'Most popular',
featured: true,
cta: 'Get Operator',
ctaVariant: 'primary',
features: [
{ text: 'Up to 50 game server instances' },
{ text: 'Commercial use permitted' },
{ text: 'All games: Rust, Dune, Soulmask, Conan' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin + mod management' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks + maintenance windows' },
{ text: 'Player management + RBAC team access' },
{ text: 'Public server page + storefront' },
{ text: 'Community support + priority bug triage' },
],
},
{
name: 'Network',
price: '$99.99',
period: '/mo',
scope: '50+ servers · hosting partners + fleets',
tag: '',
featured: false,
cta: 'Join early access',
ctaVariant: 'ghost',
features: [
{ text: '50 servers base included' },
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
{ text: 'Commercial use permitted' },
{ text: 'All games + multi-game hosts' },
{ text: 'Full Operator feature set' },
{ text: 'Fleet-level management' },
{ text: 'Priority bug triage for platform issues' },
{ text: 'Community support' },
],
},
]
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Pricing</h1>
<p class="text-lg text-neutral-400">Simple. Transparent. No hidden tiers.</p>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom: 52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
</section>
<span class="eyebrow">Pricing</span>
<h1 style="font-size:var(--text-5xl)">
Scale from one server
<span class="accent">to a fleet.</span>
</h1>
<p class="hero__sub">
Simple tiers based on how many servers you run and whether you operate commercially.
No per-seat charges. No surprises.
</p>
</div>
</section>
<!-- Pricing Cards -->
<section class="pb-20">
<div class="max-w-5xl mx-auto px-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Base License -->
<div class="bg-neutral-900 border-2 border-oxide-500/40 rounded-xl p-8 relative">
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="px-3 py-1 bg-oxide-600 text-white text-xs font-semibold rounded-full uppercase tracking-wider">Most Popular</span>
</div>
<div class="text-center mb-6">
<h3 class="text-xl font-bold text-neutral-100 mb-2">Base License</h3>
<div class="flex items-baseline justify-center gap-1">
<span class="text-4xl font-bold text-oxide-400">$50</span>
</div>
<p class="text-sm text-neutral-500 mt-1">One server. Lifetime access.</p>
<p class="text-xs text-oxide-400/70 mt-1">Launch Price</p>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Full control plane
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Auto-Wiper with rollback
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Plugin management
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Public server site
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Multi-admin RBAC
</li>
</ul>
<RouterLink to="/register" class="block w-full py-3 bg-oxide-600 hover:bg-oxide-700 text-white text-center font-semibold rounded-lg transition-colors">
Get Started
</RouterLink>
<!-- PRICING CARDS -->
<section class="sec" id="plans">
<div class="wrap">
<div class="pricing reveal">
<div
v-for="plan in plans"
:key="plan.name"
class="plan"
:class="plan.featured ? 'plan--feature' : ''"
>
<div class="plan__tag">{{ plan.tag }}</div>
<div class="plan__name">{{ plan.name }}</div>
<div class="plan__price">
{{ plan.price }}<small>{{ plan.period }}</small>
</div>
<div class="plan__scope">{{ plan.scope }}</div>
<!-- Webstore Add-On -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="text-center mb-6">
<h3 class="text-xl font-bold text-neutral-100 mb-2">Webstore Add-On</h3>
<div class="flex items-baseline justify-center gap-1">
<span class="text-4xl font-bold text-neutral-200">$10</span>
<span class="text-neutral-500">/mo</span>
</div>
<p class="text-sm text-neutral-500 mt-1">Integrated monetization platform.</p>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Item catalog
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Stripe + PayPal
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Auto-delivery via RCON
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Transaction history
</li>
</ul>
<button class="block w-full py-3 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-center font-semibold rounded-lg border border-neutral-700 transition-colors">
Coming Soon
</button>
</div>
<ul class="plan__feats">
<li v-for="feat in plan.features" :key="feat.text">
<Icon name="check" :size="13" style="color:var(--accent-text);flex:none" />
{{ feat.text }}
</li>
</ul>
<!-- Modules -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-8">
<div class="text-center mb-6">
<h3 class="text-xl font-bold text-neutral-100 mb-2">Modules</h3>
<div class="flex items-baseline justify-center gap-1">
<span class="text-4xl font-bold text-neutral-200">$9.99</span>
<span class="text-neutral-500">+</span>
</div>
<p class="text-sm text-neutral-500 mt-1">Optional feature expansions.</p>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Analytics & insights
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Discord bot integration
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
Cloud backups
</li>
<li class="flex items-center gap-3 text-sm text-neutral-300">
<Check class="w-4 h-4 text-oxide-500 shrink-0" />
More on the roadmap
</li>
</ul>
<RouterLink to="/site/roadmap" class="block w-full py-3 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-center font-semibold rounded-lg border border-neutral-700 transition-colors">
View Roadmap
</RouterLink>
</div>
<RouterLink
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:to="{ name: 'early-access' }"
>
{{ plan.cta }}
</RouterLink>
</div>
</div>
</section>
</div>
<!-- Fleet Block -->
<div class="fleetblock reveal">
<b>Fleet Block</b>
<span class="p">+$49.99/mo</span>
<span>each additional 50 servers stack as many as your network needs.</span>
</div>
<!-- Commercial use definition -->
<p class="commercial reveal">
<b>Commercial use</b> includes monetized communities, paid access, VIP slots, donations,
sponsorship-supported servers, hosting providers, or managing servers for others.
</p>
<!-- Support model -->
<p class="support-note reveal">
Community support is included with every plan documentation, community forum,
diagnostics, and structured bug reports.
<b>Direct 1:1 support</b> is available separately at $125/hour in prepaid 1-hour blocks.
Corrosion is a tool, not a managed service.
</p>
</div>
</section>
<!-- COMPARISON TABLE -->
<section class="sec" id="compare">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Feature breakdown</span>
<h2 class="title">What is included in each tier</h2>
</div>
<div class="compare-table reveal">
<div class="compare-table__head">
<div class="compare-table__label">Feature</div>
<div>Hobby</div>
<div>Community</div>
<div class="compare-table__featured">Operator</div>
<div>Network</div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Server instances</div>
<div>15</div>
<div>610</div>
<div class="compare-table__featured">Up to 50</div>
<div>50 + Fleet Blocks</div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Commercial use</div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Rust (Oxide/uMod)</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">All supported games</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Auto-wiper + rollback</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Real-time console</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">RBAC team access</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Public server page</div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
<div class="compare-table__row">
<div class="compare-table__label">Priority bug triage</div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div><Icon name="x" :size="14" style="color:var(--status-offline)" /></div>
<div class="compare-table__featured"><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
<div><Icon name="check" :size="14" style="color:var(--accent-text)" /></div>
</div>
</div>
</div>
</section>
<!-- SUPPORT MODEL -->
<section class="sec" id="support">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Support model</span>
<h2 class="title">Corrosion is a tool,<br>not a managed service</h2>
<p class="lead">
Every plan includes self-service support. Hands-on time is available separately at
an honest rate, when you actually need it.
</p>
</div>
<div class="infra reveal">
<div class="icard">
<div class="icard__ic"><Icon name="file-text" :size="16" /></div>
<b>Documentation</b>
<p>Setup guides, architecture reference, troubleshooting walkthroughs. Included on every plan.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Community forum</b>
<p>Operator-to-operator knowledge base. Questions, configs, and war stories. All plans.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
<b>Diagnostics</b>
<p>Built-in agent health checks, log access, and structured bug reports. All plans.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
<b>Priority bug triage</b>
<p>Platform bugs for Operator and Network customers go to the front of the queue.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="clock" :size="16" /></div>
<b>Direct 1:1 support</b>
<p>$125/hour, prepaid in 1-hour blocks. Available to any customer who needs it.</p>
</div>
</div>
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
Direct server administration, firewall configuration, mod installation, and wipe-day
hand-holding are not included in any plan. Corrosion gives you the panel and the tools.
You run the operation.
</p>
</div>
</section>
<!-- FINAL CTA -->
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Ready to stop babysitting<br>your servers?</h2>
<div class="cta-row">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</div>
</section>
</template>
<style scoped>
.plan__feats {
list-style: none;
padding: 0;
margin: 16px 0 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 9px;
}
.plan__feats li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.4;
}
.plan__feats li svg { margin-top: 1px; }
/* Comparison table */
.compare-table {
max-width: 1040px;
margin: 0 auto;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--ring-default);
}
.compare-table__head,
.compare-table__row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.2fr 1fr;
align-items: center;
}
.compare-table__head {
background: var(--surface-raised-2);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
color: var(--text-muted);
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.compare-table__head > div,
.compare-table__row > div {
padding: 2px 8px;
text-align: center;
}
.compare-table__head > div:first-child,
.compare-table__row > div:first-child {
text-align: left;
padding-left: 0;
}
.compare-table__row {
background: var(--surface-base);
font-size: var(--text-sm);
color: var(--text-tertiary);
padding: 11px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.compare-table__row:last-child { border-bottom: none; }
.compare-table__label { color: var(--text-secondary); font-weight: 500; }
.compare-table__featured {
background: var(--accent-soft);
color: var(--accent-text) !important;
font-weight: 600;
}
</style>

View File

@@ -1,146 +1,353 @@
<script setup lang="ts">
import { Check, Circle } from 'lucide-vue-next'
import { onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
interface Phase {
name: string
label: string
status: 'complete' | 'current' | 'upcoming'
items: { text: string; done: boolean }[]
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
type Status = 'shipped' | 'in-progress' | 'planned'
interface RoadmapItem {
text: string
note?: string
}
const phases: Phase[] = [
interface RoadmapGroup {
status: Status
label: string
description: string
items: RoadmapItem[]
}
const groups: RoadmapGroup[] = [
{
name: 'Phase 1',
label: 'Foundation',
status: 'complete',
status: 'shipped',
label: 'Phase 1 — Foundation',
description:
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.',
items: [
{ text: 'Core control plane', done: true },
{ text: 'Auto-Wiper with rollback', done: true },
{ text: 'Plugin management', done: true },
{ text: 'Public server site', done: true },
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
{ text: 'Real-time console', note: 'NATS-bridged live output' },
{ text: 'File manager', note: 'Browser-based file access via the agent' },
{ text: 'Scheduled tasks and maintenance windows' },
{ text: 'Player management and RBAC team access' },
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
{ text: 'SteamCMD game update automation' },
{ text: 'Discord and notification webhooks' },
],
},
{
name: 'Phase 2',
label: 'Analytics',
status: 'current',
status: 'in-progress',
label: 'Multi-game expansion',
description:
'The agent and control plane are being extended with per-game blueprints. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same agent model with game-specific operational logic.',
items: [
{ text: 'Player retention tracking', done: false },
{ text: 'Wipe performance insights', done: false },
{ text: 'Population heatmaps', done: false },
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' },
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
{ text: 'Multi-instance host runtime', note: 'One agent managing N game processes on the same machine' },
{ text: 'Per-game wipe and event scheduling' },
],
},
{
name: 'Phase 3',
label: 'Status Platform',
status: 'upcoming',
status: 'planned',
label: 'API access and integrations',
description:
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.',
items: [
{ text: 'Public uptime tracking', done: false },
{ text: 'Server health dashboard', done: false },
{ text: 'Public REST API for server management' },
{ text: 'Webhook events (wipe completed, server down, player banned)' },
{ text: 'API key management per license' },
],
},
{
name: 'Phase 4',
label: 'Module Marketplace',
status: 'upcoming',
status: 'planned',
label: 'Integrated storefront',
description:
'A native store layer for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
items: [
{ text: 'Loot manager', done: false },
{ text: 'Event systems', done: false },
{ text: 'Advanced gameplay modules', done: false },
{ text: 'Item catalog and categories' },
{ text: 'PayPal and Stripe payment processing' },
{ text: 'Automated in-game delivery via RCON/agent' },
{ text: 'Transaction history and revenue dashboard' },
],
},
{
name: 'Phase 5',
label: 'Integrated Webstore',
status: 'upcoming',
status: 'planned',
label: 'Fleet management for hosting partners',
description:
'Tools for hosting providers and multi-server operations running 50+ instances across multiple physical hosts.',
items: [
{ text: 'Native item store', done: false },
{ text: 'Automated delivery', done: false },
{ text: 'Revenue dashboard', done: false },
{ text: 'Fleet-level dashboards and health monitoring' },
{ text: 'Multi-host agent orchestration' },
{ text: 'Bulk wipe and update scheduling across a fleet' },
{ text: 'Fleet Block capacity management' },
],
},
{
name: 'Phase 6',
label: 'B2B Hosting Integration',
status: 'upcoming',
status: 'planned',
label: 'More games',
description:
'Corrosion\'s agent model is not Rust-specific. The platform is being designed to support any game that can be managed via process control, file operations, and RCON-style commands.',
items: [
{ text: 'White-label panel', done: false },
{ text: 'Bulk license provisioning', done: false },
{ text: 'SSO integration', done: false },
{ text: 'Additional survival and sandbox games' },
{ text: 'Community-requested game blueprints' },
],
},
]
function phaseStatusClass(status: string): string {
switch (status) {
case 'complete': return 'bg-green-500/10 text-green-400 border-green-500/20'
case 'current': return 'bg-oxide-500/10 text-oxide-400 border-oxide-500/20'
default: return 'bg-neutral-800 text-neutral-500 border-neutral-700'
}
function statusLabel(s: Status): string {
if (s === 'shipped') return 'Shipped'
if (s === 'in-progress') return 'In progress'
return 'Planned'
}
function phaseStatusLabel(status: string): string {
switch (status) {
case 'complete': return 'Shipped'
case 'current': return 'In Progress'
default: return 'Planned'
}
function statusIcon(s: Status): string {
if (s === 'shipped') return 'check'
if (s === 'in-progress') return 'refresh-cw'
return 'circle'
}
// Scroll-reveal
let io: IntersectionObserver | null = null
function initReveal(): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in')
io?.unobserve(e.target)
}
})
},
{ threshold: 0.1 },
)
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
</script>
<template>
<div>
<!-- Header -->
<section class="pt-20 pb-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4">Roadmap</h1>
<p class="text-lg text-neutral-400">
Corrosion isn't a single plugin release. It's infrastructure for the Rust ecosystem.
</p>
<!-- PAGE HEADER -->
<section class="hero" style="padding-bottom:0; border-bottom:none;">
<div class="hero__atmo" />
<div class="hero__grid" />
<div class="hero__grain" />
<div class="wrap hero__in" style="padding-bottom:52px;">
<div class="hero__mark">
<CorrosionMark :size="56" />
</div>
</section>
<span class="eyebrow">Roadmap</span>
<h1 style="font-size:var(--text-5xl)">
Where Corrosion
<span class="accent">is going.</span>
</h1>
<p class="hero__sub">
No specific dates. No fabricated percentages. Status labels only: Shipped, In progress,
Planned. This roadmap reflects what is actually being built.
</p>
</div>
</section>
<!-- Timeline -->
<section class="pb-20">
<div class="max-w-3xl mx-auto px-6">
<div class="space-y-6">
<div
v-for="phase in phases"
:key="phase.name"
class="bg-neutral-900 border rounded-xl p-8 transition-colors"
:class="phase.status === 'current' ? 'border-oxide-500/30' : 'border-neutral-800'"
>
<div class="flex items-center justify-between mb-5">
<div>
<span class="text-xs font-bold text-neutral-500 uppercase tracking-wider">{{ phase.name }}</span>
<h3 class="text-xl font-bold text-neutral-100">{{ phase.label }}</h3>
</div>
<span
class="text-xs font-semibold px-3 py-1 rounded-full border"
:class="phaseStatusClass(phase.status)"
>
{{ phaseStatusLabel(phase.status) }}
</span>
</div>
<ul class="space-y-3">
<li
v-for="item in phase.items"
:key="item.text"
class="flex items-center gap-3"
>
<Check v-if="item.done" class="w-4 h-4 text-green-400 shrink-0" />
<Circle v-else class="w-4 h-4 text-neutral-600 shrink-0" />
<span
class="text-sm"
:class="item.done ? 'text-neutral-300' : 'text-neutral-500'"
>
{{ item.text }}
</span>
</li>
</ul>
</div>
<!-- STATUS LEGEND -->
<section class="sec" style="padding:32px 0; border-bottom: 1px solid var(--border-subtle);">
<div class="wrap">
<div class="rm-legend reveal">
<div class="rm-badge rm-badge--shipped">
<Icon name="check" :size="13" />Shipped
</div>
<div class="rm-badge rm-badge--progress">
<Icon name="refresh-cw" :size="13" />In progress
</div>
<div class="rm-badge rm-badge--planned">
<Icon name="circle" :size="13" />Planned
</div>
</div>
</section>
</div>
</div>
</section>
<!-- ROADMAP GROUPS -->
<section class="sec" id="roadmap">
<div class="wrap">
<div
v-for="group in groups"
:key="group.label"
class="rm-group reveal"
:data-status="group.status"
>
<div class="rm-group__head">
<div
class="rm-group__badge"
:class="`rm-badge--${group.status}`"
>
<Icon :name="statusIcon(group.status)" :size="13" />
{{ statusLabel(group.status) }}
</div>
<h3 class="rm-group__title">{{ group.label }}</h3>
</div>
<p class="rm-group__desc">{{ group.description }}</p>
<ul class="rm-group__list">
<li
v-for="item in group.items"
:key="item.text"
class="rm-item"
>
<span class="rm-item__dot" :class="`rm-item__dot--${group.status}`" />
<span>
{{ item.text }}
<span v-if="item.note" class="rm-item__note"> {{ item.note }}</span>
</span>
</li>
</ul>
</div>
</div>
</section>
<!-- HONEST NOTE -->
<section class="sec" style="padding:40px 0; border-bottom:none;">
<div class="wrap">
<div class="closing reveal">
This roadmap reflects real development priorities, not marketing promises.
Timelines are not published because they depend on real-world testing and operator
feedback. <span class="accent">Join early access to influence what gets built next.</span>
</div>
<div class="hero__cta reveal" style="margin-top:28px">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access
</RouterLink>
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
<Icon name="key" :size="17" />Sign in
</a>
</div>
</div>
</section>
</template>
<style scoped>
/* Legend */
.rm-legend {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
/* Badges */
.rm-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
}
.rm-badge--shipped {
background: color-mix(in srgb, var(--status-online) 14%, transparent);
color: var(--status-online);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-online) 28%, transparent);
}
.rm-badge--in-progress, .rm-badge--progress {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.rm-badge--planned {
background: var(--surface-raised-2);
color: var(--text-muted);
box-shadow: var(--ring-default);
}
/* Groups */
.rm-group {
max-width: 860px;
margin: 0 auto 40px;
padding: 28px 28px 24px;
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--ring-default);
}
.rm-group[data-status="in-progress"] {
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.rm-group[data-status="shipped"] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-online) 22%, transparent);
}
.rm-group__head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.rm-group__badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 10px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
flex: none;
}
.rm-group__title {
font-family: var(--font-brand);
font-weight: 700;
font-size: var(--text-xl);
margin: 0;
}
.rm-group__desc {
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: 1.6;
margin: 0 0 18px;
max-width: 680px;
}
.rm-group__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rm-item {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.rm-item__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
margin-top: 5px;
}
.rm-item__dot--shipped { background: var(--status-online); }
.rm-item__dot--in-progress { background: var(--accent); }
.rm-item__dot--planned {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--text-muted);
}
.rm-item__note {
color: var(--text-muted);
font-size: var(--text-xs);
}
</style>

View File

@@ -41,12 +41,8 @@ interface StatusResponse {
const api = useApi()
const servers = ref<ServerStatus[]>([])
const platformHealth = ref<PlatformHealth>({
total_servers: 0,
online_servers: 0,
total_players: 0,
uptime_percent: 0,
})
// null until the first successful fetch — KPIs render '—', never fake zeros
const platformHealth = ref<PlatformHealth | null>(null)
const searchQuery = ref('')
const loading = ref(true)
@@ -148,10 +144,10 @@ onUnmounted(() => {
<!-- Platform KPIs -->
<div v-if="!loading" class="sp-kpis">
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
<StatCard icon="server" label="Total servers" :value="platformHealth ? String(platformHealth.total_servers) : '—'" />
<StatCard icon="activity" label="Online now" :value="platformHealth ? String(platformHealth.online_servers) : '—'" />
<StatCard icon="users" label="Total players" :value="platformHealth ? String(platformHealth.total_players) : '—'" />
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth?.uptime_percent ?? null, 1, '—')" unit="%" />
</div>
<!-- Body -->

View File

@@ -2,12 +2,18 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),