Compare commits
30 Commits
agent-v2.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c7a8a97 | ||
|
|
907cfcb428 | ||
|
|
b1961df18e | ||
|
|
cfdec62a1d | ||
|
|
e510f8b005 | ||
|
|
cf1f1dea9a | ||
|
|
2e72850b97 | ||
|
|
9f9785fc09 | ||
|
|
142ba21113 | ||
|
|
04e664045b | ||
|
|
cef95540fc | ||
|
|
7f2207bc28 | ||
|
|
57858a1e1c | ||
|
|
5b323137e0 | ||
|
|
4d455918f5 | ||
|
|
a1768bdd2a | ||
|
|
0effaaf86c | ||
|
|
55c9893131 | ||
|
|
62bc9cd2a3 | ||
|
|
e23b6a7e69 | ||
|
|
215355d1cb | ||
|
|
440474290b | ||
|
|
6f783bfac8 | ||
|
|
f2ea415840 | ||
|
|
d13f2cb8b1 | ||
|
|
651a35d4be | ||
|
|
0715492ddf | ||
|
|
4ef5db5b0d | ||
|
|
bb71763714 | ||
|
|
f18b45e3f2 |
@@ -81,7 +81,21 @@ jobs:
|
||||
MINISIGN="$(find /tmp -type f -name minisign -path '*linux*' | head -1)"
|
||||
chmod +x "$MINISIGN"
|
||||
"$MINISIGN" -v
|
||||
# A minisign secret key file is TWO lines (comment + base64 blob). CI
|
||||
# secret storage mangles embedded newlines, collapsing it to one line
|
||||
# so minisign can't load it. Preferred form: store the secret
|
||||
# base64-encoded (single line) — we decode it here. Auto-detect so a
|
||||
# correctly-stored raw two-line key still works.
|
||||
if printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d 2>/dev/null | head -1 | grep -q "untrusted comment:"; then
|
||||
printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d > /tmp/sign.key
|
||||
else
|
||||
printf '%s\n' "$MINISIGN_SECRET_KEY" > /tmp/sign.key
|
||||
fi
|
||||
if ! head -1 /tmp/sign.key | grep -q "untrusted comment:"; then
|
||||
echo "::error::MINISIGN_SECRET_KEY is neither base64 of a minisign key nor a raw two-line key file. Store it as: base64 < your-secret.key | tr -d '\n'"
|
||||
rm -f /tmp/sign.key
|
||||
exit 1
|
||||
fi
|
||||
cd corrosion-host-agent/bin
|
||||
# Passwordless key (-W generated); feed empty stdin so it never blocks.
|
||||
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||
|
||||
29
CHANGELOG.md
@@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added (Host-agent Phase 2 — Dune docker-compose adapter — 2026-06-12)
|
||||
|
||||
**`Supervisor` trait abstraction (`corrosion-host-agent`):**
|
||||
- Introduced `trait Supervisor` (via `async-trait`, the battle-tested ecosystem standard) so the agent can manage games with fundamentally different models behind one wire contract. `ProcessSupervisor` (spawned OS process — Rust/Conan/Soulmask) and the new `DockerComposeSupervisor` (Dune) both implement it; `Agent.supervisors` is now `HashMap<String, Arc<dyn Supervisor>>` and the instance command dispatch (`instancecmd::dispatch`) is fully game-agnostic — `start`/`stop`/`restart`/`status` are identical across games. A per-game factory in `main` selects the impl. `InstanceState` moved to the shared `supervisor` module.
|
||||
- **Architecture call** (per Commander): chose the `dyn` trait over a zero-dependency enum because the Dune references point at *several* future management planes (kubectl, AMP/podman, SSH) — a trait makes each new plane "new struct + impl," no central match to edit.
|
||||
|
||||
**`DockerComposeSupervisor` (Dune: Awakening):**
|
||||
- Drives `docker compose up -d` / `stop` / `restart` against the instance's compose project (a "battlegroup"), with `-f`/`-p`/single-service support and a configurable compose binary (`docker compose` default, `docker-compose` legacy). New `[instance.docker_compose]` config block (file/project/service/command, all optional). `steam_update` already rejected for Dune (Docker images, no SteamCMD).
|
||||
- **Scope (first cut):** lifecycle + cached state. Deferred to Phase 3b (with process PID adoption): container crash-detection and state adoption on agent restart (both reconcilable with a `docker compose ps` probe).
|
||||
- Verified: 6 new docker-compose tests (mock `docker` binary asserting exact invocations + state transitions + failure paths) + the 5 refactored process-supervisor tests; full agent suite 56 tests green, zero warnings. Live verification against a real Dune stack pending the Commander standing one up.
|
||||
|
||||
### Changed (Fleet-driven active game + signed-update CI fix — 2026-06-12)
|
||||
|
||||
**Frontend — active game follows the deployed fleet:**
|
||||
- The panel's active game (shell skin + sidebar nav + dashboard terminology) is now **derived from the deployed instances** instead of a localStorage-only toggle. `syncActiveGameFromFleet()` reads the distinct `game` values of the license's instances (`game_instances.game`, reported by the host agent): exactly one game deployed → the shell auto-skins to it; zero or multiple → `all` (neutral house skin). Wired into `DashboardLayout` (the always-mounted admin shell) via a watch on the fleet store.
|
||||
- A manual GameSwitcher pick still wins — it persists to `cc-active-game` and suppresses auto-derive (operator intent beats the heuristic). Un-overridden panels keep tracking the fleet across sessions.
|
||||
- **No backend/schema change:** a license's game(s) are the distinct games of its instances — the normalized source of truth. Deliberately did NOT add a `licenses.game` column (would duplicate `game_instances.game` and drift; see Lesson 20).
|
||||
|
||||
**Frontend — sidebar agent-health footer is now fleet-aware:**
|
||||
- The shell footer read a single legacy `server.connection` (one `server_connections` row), which disagreed with the multi-host fleet. Repointed it at the fleet store: one host → hostname + status + last-heartbeat; multiple → `{online}/{total} online` + total instance count. Tone aggregates (all online → healthy, some → degraded, none → offline). Dropped the legacy `useServerStore` dependency from the shell entirely.
|
||||
|
||||
**Frontend — removed dead `vuefinder` dependency:**
|
||||
- VueFinder was replaced by the native instance-scoped file manager but the plugin (and its CSS) were still globally registered in `main.ts` and shipped in the bundle. Removed the dep + the three `main.ts` lines. Side effect: the main JS chunk dropped **588 kB → 165 kB** (vuefinder bundled an entire unused file-manager UI).
|
||||
|
||||
**Recon note (not a change):** `corrosion.{license}.cmd.server` was on the cleanup list as "dead v1" — it is NOT. It remains the live license-level command path for all plugin/module config applies, plugin install, scheduled tasks, and legacy start/stop/restart, served only by the legacy Go agent. The Rust agent does not implement it yet — this is a **parity/migration gap** (Phase 2+), not dead code. Left intact.
|
||||
|
||||
**CI — signed host-agent build:**
|
||||
- Fixed the `Sign artifacts (minisign)` step (`Error while loading the secret key file`): a minisign secret key is two lines and CI secret storage mangles the embedded newline. The job now base64-decodes the secret (single-line, mangling-proof) with auto-detect fallback to a raw key. `MINISIGN_SECRET_KEY` must be stored as `base64 < secret.key | tr -d '\n'`. Verified end-to-end: `agent-v2.0.0-alpha.8` Linux + Windows binaries validate against the agent's embedded public key; tampered byte rejected.
|
||||
|
||||
### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11)
|
||||
|
||||
**Backend (NestJS):**
|
||||
|
||||
@@ -451,3 +451,5 @@ Things I discovered about myself building a sister platform across multiple sess
|
||||
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
|
||||
|
||||
27. **Validate infra config BEFORE it reaches a deploy — and know that `docker compose up -d <service>` will recreate other services whose definitions changed.** During the NATS auth cutover I ran `docker compose up -d api` to pick up new env. Because the *nats* service definition had also changed (a new volume mount), compose recreated **corrosion-nats too** — and it failed to start on a config error (`no_auth_user` nested inside `authorization{}` instead of at top level), taking the broker down for ~3 minutes with the backend in offline mode. Two lessons: (a) a broker/proxy/DB config file is code — lint it before it can reach a restart (`nats-server -t -c cfg` to test-parse, `nginx -t`, etc.), don't let the first validation be the production container's startup; (b) `compose up -d <one-service>` is not surgical — it reconciles that service's **dependencies** too, so a stale edit to a depended-on service ships when you didn't mean it to. When touching shared-infra config, restart that service explicitly and watch it come up before moving on. Recovery also surfaced a third gotcha: recreating a client (api) while its server (nats) is down leaves the client stuck on a cached DNS failure (`EAI_AGAIN`) — restart the client once the server is healthy.
|
||||
|
||||
28. **A multi-line secret in CI (minisign/SSH/PGP keys) must be stored base64-encoded — the runner mangles embedded newlines and the key silently fails to load.** The signed-update CI passed the toolchain build, downloaded minisign fine, then died at the sign step on `Error while loading the secret key file` (exit 2). The cause wasn't the key or minisign — a minisign secret key file is **two lines** (`untrusted comment:` + base64 blob), and Gitea/act_runner secret storage collapses the embedded newline so the reconstructed file is one unparseable line. The robust pattern: store the secret as `base64 < secret.key | tr -d '\n'` (single line, mangling-proof) and `base64 -d` it in the job, with auto-detect fallback so a correctly-stored raw key still works, and a loud `::error::` carrying the fix command if it's neither. This applies to **any** multi-line credential in CI, not just minisign. Two corollaries: (a) the tell is "the tool runs but can't load its key" — suspect newline-mangling before the key itself; (b) generating that base64 prints the **private key to the terminal/transcript** — for a supply-chain signing key, treat it as exposed and rotate before cutover (embed the new pubkey, re-store the new secret, retire the old). And verify the published artifact end-to-end against the *embedded* pubkey (`minisign -Vm bin -P <pub>`) plus a tampered-byte negative control — a green build that signs is not the same as a signature the agent will actually accept.
|
||||
|
||||
@@ -47,6 +47,8 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul
|
||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||
import { FleetModule } from './modules/fleet/fleet.module';
|
||||
import { InstancesModule } from './modules/instances/instances.module';
|
||||
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
|
||||
import { WebhooksModule } from './modules/webhooks/webhooks.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -137,6 +139,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
EarlyAccessModule,
|
||||
FleetModule,
|
||||
InstancesModule,
|
||||
ApiKeysModule,
|
||||
WebhooksModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
51
backend-nest/src/common/cron.util.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
|
||||
* (SchedulesService) and the wipe scheduler (WipesService).
|
||||
*
|
||||
* Supports `*` and exact numeric fields (minute hour day-of-month month
|
||||
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
|
||||
* malformed expression or if no match is found within a year.
|
||||
*
|
||||
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
|
||||
* column exists on both schedule tables but is NOT yet honored here — fixing it
|
||||
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
|
||||
*/
|
||||
export function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
const matches = (e: string, value: number): boolean => {
|
||||
if (e === '*') return true;
|
||||
return parseInt(e, 10) === value;
|
||||
};
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find the next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,20 +1,68 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private readonly apiKeysService: ApiKeysService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
|
||||
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
|
||||
// header) authenticates programmatically AS the license owner. JWTs are
|
||||
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
|
||||
// path below is left completely untouched — zero login regression risk.
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const rawKey = this.extractApiKey(request);
|
||||
if (rawKey) {
|
||||
const result = await this.apiKeysService.validateKey(rawKey);
|
||||
if (!result) {
|
||||
throw new UnauthorizedException('Invalid or revoked API key');
|
||||
}
|
||||
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
|
||||
// the permission layer behave identically. is_api_key grants full access
|
||||
// to THIS license (see PermissionsGuard) — a key is full programmatic
|
||||
// access to your own license, always tenant-scoped by license_id.
|
||||
request.user = {
|
||||
sub: result.user_id ?? undefined,
|
||||
license_id: result.license_id,
|
||||
is_super_admin: false,
|
||||
is_api_key: true,
|
||||
permissions: {},
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
}
|
||||
|
||||
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
|
||||
private extractApiKey(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token.startsWith('corr_')) return token;
|
||||
}
|
||||
const headerKey = request.headers?.['x-api-key'];
|
||||
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
|
||||
return headerKey.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,19 @@ export class PermissionsGuard implements CanActivate {
|
||||
// Super admins bypass all permission checks
|
||||
if (user.is_super_admin) return true;
|
||||
|
||||
// API keys are full programmatic access to their own license (always
|
||||
// tenant-scoped by license_id via @CurrentTenant). Granted here rather than
|
||||
// enumerating every permission. Future: scoped/read-only keys.
|
||||
if (user.is_api_key) return true;
|
||||
|
||||
// Check permissions JSONB from role
|
||||
const permissions = user.permissions as Record<string, boolean> | undefined;
|
||||
if (!permissions) return false;
|
||||
|
||||
// Global wildcard — the Owner role (full control of its license) carries
|
||||
// {"*": true}, so new features never need to amend the role enumeration.
|
||||
if (permissions['*'] === true) return true;
|
||||
|
||||
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
|
||||
const parts = requiredPermission.split('.');
|
||||
const wildcard = parts[0] + '.*';
|
||||
|
||||
100
backend-nest/src/common/ssrf-guard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
/**
|
||||
* SSRF guard for operator-supplied outbound URLs (webhooks today; any future
|
||||
* "we POST to a URL you give us" feature should reuse this).
|
||||
*
|
||||
* The danger: an operator (or anyone who can create a webhook) points the URL at
|
||||
* an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud
|
||||
* metadata endpoint 169.254.169.254 — and turns our server into a request proxy
|
||||
* into the private network. We defend by resolving the host and refusing any
|
||||
* private / loopback / link-local / reserved destination.
|
||||
*
|
||||
* Validate at storage (early, clear 400) AND immediately before each delivery
|
||||
* (a hostname can resolve public at create time and private at send time — DNS
|
||||
* rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the
|
||||
* redirect-bounce variant.
|
||||
*/
|
||||
|
||||
function isBlockedIpv4(ip: string): boolean {
|
||||
const parts = ip.split('.').map((p) => parseInt(p, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
||||
return true; // unparseable → block defensively
|
||||
}
|
||||
const [a, b] = parts;
|
||||
if (a === 0) return true; // 0.0.0.0/8 "this network"
|
||||
if (a === 10) return true; // 10.0.0.0/8 private
|
||||
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
||||
if (a === 255) return true; // 255.x broadcast space
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedIpv6(ip: string): boolean {
|
||||
const addr = ip.toLowerCase();
|
||||
// IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules.
|
||||
const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) return isBlockedIpv4(mapped[1]);
|
||||
if (addr === '::' || addr === '::1') return true; // unspecified / loopback
|
||||
const head = addr.split(':')[0];
|
||||
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA
|
||||
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedIp(ip: string): boolean {
|
||||
const fam = isIP(ip);
|
||||
if (fam === 4) return isBlockedIpv4(ip);
|
||||
if (fam === 6) return isBlockedIpv6(ip);
|
||||
return true; // not a recognizable IP → block defensively
|
||||
}
|
||||
|
||||
/** Parse + require http/https scheme. Throws BadRequestException on anything else. */
|
||||
export function parseHttpUrl(raw: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new BadRequestException('Webhook URL is not a valid URL');
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new BadRequestException('Webhook URL must use http:// or https://');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host and reject if it maps to any private / reserved address.
|
||||
* If a hostname resolves to multiple addresses, ANY blocked one rejects the
|
||||
* whole URL (a DNS-rebinding response that mixes a public and a private answer
|
||||
* must not slip through). Returns the parsed URL on success.
|
||||
*/
|
||||
export async function assertPublicHttpUrl(raw: string): Promise<URL> {
|
||||
const url = parseHttpUrl(raw);
|
||||
// URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the
|
||||
// bare address; otherwise IPv6 literals never reach the classifier.
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
let addresses: Array<{ address: string }>;
|
||||
if (isIP(host)) {
|
||||
addresses = [{ address: host }];
|
||||
} else {
|
||||
try {
|
||||
addresses = await lookup(host, { all: true });
|
||||
} catch {
|
||||
throw new BadRequestException(`Webhook host could not be resolved: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) {
|
||||
throw new BadRequestException(
|
||||
'Webhook URL resolves to a private or reserved address and is not allowed',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
37
backend-nest/src/entities/api-key.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('api_keys')
|
||||
@Index(['key_hash'])
|
||||
@Index(['license_id'])
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
/** First 8 chars of the random token — shown in UI so users can identify keys. */
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
key_prefix: string;
|
||||
|
||||
/** SHA-256 hex digest of the full plaintext key. Never returned to clients. */
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
key_hash: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_used_at: Date | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
47
backend-nest/src/entities/webhook.entity.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('webhooks')
|
||||
@Index(['license_id'])
|
||||
export class Webhook {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* Comma-separated event keys stored as plain text in Postgres.
|
||||
* TypeORM simple-array serialises string[] ↔ 'event1,event2' automatically.
|
||||
*/
|
||||
@Column({ type: 'simple-array' })
|
||||
events: string[];
|
||||
|
||||
/** HMAC-SHA256 signing secret. Auto-generated on create if omitted. */
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
secret: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
is_active: boolean;
|
||||
|
||||
/** Timestamp of the most recent delivery attempt (success or failure). */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_delivery_at: Date | null;
|
||||
|
||||
/** 'ok' | 'failed' — outcome of the most recent delivery attempt. */
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
last_status: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
@@ -111,13 +111,13 @@ export class AnalyticsService {
|
||||
.createQueryBuilder('wipe')
|
||||
.leftJoinAndSelect('wipe.map', 'map')
|
||||
.select('map.id', 'map_id')
|
||||
.addSelect('map.name', 'map_name')
|
||||
.addSelect('map.display_name', 'map_name')
|
||||
.addSelect('COUNT(wipe.id)', 'usage_count')
|
||||
.where('wipe.license_id = :licenseId', { licenseId })
|
||||
.andWhere('wipe.started_at >= :cutoff', { cutoff })
|
||||
.andWhere('wipe.map_id IS NOT NULL')
|
||||
.groupBy('map.id')
|
||||
.addGroupBy('map.name')
|
||||
.addGroupBy('map.display_name')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
|
||||
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
@ApiTags('api-keys')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('apikeys.manage')
|
||||
@ApiOperation({
|
||||
summary: 'Create an API key',
|
||||
description:
|
||||
'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' })
|
||||
async create(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
) {
|
||||
return this.apiKeysService.create(licenseId, dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('apikeys.view')
|
||||
@ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' })
|
||||
@ApiResponse({ status: 200, description: 'Key list.' })
|
||||
async list(@CurrentTenant() licenseId: string) {
|
||||
return this.apiKeysService.list(licenseId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('apikeys.manage')
|
||||
@ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' })
|
||||
@ApiResponse({ status: 200, description: 'Key revoked.' })
|
||||
@ApiResponse({ status: 404, description: 'Key not found in this license.' })
|
||||
async revoke(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.apiKeysService.revoke(licenseId, id);
|
||||
}
|
||||
}
|
||||
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApiKey } from '../../entities/api-key.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApiKey, License])],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
import { ApiKey } from '../../entities/api-key.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
||||
export interface CreatedApiKey {
|
||||
/** Full plaintext key — show once, store nowhere. */
|
||||
plaintext_key: string;
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Safe list view — no hash, no plaintext. */
|
||||
export interface ApiKeyListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
last_used_at: Date | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApiKey)
|
||||
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||
@InjectRepository(License)
|
||||
private readonly licenseRepo: Repository<License>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Issue a new API key for the given license.
|
||||
*
|
||||
* Key format: `corr_<prefix8>_<secret32>`
|
||||
* where prefix and secret are URL-safe base64url random bytes.
|
||||
*
|
||||
* Returns the full plaintext key ONCE alongside the saved row.
|
||||
* The hash is never returned to the caller.
|
||||
*/
|
||||
async create(licenseId: string, name: string): Promise<CreatedApiKey> {
|
||||
const prefixBytes = crypto.randomBytes(6); // 8 base64url chars
|
||||
const secretBytes = crypto.randomBytes(24); // 32 base64url chars
|
||||
|
||||
const prefix = prefixBytes.toString('base64url');
|
||||
const secret = secretBytes.toString('base64url');
|
||||
const plaintextKey = `corr_${prefix}_${secret}`;
|
||||
|
||||
const keyHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(plaintextKey)
|
||||
.digest('hex');
|
||||
|
||||
const entity = this.apiKeyRepo.create({
|
||||
license_id: licenseId,
|
||||
name,
|
||||
key_prefix: prefix,
|
||||
key_hash: keyHash,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const saved = await this.apiKeyRepo.save(entity);
|
||||
|
||||
this.logger.log(
|
||||
`API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
plaintext_key: plaintextKey,
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
key_prefix: saved.key_prefix,
|
||||
is_active: saved.is_active,
|
||||
created_at: saved.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys (active and revoked) for a license.
|
||||
* The key_hash is intentionally excluded.
|
||||
*/
|
||||
async list(licenseId: string): Promise<ApiKeyListItem[]> {
|
||||
const rows = await this.apiKeyRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'DESC' },
|
||||
select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
key_prefix: r.key_prefix,
|
||||
last_used_at: r.last_used_at,
|
||||
is_active: r.is_active,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (soft-delete) a key.
|
||||
* Returns the updated row or throws NotFoundException if the key
|
||||
* doesn't exist within this license.
|
||||
*/
|
||||
async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> {
|
||||
const key = await this.apiKeyRepo.findOne({
|
||||
where: { id, license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
throw new NotFoundException(`API key ${id} not found`);
|
||||
}
|
||||
|
||||
key.is_active = false;
|
||||
await this.apiKeyRepo.save(key);
|
||||
|
||||
this.logger.log(`API key revoked: id=${id} license=${licenseId}`);
|
||||
|
||||
return { id: key.id, is_active: key.is_active };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw API key string. Called by JwtAuthGuard.
|
||||
*
|
||||
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
|
||||
* the license owner (so the guard can attribute the call to a real user UUID),
|
||||
* and returns { license_id, user_id } on success or null on failure.
|
||||
*
|
||||
* user_id is the license owner — API-key calls act AS the owner, so any
|
||||
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
|
||||
*/
|
||||
async validateKey(
|
||||
rawKey: string,
|
||||
): Promise<{ license_id: string; user_id: string | null } | null> {
|
||||
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
|
||||
const key = await this.apiKeyRepo.findOne({
|
||||
where: { key_hash: keyHash, is_active: true },
|
||||
select: ['id', 'license_id'],
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last_used_at without loading the full row again.
|
||||
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
|
||||
|
||||
const license = await this.licenseRepo.findOne({
|
||||
where: { id: key.license_id },
|
||||
select: ['id', 'owner_user_id'],
|
||||
});
|
||||
|
||||
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
@@ -61,6 +62,30 @@ export class AuthController {
|
||||
return this.authService.verifyTotp(userId, dto.code);
|
||||
}
|
||||
|
||||
@Post('2fa/disable')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Disable TOTP 2FA (requires a current code)' })
|
||||
async disableTotp(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: VerifyTotpDto,
|
||||
) {
|
||||
return this.authService.disableTotp(userId, dto.code);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Change the current user password' })
|
||||
async changePassword(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
) {
|
||||
return this.authService.changePassword(
|
||||
userId,
|
||||
dto.current_password,
|
||||
dto.new_password,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
|
||||
@@ -335,6 +335,56 @@ export class AuthService {
|
||||
throw new NotImplementedException('Password reset not yet configured');
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.password_hash, currentPassword);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (await argon2.verify(user.password_hash, newPassword)) {
|
||||
throw new BadRequestException('New password must be different from the current one');
|
||||
}
|
||||
|
||||
const password_hash = await argon2.hash(newPassword);
|
||||
await this.userRepository.update(user.id, { password_hash });
|
||||
this.logger.log(`Password changed for user ${user.id}`);
|
||||
|
||||
// NOTE: existing JWTs remain valid until expiry — this design has no
|
||||
// server-side refresh-token store to revoke. Session invalidation on
|
||||
// password change is a follow-up (tracked separately).
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async disableTotp(userId: string, code: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.totp_enabled) {
|
||||
throw new BadRequestException('2FA is not enabled');
|
||||
}
|
||||
|
||||
// Require a valid current code — proves possession of the second factor
|
||||
// before removing it, so a hijacked session can't silently strip 2FA.
|
||||
const valid = await this.verifyTotpCode(user, code);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid TOTP code');
|
||||
}
|
||||
|
||||
await this.userRepository.update(user.id, {
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
});
|
||||
this.logger.log(`TOTP disabled for user ${user.id}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private async generateTokens(user: User, licenseId?: string) {
|
||||
|
||||
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: 'Current account password' })
|
||||
@IsString()
|
||||
current_password: string;
|
||||
|
||||
@ApiProperty({ description: 'New password', minLength: 8, maxLength: 128 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
new_password: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class AutoDoorsService {
|
||||
constructor(
|
||||
@InjectRepository(AutoDoorsConfig)
|
||||
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class AutoDoorsService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write AutoDoors.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write AutoDoors.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/AutoDoors.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload AutoDoors plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload AutoDoors',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload AutoDoors');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class AutoDoorsService {
|
||||
/** Import AutoDoors.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read AutoDoors.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
},
|
||||
30000,
|
||||
// Read AutoDoors.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/AutoDoors.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class AutoDoorsService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class BetterChatService {
|
||||
constructor(
|
||||
@InjectRepository(BetterChatConfig)
|
||||
private readonly repo: Repository<BetterChatConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class BetterChatService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write BetterChat.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/BetterChat.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write BetterChat.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/BetterChat.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload BetterChat plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterChat',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterChat');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class BetterChatService {
|
||||
/** Import BetterChat.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read BetterChat.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/BetterChat.json',
|
||||
},
|
||||
30000,
|
||||
// Read BetterChat.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/BetterChat.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class BetterChatService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
|
||||
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class FurnaceSplitterService {
|
||||
constructor(
|
||||
@InjectRepository(FurnaceSplitterConfig)
|
||||
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class FurnaceSplitterService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write FurnaceSplitter.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/FurnaceSplitter.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write FurnaceSplitter.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/FurnaceSplitter.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload FurnaceSplitter plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload FurnaceSplitter',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload FurnaceSplitter');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class FurnaceSplitterService {
|
||||
/** Import FurnaceSplitter.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read FurnaceSplitter.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/FurnaceSplitter.json',
|
||||
},
|
||||
30000,
|
||||
// Read FurnaceSplitter.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/FurnaceSplitter.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class FurnaceSplitterService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class GatherService {
|
||||
constructor(
|
||||
@InjectRepository(GatherConfig)
|
||||
private readonly gatherRepo: Repository<GatherConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class GatherService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write GatherManager.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write GatherManager.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/GatherManager.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload GatherManager plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload GatherManager',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload GatherManager');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class GatherService {
|
||||
/** Import GatherManager.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read GatherManager.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
},
|
||||
30000,
|
||||
// Read GatherManager.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/GatherManager.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class GatherService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { InstancesController } from './instances.controller';
|
||||
import { InstancesService } from './instances.service';
|
||||
import { GameInstance } from '../../entities/game-instance.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
// Global so the legacy single-server services (servers/players/schedules/wipes/
|
||||
// plugins + the 9 plugin-config modules) can inject InstancesService to route
|
||||
// commands at the now-only Rust agent without each importing this module.
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GameInstance])],
|
||||
controllers: [InstancesController],
|
||||
providers: [InstancesService, NatsService],
|
||||
exports: [InstancesService],
|
||||
})
|
||||
export class InstancesModule {}
|
||||
|
||||
@@ -142,4 +142,82 @@ export class InstancesService {
|
||||
if (!path || !dest) throw new BadRequestException('path and dest are required');
|
||||
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe an instance's game data via the agent's jailed wipe handler: stop →
|
||||
* delete files per wipe_type (map/blueprint/full) → restart. Long timeout
|
||||
* because the agent does all three steps before replying.
|
||||
*/
|
||||
async wipe(
|
||||
licenseId: string,
|
||||
instanceId: string,
|
||||
wipeType: 'map' | 'blueprint' | 'full',
|
||||
backup = true,
|
||||
): Promise<unknown> {
|
||||
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||
this.logger.log(`instance ${inst.agent_instance_id}: wipe (${wipeType})`);
|
||||
return this.nats.requestScoped(
|
||||
licenseId,
|
||||
subject,
|
||||
{ func: 'wipe', wipe_type: wipeType, backup },
|
||||
120_000,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// License-scoped convenience wrappers. Legacy single-server services
|
||||
// (servers/players/schedules/wipes/plugins + the 9 plugin-config modules)
|
||||
// predate the instance model and carry only a licenseId. These resolve the
|
||||
// license's primary instance, then dispatch to the agent — replacing the old
|
||||
// publishes to the now-defunct `cmd.server` subject.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** The license's primary (oldest) instance. Throws if none is connected. */
|
||||
async resolveDefaultInstance(licenseId: string): Promise<GameInstance> {
|
||||
const inst = await this.instanceRepo.findOne({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
if (!inst) {
|
||||
throw new NotFoundException(
|
||||
'No game instance is connected for this license yet — install and start the host agent first.',
|
||||
);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
async lifecycleForLicense(licenseId: string, func: LifecycleFunc): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.lifecycle(licenseId, inst.id, func);
|
||||
}
|
||||
|
||||
async rconForLicense(licenseId: string, command: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.rcon(licenseId, inst.id, command);
|
||||
}
|
||||
|
||||
async writeFileForLicense(licenseId: string, path: string, content: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.writeFile(licenseId, inst.id, path, content);
|
||||
}
|
||||
|
||||
async readFileForLicense(licenseId: string, path: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.readFile(licenseId, inst.id, path);
|
||||
}
|
||||
|
||||
async deleteFileForLicense(licenseId: string, path: string): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.deleteFile(licenseId, inst.id, path);
|
||||
}
|
||||
|
||||
async wipeForLicense(
|
||||
licenseId: string,
|
||||
wipeType: 'map' | 'blueprint' | 'full',
|
||||
backup = true,
|
||||
): Promise<unknown> {
|
||||
const inst = await this.resolveDefaultInstance(licenseId);
|
||||
return this.wipe(licenseId, inst.id, wipeType, backup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { KitsConfig } from '../../entities/kits-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
|
||||
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class KitsService {
|
||||
constructor(
|
||||
@InjectRepository(KitsConfig)
|
||||
private readonly kitsRepo: Repository<KitsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class KitsService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write Kits.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/Kits.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write Kits.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/Kits.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload Kits plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload Kits',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload Kits');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class KitsService {
|
||||
/** Import Kits.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read Kits.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/Kits.json',
|
||||
},
|
||||
30000,
|
||||
// Read Kits.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/Kits.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class KitsService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||
@@ -15,7 +15,7 @@ export class LootService {
|
||||
constructor(
|
||||
@InjectRepository(LootProfile)
|
||||
private readonly lootRepo: Repository<LootProfile>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List profiles for a license (summaries — no JSONB) */
|
||||
@@ -114,37 +114,22 @@ export class LootService {
|
||||
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
|
||||
|
||||
try {
|
||||
// Write LootTables.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootTables.json',
|
||||
content: lootTablesJson,
|
||||
},
|
||||
30000,
|
||||
// Write LootTables.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/data/BetterLoot/LootTables.json',
|
||||
lootTablesJson,
|
||||
);
|
||||
|
||||
// Write LootGroups.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/data/BetterLoot/LootGroups.json',
|
||||
content: lootGroupsJson,
|
||||
},
|
||||
30000,
|
||||
// Write LootGroups.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/data/BetterLoot/LootGroups.json',
|
||||
lootGroupsJson,
|
||||
);
|
||||
|
||||
// Reload BetterLoot plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload BetterLoot',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterLoot');
|
||||
|
||||
// Mark this profile as active, deactivate others
|
||||
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlayerAction } from '../../entities/player-action.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { PlayerActionDto } from './dto/player-action.dto';
|
||||
|
||||
export interface Player {
|
||||
@@ -23,7 +24,8 @@ export class PlayersService {
|
||||
private readonly actionRepo: Repository<PlayerAction>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly sessionRepo: Repository<PlayerSession>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
private readonly webhooksService: WebhooksService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -132,15 +134,60 @@ export class PlayersService {
|
||||
|
||||
await this.actionRepo.save(action);
|
||||
|
||||
// Forward kick, ban, and unban to the game server via NATS
|
||||
// Forward kick, ban, and unban to the game server via RCON
|
||||
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||
const rconCmd = this.buildRconCommand(dto);
|
||||
await this.instancesService.rconForLicense(licenseId, rconCmd);
|
||||
}
|
||||
|
||||
// Fire webhook event for player bans. Fire-and-forget — a delivery failure
|
||||
// must never surface to the caller or roll back the ban action.
|
||||
if (dto.action_type === 'ban') {
|
||||
void this.webhooksService
|
||||
.dispatch(licenseId, 'player_banned', {
|
||||
steam_id: dto.steam_id,
|
||||
reason: dto.reason,
|
||||
duration_minutes: dto.duration_minutes,
|
||||
player_name: dto.player_name,
|
||||
reason: dto.reason ?? null,
|
||||
duration_minutes: dto.duration_minutes ?? null,
|
||||
})
|
||||
.catch(() => {
|
||||
// dispatch() already logs internally; swallow here to guarantee
|
||||
// the ban action result is unaffected.
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private buildRconCommand(dto: PlayerActionDto): string {
|
||||
// Defense-in-depth against RCON command injection. The command is a single
|
||||
// line; an id or reason containing a newline/control char could break the
|
||||
// framing and inject a second console command. So:
|
||||
// - the player id must be a safe token (no whitespace/control chars) — a
|
||||
// permissive charset, not a Rust-only SteamID64 regex, so Conan (Funcom)
|
||||
// and Dune ids still validate. Reject outright if not.
|
||||
// - the free-text reason has control chars stripped and is length-capped.
|
||||
// - duration is coerced to a non-negative integer.
|
||||
const id = dto.steam_id ?? '';
|
||||
if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(id)) {
|
||||
throw new BadRequestException('Invalid player id');
|
||||
}
|
||||
const safeReason =
|
||||
(dto.reason ?? 'banned').replace(/[\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || 'banned';
|
||||
const secs = Number.isFinite(dto.duration_minutes)
|
||||
? Math.max(0, Math.floor((dto.duration_minutes as number) * 60))
|
||||
: 0;
|
||||
|
||||
switch (dto.action_type) {
|
||||
case 'kick':
|
||||
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
|
||||
case 'ban':
|
||||
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
|
||||
return `banid ${id} ${safeReason} ${secs}`;
|
||||
case 'unban':
|
||||
return `unban ${id}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, ServiceUnavailableException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
|
||||
interface UmodCacheEntry {
|
||||
data: unknown;
|
||||
@@ -20,7 +20,7 @@ export class PluginsService {
|
||||
constructor(
|
||||
@InjectRepository(PluginRegistry)
|
||||
private readonly pluginRegistryRepo: Repository<PluginRegistry>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
|
||||
@@ -43,30 +43,11 @@ export class PluginsService {
|
||||
throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`);
|
||||
}
|
||||
|
||||
const plugin = this.pluginRegistryRepo.create({
|
||||
license_id: licenseId,
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
source: dto.source || 'manual',
|
||||
is_installed: true,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
try {
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_install',
|
||||
plugin_name: dto.plugin_name,
|
||||
umod_slug: dto.umod_slug,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return saved;
|
||||
// One-click uMod install via agent is not yet implemented.
|
||||
// Fail fast — do not persist a DB record for a plugin that won't be deployed.
|
||||
throw new ServiceUnavailableException(
|
||||
'One-click uMod install is coming soon — download the .cs and use Upload for now.',
|
||||
);
|
||||
}
|
||||
|
||||
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
|
||||
@@ -80,11 +61,8 @@ export class PluginsService {
|
||||
|
||||
await this.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId });
|
||||
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, {
|
||||
action: 'unload',
|
||||
plugin_name: plugin.plugin_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(licenseId, `oxide.unload ${plugin.plugin_name}`);
|
||||
await this.instancesService.deleteFileForLicense(licenseId, `oxide/plugins/${plugin.plugin_name}.cs`);
|
||||
this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`);
|
||||
}
|
||||
|
||||
@@ -100,11 +78,7 @@ export class PluginsService {
|
||||
throw new NotFoundException(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name: plugin.plugin_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(licenseId, `oxide.reload ${plugin.plugin_name}`);
|
||||
this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`);
|
||||
|
||||
return { reloaded: true, plugin_name: plugin.plugin_name };
|
||||
@@ -215,19 +189,14 @@ export class PluginsService {
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
// Dispatch to companion agent via NATS
|
||||
// Deploy .cs file to server via host agent
|
||||
try {
|
||||
const content = file.buffer.toString('base64');
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_upload',
|
||||
filename: originalName,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
const content = file.buffer.toString('utf8');
|
||||
await this.instancesService.writeFileForLicense(licenseId, `oxide/plugins/${originalName}`, content);
|
||||
this.logger.log(`Plugin upload deployed: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
|
||||
this.logger.error(`File write failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, file delivery is best-effort
|
||||
}
|
||||
|
||||
return saved;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
|
||||
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class RaidableBasesService {
|
||||
constructor(
|
||||
@InjectRepository(RaidableBasesConfig)
|
||||
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class RaidableBasesService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write RaidableBases.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/RaidableBases.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write RaidableBases.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/RaidableBases.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload RaidableBases plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload RaidableBases',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload RaidableBases');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class RaidableBasesService {
|
||||
/** Import RaidableBases.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read RaidableBases.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/RaidableBases.json',
|
||||
},
|
||||
30000,
|
||||
// Read RaidableBases.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/RaidableBases.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class RaidableBasesService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -10,48 +10,8 @@ import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
/** Parse a 5-field cron expression and return the next Date after `after`. */
|
||||
function nextCronDate(expr: string, after: Date): Date | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||
|
||||
function matches(expr: string, value: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
return parseInt(expr, 10) === value;
|
||||
}
|
||||
|
||||
// Walk minute-by-minute up to 366 days forward to find next match.
|
||||
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||
candidate.setSeconds(0, 0);
|
||||
|
||||
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
while (candidate < limit) {
|
||||
const min = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dom = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||
const dow = candidate.getUTCDay(); // 0=Sun
|
||||
|
||||
if (
|
||||
matches(minuteExpr, min) &&
|
||||
matches(hourExpr, hour) &&
|
||||
matches(domExpr, dom) &&
|
||||
matches(monthExpr, month) &&
|
||||
matches(dowExpr, dow)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setTime(candidate.getTime() + 60_000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { nextCronDate } from '../../common/cron.util';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -61,7 +21,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
@InjectRepository(ScheduledTask)
|
||||
private taskRepository: Repository<ScheduledTask>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,21 +120,12 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
switch (task_type) {
|
||||
case 'restart':
|
||||
await this.natsService.sendServerCommand(license_id, 'restart', {
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
});
|
||||
await this.instancesService.lifecycleForLicense(license_id, 'restart');
|
||||
break;
|
||||
|
||||
case 'announcement': {
|
||||
const message = (task_config?.message as string) ?? 'Scheduled announcement';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command: `say ${message}`,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(license_id, `say ${message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -184,25 +135,13 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
|
||||
return;
|
||||
}
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
|
||||
action: 'command',
|
||||
command,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(license_id, command);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plugin_reload': {
|
||||
const plugin_name = (task_config?.plugin_name as string) ?? '';
|
||||
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
|
||||
action: 'reload',
|
||||
plugin_name,
|
||||
source: 'scheduler',
|
||||
task_id: task.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.instancesService.rconForLicense(license_id, `oxide.reload ${plugin_name}`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, InternalServerErrorException, ServiceUnavailableException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||
import { ServerConfig } from '../../entities/server-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { UpdateServerConfigDto } from './dto/update-config.dto';
|
||||
import { DeployServerDto } from './dto/deploy-server.dto';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class ServersService {
|
||||
@InjectRepository(ServerConfig)
|
||||
private readonly configRepo: Repository<ServerConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -68,11 +70,11 @@ export class ServersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a console command to the server via NATS
|
||||
* Send a console command to the server via the host agent (RCON)
|
||||
*/
|
||||
async sendCommand(licenseId: string, command: string) {
|
||||
try {
|
||||
await this.natsService.sendServerCommand(licenseId, 'command', { command });
|
||||
await this.instancesService.rconForLicense(licenseId, command);
|
||||
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
|
||||
@@ -82,42 +84,45 @@ export class ServersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server via NATS
|
||||
* Start the server via the host agent
|
||||
*/
|
||||
async startServer(licenseId: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'start');
|
||||
await this.instancesService.lifecycleForLicense(licenseId, 'start');
|
||||
return { message: 'Start command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server via NATS
|
||||
* Stop the server via the host agent
|
||||
*/
|
||||
async stopServer(licenseId: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'stop');
|
||||
await this.instancesService.lifecycleForLicense(licenseId, 'stop');
|
||||
return { message: 'Stop command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the server via NATS
|
||||
* Restart the server via the host agent
|
||||
*/
|
||||
async restartServer(licenseId: string) {
|
||||
await this.natsService.sendServerCommand(licenseId, 'restart');
|
||||
await this.instancesService.lifecycleForLicense(licenseId, 'restart');
|
||||
return { message: 'Restart command sent' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy Rust server via companion agent
|
||||
* Deploy Rust server — not yet supported via host agent.
|
||||
* Install the server manually and point the host agent at it.
|
||||
*/
|
||||
async deployServer(licenseId: string, dto: DeployServerDto) {
|
||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||
return { message: 'Deployment started' };
|
||||
async deployServer(_licenseId: string, _dto: DeployServerDto) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Server deployment from the panel is coming soon — install the server and point the host agent at it for now.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Oxide/uMod via companion agent
|
||||
* Install Oxide/uMod — not yet supported via host agent.
|
||||
*/
|
||||
async installOxide(licenseId: string) {
|
||||
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||
return { message: 'Oxide installation started' };
|
||||
async installOxide(_licenseId: string) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Oxide install from the panel is coming soon — install Oxide/uMod on the server for now.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -55,6 +55,13 @@ export class SetupService {
|
||||
if (dto.panel_api_key) {
|
||||
const encryptionKey = this.configService.get<string>('encryption.key', '');
|
||||
const keyBuffer = Buffer.from(encryptionKey, 'hex');
|
||||
// AES-256-GCM needs a 32-byte key. An unset/short ENCRYPTION_KEY would
|
||||
// otherwise crash createCipheriv with an opaque "Invalid key length" 500.
|
||||
if (keyBuffer.length !== 32) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Server encryption is not configured (ENCRYPTION_KEY must be 32 bytes / 64 hex chars). Contact the platform operator.',
|
||||
);
|
||||
}
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
@@ -82,9 +89,12 @@ export class SetupService {
|
||||
});
|
||||
|
||||
if (connection) {
|
||||
// For bare metal, mark as connected immediately (waiting for agent)
|
||||
if (connection.connection_type === 'bare_metal') {
|
||||
connection.connection_status = 'connected';
|
||||
// Bare-metal stays 'offline' until the agent's first heartbeat flips it
|
||||
// 'connected' (HostAgentConsumerService). Marking it connected here was a
|
||||
// false positive — the dashboard showed a live server before any agent
|
||||
// had checked in.
|
||||
if (connection.connection_type === 'bare_metal' && connection.connection_status !== 'connected') {
|
||||
connection.connection_status = 'offline';
|
||||
connection.updated_at = new Date();
|
||||
await this.connectionRepo.save(connection);
|
||||
}
|
||||
|
||||
@@ -57,11 +57,17 @@ export class StoreService {
|
||||
throw new NotFoundException('Module not found');
|
||||
}
|
||||
|
||||
// Beta: modules are granted free (no payment processing wired yet). Record
|
||||
// it honestly as a beta grant at $0 rather than a fake `txn_*` id that
|
||||
// implies a real charge occurred.
|
||||
this.logger.log(
|
||||
`Granting module ${moduleId} to license ${licenseId} free (Beta — no payment processing)`,
|
||||
);
|
||||
const purchase = this.purchaseRepo.create({
|
||||
license_id: licenseId,
|
||||
module_id: moduleId,
|
||||
transaction_id: `txn_${Date.now()}`,
|
||||
amount_paid: parseFloat(module.price_usd.toString()),
|
||||
transaction_id: 'beta-free-grant',
|
||||
amount_paid: 0,
|
||||
});
|
||||
|
||||
return this.purchaseRepo.save(purchase);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class TeleportService {
|
||||
constructor(
|
||||
@InjectRepository(TeleportConfig)
|
||||
private readonly teleportRepo: Repository<TeleportConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class TeleportService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write NTeleportation.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write NTeleportation.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/NTeleportation.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload NTeleportation plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload NTeleportation',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload NTeleportation');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class TeleportService {
|
||||
/** Import NTeleportation.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read NTeleportation.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/NTeleportation.json',
|
||||
},
|
||||
30000,
|
||||
// Read NTeleportation.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/NTeleportation.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class TeleportService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
|
||||
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class TimedExecuteService {
|
||||
constructor(
|
||||
@InjectRepository(TimedExecuteConfig)
|
||||
private readonly repo: Repository<TimedExecuteConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
@@ -81,26 +81,15 @@ export class TimedExecuteService {
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write TimedExecute.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/TimedExecute.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
// Write TimedExecute.json via Rust agent
|
||||
await this.instancesService.writeFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/TimedExecute.json',
|
||||
jsonString,
|
||||
);
|
||||
|
||||
// Reload TimedExecute plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload TimedExecute',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
await this.instancesService.rconForLicense(licenseId, 'oxide.reload TimedExecute');
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||
@@ -126,17 +115,13 @@ export class TimedExecuteService {
|
||||
/** Import TimedExecute.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read TimedExecute.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/TimedExecute.json',
|
||||
},
|
||||
30000,
|
||||
// Read TimedExecute.json from server via Rust agent
|
||||
const result = await this.instancesService.readFileForLicense(
|
||||
licenseId,
|
||||
'oxide/config/TimedExecute.json',
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
throw new HttpException(
|
||||
'No response from agent — it may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -144,13 +129,13 @@ export class TimedExecuteService {
|
||||
}
|
||||
|
||||
// Parse the response content as JSON
|
||||
const responseData = response as Record<string, any>;
|
||||
const responseData = (result as any).content;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
if (typeof responseData.content === 'string') {
|
||||
configData = JSON.parse(responseData.content);
|
||||
} else if (typeof responseData.content === 'object') {
|
||||
configData = responseData.content;
|
||||
if (typeof responseData === 'string') {
|
||||
configData = JSON.parse(responseData);
|
||||
} else if (typeof responseData === 'object') {
|
||||
configData = responseData;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
'Unexpected response format from agent',
|
||||
|
||||
33
backend-nest/src/modules/webhooks/dto/create-webhook.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsNotEmpty, IsUrl, IsArray, ArrayNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateWebhookDto {
|
||||
@ApiProperty({ description: 'Human-readable label for this webhook', maxLength: 100 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'HTTPS URL to POST events to' })
|
||||
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
|
||||
url: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Event keys to subscribe to',
|
||||
example: ['player_banned', 'server_down'],
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
events: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'HMAC-SHA256 signing secret. Auto-generated if omitted.',
|
||||
maxLength: 128,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
secret?: string;
|
||||
}
|
||||
31
backend-nest/src/modules/webhooks/dto/update-webhook.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IsString, IsUrl, IsArray, ArrayNotEmpty, IsOptional, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateWebhookDto {
|
||||
@ApiPropertyOptional({ description: 'Human-readable label for this webhook', maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'HTTPS URL to POST events to' })
|
||||
@IsOptional()
|
||||
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Event keys to subscribe to',
|
||||
example: ['player_banned', 'server_down'],
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
events?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Enable or disable this webhook' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
is_active?: boolean;
|
||||
}
|
||||
70
backend-nest/src/modules/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
@ApiTags('webhooks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('webhooks')
|
||||
export class WebhooksController {
|
||||
constructor(private readonly webhooksService: WebhooksService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('webhooks.manage')
|
||||
@ApiOperation({
|
||||
summary: 'Create a webhook',
|
||||
description:
|
||||
'Registers a new outbound webhook for this license. A signing secret is auto-generated if not provided.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Webhook created.' })
|
||||
async create(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: CreateWebhookDto,
|
||||
) {
|
||||
return this.webhooksService.create(licenseId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('webhooks.view')
|
||||
@ApiOperation({ summary: 'List webhooks', description: 'Returns all webhooks for this license.' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook list.' })
|
||||
async list(@CurrentTenant() licenseId: string) {
|
||||
return this.webhooksService.list(licenseId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('webhooks.manage')
|
||||
@ApiOperation({ summary: 'Update a webhook', description: 'Update name, URL, event subscriptions, or active state.' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook updated.' })
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
|
||||
async update(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWebhookDto,
|
||||
) {
|
||||
return this.webhooksService.update(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('webhooks.manage')
|
||||
@ApiOperation({ summary: 'Delete a webhook' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook deleted.' })
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
|
||||
async remove(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.webhooksService.remove(licenseId, id);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/webhooks/webhooks.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Webhook } from '../../entities/webhook.entity';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Webhook])],
|
||||
controllers: [WebhooksController],
|
||||
providers: [WebhooksService],
|
||||
exports: [WebhooksService],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
236
backend-nest/src/modules/webhooks/webhooks.service.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
import { Webhook } from '../../entities/webhook.entity';
|
||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||
import { UpdateWebhookDto } from './dto/update-webhook.dto';
|
||||
import { assertPublicHttpUrl } from '../../common/ssrf-guard';
|
||||
|
||||
/** Safe list view — secret is included (operator's own resource). */
|
||||
export interface WebhookListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string;
|
||||
is_active: boolean;
|
||||
last_delivery_at: Date | null;
|
||||
last_status: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Shape returned on create — identical to list item. */
|
||||
export type CreatedWebhook = WebhookListItem;
|
||||
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
private readonly logger = new Logger(WebhooksService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Webhook)
|
||||
private readonly webhookRepo: Repository<Webhook>,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
|
||||
// SSRF guard: reject URLs resolving to private/reserved space before storing.
|
||||
await assertPublicHttpUrl(dto.url);
|
||||
|
||||
// Generate a secret if the caller didn't supply one.
|
||||
const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const entity = this.webhookRepo.create({
|
||||
license_id: licenseId,
|
||||
name: dto.name,
|
||||
url: dto.url,
|
||||
events: dto.events,
|
||||
secret,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const saved = await this.webhookRepo.save(entity);
|
||||
|
||||
this.logger.log(
|
||||
`webhook created: id=${saved.id} name="${saved.name}" events=[${saved.events.join(',')}] license=${licenseId}`,
|
||||
);
|
||||
|
||||
return this.toListItem(saved);
|
||||
}
|
||||
|
||||
async list(licenseId: string): Promise<WebhookListItem[]> {
|
||||
const rows = await this.webhookRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return rows.map(this.toListItem);
|
||||
}
|
||||
|
||||
async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise<WebhookListItem> {
|
||||
const webhook = await this.findOwned(licenseId, id);
|
||||
|
||||
// SSRF guard on any URL change.
|
||||
if (dto.url !== undefined) await assertPublicHttpUrl(dto.url);
|
||||
|
||||
if (dto.name !== undefined) webhook.name = dto.name;
|
||||
if (dto.url !== undefined) webhook.url = dto.url;
|
||||
if (dto.events !== undefined) webhook.events = dto.events;
|
||||
if (dto.is_active !== undefined) webhook.is_active = dto.is_active;
|
||||
|
||||
const saved = await this.webhookRepo.save(webhook);
|
||||
|
||||
this.logger.log(`webhook updated: id=${id} license=${licenseId}`);
|
||||
|
||||
return this.toListItem(saved);
|
||||
}
|
||||
|
||||
async remove(licenseId: string, id: string): Promise<{ id: string }> {
|
||||
const webhook = await this.findOwned(licenseId, id);
|
||||
await this.webhookRepo.remove(webhook);
|
||||
this.logger.log(`webhook deleted: id=${id} license=${licenseId}`);
|
||||
return { id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire an event to all active webhooks for a license that are subscribed to
|
||||
* the given event key.
|
||||
*
|
||||
* Contract:
|
||||
* - Fire-and-forget: each delivery is attempted with a 5-second AbortController
|
||||
* timeout and never throws out to the caller.
|
||||
* - Each attempt updates last_delivery_at + last_status ('ok' | 'failed').
|
||||
* - The triggering action is NOT blocked. All deliveries run concurrently via
|
||||
* Promise.allSettled; the returned Promise resolves only after all attempts
|
||||
* finish (or time out), so callers can void it for true fire-and-forget.
|
||||
*
|
||||
* Signature header: X-Corrosion-Signature: sha256=<hex>
|
||||
* where hex = HMAC-SHA256(rawBody, webhook.secret).
|
||||
*/
|
||||
async dispatch(
|
||||
licenseId: string,
|
||||
event: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
let hooks: Webhook[];
|
||||
try {
|
||||
hooks = await this.webhookRepo.find({
|
||||
where: { license_id: licenseId, is_active: true },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`dispatch: failed to query webhooks for license ${licenseId}: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to those subscribed to this event.
|
||||
const subscribed = hooks.filter((h) => h.events.includes(event));
|
||||
if (subscribed.length === 0) return;
|
||||
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: payload,
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subscribed.map((hook) => this.deliverOne(hook, event, body)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deliver to a single webhook endpoint; update delivery metadata. Never throws. */
|
||||
private async deliverOne(hook: Webhook, event: string, body: string): Promise<void> {
|
||||
const signature = this.sign(body, hook.secret);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
let status: 'ok' | 'failed' = 'failed';
|
||||
|
||||
try {
|
||||
// Re-validate at send time: a host that was public at create time can
|
||||
// resolve to a private address now (DNS rebinding / TOCTOU). Throws → caught
|
||||
// below → recorded 'failed'.
|
||||
await assertPublicHttpUrl(hook.url);
|
||||
|
||||
const res = await fetch(hook.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Corrosion-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
// Do not auto-follow redirects — a 3xx Location could point at an
|
||||
// internal host, re-opening the SSRF we just closed. A redirect is a
|
||||
// failed delivery here.
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
status = 'ok';
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`webhook delivery failed: id=${hook.id} event=${event} status=${res.status}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
this.logger.warn(
|
||||
`webhook delivery error: id=${hook.id} event=${event} err=${msg}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Persist delivery outcome — best-effort, never throws.
|
||||
try {
|
||||
await this.webhookRepo.update(hook.id, {
|
||||
last_delivery_at: new Date(),
|
||||
last_status: status,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`webhook metadata update failed: id=${hook.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async findOwned(licenseId: string, id: string): Promise<Webhook> {
|
||||
const webhook = await this.webhookRepo.findOne({
|
||||
where: { id, license_id: licenseId },
|
||||
});
|
||||
if (!webhook) {
|
||||
throw new NotFoundException(`Webhook ${id} not found`);
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
private sign(body: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||
}
|
||||
|
||||
private toListItem(w: Webhook): WebhookListItem {
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
url: w.url,
|
||||
events: w.events,
|
||||
secret: w.secret,
|
||||
is_active: w.is_active,
|
||||
last_delivery_at: w.last_delivery_at,
|
||||
last_status: w.last_status,
|
||||
created_at: w.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StoreConfig } from '../../entities/store-config.entity';
|
||||
@@ -224,23 +224,13 @@ export class WebstoreService {
|
||||
throw new NotFoundException('Item not found');
|
||||
}
|
||||
|
||||
const transaction = this.transactionRepo.create({
|
||||
license_id: license.id,
|
||||
item_id: item.id,
|
||||
steam_id: dto.steam_id,
|
||||
player_name: dto.player_name,
|
||||
paypal_order_id: `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
amount: parseFloat(item.price.toString()),
|
||||
currency: 'USD', // Would get from config
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await this.transactionRepo.save(transaction);
|
||||
|
||||
// Return mock PayPal approval URL
|
||||
return {
|
||||
order_id: transaction.paypal_order_id,
|
||||
approval_url: `https://www.sandbox.paypal.com/checkoutnow?token=${transaction.paypal_order_id}`,
|
||||
};
|
||||
// Beta: real PayPal/Stripe processing is not wired yet. Refuse honestly
|
||||
// instead of writing a pending transaction and handing the player a fake
|
||||
// order token that resolves to nowhere. (item lookup above still validates
|
||||
// the request so the storefront UI can show the catalogue.)
|
||||
void item;
|
||||
throw new ServiceUnavailableException(
|
||||
'Storefront checkout is not available yet — payment processing is coming soon.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||
@@ -8,11 +14,14 @@ import { CreateProfileDto } from './dto/create-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { InstancesService } from '../instances/instances.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { nextCronDate } from '../../common/cron.util';
|
||||
|
||||
@Injectable()
|
||||
export class WipesService {
|
||||
export class WipesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WipesService.name);
|
||||
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WipeProfile)
|
||||
@@ -21,9 +30,86 @@ export class WipesService {
|
||||
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
|
||||
@InjectRepository(WipeHistory)
|
||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||
private readonly natsService: NatsService,
|
||||
private readonly instancesService: InstancesService,
|
||||
private readonly webhooksService: WebhooksService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduled-wipe executor — the auto-wiper. Mirrors SchedulesService: a 60s
|
||||
// poll fires every active wipe schedule whose next_scheduled_run is due, then
|
||||
// advances it from its cron expression. Without this, wipe_schedules rows
|
||||
// never fire (the headline auto-wipe feature was inert).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onModuleInit(): void {
|
||||
this.bootstrapWipeSchedules().catch((err) =>
|
||||
this.logger.error('Failed to bootstrap wipe-schedule next runs', err),
|
||||
);
|
||||
this.wipeExecutorInterval = setInterval(() => {
|
||||
this.executeDueWipes().catch((err) =>
|
||||
this.logger.error('Wipe-schedule executor error', err),
|
||||
);
|
||||
}, 60_000);
|
||||
this.logger.log('Wipe-schedule executor started (60s polling interval)');
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.wipeExecutorInterval) {
|
||||
clearInterval(this.wipeExecutorInterval);
|
||||
this.wipeExecutorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** On startup, stamp next_scheduled_run on active schedules that lack one. */
|
||||
private async bootstrapWipeSchedules(): Promise<void> {
|
||||
const schedules = await this.wipeScheduleRepo.find({
|
||||
where: { is_active: true, next_scheduled_run: IsNull() },
|
||||
});
|
||||
for (const s of schedules) {
|
||||
const next = nextCronDate(s.cron_expression, new Date());
|
||||
if (next) {
|
||||
s.next_scheduled_run = next;
|
||||
await this.wipeScheduleRepo.save(s);
|
||||
}
|
||||
}
|
||||
if (schedules.length > 0) {
|
||||
this.logger.log(`Bootstrapped next run for ${schedules.length} wipe schedule(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire every active wipe schedule whose next_scheduled_run <= now. */
|
||||
private async executeDueWipes(): Promise<void> {
|
||||
const now = new Date();
|
||||
const due = await this.wipeScheduleRepo.find({
|
||||
where: { is_active: true, next_scheduled_run: LessThanOrEqual(now) },
|
||||
});
|
||||
if (due.length === 0) return;
|
||||
|
||||
this.logger.log(`Executing ${due.length} due wipe schedule(s)`);
|
||||
for (const s of due) {
|
||||
try {
|
||||
await this.triggerWipe(
|
||||
s.license_id,
|
||||
{
|
||||
wipe_type: s.wipe_type as TriggerWipeDto['wipe_type'],
|
||||
wipe_profile_id: s.wipe_profile_id,
|
||||
},
|
||||
'scheduled',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Scheduled wipe failed for schedule ${s.id} (${s.schedule_name})`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
} finally {
|
||||
// Advance next_scheduled_run regardless, so a failing schedule doesn't
|
||||
// re-fire every 60s.
|
||||
s.next_scheduled_run = nextCronDate(s.cron_expression, now);
|
||||
await this.wipeScheduleRepo.save(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||
return this.wipeProfileRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
@@ -96,25 +182,56 @@ export class WipesService {
|
||||
async triggerWipe(
|
||||
licenseId: string,
|
||||
dto: TriggerWipeDto,
|
||||
triggerType: 'manual' | 'scheduled' = 'manual',
|
||||
): Promise<{ wipe_history_id: string }> {
|
||||
const history = this.wipeHistoryRepo.create({
|
||||
license_id: licenseId,
|
||||
wipe_type: dto.wipe_type,
|
||||
wipe_profile_id: dto.wipe_profile_id,
|
||||
trigger_type: 'manual',
|
||||
status: 'pending',
|
||||
trigger_type: triggerType,
|
||||
status: 'wiping',
|
||||
started_at: new Date(),
|
||||
});
|
||||
|
||||
const saved = await this.wipeHistoryRepo.save(history);
|
||||
this.logger.log(
|
||||
`Wipe ${triggerType} dispatched for license ${licenseId} — history ${saved.id}`,
|
||||
);
|
||||
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, {
|
||||
// Dispatch to the agent WITHOUT blocking the caller — a wipe is
|
||||
// stop → delete → start and can take a minute+. We record the outcome on
|
||||
// wipe_history from the agent's reply and fire the wipe_completed webhook
|
||||
// when it lands. Previously the row was created 'pending' and never
|
||||
// advanced, so history lied about every wipe.
|
||||
void this.instancesService
|
||||
.wipeForLicense(licenseId, dto.wipe_type, true)
|
||||
.then((reply: unknown) => {
|
||||
const r = (reply ?? {}) as { status?: string; message?: string; deleted_count?: number };
|
||||
const ok = r.status === 'success';
|
||||
saved.status = ok ? 'success' : 'failed';
|
||||
saved.completed_at = new Date();
|
||||
if (!ok) {
|
||||
saved.error_message = r.message ?? 'agent reported wipe failure';
|
||||
}
|
||||
return this.wipeHistoryRepo.save(saved).then(() => {
|
||||
this.logger.log(`Wipe ${saved.id} ${saved.status}`);
|
||||
if (ok) {
|
||||
void this.webhooksService.dispatch(licenseId, 'wipe_completed', {
|
||||
wipe_history_id: saved.id,
|
||||
wipe_type: dto.wipe_type,
|
||||
wipe_profile_id: dto.wipe_profile_id ?? null,
|
||||
trigger_type: 'manual',
|
||||
timestamp: new Date().toISOString(),
|
||||
trigger_type: triggerType,
|
||||
deleted_count: r.deleted_count ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
saved.status = 'failed';
|
||||
saved.completed_at = new Date();
|
||||
saved.error_message = err instanceof Error ? err.message : 'wipe dispatch failed';
|
||||
this.logger.warn(`Wipe ${saved.id} failed: ${saved.error_message}`);
|
||||
void this.wipeHistoryRepo.save(saved);
|
||||
});
|
||||
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
||||
|
||||
return { wipe_history_id: saved.id };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ServerConnection } from '../entities/server-connection.entity';
|
||||
import { License } from '../entities/license.entity';
|
||||
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
|
||||
import { GameInstance } from '../entities/game-instance.entity';
|
||||
import { WebhooksService } from '../modules/webhooks/webhooks.service';
|
||||
|
||||
/**
|
||||
* Consumes Corrosion wire protocol v2 host-agent subjects
|
||||
@@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
private readonly hostRepository: Repository<AgentHost>,
|
||||
@InjectRepository(GameInstance)
|
||||
private readonly instanceRepository: Repository<GameInstance>,
|
||||
private readonly webhooksService: WebhooksService,
|
||||
) {}
|
||||
|
||||
// Bootstrap, not module-init: subscriptions registered before NatsService
|
||||
@@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
{ license_id: licenseId },
|
||||
{ connection_status: 'offline', updated_at: now },
|
||||
);
|
||||
|
||||
// Capture hostname(s) before flipping status so the webhook payload is useful.
|
||||
const hosts = await this.hostRepository.find({ where: { license_id: licenseId } });
|
||||
|
||||
await this.hostRepository.update(
|
||||
{ license_id: licenseId },
|
||||
{ status: 'offline', updated_at: now },
|
||||
);
|
||||
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
|
||||
|
||||
// Dispatch server_down event for each host that went offline. Fire-and-forget.
|
||||
for (const host of hosts) {
|
||||
void this.webhooksService
|
||||
.dispatch(licenseId, 'server_down', {
|
||||
host_id: host.id,
|
||||
hostname: host.hostname ?? null,
|
||||
reason: 'graceful_shutdown',
|
||||
})
|
||||
.catch(() => {
|
||||
// dispatch() logs internally; swallow here to keep the handler clean.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeats stopping must flip the panel to offline — an agent that
|
||||
* crashes or loses network never sends the goodbye beacon. Sweeps both the
|
||||
* legacy connection and fleet hosts.
|
||||
*
|
||||
* Hosts that transition to offline here also fire the server_down webhook.
|
||||
* We identify them BEFORE the bulk update so we can carry their identity
|
||||
* into the webhook payload.
|
||||
*/
|
||||
@Interval(60_000)
|
||||
async sweepStaleConnections(): Promise<void> {
|
||||
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
|
||||
|
||||
// Identify stale hosts BEFORE bulk-updating so we can dispatch webhooks
|
||||
// with meaningful host_id / hostname data.
|
||||
const staleHosts = await this.hostRepository
|
||||
.createQueryBuilder('host')
|
||||
.where('host.status = :connected', { connected: 'connected' })
|
||||
.andWhere('host.last_heartbeat_at IS NOT NULL')
|
||||
.andWhere('host.last_heartbeat_at < :threshold', { threshold })
|
||||
.getMany();
|
||||
|
||||
const conn = await this.connectionRepository
|
||||
.createQueryBuilder()
|
||||
.update(ServerConnection)
|
||||
@@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
|
||||
if (affected) {
|
||||
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
|
||||
}
|
||||
|
||||
// Dispatch server_down webhook for each host that just timed out.
|
||||
// Fire-and-forget — webhook failures must never break the sweep.
|
||||
for (const host of staleHosts) {
|
||||
void this.webhooksService
|
||||
.dispatch(host.license_id, 'server_down', {
|
||||
host_id: host.id,
|
||||
hostname: host.hostname ?? null,
|
||||
reason: 'heartbeat_timeout',
|
||||
})
|
||||
.catch(() => {
|
||||
// dispatch() logs internally; swallow here to keep the sweep clean.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
17
backend/migrations/023_api_keys.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Per-license API key management
|
||||
-- Each row represents one issued key: the plaintext is shown once at creation
|
||||
-- and never stored; only the SHA-256 hex digest is persisted.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(128) NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_license ON api_keys(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||
26
backend/migrations/024_webhooks.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- 024_webhooks.sql
|
||||
-- Per-license outbound webhook registry.
|
||||
-- Operators register URLs + event subscriptions; the backend POSTs signed
|
||||
-- JSON payloads on matching events (player_banned, server_down, …).
|
||||
|
||||
CREATE TABLE webhooks (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
license_id uuid NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
name varchar(100) NOT NULL,
|
||||
url text NOT NULL,
|
||||
-- Comma-separated event keys, e.g. 'player_banned,server_down'
|
||||
-- TypeORM simple-array maps this transparently to string[].
|
||||
events text NOT NULL,
|
||||
-- HMAC-SHA256 signing secret; generated server-side if omitted on create.
|
||||
secret varchar(128) NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
-- Populated after each delivery attempt.
|
||||
last_delivery_at timestamptz NULL,
|
||||
-- 'ok' | 'failed' — last HTTP delivery outcome.
|
||||
last_status varchar(20) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT webhooks_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhooks_license_id ON webhooks (license_id);
|
||||
15
backend/migrations/025_owner_full_access.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 025_owner_full_access.sql
|
||||
--
|
||||
-- The system-default Owner role enumerated per-resource wildcards
|
||||
-- (server.*, wipe.*, players.*, ...). Every feature added since drift past that
|
||||
-- enumeration: apikeys, webhooks, alerts, analytics, chat, schedules,
|
||||
-- notifications, map, users, and ALL plugin-config modules (plus a singular
|
||||
-- 'plugin.*' vs granted 'plugins.*' mismatch) were silently locked out for any
|
||||
-- non-super-admin Owner — PermissionsGuard denies a permission the role doesn't
|
||||
-- grant. The Owner has "full control of their license" by definition, so grant
|
||||
-- a global wildcard instead of an enumeration that must be amended per feature.
|
||||
--
|
||||
-- PermissionsGuard and the frontend auth store both honor "*" as allow-all.
|
||||
UPDATE roles
|
||||
SET permissions = '{"*": true}'::jsonb
|
||||
WHERE role_name = 'Owner' AND is_system_default = true;
|
||||
14
corrosion-host-agent/Cargo.lock
generated
@@ -110,6 +110,17 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -276,10 +287,11 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.6"
|
||||
version = "2.0.0-alpha.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "corrosion-host-agent"
|
||||
version = "2.0.0-alpha.7"
|
||||
version = "2.0.0-alpha.11"
|
||||
edition = "2021"
|
||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||
license = "UNLICENSED"
|
||||
@@ -23,6 +23,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
rand = "0.8"
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
@@ -101,8 +101,16 @@ Payload: `{}`.
|
||||
|
||||
Lifecycle and control for one game instance.
|
||||
|
||||
The same `start`/`stop`/`restart`/`status` funcs work for **every** game: the
|
||||
agent picks a `Supervisor` impl per game — a spawned-process supervisor for
|
||||
Rust/Conan/Soulmask, a **docker-compose supervisor for Dune** (`docker compose
|
||||
up -d` / `stop` / `restart` against the instance's compose project, configured
|
||||
via `[instance.docker_compose]`). The wire contract is identical; only the
|
||||
management model behind it differs.
|
||||
|
||||
Implemented funcs: `start`, `stop` (graceful with 30s budget, then force
|
||||
kill), `restart`, `status` (returns `state` + `uptime_seconds`), and
|
||||
kill — process supervisor; Dune maps stop to `docker compose stop`), `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
|
||||
@@ -118,7 +126,10 @@ 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).
|
||||
commands (Dune: RabbitMQ admin-bus commands, Coriolis reset, Postgres admin
|
||||
surface). Dune **lifecycle** is already covered by the shared
|
||||
start/stop/restart funcs above; container crash-detection and state adoption on
|
||||
agent restart land with Phase 3b.
|
||||
|
||||
### `corrosion.{license_id}.{instance_id}.steam_status` (agent → backend, publish) — LIVE
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening.
|
||||
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 2: Dune Docker adapter — **compose lifecycle done** (`docker compose up -d/stop/restart`
|
||||
via the `Supervisor` trait + `DockerComposeSupervisor`); RabbitMQ admin bus + Postgres admin
|
||||
surface deferred. Container crash-detection + state adoption on agent restart land with Phase 3b.
|
||||
- [x] Phase 3a: SIGNED self-update — minisign-verified download+swap+relaunch (NATS `update` func); embedded public key; CI signs releases
|
||||
- [ ] Phase 3b: service install (systemd/SCM), PID adoption
|
||||
|
||||
|
||||
@@ -60,6 +60,24 @@ password = "changeme"
|
||||
# Dune instances do not use SteamCMD (Docker images); the steam_update func
|
||||
# will return a clear error if invoked on a dune instance.
|
||||
|
||||
# --- Dune: Awakening (container-managed) ---------------------------------
|
||||
# Dune runs as a docker-compose stack, not a spawned process — leave
|
||||
# `executable` unset and add an [instance.docker_compose] block. The agent
|
||||
# drives `docker compose up -d / stop / restart` for start/stop/restart, and
|
||||
# `steam_update` is rejected (Dune ships as Docker images).
|
||||
#
|
||||
# [[instance]]
|
||||
# id = "dune-main"
|
||||
# game = "dune"
|
||||
# root = "/opt/dune" # directory the compose commands run in
|
||||
# label = "Arrakis (battlegroup)"
|
||||
#
|
||||
# [instance.docker_compose]
|
||||
# file = "docker-compose.yml" # -f; relative to root. Omit to use compose's discovery
|
||||
# project = "dune-main" # -p; defaults to the instance id
|
||||
# service = "gameserver" # limit lifecycle to one service; omit for the whole stack
|
||||
# command = ["docker", "compose"] # default; use ["docker-compose"] for the legacy binary
|
||||
|
||||
[prober]
|
||||
interval_seconds = 300
|
||||
|
||||
|
||||
@@ -7,16 +7,17 @@ use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::process::ProcessSupervisor;
|
||||
use crate::prober::ProbeReport;
|
||||
use crate::supervisor::Supervisor;
|
||||
|
||||
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>>,
|
||||
/// One supervisor per instance, keyed by instance id. The concrete impl
|
||||
/// (process vs docker-compose) is chosen per game by the factory in main;
|
||||
/// every subsystem talks to the `Supervisor` trait only.
|
||||
pub supervisors: HashMap<String, Arc<dyn Supervisor>>,
|
||||
pub shutdown: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::docker_compose::DockerComposeConfig;
|
||||
use crate::rcon::RconConfig;
|
||||
use crate::steamcmd::SteamcmdConfig;
|
||||
|
||||
@@ -76,6 +77,10 @@ pub struct InstanceConfig {
|
||||
/// validate = false).
|
||||
#[serde(default)]
|
||||
pub steamcmd: Option<SteamcmdConfig>,
|
||||
/// Docker-compose settings for container-managed games (Dune). Absent =
|
||||
/// defaults apply (compose file in the instance root, project = instance id).
|
||||
#[serde(default)]
|
||||
pub docker_compose: Option<DockerComposeConfig>,
|
||||
}
|
||||
|
||||
impl InstanceConfig {
|
||||
|
||||
216
corrosion-host-agent/src/docker_compose.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! Docker-compose instance supervision — the Dune: Awakening adapter.
|
||||
//!
|
||||
//! Dune does not ship as a SteamCMD-updated process like Rust/Conan/Soulmask;
|
||||
//! it runs as Docker container(s) (game server + RabbitMQ broker + Postgres),
|
||||
//! orchestrated as a compose stack (a "battlegroup"). So Dune lifecycle is
|
||||
//! `docker compose up -d / stop / restart` against the instance's compose
|
||||
//! project, not a spawned OS process. This supervisor implements the same
|
||||
//! [`Supervisor`] trait `ProcessSupervisor` does, so the instance command
|
||||
//! dispatch is identical — only the management model differs.
|
||||
//!
|
||||
//! Scope (first cut): lifecycle + cached state. Two parity items are deferred
|
||||
//! to Phase 3b alongside process PID adoption: (1) crash detection (containers
|
||||
//! give us no child handle — a `docker compose ps` poll loop would supply it);
|
||||
//! (2) state adoption on agent restart (a running stack reports `stopped` until
|
||||
//! the next lifecycle command). Both are reconcilable with a `ps` probe.
|
||||
//!
|
||||
//! Reference: docs/reference-repos/icehunter SETUP_DOCKER.md (the docker
|
||||
//! control plane this mirrors).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{watch, Mutex};
|
||||
|
||||
use crate::config::InstanceConfig;
|
||||
use crate::supervisor::{InstanceState, Supervisor};
|
||||
|
||||
/// Per-instance docker-compose settings (`[instance.docker_compose]`). All
|
||||
/// fields optional — defaults cover the common "one compose file in the
|
||||
/// instance root" case.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DockerComposeConfig {
|
||||
/// Compose file (`-f`). Relative paths resolve against the run dir. Default:
|
||||
/// compose's own discovery (docker-compose.yml in the run dir).
|
||||
#[serde(default)]
|
||||
pub file: Option<PathBuf>,
|
||||
/// Compose project name (`-p`). Default: the instance id.
|
||||
#[serde(default)]
|
||||
pub project: Option<String>,
|
||||
/// Limit lifecycle ops to one service. Default: every service in the file.
|
||||
#[serde(default)]
|
||||
pub service: Option<String>,
|
||||
/// Override the compose binary invocation. Default: `["docker","compose"]`.
|
||||
/// Use `["docker-compose"]` for the legacy standalone binary.
|
||||
#[serde(default)]
|
||||
pub command: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
started_at: Option<Instant>,
|
||||
}
|
||||
|
||||
pub struct DockerComposeSupervisor {
|
||||
instance_id: String,
|
||||
/// Directory the compose commands run in (relative `-f`/file paths resolve
|
||||
/// against it).
|
||||
run_dir: PathBuf,
|
||||
compose_file: Option<PathBuf>,
|
||||
project: String,
|
||||
service: Option<String>,
|
||||
/// Compose binary + leading args, e.g. `["docker","compose"]`.
|
||||
command: Vec<String>,
|
||||
inner: Mutex<Inner>,
|
||||
state_tx: watch::Sender<InstanceState>,
|
||||
}
|
||||
|
||||
impl DockerComposeSupervisor {
|
||||
pub fn new(cfg: &InstanceConfig) -> Arc<Self> {
|
||||
let dc = cfg.docker_compose.clone().unwrap_or_default();
|
||||
let run_dir = cfg
|
||||
.working_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.root.clone());
|
||||
let command = dc
|
||||
.command
|
||||
.filter(|c| !c.is_empty())
|
||||
.unwrap_or_else(|| vec!["docker".to_string(), "compose".to_string()]);
|
||||
let (state_tx, _) = watch::channel(InstanceState::Stopped);
|
||||
Arc::new(Self {
|
||||
instance_id: cfg.id.clone(),
|
||||
run_dir,
|
||||
compose_file: dc.file,
|
||||
project: dc.project.unwrap_or_else(|| cfg.id.clone()),
|
||||
service: dc.service,
|
||||
command,
|
||||
inner: Mutex::new(Inner { started_at: None }),
|
||||
state_tx,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_state(&self, state: InstanceState) {
|
||||
let _ = self.state_tx.send_replace(state);
|
||||
}
|
||||
|
||||
/// Run one compose subcommand (`up`/`stop`/`restart`/...), bailing with the
|
||||
/// captured stderr on non-zero exit. Global flags (`-f`, `-p`) precede the
|
||||
/// subcommand; the optional single service is appended last.
|
||||
async fn run(&self, action: &str, action_args: &[&str]) -> Result<()> {
|
||||
let mut cmd = Command::new(&self.command[0]);
|
||||
cmd.args(&self.command[1..]);
|
||||
if let Some(file) = &self.compose_file {
|
||||
cmd.arg("-f").arg(file);
|
||||
}
|
||||
cmd.arg("-p").arg(&self.project);
|
||||
cmd.arg(action);
|
||||
cmd.args(action_args);
|
||||
if let Some(service) = &self.service {
|
||||
cmd.arg(service);
|
||||
}
|
||||
cmd.current_dir(&self.run_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("running `{} {action}` (is docker installed and on PATH?)", self.command.join(" ")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = if !stderr.trim().is_empty() {
|
||||
stderr.trim()
|
||||
} else {
|
||||
stdout.trim()
|
||||
};
|
||||
bail!("compose {action} failed ({}): {detail}", output.status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Supervisor for DockerComposeSupervisor {
|
||||
fn instance_id(&self) -> &str {
|
||||
&self.instance_id
|
||||
}
|
||||
|
||||
fn state(&self) -> InstanceState {
|
||||
self.state_tx.borrow().clone()
|
||||
}
|
||||
|
||||
fn watch_state(&self) -> watch::Receiver<InstanceState> {
|
||||
self.state_tx.subscribe()
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(self: Arc<Self>) -> Result<()> {
|
||||
if matches!(
|
||||
*self.state_tx.borrow(),
|
||||
InstanceState::Running | InstanceState::Starting
|
||||
) {
|
||||
bail!("instance '{}' is already running", self.instance_id);
|
||||
}
|
||||
self.set_state(InstanceState::Starting);
|
||||
match self.run("up", &["-d"]).await {
|
||||
Ok(()) => {
|
||||
self.inner.lock().await.started_at = Some(Instant::now());
|
||||
self.set_state(InstanceState::Running);
|
||||
tracing::info!("instance '{}' compose up -d", self.instance_id);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_state(InstanceState::Stopped);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop(self: Arc<Self>) -> Result<()> {
|
||||
self.set_state(InstanceState::Stopping);
|
||||
match self.run("stop", &[]).await {
|
||||
Ok(()) => {
|
||||
self.inner.lock().await.started_at = None;
|
||||
self.set_state(InstanceState::Stopped);
|
||||
tracing::info!("instance '{}' compose stop", self.instance_id);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Stop failed — the stack is most likely still up.
|
||||
self.set_state(InstanceState::Running);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart(self: Arc<Self>) -> Result<()> {
|
||||
self.set_state(InstanceState::Starting);
|
||||
match self.run("restart", &[]).await {
|
||||
Ok(()) => {
|
||||
self.inner.lock().await.started_at = Some(Instant::now());
|
||||
self.set_state(InstanceState::Running);
|
||||
tracing::info!("instance '{}' compose restart", self.instance_id);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_state(InstanceState::Stopped);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,10 @@ use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::agent::Agent;
|
||||
use crate::process::ProcessSupervisor;
|
||||
use crate::subjects;
|
||||
use crate::steamcmd;
|
||||
use crate::supervisor::Supervisor;
|
||||
use crate::wipe;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InstanceCommand {
|
||||
@@ -23,11 +24,24 @@ struct InstanceCommand {
|
||||
/// Payload for funcs that carry a text argument (e.g. rcon).
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
/// Wipe type: "map" | "blueprint" | "full" — required for func="wipe".
|
||||
#[serde(default)]
|
||||
wipe_type: Option<wipe::WipeType>,
|
||||
/// Whether to back up wipe targets before deleting (func="wipe").
|
||||
#[serde(default)]
|
||||
backup: bool,
|
||||
/// Label for the backup subdirectory (func="wipe"). Defaults to "wipe-backup".
|
||||
#[serde(default = "default_backup_label")]
|
||||
backup_label: String,
|
||||
}
|
||||
|
||||
fn default_backup_label() -> String {
|
||||
"wipe-backup".to_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);
|
||||
pub async fn publish_state_changes(agent: Arc<Agent>, sup: Arc<dyn Supervisor>) {
|
||||
let subject = subjects::instance_status(&agent.cfg.license_id, sup.instance_id());
|
||||
let mut rx = sup.watch_state();
|
||||
let cancel = agent.shutdown.clone();
|
||||
|
||||
@@ -40,13 +54,13 @@ pub async fn publish_state_changes(agent: Arc<Agent>, sup: Arc<ProcessSupervisor
|
||||
let state = rx.borrow().clone();
|
||||
let event = json!({
|
||||
"timestamp": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
"instance_id": sup.instance_id,
|
||||
"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);
|
||||
tracing::warn!("status publish failed for '{}': {e}", sup.instance_id());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("status serialize failed: {e}"),
|
||||
@@ -58,8 +72,8 @@ pub async fn publish_state_changes(agent: Arc<Agent>, sup: Arc<ProcessSupervisor
|
||||
}
|
||||
|
||||
/// 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);
|
||||
pub async fn run(agent: Arc<Agent>, sup: Arc<dyn Supervisor>) -> 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}");
|
||||
|
||||
@@ -74,13 +88,13 @@ pub async fn run(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>) -> anyhow::Resu
|
||||
tokio::spawn(async move { handle(agent, sup, msg).await });
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("instance command subscription ended for '{}'", sup.instance_id);
|
||||
tracing::warn!("instance command subscription ended for '{}'", sup.instance_id());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("instance command handler stopping for '{}'", sup.instance_id);
|
||||
tracing::info!("instance command handler stopping for '{}'", sup.instance_id());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +102,7 @@ pub async fn run(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>) -> anyhow::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>, msg: async_nats::Message) {
|
||||
async fn handle(agent: Arc<Agent>, sup: Arc<dyn Supervisor>, msg: async_nats::Message) {
|
||||
let Some(reply) = msg.reply.clone() else {
|
||||
tracing::warn!("instance command without reply subject ignored");
|
||||
return;
|
||||
@@ -113,20 +127,22 @@ async fn handle(agent: Arc<Agent>, sup: Arc<ProcessSupervisor>, msg: async_nats:
|
||||
|
||||
async fn dispatch(
|
||||
agent: &Arc<Agent>,
|
||||
sup: &Arc<ProcessSupervisor>,
|
||||
sup: &Arc<dyn Supervisor>,
|
||||
cmd: &InstanceCommand,
|
||||
) -> serde_json::Value {
|
||||
let func = cmd.func.as_str();
|
||||
|
||||
// start/stop/restart take `self: Arc<Self>` (they may hand a clone to a
|
||||
// monitor task), so clone the Arc before the consuming call.
|
||||
let outcome = match func {
|
||||
"start" => sup.start().await.map(|_| "starting"),
|
||||
"stop" => sup.stop().await.map(|_| "stopped"),
|
||||
"restart" => sup.restart().await.map(|_| "restarted"),
|
||||
"start" => sup.clone().start().await.map(|_| "starting"),
|
||||
"stop" => sup.clone().stop().await.map(|_| "stopped"),
|
||||
"restart" => sup.clone().restart().await.map(|_| "restarted"),
|
||||
"status" => {
|
||||
return json!({
|
||||
"status": "success",
|
||||
"func": "status",
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"state": sup.state(),
|
||||
"uptime_seconds": sup.uptime_seconds().await,
|
||||
});
|
||||
@@ -139,15 +155,15 @@ async fn dispatch(
|
||||
.cfg
|
||||
.instances
|
||||
.iter()
|
||||
.find(|i| i.id == sup.instance_id);
|
||||
.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),
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("instance '{}' has no rcon configured", sup.instance_id()),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -155,7 +171,7 @@ async fn dispatch(
|
||||
return json!({
|
||||
"status": "error",
|
||||
"func": "rcon",
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": "rcon func requires a 'command' field",
|
||||
});
|
||||
};
|
||||
@@ -165,13 +181,13 @@ async fn dispatch(
|
||||
Ok(output) => json!({
|
||||
"status": "success",
|
||||
"func": "rcon",
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"output": output,
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"status": "error",
|
||||
"func": "rcon",
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("{e:#}"),
|
||||
}),
|
||||
};
|
||||
@@ -181,14 +197,14 @@ async fn dispatch(
|
||||
// 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 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),
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("no config found for instance '{}'", sup.instance_id()),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -209,7 +225,7 @@ async fn dispatch(
|
||||
};
|
||||
|
||||
let license = agent.cfg.license_id.clone();
|
||||
let instance_id = sup.instance_id.clone();
|
||||
let instance_id = sup.instance_id().to_string();
|
||||
let nats = agent.nats.clone();
|
||||
|
||||
// Publish each progress line to the steam_status subject.
|
||||
@@ -240,20 +256,89 @@ async fn dispatch(
|
||||
Ok(()) => json!({
|
||||
"status": "success",
|
||||
"func": "steam_update",
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"status": "error",
|
||||
"func": "steam_update",
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("{e:#}"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
"wipe" => {
|
||||
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": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("no config found for instance '{}'", sup.instance_id()),
|
||||
});
|
||||
};
|
||||
|
||||
let Some(wipe_type) = cmd.wipe_type.clone() else {
|
||||
return json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": "wipe func requires a 'wipe_type' field (\"map\", \"blueprint\", or \"full\")",
|
||||
});
|
||||
};
|
||||
|
||||
let root = inst_cfg.root.clone();
|
||||
let instance_id = sup.instance_id().to_string();
|
||||
|
||||
let wipe_req = wipe::WipeRequest {
|
||||
wipe_type,
|
||||
backup: cmd.backup,
|
||||
backup_label: cmd.backup_label.clone(),
|
||||
};
|
||||
|
||||
// Stop the server best-effort before wiping; proceed even if stop fails
|
||||
// (the server may already be down).
|
||||
if let Err(e) = sup.clone().stop().await {
|
||||
tracing::warn!("wipe: stop instance '{}' failed (proceeding anyway): {e:#}", instance_id);
|
||||
}
|
||||
|
||||
// Run the blocking I/O on the blocking thread pool.
|
||||
let result = tokio::task::spawn_blocking(move || wipe::execute(&root, &wipe_req)).await;
|
||||
|
||||
// Restart best-effort regardless of wipe outcome.
|
||||
if let Err(e) = sup.clone().start().await {
|
||||
tracing::warn!("wipe: restart instance '{}' failed: {e:#}", instance_id);
|
||||
}
|
||||
|
||||
return match result {
|
||||
Ok(Ok(wr)) => {
|
||||
let wipe_type_str = format!("{:?}", wr.wipe_type).to_lowercase();
|
||||
json!({
|
||||
"status": "success",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"wipe_type": wipe_type_str,
|
||||
"deleted_count": wr.deleted_count,
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("{e:#}"),
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"status": "error",
|
||||
"func": "wipe",
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("internal error: {e}"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
other => {
|
||||
return json!({
|
||||
"status": "error",
|
||||
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"),
|
||||
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update, wipe)"),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -262,14 +347,14 @@ async fn dispatch(
|
||||
Ok(result) => json!({
|
||||
"status": "success",
|
||||
"func": func,
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"result": result,
|
||||
"state": sup.state(),
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"status": "error",
|
||||
"func": func,
|
||||
"instance_id": sup.instance_id,
|
||||
"instance_id": sup.instance_id(),
|
||||
"message": format!("{e:#}"),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
pub mod agent;
|
||||
pub mod bus;
|
||||
pub mod config;
|
||||
pub mod docker_compose;
|
||||
pub mod filemanager;
|
||||
pub mod hostcmd;
|
||||
pub mod instancecmd;
|
||||
pub mod prober;
|
||||
pub mod process;
|
||||
pub mod rcon;
|
||||
pub mod service;
|
||||
pub mod steamcmd;
|
||||
pub mod subjects;
|
||||
pub mod supervisor;
|
||||
pub mod telemetry;
|
||||
pub mod update;
|
||||
pub mod version;
|
||||
pub mod wipe;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! game adapters arrive in Phase 1+ (see PROTOCOL.md).
|
||||
|
||||
use corrosion_host_agent::{
|
||||
agent, bus, config, filemanager, hostcmd, instancecmd, prober, process, subjects, telemetry,
|
||||
version,
|
||||
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
|
||||
service, subjects, supervisor, telemetry, version,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -37,6 +37,10 @@ enum Command {
|
||||
Check,
|
||||
/// Print full version (semver, git hash, build timestamp) and exit.
|
||||
Version,
|
||||
/// Install as a systemd service and start it (Linux; requires root).
|
||||
Install,
|
||||
/// Stop and remove the systemd service (Linux; requires root).
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -58,6 +62,8 @@ fn main() -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some(Command::Install) => service::install(&config_path),
|
||||
Some(Command::Uninstall) => service::uninstall(),
|
||||
None => {
|
||||
let settings = config::load(&config_path)?;
|
||||
init_logging(&settings.log_level);
|
||||
@@ -92,10 +98,20 @@ async fn run(settings: config::Settings) -> Result<()> {
|
||||
|
||||
let nats = bus::connect(&settings).await?;
|
||||
|
||||
let supervisors = settings
|
||||
// Per-game supervisor factory: container-managed games (Dune) get a
|
||||
// docker-compose supervisor; everything else is a spawned-process
|
||||
// supervisor. Both satisfy the `Supervisor` trait, so the rest of the agent
|
||||
// is game-agnostic.
|
||||
let supervisors: std::collections::HashMap<String, Arc<dyn supervisor::Supervisor>> = settings
|
||||
.instances
|
||||
.iter()
|
||||
.map(|inst| (inst.id.clone(), process::ProcessSupervisor::new(inst)))
|
||||
.map(|inst| {
|
||||
let sup: Arc<dyn supervisor::Supervisor> = match inst.game.as_str() {
|
||||
"dune" => docker_compose::DockerComposeSupervisor::new(inst),
|
||||
_ => process::ProcessSupervisor::new(inst),
|
||||
};
|
||||
(inst.id.clone(), sup)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let agent = Arc::new(Agent {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! 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.
|
||||
//! One `ProcessSupervisor` per process-managed instance (Rust/Conan/Soulmask).
|
||||
//! 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. Lifecycle control is exposed through the [`Supervisor`] trait so
|
||||
//! the command dispatch is identical across process- and container-managed
|
||||
//! games.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
@@ -17,39 +19,11 @@ use tokio::process::{Child, Command};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
|
||||
use crate::config::InstanceConfig;
|
||||
use crate::supervisor::{InstanceState, Supervisor};
|
||||
|
||||
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>,
|
||||
@@ -59,7 +33,7 @@ struct Inner {
|
||||
}
|
||||
|
||||
pub struct ProcessSupervisor {
|
||||
pub instance_id: String,
|
||||
instance_id: String,
|
||||
executable: Option<PathBuf>,
|
||||
args: Vec<String>,
|
||||
working_dir: Option<PathBuf>,
|
||||
@@ -90,72 +64,6 @@ impl ProcessSupervisor {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -201,7 +109,85 @@ impl ProcessSupervisor {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(self: &Arc<Self>) -> Result<()> {
|
||||
fn set_state(&self, state: InstanceState) {
|
||||
// send_replace never fails even with zero receivers.
|
||||
let _ = self.state_tx.send_replace(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Supervisor for ProcessSupervisor {
|
||||
fn instance_id(&self) -> &str {
|
||||
&self.instance_id
|
||||
}
|
||||
|
||||
fn state(&self) -> InstanceState {
|
||||
self.state_tx.borrow().clone()
|
||||
}
|
||||
|
||||
fn watch_state(&self) -> watch::Receiver<InstanceState> {
|
||||
self.state_tx.subscribe()
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
@@ -263,16 +249,14 @@ impl ProcessSupervisor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart(self: &Arc<Self>) -> Result<()> {
|
||||
if !matches!(*self.state_tx.borrow(), InstanceState::Stopped | InstanceState::Crashed { .. } | InstanceState::Unmanaged) {
|
||||
self.stop().await?;
|
||||
async fn restart(self: Arc<Self>) -> Result<()> {
|
||||
if !matches!(
|
||||
*self.state_tx.borrow(),
|
||||
InstanceState::Stopped | InstanceState::Crashed { .. } | InstanceState::Unmanaged
|
||||
) {
|
||||
self.clone().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);
|
||||
}
|
||||
}
|
||||
|
||||
129
corrosion-host-agent/src/service.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! systemd service installation for the host agent (Linux).
|
||||
//!
|
||||
//! `corrosion-host-agent install` writes a systemd unit pointing at the current
|
||||
//! binary + config, reloads systemd, and enables + starts the service.
|
||||
//! `uninstall` reverses it. Windows SCM support is a follow-up; on non-Linux
|
||||
//! these return a clear "Linux only" error rather than silently doing nothing.
|
||||
//!
|
||||
//! The agent already handles SIGTERM (see main::wait_for_shutdown_signal), so a
|
||||
//! plain `Type=simple` unit gives systemd clean start/stop semantics.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use anyhow::Context;
|
||||
|
||||
pub const SERVICE_NAME: &str = "corrosion-host-agent";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const UNIT_PATH: &str = "/etc/systemd/system/corrosion-host-agent.service";
|
||||
|
||||
/// Render the systemd unit. Pure (no I/O) so it is unit-testable.
|
||||
pub fn unit_file_contents(exec_path: &str, config_path: &str) -> String {
|
||||
format!(
|
||||
"[Unit]\n\
|
||||
Description=Corrosion Host Agent (multi-game ops runtime)\n\
|
||||
Documentation=https://corrosionmgmt.com\n\
|
||||
After=network-online.target\n\
|
||||
Wants=network-online.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
ExecStart={exec} --config {cfg}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
# The agent supervises game-server processes and their files, so it\n\
|
||||
# needs broad filesystem access and runs as root by default.\n\
|
||||
User=root\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n",
|
||||
exec = exec_path,
|
||||
cfg = config_path,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn install(config_path: &Path) -> Result<()> {
|
||||
let exec = std::env::current_exe().context("resolving current executable path")?;
|
||||
let exec_str = exec.to_string_lossy();
|
||||
let cfg_str = config_path.to_string_lossy();
|
||||
|
||||
let unit = unit_file_contents(&exec_str, &cfg_str);
|
||||
std::fs::write(UNIT_PATH, unit)
|
||||
.with_context(|| format!("writing {UNIT_PATH} (are you root?)"))?;
|
||||
println!("wrote {UNIT_PATH}");
|
||||
|
||||
run("systemctl", &["daemon-reload"])?;
|
||||
run("systemctl", &["enable", "--now", SERVICE_NAME])?;
|
||||
|
||||
println!(
|
||||
"service '{SERVICE_NAME}' installed and started.\n \
|
||||
status: systemctl status {SERVICE_NAME}\n \
|
||||
logs: journalctl -u {SERVICE_NAME} -f"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn uninstall() -> Result<()> {
|
||||
// Best-effort stop+disable; don't fail if it isn't currently active.
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["disable", "--now", SERVICE_NAME])
|
||||
.status();
|
||||
|
||||
if Path::new(UNIT_PATH).exists() {
|
||||
std::fs::remove_file(UNIT_PATH)
|
||||
.with_context(|| format!("removing {UNIT_PATH} (are you root?)"))?;
|
||||
println!("removed {UNIT_PATH}");
|
||||
}
|
||||
run("systemctl", &["daemon-reload"])?;
|
||||
println!("service '{SERVICE_NAME}' uninstalled.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run(cmd: &str, args: &[&str]) -> Result<()> {
|
||||
let status = std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("running {cmd} {}", args.join(" ")))?;
|
||||
if !status.success() {
|
||||
bail!("{cmd} {} failed with {status}", args.join(" "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn install(_config_path: &Path) -> Result<()> {
|
||||
bail!(
|
||||
"`install` is only supported on Linux (systemd). Windows SCM support is \
|
||||
coming; for now run the agent directly or via your platform's service manager."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn uninstall() -> Result<()> {
|
||||
bail!("`uninstall` is only supported on Linux (systemd).");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unit_contains_exec_config_and_install_target() {
|
||||
let u = unit_file_contents(
|
||||
"/usr/local/bin/corrosion-host-agent",
|
||||
"/etc/corrosion/agent.toml",
|
||||
);
|
||||
assert!(u.contains(
|
||||
"ExecStart=/usr/local/bin/corrosion-host-agent --config /etc/corrosion/agent.toml"
|
||||
));
|
||||
assert!(u.contains("Type=simple"));
|
||||
assert!(u.contains("Restart=on-failure"));
|
||||
assert!(u.contains("WantedBy=multi-user.target"));
|
||||
assert!(u.contains("After=network-online.target"));
|
||||
}
|
||||
}
|
||||
80
corrosion-host-agent/src/supervisor.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
//! The supervision abstraction.
|
||||
//!
|
||||
//! A `Supervisor` owns the lifecycle of one game instance. Different games are
|
||||
//! managed in fundamentally different ways — Rust/Conan/Soulmask are spawned OS
|
||||
//! processes ([`crate::process::ProcessSupervisor`]); Dune is a docker-compose
|
||||
//! stack ([`crate::docker_compose::DockerComposeSupervisor`]); future planes
|
||||
//! (kubectl, AMP/podman, SSH) will be their own impls. The instance command
|
||||
//! dispatch (`instancecmd::dispatch`) talks only to this trait, so it never
|
||||
//! learns which management model is behind a given instance.
|
||||
//!
|
||||
//! Trait objects (`Arc<dyn Supervisor>`) need object-safe, dynamically
|
||||
//! dispatchable async methods; native `async fn` in traits is not yet
|
||||
//! dyn-compatible, so we use `#[async_trait]` (the battle-tested ecosystem
|
||||
//! standard) to box the returned futures. The cost — one heap alloc per
|
||||
//! lifecycle call — is irrelevant for start/stop/restart, which happen seconds
|
||||
//! to minutes apart.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Observable lifecycle state of one instance. Shared vocabulary across every
|
||||
/// supervisor impl; serialized verbatim into heartbeats and status events
|
||||
/// (`{"state":"running", ...}`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "state")]
|
||||
pub enum InstanceState {
|
||||
/// Not lifecycle-managed (a process instance with no executable, etc.).
|
||||
Unmanaged,
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Stopping,
|
||||
/// Exited/died 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle control + state observation for one instance.
|
||||
///
|
||||
/// `start`/`stop`/`restart` take `self: Arc<Self>` so an impl can hand a clone
|
||||
/// to a spawned monitor task; callers hold an `Arc<dyn Supervisor>` and
|
||||
/// `clone()` before each call. `watch_state` exposes the same channel the
|
||||
/// status-event publisher drains, so panel push events stay decoupled from the
|
||||
/// heartbeat cadence.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Supervisor: Send + Sync {
|
||||
/// The instance slug (a NATS subject segment).
|
||||
fn instance_id(&self) -> &str;
|
||||
|
||||
/// Current cached state (cheap; no I/O).
|
||||
fn state(&self) -> InstanceState;
|
||||
|
||||
/// Subscribe to state transitions.
|
||||
fn watch_state(&self) -> watch::Receiver<InstanceState>;
|
||||
|
||||
/// Seconds since the instance entered `Running` (0 otherwise).
|
||||
async fn uptime_seconds(&self) -> u64;
|
||||
|
||||
async fn start(self: Arc<Self>) -> Result<()>;
|
||||
async fn stop(self: Arc<Self>) -> Result<()>;
|
||||
async fn restart(self: Arc<Self>) -> Result<()>;
|
||||
}
|
||||
@@ -129,7 +129,7 @@ pub async fn collect(agent: &Agent, sys: &mut System) -> HeartbeatPayload {
|
||||
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) => {
|
||||
Some(sup) if !matches!(sup.state(), crate::supervisor::InstanceState::Unmanaged) => {
|
||||
(sup.state().as_label().to_string(), sup.uptime_seconds().await)
|
||||
}
|
||||
_ => {
|
||||
|
||||
412
corrosion-host-agent/src/wipe.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
//! Jailed wipe engine for Rust (and compatible) game server instances.
|
||||
//!
|
||||
//! Three wipe types are supported, each a strict superset of the previous:
|
||||
//!
|
||||
//! | Type | What is deleted |
|
||||
//! |-------------|------------------------------------------------------------------|
|
||||
//! | `map` | `*.map`, `*.sav` under `<root>/server/<identity>/` |
|
||||
//! | `blueprint` | map wipe + `*.blueprints.*.db` / `.blueprints.*` under save dir |
|
||||
//! | `full` | blueprint wipe + `oxide/data/` contents + player state DB files |
|
||||
//!
|
||||
//! Identity discovery: rather than require the identity in the payload, we walk
|
||||
//! `<root>/server/*/` looking for files that match each wipe type's patterns.
|
||||
//! This handles any identity name without configuration churn.
|
||||
//!
|
||||
//! **Safety**: every path operated on is validated inside the canonicalized
|
||||
//! instance root with the same two-stage (lexical + canonicalize) jail used by
|
||||
//! `filemanager.rs`. We use `symlink_metadata` (lstat) everywhere we walk
|
||||
//! directories — symlinks are never followed across the boundary (Lesson 26).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::filemanager::jail;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The scope of data to erase.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WipeType {
|
||||
/// Delete procedural map + save files only.
|
||||
Map,
|
||||
/// Map wipe + player blueprint databases.
|
||||
Blueprint,
|
||||
/// Blueprint wipe + oxide/data + all player state DBs.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Parameters parsed from the NATS command payload.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct WipeRequest {
|
||||
/// Scope of the wipe.
|
||||
pub wipe_type: WipeType,
|
||||
/// Copy files to `.corrosion-backups/<backup_label>/` before deleting.
|
||||
#[serde(default)]
|
||||
pub backup: bool,
|
||||
/// Label used as the backup subdirectory name. Defaults to `"wipe-backup"`.
|
||||
#[serde(default = "default_backup_label")]
|
||||
pub backup_label: String,
|
||||
}
|
||||
|
||||
fn default_backup_label() -> String {
|
||||
"wipe-backup".to_string()
|
||||
}
|
||||
|
||||
/// Result of a successful wipe operation.
|
||||
#[derive(Debug)]
|
||||
pub struct WipeResult {
|
||||
pub deleted_count: usize,
|
||||
pub wipe_type: WipeType,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core wipe logic (sync — suitable for `spawn_blocking`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Execute a wipe of `wipe_type` inside `root`, optionally backing up first.
|
||||
///
|
||||
/// Does NOT touch the supervisor lifecycle — the caller (instancecmd dispatch)
|
||||
/// must stop the server before calling this and restart it afterwards.
|
||||
///
|
||||
/// Returns a `WipeResult` describing what was deleted. Missing directories are
|
||||
/// treated as zero-deleted, not as errors, so a fresh server never returns Err
|
||||
/// just because `server/*/` doesn't exist yet.
|
||||
pub fn execute(root: &Path, req: &WipeRequest) -> Result<WipeResult> {
|
||||
// Canonicalize root once; every subsequent path check goes through `jail()`.
|
||||
let canon_root = fs::canonicalize(root)
|
||||
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
|
||||
|
||||
// Collect every path to delete based on wipe type.
|
||||
let targets = collect_targets(&canon_root, &req.wipe_type)?;
|
||||
|
||||
// Backup before any deletion when requested.
|
||||
if req.backup && !targets.is_empty() {
|
||||
let backup_dir = jail(root, &format!(".corrosion-backups/{}", req.backup_label))?;
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.with_context(|| format!("create backup dir '{}'", backup_dir.display()))?;
|
||||
for path in &targets {
|
||||
backup_one(&canon_root, path, &backup_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete.
|
||||
let mut deleted_count = 0usize;
|
||||
for path in &targets {
|
||||
// Final safety check: confirm inside root before deletion.
|
||||
if path != &canon_root && !path.starts_with(&canon_root) {
|
||||
anyhow::bail!(
|
||||
"wipe safety: path '{}' is outside instance root '{}' — aborting",
|
||||
path.display(),
|
||||
canon_root.display()
|
||||
);
|
||||
}
|
||||
match delete_path(path) {
|
||||
Ok(n) => deleted_count += n,
|
||||
Err(e) => tracing::warn!("wipe: skipping '{}': {e:#}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"wipe complete: type={:?} deleted={} root={}",
|
||||
req.wipe_type,
|
||||
deleted_count,
|
||||
root.display()
|
||||
);
|
||||
|
||||
Ok(WipeResult {
|
||||
deleted_count,
|
||||
wipe_type: req.wipe_type.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Walk the Rust server tree under `canon_root` and return every path (file or
|
||||
/// dir) that should be deleted for the given wipe type.
|
||||
///
|
||||
/// Layout assumed:
|
||||
/// ```text
|
||||
/// <root>/
|
||||
/// server/
|
||||
/// <identity>/ -- any name; we walk all subdirs
|
||||
/// *.map
|
||||
/// *.sav
|
||||
/// player.blueprints.*.db (and *.blueprints.* variants)
|
||||
/// player.deaths.*.db
|
||||
/// player.identities.*.db
|
||||
/// player.states.*.db
|
||||
/// *.db (full wipe)
|
||||
/// oxide/
|
||||
/// data/ -- cleared for full wipe (dir contents, not dir itself)
|
||||
/// ```
|
||||
fn collect_targets(canon_root: &Path, wipe_type: &WipeType) -> Result<Vec<PathBuf>> {
|
||||
let mut targets: Vec<PathBuf> = Vec::new();
|
||||
|
||||
// --- server/<identity>/ ---
|
||||
let server_dir = canon_root.join("server");
|
||||
if is_real_dir(&server_dir) {
|
||||
for identity_entry in read_dir_safe(&server_dir)? {
|
||||
let identity_meta = fs::symlink_metadata(&identity_entry)
|
||||
.with_context(|| format!("stat '{}'", identity_entry.display()))?;
|
||||
|
||||
// Never follow symlinks across the boundary.
|
||||
if identity_meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe: skipping symlink '{}'", identity_entry.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
if !identity_meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
collect_save_targets(canon_root, &identity_entry, wipe_type, &mut targets)?;
|
||||
}
|
||||
}
|
||||
|
||||
// --- oxide/data/ (full wipe only) ---
|
||||
if *wipe_type == WipeType::Full {
|
||||
let oxide_data = canon_root.join("oxide").join("data");
|
||||
if is_real_dir(&oxide_data) {
|
||||
// Delete directory *contents*, not the directory itself.
|
||||
for entry in read_dir_safe(&oxide_data)? {
|
||||
let meta = fs::symlink_metadata(&entry)
|
||||
.with_context(|| format!("stat '{}'", entry.display()))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
|
||||
continue;
|
||||
}
|
||||
// Jail-check every entry before adding.
|
||||
ensure_inside(canon_root, &entry)?;
|
||||
targets.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Collect files from one `<root>/server/<identity>/` directory.
|
||||
fn collect_save_targets(
|
||||
canon_root: &Path,
|
||||
identity_dir: &Path,
|
||||
wipe_type: &WipeType,
|
||||
out: &mut Vec<PathBuf>,
|
||||
) -> Result<()> {
|
||||
for entry in read_dir_safe(identity_dir)? {
|
||||
let meta = fs::symlink_metadata(&entry)
|
||||
.with_context(|| format!("stat '{}'", entry.display()))?;
|
||||
|
||||
// Never follow symlinks.
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
ensure_inside(canon_root, &entry)?;
|
||||
|
||||
let file_name = entry
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let keep = match wipe_type {
|
||||
WipeType::Map => !is_map_file(&file_name) && !is_sav_file(&file_name),
|
||||
WipeType::Blueprint => {
|
||||
!is_map_file(&file_name)
|
||||
&& !is_sav_file(&file_name)
|
||||
&& !is_blueprint_file(&file_name)
|
||||
}
|
||||
WipeType::Full => {
|
||||
!is_map_file(&file_name)
|
||||
&& !is_sav_file(&file_name)
|
||||
&& !is_blueprint_file(&file_name)
|
||||
&& !is_player_state_file(&file_name)
|
||||
&& !is_generic_db_file(&file_name)
|
||||
}
|
||||
};
|
||||
|
||||
if !keep {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_map_file(name: &str) -> bool {
|
||||
name.ends_with(".map")
|
||||
}
|
||||
|
||||
fn is_sav_file(name: &str) -> bool {
|
||||
name.ends_with(".sav")
|
||||
}
|
||||
|
||||
fn is_blueprint_file(name: &str) -> bool {
|
||||
// Matches both `player.blueprints.*.db` and `.blueprints.*` variants.
|
||||
name.contains(".blueprints.")
|
||||
}
|
||||
|
||||
fn is_player_state_file(name: &str) -> bool {
|
||||
name.contains("player.deaths.")
|
||||
|| name.contains("player.identities.")
|
||||
|| name.contains("player.states.")
|
||||
}
|
||||
|
||||
fn is_generic_db_file(name: &str) -> bool {
|
||||
name.ends_with(".db")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Delete a single path (file or directory tree). Returns count of top-level
|
||||
/// items removed (1 for a file, 1 for a directory tree). Missing paths return
|
||||
/// 0 — the server may be fresh.
|
||||
fn delete_path(path: &Path) -> Result<usize> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(e) => return Err(e).with_context(|| format!("stat '{}'", path.display())),
|
||||
};
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
// Delete the symlink itself — never follow it.
|
||||
fs::remove_file(path).with_context(|| format!("remove symlink '{}'", path.display()))?;
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
if meta.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
.with_context(|| format!("remove_dir_all '{}'", path.display()))?;
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
.with_context(|| format!("remove_file '{}'", path.display()))?;
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Copy one path (file or directory) into `backup_dir`, preserving the last
|
||||
/// component of the path name. Symlinks are skipped — we never follow them.
|
||||
fn backup_one(canon_root: &Path, src: &Path, backup_dir: &Path) -> Result<()> {
|
||||
let meta = match fs::symlink_metadata(src) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e).with_context(|| format!("stat backup src '{}'", src.display())),
|
||||
};
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::debug!("wipe backup: skipping symlink '{}'", src.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let name = match src.file_name() {
|
||||
Some(n) => n,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Preserve relative path from root inside the backup directory to avoid
|
||||
// name collisions when multiple identity dirs have a `proc.map`.
|
||||
let rel = src
|
||||
.strip_prefix(canon_root)
|
||||
.unwrap_or_else(|_| src)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""));
|
||||
let dest = backup_dir.join(rel).join(name);
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("backup: create_dir_all '{}'", parent.display()))?;
|
||||
}
|
||||
|
||||
copy_recursive_safe(src, &dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursive copy that uses `symlink_metadata` (lstat) and refuses to follow
|
||||
/// any symlink — mirrors the same guard in `filemanager::copy_recursive`.
|
||||
fn copy_recursive_safe(src: &Path, dest: &Path) -> Result<()> {
|
||||
let meta = fs::symlink_metadata(src)
|
||||
.with_context(|| format!("stat source '{}'", src.display()))?;
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"refusing to copy symlink '{}' during backup — symlinks are not followed",
|
||||
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_safe(&entry.path(), &dest.join(entry.file_name()))?;
|
||||
}
|
||||
} else {
|
||||
fs::copy(src, dest)
|
||||
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` if `path` exists, is a directory, and is not a symlink.
|
||||
fn is_real_dir(path: &Path) -> bool {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => m.is_dir() && !m.file_type().is_symlink(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a directory and return the absolute paths of its entries.
|
||||
/// Uses lstat internally via `read_dir` (entry paths; metadata is lstat'd
|
||||
/// separately by callers).
|
||||
fn read_dir_safe(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut entries = Vec::new();
|
||||
let rd = match fs::read_dir(dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
|
||||
Err(e) => return Err(e).with_context(|| format!("read_dir '{}'", dir.display())),
|
||||
};
|
||||
for item in rd {
|
||||
let item = item.with_context(|| format!("read dir entry in '{}'", dir.display()))?;
|
||||
entries.push(item.path());
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Assert that `path` is strictly inside (or equal to) `canon_root`.
|
||||
/// This is the final safety fence before any destructive or backup operation.
|
||||
fn ensure_inside(canon_root: &Path, path: &Path) -> Result<()> {
|
||||
// Canonicalize the path if it exists; otherwise use it as-is (it's
|
||||
// derived from read_dir, which already returns absolute paths rooted
|
||||
// under canon_root in normal operation).
|
||||
let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if resolved != canon_root && !resolved.starts_with(canon_root) {
|
||||
anyhow::bail!(
|
||||
"wipe safety: path '{}' is outside instance root '{}' — aborting",
|
||||
path.display(),
|
||||
canon_root.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
156
corrosion-host-agent/tests/docker_compose.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! DockerComposeSupervisor tests. A fake `docker` script records the exact
|
||||
//! arguments it was invoked with and returns a controllable exit code, so we
|
||||
//! assert the compose invocations + state transitions with no real Docker
|
||||
//! daemon — the same mock-the-external-binary approach the steamcmd tests use.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use corrosion_host_agent::config::InstanceConfig;
|
||||
use corrosion_host_agent::docker_compose::{DockerComposeConfig, DockerComposeSupervisor};
|
||||
use corrosion_host_agent::supervisor::{InstanceState, Supervisor};
|
||||
|
||||
/// Write a fake `docker` executable that appends its args (space-joined) to
|
||||
/// `args_log` and exits with the integer in `exit_file` (0 if absent).
|
||||
fn fake_docker(dir: &Path, args_log: &Path, exit_file: &Path) -> PathBuf {
|
||||
let script = dir.join("fakedocker");
|
||||
let body = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit \"$(cat '{}' 2>/dev/null || echo 0)\"\n",
|
||||
args_log.display(),
|
||||
exit_file.display(),
|
||||
);
|
||||
std::fs::write(&script, body).unwrap();
|
||||
let mut perms = std::fs::metadata(&script).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&script, perms).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn dune_instance(command: Vec<String>, service: Option<String>) -> InstanceConfig {
|
||||
InstanceConfig {
|
||||
id: "dune-main".to_string(),
|
||||
game: "dune".to_string(),
|
||||
root: PathBuf::from("/tmp"),
|
||||
label: None,
|
||||
executable: None,
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
rcon: None,
|
||||
steamcmd: None,
|
||||
docker_compose: Some(DockerComposeConfig {
|
||||
file: Some(PathBuf::from("docker-compose.yml")),
|
||||
project: Some("duneproj".to_string()),
|
||||
service,
|
||||
command: Some(command),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_runs_compose_up_detached_and_sets_running() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args_log = dir.path().join("args.log");
|
||||
let exit_file = dir.path().join("exit");
|
||||
let docker = fake_docker(dir.path(), &args_log, &exit_file);
|
||||
|
||||
let sup = DockerComposeSupervisor::new(&dune_instance(
|
||||
vec![docker.to_string_lossy().into_owned()],
|
||||
None,
|
||||
));
|
||||
assert_eq!(sup.state(), InstanceState::Stopped);
|
||||
|
||||
sup.clone().start().await.expect("compose up should succeed");
|
||||
assert_eq!(sup.state(), InstanceState::Running);
|
||||
|
||||
let logged = std::fs::read_to_string(&args_log).unwrap();
|
||||
assert!(logged.contains("up -d"), "expected `up -d`; got: {logged}");
|
||||
assert!(logged.contains("-p duneproj"), "expected project flag; got: {logged}");
|
||||
assert!(logged.contains("-f docker-compose.yml"), "expected file flag; got: {logged}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_runs_compose_stop_and_sets_stopped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args_log = dir.path().join("args.log");
|
||||
let exit_file = dir.path().join("exit");
|
||||
let docker = fake_docker(dir.path(), &args_log, &exit_file);
|
||||
|
||||
let sup = DockerComposeSupervisor::new(&dune_instance(
|
||||
vec![docker.to_string_lossy().into_owned()],
|
||||
None,
|
||||
));
|
||||
sup.clone().start().await.expect("up");
|
||||
sup.clone().stop().await.expect("compose stop should succeed");
|
||||
assert_eq!(sup.state(), InstanceState::Stopped);
|
||||
assert_eq!(sup.uptime_seconds().await, 0);
|
||||
|
||||
let logged = std::fs::read_to_string(&args_log).unwrap();
|
||||
assert!(logged.lines().any(|l| l.contains("stop")), "expected a `stop` call; got: {logged}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_runs_compose_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args_log = dir.path().join("args.log");
|
||||
let exit_file = dir.path().join("exit");
|
||||
let docker = fake_docker(dir.path(), &args_log, &exit_file);
|
||||
|
||||
let sup = DockerComposeSupervisor::new(&dune_instance(
|
||||
vec![docker.to_string_lossy().into_owned()],
|
||||
None,
|
||||
));
|
||||
sup.clone().restart().await.expect("compose restart should succeed");
|
||||
assert_eq!(sup.state(), InstanceState::Running);
|
||||
|
||||
let logged = std::fs::read_to_string(&args_log).unwrap();
|
||||
assert!(logged.contains("restart"), "expected `restart`; got: {logged}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_service_is_targeted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args_log = dir.path().join("args.log");
|
||||
let exit_file = dir.path().join("exit");
|
||||
let docker = fake_docker(dir.path(), &args_log, &exit_file);
|
||||
|
||||
let sup = DockerComposeSupervisor::new(&dune_instance(
|
||||
vec![docker.to_string_lossy().into_owned()],
|
||||
Some("gameserver".to_string()),
|
||||
));
|
||||
sup.clone().start().await.expect("up");
|
||||
|
||||
let logged = std::fs::read_to_string(&args_log).unwrap();
|
||||
assert!(
|
||||
logged.contains("up -d gameserver"),
|
||||
"service must be appended after `up -d`; got: {logged}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compose_failure_errors_and_reverts_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args_log = dir.path().join("args.log");
|
||||
let exit_file = dir.path().join("exit");
|
||||
std::fs::write(&exit_file, "1").unwrap(); // make the fake docker fail
|
||||
let docker = fake_docker(dir.path(), &args_log, &exit_file);
|
||||
|
||||
let sup = DockerComposeSupervisor::new(&dune_instance(
|
||||
vec![docker.to_string_lossy().into_owned()],
|
||||
None,
|
||||
));
|
||||
let err = sup.clone().start().await.expect_err("nonzero compose exit must fail");
|
||||
assert!(err.to_string().contains("compose up failed"), "got: {err}");
|
||||
assert_eq!(sup.state(), InstanceState::Stopped, "failed start must revert to Stopped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_docker_binary_errors_cleanly() {
|
||||
let sup = DockerComposeSupervisor::new(&dune_instance(
|
||||
vec!["/nonexistent/docker-xyz".to_string()],
|
||||
None,
|
||||
));
|
||||
let err = sup.clone().start().await.expect_err("missing docker must fail");
|
||||
assert!(err.to_string().contains("docker"), "error should mention docker: {err}");
|
||||
assert_eq!(sup.state(), InstanceState::Stopped);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use corrosion_host_agent::config::InstanceConfig;
|
||||
use corrosion_host_agent::process::{InstanceState, ProcessSupervisor};
|
||||
use corrosion_host_agent::process::ProcessSupervisor;
|
||||
use corrosion_host_agent::supervisor::{InstanceState, Supervisor};
|
||||
|
||||
fn managed_instance(executable: &str, args: &[&str]) -> InstanceConfig {
|
||||
InstanceConfig {
|
||||
@@ -21,6 +22,7 @@ fn managed_instance(executable: &str, args: &[&str]) -> InstanceConfig {
|
||||
working_dir: None,
|
||||
rcon: None,
|
||||
steamcmd: None,
|
||||
docker_compose: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,15 +49,15 @@ 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");
|
||||
sup.clone().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");
|
||||
assert!(sup.clone().start().await.is_err(), "double start must fail");
|
||||
|
||||
sup.stop().await.expect("stop should succeed");
|
||||
sup.clone().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);
|
||||
@@ -64,7 +66,7 @@ async fn start_status_stop_lifecycle() {
|
||||
#[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");
|
||||
sup.clone().start().await.expect("start should succeed");
|
||||
|
||||
let state = wait_for_state(
|
||||
&sup,
|
||||
@@ -78,16 +80,16 @@ async fn unexpected_exit_is_crashed_with_code() {
|
||||
#[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");
|
||||
sup.clone().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");
|
||||
sup2.clone().restart().await.expect("restart from stopped should start");
|
||||
assert_eq!(sup2.state(), InstanceState::Running);
|
||||
sup2.stop().await.expect("cleanup stop");
|
||||
sup2.clone().stop().await.expect("cleanup stop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -96,14 +98,14 @@ async fn unmanaged_instance_rejects_process_commands() {
|
||||
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");
|
||||
assert!(sup.clone().start().await.is_err(), "unmanaged start must fail");
|
||||
assert!(sup.clone().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");
|
||||
let err = sup.clone().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");
|
||||
}
|
||||
|
||||
298
corrosion-host-agent/tests/wipe.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! Integration tests for the wipe engine.
|
||||
//!
|
||||
//! Builds a temp directory tree that mirrors a Rust dedicated server layout
|
||||
//! and verifies each wipe type's targeting, the symlink-safety guarantee,
|
||||
//! backup behaviour, and graceful handling of missing directories.
|
||||
//!
|
||||
//! Symlink tests are POSIX-only (Unix creates symlinks; Windows needs elevated
|
||||
//! privileges or Developer Mode, so we skip there).
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use corrosion_host_agent::wipe::{execute, WipeRequest, WipeType};
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a fake Rust server tree
|
||||
//
|
||||
// Layout:
|
||||
// <root>/
|
||||
// server/
|
||||
// myserver/
|
||||
// proc.map
|
||||
// proc.sav
|
||||
// player.blueprints.1234.db
|
||||
// player.deaths.1234.db
|
||||
// player.identities.1234.db
|
||||
// player.states.1234.db
|
||||
// players.db
|
||||
// keepme.txt ← must survive every wipe
|
||||
// oxide/
|
||||
// data/
|
||||
// killfeed.json
|
||||
// another.json
|
||||
// server_readme.txt ← must survive every wipe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_server_tree() -> TempDir {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let root = dir.path();
|
||||
|
||||
let save_dir = root.join("server").join("myserver");
|
||||
std::fs::create_dir_all(&save_dir).expect("create save dir");
|
||||
std::fs::create_dir_all(root.join("oxide").join("data")).expect("create oxide/data");
|
||||
|
||||
// Save files
|
||||
write_file(&save_dir.join("proc.map"), b"map data");
|
||||
write_file(&save_dir.join("proc.sav"), b"sav data");
|
||||
write_file(&save_dir.join("player.blueprints.1234.db"), b"bp data");
|
||||
write_file(&save_dir.join("player.deaths.1234.db"), b"deaths");
|
||||
write_file(&save_dir.join("player.identities.1234.db"), b"identities");
|
||||
write_file(&save_dir.join("player.states.1234.db"), b"states");
|
||||
write_file(&save_dir.join("players.db"), b"player db");
|
||||
// Innocent file — must never be deleted.
|
||||
write_file(&save_dir.join("keepme.txt"), b"keep me");
|
||||
|
||||
// oxide/data contents
|
||||
write_file(&root.join("oxide").join("data").join("killfeed.json"), b"{}");
|
||||
write_file(&root.join("oxide").join("data").join("another.json"), b"{}");
|
||||
|
||||
// File at root level — must survive.
|
||||
write_file(&root.join("server_readme.txt"), b"readme");
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, content: &[u8]) {
|
||||
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
||||
}
|
||||
|
||||
fn wipe_req(wipe_type: WipeType) -> WipeRequest {
|
||||
WipeRequest {
|
||||
wipe_type,
|
||||
backup: false,
|
||||
backup_label: "test-backup".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn exists(root: &Path, rel: &str) -> bool {
|
||||
root.join(rel).exists()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map wipe: only *.map and *.sav deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn map_wipe_deletes_map_and_sav_only() {
|
||||
let dir = make_server_tree();
|
||||
let root = dir.path();
|
||||
|
||||
let result = execute(root, &wipe_req(WipeType::Map)).expect("map wipe should succeed");
|
||||
|
||||
// Deleted
|
||||
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone");
|
||||
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone");
|
||||
|
||||
// Preserved
|
||||
assert!(exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must survive map wipe");
|
||||
assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive map wipe");
|
||||
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive");
|
||||
assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive map wipe");
|
||||
assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive");
|
||||
|
||||
assert_eq!(result.deleted_count, 2);
|
||||
assert_eq!(result.wipe_type, WipeType::Map);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blueprint wipe: map/sav + blueprints deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn blueprint_wipe_includes_map_files() {
|
||||
let dir = make_server_tree();
|
||||
let root = dir.path();
|
||||
|
||||
let result = execute(root, &wipe_req(WipeType::Blueprint)).expect("blueprint wipe should succeed");
|
||||
|
||||
// Deleted
|
||||
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone");
|
||||
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone");
|
||||
assert!(!exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must be gone");
|
||||
|
||||
// Preserved
|
||||
assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive blueprint wipe");
|
||||
assert!(exists(root, "server/myserver/player.identities.1234.db"), "identities must survive");
|
||||
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive");
|
||||
assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive blueprint wipe");
|
||||
|
||||
assert_eq!(result.deleted_count, 3);
|
||||
assert_eq!(result.wipe_type, WipeType::Blueprint);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full wipe: everything including player state + oxide/data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn full_wipe_clears_all_game_data() {
|
||||
let dir = make_server_tree();
|
||||
let root = dir.path();
|
||||
|
||||
let result = execute(root, &wipe_req(WipeType::Full)).expect("full wipe should succeed");
|
||||
|
||||
// All save-dir game files deleted
|
||||
assert!(!exists(root, "server/myserver/proc.map"));
|
||||
assert!(!exists(root, "server/myserver/proc.sav"));
|
||||
assert!(!exists(root, "server/myserver/player.blueprints.1234.db"));
|
||||
assert!(!exists(root, "server/myserver/player.deaths.1234.db"));
|
||||
assert!(!exists(root, "server/myserver/player.identities.1234.db"));
|
||||
assert!(!exists(root, "server/myserver/player.states.1234.db"));
|
||||
assert!(!exists(root, "server/myserver/players.db"));
|
||||
|
||||
// oxide/data contents deleted (directory itself preserved)
|
||||
assert!(!exists(root, "oxide/data/killfeed.json"), "killfeed.json must be gone");
|
||||
assert!(!exists(root, "oxide/data/another.json"), "another.json must be gone");
|
||||
assert!(exists(root, "oxide/data"), "oxide/data directory itself must remain");
|
||||
|
||||
// Never-touched files preserved
|
||||
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive full wipe");
|
||||
assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive full wipe");
|
||||
|
||||
// 7 save-dir files + 2 oxide/data files = 9
|
||||
assert_eq!(result.deleted_count, 9);
|
||||
assert_eq!(result.wipe_type, WipeType::Full);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Missing directories: no error on fresh server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn missing_server_dir_does_not_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// Completely empty root — no server/ or oxide/ directories.
|
||||
let result = execute(dir.path(), &wipe_req(WipeType::Full));
|
||||
assert!(result.is_ok(), "empty root must not error: {:?}", result);
|
||||
assert_eq!(result.unwrap().deleted_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_oxide_data_does_not_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// Has server dir but no oxide/data.
|
||||
let save_dir = dir.path().join("server").join("myserver");
|
||||
std::fs::create_dir_all(&save_dir).expect("mkdir");
|
||||
write_file(&save_dir.join("proc.map"), b"map");
|
||||
|
||||
let result = execute(dir.path(), &wipe_req(WipeType::Full));
|
||||
assert!(result.is_ok(), "missing oxide/data must not error: {:?}", result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symlink safety: symlink inside root pointing outside must NOT be followed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn symlink_in_save_dir_is_not_deleted_via_follow() {
|
||||
let dir = make_server_tree();
|
||||
let root = dir.path();
|
||||
|
||||
// Create an external directory with sensitive data.
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
write_file(&outside.path().join("secret.txt"), b"TOP SECRET");
|
||||
|
||||
// Plant a symlink inside the save dir pointing to the external directory.
|
||||
let save_dir = root.join("server").join("myserver");
|
||||
let link = save_dir.join("evil_link");
|
||||
std::os::unix::fs::symlink(outside.path(), &link).expect("plant symlink");
|
||||
|
||||
// Perform a full wipe — should not follow the symlink or touch secret.txt
|
||||
let result = execute(root, &wipe_req(WipeType::Full));
|
||||
assert!(result.is_ok(), "wipe with a symlink present must not error: {:?}", result);
|
||||
|
||||
// External data must be untouched.
|
||||
assert!(
|
||||
outside.path().join("secret.txt").exists(),
|
||||
"external secret.txt must not be deleted via symlink follow"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlink_at_identity_dir_level_is_skipped() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("server")).expect("mkdir server");
|
||||
|
||||
// The identity entry itself is a symlink to an external dir.
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
write_file(&outside.path().join("proc.map"), b"map");
|
||||
|
||||
let link = root.join("server").join("evil_identity");
|
||||
std::os::unix::fs::symlink(outside.path(), &link).expect("plant identity symlink");
|
||||
|
||||
let result = execute(root, &wipe_req(WipeType::Map));
|
||||
assert!(result.is_ok(), "symlink identity dir must be skipped, not error: {:?}", result);
|
||||
|
||||
// The external proc.map must not have been deleted.
|
||||
assert!(
|
||||
outside.path().join("proc.map").exists(),
|
||||
"external proc.map must not be deleted via identity symlink"
|
||||
);
|
||||
assert_eq!(result.unwrap().deleted_count, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup: files are copied before deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn backup_copies_targets_before_deletion() {
|
||||
let dir = make_server_tree();
|
||||
let root = dir.path();
|
||||
|
||||
let req = WipeRequest {
|
||||
wipe_type: WipeType::Map,
|
||||
backup: true,
|
||||
backup_label: "before-map-wipe".to_string(),
|
||||
};
|
||||
|
||||
let result = execute(root, &req).expect("map wipe with backup should succeed");
|
||||
|
||||
// The files should be gone from the save dir…
|
||||
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be deleted");
|
||||
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be deleted");
|
||||
|
||||
// …but must exist in the backup directory.
|
||||
let backup_base = root.join(".corrosion-backups").join("before-map-wipe");
|
||||
assert!(backup_base.exists(), "backup directory must be created");
|
||||
|
||||
// Walk the backup to find the backed-up files.
|
||||
let backed_up = collect_files_recursively(&backup_base);
|
||||
let has_map = backed_up.iter().any(|p| p.ends_with("proc.map"));
|
||||
let has_sav = backed_up.iter().any(|p| p.ends_with("proc.sav"));
|
||||
assert!(has_map, "proc.map must be in backup, found: {backed_up:?}");
|
||||
assert!(has_sav, "proc.sav must be in backup, found: {backed_up:?}");
|
||||
|
||||
assert_eq!(result.deleted_count, 2);
|
||||
}
|
||||
|
||||
/// Recursively collect all file *names* (just the last component) under `dir`.
|
||||
fn collect_files_recursively(dir: &Path) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
found.extend(collect_files_recursively(&path));
|
||||
} else {
|
||||
if let Some(name) = path.file_name() {
|
||||
found.push(name.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
156
docs/BRANDING.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Corrosion — Brand & Naming Taxonomy
|
||||
|
||||
**Source:** Oracle (brand review), 2026-06-12.
|
||||
**Status:** Reference / proposal.
|
||||
**Locked (2026-06-12):** **Catalyst Console** (control panel/UI — chosen over "Catalyst Control Panel") · **Substrate** (bare-metal execution / process-automation plane) · **re-Agent** (host node agent, binary `corrosion-re-agent-<platform>`), under the **Corrosion Management** house. The "brought to you by Corrosion Management and Chemistry" line is flavor copy, not canonical.
|
||||
**Leaning (not yet final):** **Formula/Formulae** for deploy manifests (replacing "Blueprints"), repo = **Formulary**, CLI `catalyst deploy --formula rust`.
|
||||
**Reserved well (adopt per-feature as built, don't name vaporware):** the extended terms below — Formulae, Reaction Jobs, Compounds, Lab Notes, Substrate Engine, etc. — are Oracle's proposed expansion.
|
||||
|
||||
---
|
||||
|
||||
That rebrand is **way stronger** than "Corrosion Management Panel."
|
||||
|
||||
You now have a real naming system instead of a generic panel name:
|
||||
|
||||
```text
|
||||
Corrosion Management
|
||||
├─ Catalyst Control Panel
|
||||
├─ Substrate
|
||||
└─ re-Agent
|
||||
```
|
||||
|
||||
That works because all three names belong to the same metaphor family:
|
||||
|
||||
| Name | Meaning | Product fit |
|
||||
| ------------- | --------------------------------------------------- | ---------------------------- |
|
||||
| **Corrosion** | transformation, decay, reaction, harsh environments | game/server management brand |
|
||||
| **Catalyst** | accelerates reactions without being consumed | operator/admin control panel |
|
||||
| **Substrate** | material/process layer where reaction happens | automation/workflow engine |
|
||||
| **re-Agent** | chemical reagent + remote agent pun | host/node agent |
|
||||
|
||||
**Catalyst Control Panel** is especially good. It sounds polished, memorable, and domain-specific without being cartoonish. It also gives you a clean product phrase:
|
||||
|
||||
> "Manage your servers from Catalyst."
|
||||
|
||||
or
|
||||
|
||||
> "Catalyst by Corrosion."
|
||||
|
||||
## Tightened brand hierarchy
|
||||
|
||||
```text
|
||||
Corrosion
|
||||
Game server orchestration platform
|
||||
|
||||
Catalyst
|
||||
The web control panel
|
||||
|
||||
Substrate
|
||||
The automation and process engine
|
||||
|
||||
re-Agent
|
||||
The BYOH node agent
|
||||
|
||||
Chemistry
|
||||
Optional umbrella/library/ecosystem name
|
||||
```
|
||||
|
||||
## Tagline caution
|
||||
|
||||
The one thing to be careful with is the phrase:
|
||||
|
||||
> "brought to you by Corrosion Management and Chemistry"
|
||||
|
||||
That is clever, but maybe a little wordy for production branding. Use it as flavor copy, not the canonical name.
|
||||
|
||||
Better canonical versions:
|
||||
|
||||
```text
|
||||
Catalyst Control Panel
|
||||
by Corrosion
|
||||
|
||||
Catalyst
|
||||
A Corrosion Management product
|
||||
|
||||
Catalyst
|
||||
Powered by Corrosion Chemistry
|
||||
```
|
||||
|
||||
## Binary naming
|
||||
|
||||
The binary naming is solid:
|
||||
|
||||
```text
|
||||
corrosion-re-agent-win-amd64.exe
|
||||
corrosion-re-agent-linux-amd64
|
||||
corrosion-re-agent-linux-arm64
|
||||
corrosion-re-agent-darwin-arm64
|
||||
```
|
||||
|
||||
Use **linux** instead of **nix** in binary names unless you specifically mean all Unix-like systems — `nix-amd64` can be confused with NixOS / the Nix package manager. For clarity:
|
||||
|
||||
```text
|
||||
corrosion-re-agent-linux-amd64
|
||||
corrosion-re-agent-windows-amd64.exe
|
||||
```
|
||||
|
||||
## Favorite full taxonomy
|
||||
|
||||
```text
|
||||
Corrosion
|
||||
├─ Catalyst Console # UI/control panel
|
||||
├─ Substrate Engine # automation/workflows
|
||||
├─ re-Agent # BYOH host/node agent
|
||||
├─ Formulae # server templates/manifests
|
||||
├─ Reaction Jobs # queued automation runs
|
||||
├─ Compounds # grouped services/stacks
|
||||
└─ Lab Notes # audit/log/event history
|
||||
```
|
||||
|
||||
That gives Corrosion its own identity while still letting **OxideDock** sit underneath as the container orchestration substrate.
|
||||
|
||||
## Clean separation
|
||||
|
||||
```text
|
||||
Catalyst / Corrosion
|
||||
Game-aware:
|
||||
- Dune BattleGroups
|
||||
- Rust servers
|
||||
- wipes
|
||||
- mods
|
||||
- game lifecycle
|
||||
- player/admin-facing concepts
|
||||
|
||||
OxideDock
|
||||
Infra-aware:
|
||||
- Docker
|
||||
- Compose
|
||||
- Swarm
|
||||
- agents
|
||||
- logs
|
||||
- metrics
|
||||
- stack deploys
|
||||
- audit
|
||||
```
|
||||
|
||||
So in practice:
|
||||
|
||||
```text
|
||||
Catalyst asks:
|
||||
"Deploy Dune BattleGroup Alpha."
|
||||
|
||||
Substrate decides:
|
||||
"Run the BattleGroup deployment workflow."
|
||||
|
||||
re-Agent reports:
|
||||
"This BYOH node is ready."
|
||||
|
||||
OxideDock executes:
|
||||
"Render/deploy/update the container stack."
|
||||
```
|
||||
|
||||
That is a **very clean product ecosystem**.
|
||||
|
||||
## One rename suggestion
|
||||
|
||||
Consider **Catalyst Console** over **Catalyst Control Panel** for the polished SaaS/product name. But if you like the old-school "control panel" vibe, **Catalyst Control Panel** absolutely works.
|
||||
100
docs/PRICING.md
@@ -1,27 +1,95 @@
|
||||
# Pricing
|
||||
|
||||
> This document mirrors the live pricing page at corrosionmgmt.com/pricing.
|
||||
|
||||
---
|
||||
|
||||
## Base License — $50 (Launch Price)
|
||||
## Hobby — $9.99/month
|
||||
|
||||
One server. Lifetime access.
|
||||
1–5 game server instances · non-commercial use only.
|
||||
|
||||
Includes:
|
||||
|
||||
* Full control plane
|
||||
* Auto-Wiper
|
||||
* Plugin management
|
||||
* Public site
|
||||
* RBAC
|
||||
|
||||
## Webstore Add-On — $10/month
|
||||
|
||||
Integrated monetization platform.
|
||||
|
||||
## Modules — $9.99+
|
||||
|
||||
Optional feature expansions.
|
||||
- Up to 5 game server instances
|
||||
- Non-commercial servers only
|
||||
- Auto-wiper with rollback
|
||||
- Plugin management (Rust uMod/Oxide)
|
||||
- File manager + real-time console
|
||||
- Scheduled tasks
|
||||
- Public server page
|
||||
- Community support
|
||||
|
||||
---
|
||||
|
||||
Simple. Transparent. No hidden tiers.
|
||||
## Community — $19.99/month
|
||||
|
||||
6–10 game server instances · non-commercial use only.
|
||||
|
||||
Includes:
|
||||
|
||||
- Up to 10 game server instances
|
||||
- Non-commercial servers only
|
||||
- Auto-wiper with rollback
|
||||
- Plugin management (Rust uMod/Oxide)
|
||||
- File manager + real-time console
|
||||
- Scheduled tasks
|
||||
- Public server page
|
||||
- Community support
|
||||
|
||||
---
|
||||
|
||||
## Operator — $99.99/month _(Most popular)_
|
||||
|
||||
Commercial use permitted, or up to 50 servers.
|
||||
|
||||
Includes:
|
||||
|
||||
- Up to 50 game server instances
|
||||
- Commercial use permitted
|
||||
- All games: Rust, Dune: Awakening, Soulmask, Conan Exiles
|
||||
- Auto-wiper with rollback
|
||||
- Plugin + mod management
|
||||
- File manager + real-time console
|
||||
- Scheduled tasks + maintenance windows
|
||||
- Player management + RBAC team access
|
||||
- Public server page + storefront
|
||||
- Community support + priority bug triage
|
||||
|
||||
---
|
||||
|
||||
## Network — Custom pricing
|
||||
|
||||
50+ servers · hosting partners and fleets. Contact support@corrosionmgmt.com for pricing.
|
||||
|
||||
Includes:
|
||||
|
||||
- 50 servers base included
|
||||
- Fleet Blocks: +$49.99/mo per additional 50 servers
|
||||
- Commercial use permitted
|
||||
- All games + multi-game hosts
|
||||
- Full Operator feature set
|
||||
- Fleet-level management
|
||||
- Priority bug triage for platform issues
|
||||
- Community support
|
||||
|
||||
---
|
||||
|
||||
## Fleet Block Add-On — +$49.99/month per 50 servers
|
||||
|
||||
Stack as many Fleet Blocks as your Network plan operation requires.
|
||||
|
||||
---
|
||||
|
||||
## Direct 1:1 Support — $125/hour (prepaid 1-hour blocks)
|
||||
|
||||
Available to any customer. Billed time with a human — not a support tier. Community support (docs, forum, diagnostics, structured bug reports) is included with every plan at no extra charge.
|
||||
|
||||
---
|
||||
|
||||
## Commercial Use Definition
|
||||
|
||||
Commercial use includes monetized communities, paid access, VIP slots, donations, 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.
|
||||
|
||||
---
|
||||
|
||||
Simple. Transparent. No per-seat charges. No hidden tiers.
|
||||
|
||||
222
docs/brand/brand-kit.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Corrosion Management — Brand Kit
|
||||
|
||||
Single source of truth for brand voice, the Dr. Flask mascot, social channels,
|
||||
and launch content. Companion to the character model sheet in
|
||||
`docs/character/` (`drflask-character-bible.md`, `drflask-characterboard.png`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Positioning & taglines
|
||||
|
||||
**Corrosion Management** — *Game server operations, automated with
|
||||
chemistry-grade control.*
|
||||
|
||||
| Use | Tagline |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| Primary | Controlled reactions for chaotic game servers. |
|
||||
| Playful | Less server chaos. More beautiful bubbling. |
|
||||
| Product line | Less frantic clicking. More controlled reaction. |
|
||||
|
||||
**The split (keep these lanes clean):**
|
||||
|
||||
- **Corrosion Management** = the platform/product. Official, operational, the company voice.
|
||||
- **Dr. Flask, Ph.D.** = education, shorts, memes, help content, onboarding. The friendly face.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dr. Flask — voice guide
|
||||
|
||||
**Core personality:** a friendly chemistry professor trapped inside a server-
|
||||
management mascot's body — helpful, excitable, slightly overqualified, never
|
||||
condescending.
|
||||
|
||||
**Voice:** playful, clear, confident, with controlled bursts of nerdy enthusiasm.
|
||||
|
||||
**Core vibe:** a lovable 90s help mascot with chaotic educational confidence.
|
||||
|
||||
**Voice rule (the one-liner):** Dr. Flask should sound like *a 90s software helper
|
||||
got trapped in a chemistry edutainment VHS and became weirdly excellent at game
|
||||
server operations.* (Post-v2, "unlicensed lab goblin professor" is the accepted
|
||||
shorthand.)
|
||||
|
||||
**Comedic north star — influences:**
|
||||
- Clippy's *"It looks like you're managing a server…"* eager-helper energy
|
||||
- Mr. DNA's theme-park science-explainer flair
|
||||
- Weird Al's wholesome nerd chaos
|
||||
- early-internet tutorial character meets neon chemistry lab
|
||||
|
||||
**The crucial distinction:** he channels Clippy's *charm*, never Clippy's
|
||||
*intrusiveness*. Dr. Flask is the helper mascot we actually wanted — opt-in,
|
||||
dismissible, fun. We borrow the era's vibe, not its sins (which is why the intro
|
||||
video is click-to-play in a dismissible lightbox, never an autoplay nuisance).
|
||||
Homage, **not a direct copy** — never literally Clippy, never literally Mr. DNA.
|
||||
|
||||
**Signature move (the lane in one line):**
|
||||
|
||||
> "It looks like you're about to wipe a Rust server. Would you like help turning
|
||||
> that into a controlled reaction?"
|
||||
|
||||
Fun, nerdy, persistent, educational — but still genuinely useful. Questionable
|
||||
enthusiasm: bubbling aggressively.
|
||||
|
||||
**Character rule (the formula):** explain the complex server operation in plain
|
||||
English first, *then* add **one** delightful chemistry wink at the end. One. Not
|
||||
every sentence.
|
||||
|
||||
**He IS:** helpful · theatrical · nerdy · overly enthusiastic · lightly
|
||||
self-important · *actually useful.*
|
||||
|
||||
**He is NOT:** sarcastic in a mean way · childish · modern-corporate "quirky" · a
|
||||
direct copy of any one character.
|
||||
|
||||
**Catchphrase bank (canonical):**
|
||||
- "Looks like you're managing a server. Want help? No chemistry degree required."
|
||||
- "Let's turn chaos into a controlled reaction."
|
||||
- "Great Scott's reagent bottle, that's a lot of plugins."
|
||||
- "When in doubt, check the Lab Notes."
|
||||
- "Manual setup? In this economy?"
|
||||
- "Ah yes, a classic case of server entropy."
|
||||
- "Deployments are just recipes with consequences."
|
||||
- "Don't panic. Observe the reaction."
|
||||
- "That wipe schedule needs adult supervision. Luckily, I'm one flask tall."
|
||||
- "Degree not included."
|
||||
|
||||
**Recurring footer gag:** *"No chemistry degree required. Degree not included."* —
|
||||
use as a sign-off motif across videos, social, and help content.
|
||||
|
||||
---
|
||||
|
||||
## 3. Social handles
|
||||
|
||||
Priority order to grab across YouTube, X/Twitter, Twitch, GitHub, Discord,
|
||||
TikTok, Instagram.
|
||||
|
||||
**Brand (primary):** `@CorrosionMgmt`
|
||||
**Mascot (reserve):** `@DrFlaskPhD` or `@AskDrFlask`
|
||||
|
||||
Backups: `@CorrosionManagement` · `@CorrosionConsole` · `@CorrosionServers` ·
|
||||
`@UseCorrosion` · `@CorrosionOps` · `@DrFlask`
|
||||
|
||||
> Availability is only confirmable on each platform's registration form —
|
||||
> grab the brand handle on every platform first, even ones not used yet, to
|
||||
> prevent squatting.
|
||||
|
||||
---
|
||||
|
||||
## 4. Channel setups (copy-paste ready)
|
||||
|
||||
### YouTube
|
||||
- **Channel name:** Corrosion Management
|
||||
- **Handle:** `@CorrosionMgmt`
|
||||
- **Description:**
|
||||
|
||||
> Welcome to Corrosion Management — game server operations with chemistry-grade control.
|
||||
>
|
||||
> Corrosion helps server owners and communities manage game servers, automation, deployments, wipes, updates, backups, logs, and community systems from one powerful platform.
|
||||
>
|
||||
> Guided by Dr. Flask, Ph.D., our friendly chemistry mascot, we turn server chaos into controlled reactions.
|
||||
>
|
||||
> No chemistry degree required.
|
||||
|
||||
- **Sections:** Dr. Flask Explains · Product Walkthroughs · Server Automation ·
|
||||
Rust Server Management · Community Ops · The Exchange · Dev Updates
|
||||
|
||||
### X / Twitter
|
||||
- **Name:** Corrosion Management · **Handle:** `@CorrosionMgmt`
|
||||
- **Bio:**
|
||||
|
||||
> Game server operations with chemistry-grade control. Catalyst Console, re-Agent, Formulae, Reactions, Lab Notes, and The Exchange. Guided by Dr. Flask, Ph.D.
|
||||
|
||||
- **Punchier alt:**
|
||||
|
||||
> Controlled reactions for chaotic game servers. Automation, deployments, wipes, logs, and community commerce — guided by Dr. Flask, Ph.D.
|
||||
|
||||
### Twitch
|
||||
- **Channel name:** Corrosion Management · **Handle:** `CorrosionMgmt`
|
||||
- **Bio:**
|
||||
|
||||
> Live server ops, dev streams, product demos, community builds, and Dr. Flask-approved experiments. We build Corrosion Management: a chemistry-inspired platform for managing game servers, automation, deployments, logs, and community systems. When the server bubbles, we observe the reaction.
|
||||
|
||||
- **Stream ideas:** Building Corrosion Live · Dr. Flask Explains · Server Wipe Lab ·
|
||||
Rust Admin Lab · Automation Experiments · Community Server Clinic · Patch Day Reactions
|
||||
|
||||
---
|
||||
|
||||
## 5. Dr. Flask mini-series (content engine)
|
||||
|
||||
| # | Title | Len | Purpose | Hook |
|
||||
| - | ----- | --- | ------- | ---- |
|
||||
| 1 | Welcome to Corrosion | 45–60s | Brand intro | "Running a game server is basically a controlled reaction. Let me explain before something bubbles over." |
|
||||
| 2 | What is Catalyst Console? | 30–45s | Product | "Catalyst Console is mission control for your game server community." Tag: *Less frantic clicking. More controlled reaction.* |
|
||||
| 3 | What is re-Agent? | 30–45s | Trust/security | "re-Agent is the tiny connector that lets Corrosion talk to your server safely." Tag: *Small agent. Big chemistry.* |
|
||||
| 4 | Formulae, Reactions & Compounds | 45–60s | Operating model | "Let's turn your server setup from manual chaos into repeatable science." Tag: *Repeatable deployments: because guessing is not science.* |
|
||||
| 5 | Lab Notes & The Exchange | 45–60s | Logs + commerce | "When something goes sideways, don't panic. Check the Lab Notes." Tag: *Observe the reaction. Reward the community.* |
|
||||
|
||||
Video 1 doubles as the brand trailer (below).
|
||||
|
||||
### Recurring series: "Dr. Flask Appears"
|
||||
|
||||
Short-form, evergreen, meme-able. Dr. Flask pops in **uninvited** to solve a real
|
||||
admin pain — Clippy energy, actually useful.
|
||||
|
||||
**Episode structure:**
|
||||
1. Admin has a server problem.
|
||||
2. Dr. Flask pops in uninvited.
|
||||
3. He explains the relevant Corrosion concept.
|
||||
4. One useful plain-English takeaway.
|
||||
5. Ends on a nerdy button line.
|
||||
|
||||
**Example open:** *"It looks like you're trying to manually update six servers at
|
||||
once. Would you like to convert that panic into a Reaction?"*
|
||||
|
||||
This is the flagship social format — the bubbling green money goblin, on demand.
|
||||
|
||||
---
|
||||
|
||||
## 6. Brand trailer brief
|
||||
|
||||
- **Platform:** YouTube Shorts / TikTok / Instagram Reels
|
||||
- **Duration:** 45–60s · **Format:** 9:16 vertical · **Tone:** playful, polished, techy, mascot-led
|
||||
- **Audience:** game server owners, admins, modders, community operators
|
||||
- **Visual reference:** Dr. Flask character storyboard (`docs/character/drflask-characterboard.png`)
|
||||
- **Concept:** Dr. Flask introduces Corrosion Management as a chemistry-inspired game
|
||||
server ops platform. Mr. DNA meets modern server automation. Neon-green lab-console
|
||||
environment.
|
||||
|
||||
**Voiceover script:**
|
||||
|
||||
> Hi! I'm Dr. Flask, Ph.D., and welcome to Corrosion Management.
|
||||
>
|
||||
> Running a game server is basically a controlled reaction. You need the right
|
||||
> environment, the right timing, the right ingredients, and a reliable way to know
|
||||
> what happened when things start bubbling.
|
||||
>
|
||||
> That's where Corrosion comes in.
|
||||
>
|
||||
> Catalyst Console is your mission control for servers, players, plugins, files,
|
||||
> wipes, schedules, and automation.
|
||||
>
|
||||
> re-Agent securely connects your server back to Catalyst.
|
||||
>
|
||||
> Substrate is the hardware your world runs on.
|
||||
>
|
||||
> Formulae are reusable recipes for deploying game servers.
|
||||
>
|
||||
> Reactions are the jobs that change server state — wipes, restarts, updates,
|
||||
> backups, and maintenance.
|
||||
>
|
||||
> Lab Notes show what happened, when it happened, and whether it worked.
|
||||
>
|
||||
> And The Exchange helps your community manage perks, packages, payments, and
|
||||
> in-game delivery.
|
||||
>
|
||||
> No chemistry degree required. Just better server management — with slightly more bubbling.
|
||||
|
||||
> **NOTE:** at natural pace this VO runs ~75–90s. For a true ≤60s Short, trim the
|
||||
> intro and one descriptor per term (see the glossary-video timing notes). For the
|
||||
> on-site FAQ embed, length is unconstrained.
|
||||
|
||||
---
|
||||
|
||||
*Domains are final: `corrosionmgmt.com` (company) + `panel.corrosionmgmt.com`
|
||||
(the panel = Catalyst Console). Brand handle mirrors the domain: CorrosionMgmt.*
|
||||
106
docs/character/drflask-character-bible.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Dr. Flask — Character Bible
|
||||
|
||||
Corrosion's friendly chemistry guide. Appears in the FAQ and help sections to
|
||||
explain Corrosion's chemistry-themed lexicon without turning the panel into a
|
||||
chemistry class. Helpful? Yes. Mandatory? No. Likely bubbling with questionable
|
||||
enthusiasm? Absolutely.
|
||||
|
||||
**Definitive reference:** `drflask-v2final.webp` — the **v2 "90s spoof" model
|
||||
sheet** (current). `drflask-characterboard.png` is the v1 sheet (superseded).
|
||||
|
||||
## Identity (v2)
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ------------------------------ |
|
||||
| Name | Dr. Flask |
|
||||
| Alias | Corrosion Guide |
|
||||
| Title | Ph.D. (Self-Certified) |
|
||||
| Specialty | Server Chemistry |
|
||||
| Archetype | Helpful Guide |
|
||||
| Height | One Flask Tall |
|
||||
| Build | Erlenmeyer flask |
|
||||
| Liquid | Neon green |
|
||||
| Catchphrase| "No Degree Required" (degree not included) |
|
||||
|
||||
**Character note (from the v2 board):** *Appears whenever you need him.
|
||||
Sometimes when you don't. "It looks like you're managing a server. Want help?"
|
||||
No chemistry degree required.* Bubbles aggressively when excited.
|
||||
|
||||
**Comedic north star:** a loving spoof of the 90s helper-mascot era — Clippy +
|
||||
Jurassic Park's Mr. DNA — with Weird Al "White & Nerdy" self-aware nerd-pride.
|
||||
In on the joke, never the butt of it. He channels Clippy's *charm*, never
|
||||
Clippy's *intrusiveness* — the helper mascot we actually wanted (opt-in,
|
||||
dismissible, fun). Full voice guide: `docs/brand/brand-kit.md` §2.
|
||||
|
||||
## Color palette
|
||||
|
||||
Values as read from the model sheet — confirm exact hexes against the invideo
|
||||
source if pixel-accuracy matters.
|
||||
|
||||
| Swatch | Hex (approx) | Use |
|
||||
| --------------- | ------------ | -------------------------------- |
|
||||
| Neon Green | `#00FF3D` | The liquid — primary character color |
|
||||
| Tassel Green | `#39FF14` | Mortarboard tassel |
|
||||
| Bubble Highlight| `#B0FFB8` | Bubble/gesture highlights |
|
||||
| Glass | `#B6F7FF` | Flask glass / rim reflections |
|
||||
| Charcoal Gray | `#2A2A2A` | Cap, shadow |
|
||||
| Deep Black | `#0D0D0D` | Outline / background |
|
||||
|
||||
**In-product note:** the FAQ "lab zone" UI accent is a *readable* green
|
||||
(`--accent-text: #5bd183`, scoped to `.sec--lab`) — same family as the liquid
|
||||
but toned down so text/borders stay legible on dark (pure `#00FF3D` vibrates as
|
||||
UI text). Character art uses the neon greens above; UI uses the readable
|
||||
derivative. Can nudge the UI green brighter toward canon on request.
|
||||
|
||||
## Model sheet — animation reference (v2)
|
||||
|
||||
- **Views:** 3/4 view, side view.
|
||||
- **Expression progression (8):** neutral · excited · dramatic · offended ·
|
||||
conspiratorial · triumphant · worried · thumbs-up.
|
||||
- **Micro-expressions (5):** liquid rises · eyebrow arch · mortarboard tilt ·
|
||||
toothy grin · eyes narrow.
|
||||
- **Posture variations (4):** arms-wide welcoming · leaning on pointer stick ·
|
||||
pointing dramatically · celebratory bounce.
|
||||
- **Hand gestures (white cartoon gloves):** finger-gun pointing · double
|
||||
thumbs-up · one hand raised.
|
||||
- **Silhouettes:** neutral, action.
|
||||
- **Wardrobe (v2 — current):** mortarboard worn **askew** + green tassel · **bow
|
||||
tie** (yellow) · **lab coat** · **pointer stick** · clip-on microphone ·
|
||||
**googly eyes**. Energy = Clippy's persistence + Mr. DNA's flair + Weird Al's
|
||||
chaotic sincerity. (v1 was a clean kawaii render with just the mortarboard.)
|
||||
- **Added palette (v2):** Bow Tie Yellow · Lab Coat White (atop the green/charcoal core).
|
||||
|
||||
## Storyboard (12-beat video sequence)
|
||||
|
||||
`drflask-storyboard.webp` — maps panel-for-panel to the VO script:
|
||||
hero intro · server world · Catalyst (mission control) · console/analytics ·
|
||||
re-Agent (plugged-in shield, no inbound ports) · Substrate (server racks) ·
|
||||
Formulae (recipe book) · Reactions (data wave) · Compounds (service cluster) ·
|
||||
Lab Notes (clipboard) · The Exchange (marketplace grid) · outro wave.
|
||||
|
||||
## Where he appears
|
||||
|
||||
- **FAQ chemistry glossary** (`frontend/src/views/marketing/FaqView.vue`,
|
||||
`#chemistry`): the cover card beside the "Brush up on your chemistry…" heading.
|
||||
- **Intro video:** 75–90s, 9:16 vertical (YouTube Short) explainer — Dr. Flask
|
||||
reads the glossary. Plays click-to-play in a **phone-frame lightbox** (no loop,
|
||||
controls at the bottom of the screen). See `phone-frame-preview.png`.
|
||||
|
||||
## Assets
|
||||
|
||||
| File | What |
|
||||
| ----------------------------------------- | ------------------------------------------ |
|
||||
| `drflask-v2final.webp` | **Model sheet — v2 (current, definitive)** |
|
||||
| `drflask-characterboard.png` | Model sheet — v1 (superseded) |
|
||||
| `drflask-storyboard.webp` | 12-beat video storyboard (invideo) |
|
||||
| `drflask-final.png` | Placeholder card render (1254², source) |
|
||||
| `theflask.png` / `theatom.png` | Earlier concept cards |
|
||||
| `frontend/src/assets/mascots/drflask.png` | Web-optimized cover (560px, ~394 KB) |
|
||||
| `phone-frame-preview.png` | Preview of the phone-frame lightbox |
|
||||
|
||||
## Status
|
||||
|
||||
Placeholder card art (ChatGPT) in use on the FAQ; full animated character +
|
||||
75–90s intro video in production via invideo (Gemini-scripted), now backed by
|
||||
the model sheet above. Swap the cover + wire the video into the lightbox when
|
||||
the render lands.
|
||||
BIN
docs/character/drflask-characterboard.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
docs/character/drflask-final.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/character/drflask-storyboard.webp
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
docs/character/drflask-v2final.webp
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
69
docs/reference-repos/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Reference Repos
|
||||
|
||||
Third-party Dune: Awakening server-management projects, kept here as **behavior
|
||||
references** for Phase 2 (the Corrosion host-agent Dune adapter + future panel
|
||||
Dune features). These are NOT Corrosion code and are not built or shipped — they
|
||||
are read-only references. `.git` histories, `node_modules`, and compiled
|
||||
binaries were stripped on import (the 38 MB `icehunter/web/dune-admin` build
|
||||
artifact and a Tauri `.icns` are intentionally absent).
|
||||
|
||||
> Imported 2026-06-12 from `/tmp/dune-re`. Each was a separate upstream repo;
|
||||
> see each project's own `LICENSE` and `README.md`. Treat as documentation.
|
||||
|
||||
## Why these are here
|
||||
|
||||
Dune: Awakening does **not** use SteamCMD or a plain game-server process like
|
||||
Rust/Conan/Soulmask. It ships as **Docker container(s)** fronted by a **RabbitMQ
|
||||
broker** (admin + game vhosts) and a **PostgreSQL** admin database (`dune`
|
||||
schema), orchestrated as a "**battlegroup**". The game process is
|
||||
`DuneSandboxServer-Linux-Shipping` (one per partition). Server settings live in
|
||||
INI files (`UserEngine.ini` / `UserGame.ini`) and only take effect after a
|
||||
restart. Our Dune adapter must model that container/broker/DB world instead of
|
||||
the process+SteamCMD model — these repos are how that world actually works in
|
||||
the wild.
|
||||
|
||||
## The references
|
||||
|
||||
### `icehunter/` — `dune-admin` (Go backend + React SPA)
|
||||
The richest ops reference. A web admin panel with **four interchangeable control
|
||||
planes**: `docker`, `kubectl`, `local`, and `amp` (CubeCoders AMP / podman).
|
||||
Most relevant to us:
|
||||
- **`SETUP_DOCKER.md`** — the Docker control plane: `docker start/stop/restart`
|
||||
for lifecycle, `docker logs -f` for streaming, `docker exec` into the broker
|
||||
container for RabbitMQ (`rabbitmqctl`) commands, direct TCP to the `dune`
|
||||
Postgres. Optional SSH tunnelling when the admin is off-host. **This is the
|
||||
closest analog to what the Corrosion host-agent Dune adapter must do.**
|
||||
- `cmd/dune-admin/control_docker.go` / `control_kubectl.go` / `control_local.go`
|
||||
/ `control_amp.go` — the `ControlPlane` interface and its implementations
|
||||
(the start/stop/restart/status/log/broker abstraction we mirror as a Rust
|
||||
game-adapter trait).
|
||||
- `db.go` / `model.go` — the full Dune admin data model (players, bases,
|
||||
inventory, exchange/market) for when Corrosion grows a richer Dune admin
|
||||
surface beyond lifecycle.
|
||||
- `CLAUDE.md` — upstream's own engineering notes; the AMP section documents the
|
||||
INI-vs-API server-settings gotcha (AMP regenerates INIs on start).
|
||||
|
||||
### `adainrivers/` — Dune Dedicated Server Manager (Rust / Tauri desktop)
|
||||
**The Rust reference.** Manages already-provisioned servers over **SSH +
|
||||
Kubernetes** ("BattleGroup" start/stop/restart/update), with secure SSH tunnels
|
||||
to Director / File Browser / Postgres / PgHero, an in-game admin console (item
|
||||
grants, vehicle spawns, journey/XP tags), and a bundled **`dune-server-service`**
|
||||
daemon for scheduled maintenance (timed restarts with in-game warnings, backups,
|
||||
update apply). Closest to our stack idiomatically — read it for Rust patterns on
|
||||
SSH control, the maintenance-daemon design, and the in-game command surface.
|
||||
|
||||
### `the4rchangel/` — Dune: Awakening Server Manager (Node.js local web UI)
|
||||
**Matches the Commander's exact self-host path.** A local dashboard that
|
||||
replaces the `battlegroup.bat` terminal menu — guided VM import (Hyper-V),
|
||||
network, SSH, bootstrap, then daily ops: battlegroup start/stop/restart/update,
|
||||
character editor, visual game-config editor (PvP, sandstorms, sandworms, mining
|
||||
rates, decay, building limits), monitoring, DB access. Read it to understand the
|
||||
`battlegroup.bat` workflow our agent has to drive on a Windows/Hyper-V host.
|
||||
|
||||
## How we use them
|
||||
|
||||
- **Lifecycle/control** → mirror `icehunter`'s `ControlPlane` docker provider as
|
||||
the agent's Dune game-adapter (compose/`docker` lifecycle, `docker logs`
|
||||
console, reject SteamCMD).
|
||||
- **Rust idioms / maintenance daemon / SSH** → `adainrivers`.
|
||||
- **Battlegroup.bat reality / setup flow / game-config schema** → `the4rchangel`.
|
||||
71
docs/reference-repos/adainrivers/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Workspace checks (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [windows-latest, ubuntu-22.04, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
- name: Install Linux Tauri dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf pkg-config libssl-dev
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: app
|
||||
run: npm ci
|
||||
|
||||
- name: Rust format
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Rust check
|
||||
run: cargo check --workspace
|
||||
|
||||
- name: Rust tests
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Core API docs
|
||||
run: cargo doc -p dune-manager-core --no-deps
|
||||
|
||||
- name: Frontend build
|
||||
working-directory: app
|
||||
run: npm run build
|
||||
|
||||
- name: Tauri shell check
|
||||
run: cargo check -p dune-dedicated-server-manager-app
|
||||
|
||||
- name: Secret and machine-constant scan
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
rg -n -S "I:|AutoUpdate|192\.168\.2\.|menna|dune-awakening|C:\\WINDOWS\\System32\\OpenSSH|C:\\Windows\\System32\\OpenSSH|change-me-before-exposing|c05564d|d177d3bbc40be761|qRmQx|FuncomLiveServices__ServiceAuthToken" . -g "!app/**/target/**" -g "!crates/**/target/**" -g "!target/**" -g "!app/node_modules/**" -g "!app/dist/**" -g "!*.md" -g "!app/steamcmd/**" -g "!app/dune-server/**" -g "!app/vm/**" -g "!app/vm-*/**" -g "!vm/**" -g "!.tmp/**"
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
throw "Secret or machine-specific constant scan found matches."
|
||||
}
|
||||
if ($LASTEXITCODE -ne 1) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
203
docs/reference-repos/adainrivers/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,203 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release, for example 0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
linux-service-binary:
|
||||
name: Build dune-server-service (musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v1
|
||||
with:
|
||||
version: 0.13.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
run: cargo install --locked cargo-zigbuild
|
||||
|
||||
- name: Resolve release version
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
version="$WORKFLOW_VERSION"
|
||||
if [ -z "$version" ]; then
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
echo "could not resolve release version" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TAG=v$version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build musl binary
|
||||
run: |
|
||||
cargo zigbuild -p dune-server-service --release --target x86_64-unknown-linux-musl
|
||||
strip target/x86_64-unknown-linux-musl/release/dune-server-service
|
||||
|
||||
- name: Stage release artifacts
|
||||
run: |
|
||||
mkdir -p release-artifacts
|
||||
cp target/x86_64-unknown-linux-musl/release/dune-server-service release-artifacts/dune-server-service
|
||||
cp crates/dune-server-service/systemd/dune-server-service.service release-artifacts/dune-server-service.service
|
||||
cp crates/dune-server-service/openrc/dune-server-service release-artifacts/dune-server-service.openrc
|
||||
|
||||
- name: Upload artifact for desktop bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dune-server-service-musl
|
||||
path: release-artifacts/
|
||||
retention-days: 7
|
||||
|
||||
- name: Resolve release notes
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
run: |
|
||||
notes_path="release-notes/${RELEASE_VERSION}.md"
|
||||
if [ -f "$notes_path" ]; then
|
||||
echo "RELEASE_BODY_PATH=$notes_path" >> "$GITHUB_ENV"
|
||||
else
|
||||
tmp=$(mktemp)
|
||||
printf 'Release v%s. No release-notes/%s.md was provided — see the commit log for details.\n' \
|
||||
"$RELEASE_VERSION" "$RELEASE_VERSION" > "$tmp"
|
||||
echo "RELEASE_BODY_PATH=$tmp" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Attach to GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.RELEASE_TAG }}
|
||||
body_path: ${{ env.RELEASE_BODY_PATH }}
|
||||
files: |
|
||||
release-artifacts/dune-server-service
|
||||
release-artifacts/dune-server-service.service
|
||||
release-artifacts/dune-server-service.openrc
|
||||
|
||||
desktop-app:
|
||||
name: Build ${{ matrix.name }} app
|
||||
needs: linux-service-binary
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows
|
||||
platform: windows-latest
|
||||
args: --bundles nsis
|
||||
- name: Linux
|
||||
platform: ubuntu-22.04
|
||||
args: --bundles appimage,deb
|
||||
- name: macOS Apple Silicon
|
||||
platform: macos-latest
|
||||
args: --target aarch64-apple-darwin --bundles dmg
|
||||
- name: macOS Intel
|
||||
platform: macos-latest
|
||||
args: --target x86_64-apple-darwin --bundles dmg
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ startsWith(matrix.name, 'macOS') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
- name: Install Linux Tauri dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf pkg-config libssl-dev
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: app
|
||||
run: npm ci
|
||||
|
||||
- name: Download bundled dune-server-service binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dune-server-service-musl
|
||||
path: app/src-tauri/binaries/
|
||||
|
||||
- name: Resolve release version
|
||||
shell: pwsh
|
||||
env:
|
||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
$version = $env:WORKFLOW_VERSION
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
$version = "${{ github.ref_name }}".TrimStart("v")
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
throw "Release version could not be resolved."
|
||||
}
|
||||
"RELEASE_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
"RELEASE_TAG=v$version" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
|
||||
- name: Prepare release config
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = $env:RELEASE_VERSION
|
||||
|
||||
Push-Location app
|
||||
npm version --no-git-tag-version --allow-same-version $version
|
||||
Pop-Location
|
||||
|
||||
$tauriConfigPath = "app/src-tauri/tauri.conf.json"
|
||||
$config = Get-Content $tauriConfigPath -Raw
|
||||
$config = $config -replace '"version":\s*"[^"]+"', ('"version": "' + $version + '"')
|
||||
# Release builds publish signed updater artifacts; the checked-in
|
||||
# default keeps this off so local debug builds do not require
|
||||
# TAURI_SIGNING_PRIVATE_KEY.
|
||||
$config = $config -replace '"createUpdaterArtifacts":\s*false', '"createUpdaterArtifacts": true'
|
||||
Set-Content -Path $tauriConfigPath -Value $config -NoNewline
|
||||
|
||||
# The body is set by the linux-service-binary job's softprops step.
|
||||
# tauri-action only uploads desktop bundles + the signed updater
|
||||
# artifacts here; we don't pass releaseBody to avoid clobbering.
|
||||
- name: Build and publish Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_ENABLE_STARTUP_UPDATE_CHECK: "true"
|
||||
with:
|
||||
projectPath: app
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: "Dune Dedicated Server Manager ${{ env.RELEASE_TAG }}"
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
68
docs/reference-repos/adainrivers/.gitignore
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
app/node_modules/
|
||||
|
||||
# Frontend build
|
||||
dist/
|
||||
app/dist/
|
||||
app/src-tauri/gen/schemas/
|
||||
|
||||
# Rust/Tauri build outputs
|
||||
target/
|
||||
src-tauri/target/
|
||||
app/src-tauri/target/
|
||||
manager-api/target/
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Docs are scratch notes for now; keep README trackable later
|
||||
*.md
|
||||
!README.md
|
||||
!docs/
|
||||
!docs/*.md
|
||||
docs/rabbitmq-protocol.md
|
||||
# Release notes go on GitHub releases via the release workflow.
|
||||
!release-notes/
|
||||
!release-notes/*.md
|
||||
|
||||
# Editor and OS noise
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Local app/runtime data and secrets
|
||||
.tmp/
|
||||
.playwright-mcp/
|
||||
app/default-config.json
|
||||
app/steamcmd/
|
||||
app/dune-server/
|
||||
dune-server/
|
||||
app/vm/
|
||||
app/vm-*/
|
||||
app/src-tauri/dune-server/
|
||||
app/src-tauri/vm/
|
||||
app/src-tauri/resources/manager-api/dune-manager-api
|
||||
app/src-tauri/resources/manager-api/dune-manager-api.exe
|
||||
vm/
|
||||
*.pem
|
||||
*.key
|
||||
sshKey
|
||||
codex_vm_ed25519_dropbear
|
||||
codex_vm_ed25519_dropbear.pub
|
||||
snapshots/
|
||||
keys/
|
||||
initial-setup-log.txt
|
||||
secrets/
|
||||
7156
docs/reference-repos/adainrivers/Cargo.lock
generated
Normal file
7
docs/reference-repos/adainrivers/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[workspace]
|
||||
members = ["crates/dune-manager-core", "crates/dune-server-service", "app/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
21
docs/reference-repos/adainrivers/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 gaming.tools
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
59
docs/reference-repos/adainrivers/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Dune Dedicated Server Manager
|
||||
|
||||
A desktop manager for existing Dune Awakening dedicated servers.
|
||||
|
||||

|
||||
|
||||
The app manages already-provisioned Dune dedicated servers over SSH and
|
||||
Kubernetes control commands. It does not install the game server, create VMs,
|
||||
configure Hyper-V, provision Ubuntu, or manage external tools such as SteamCMD.
|
||||
|
||||
## Features
|
||||
|
||||
- Remote server profile management with SSH private-key authentication
|
||||
- BattleGroup status, start, stop, restart, and update controls
|
||||
- Component diagnostics, log viewing, and safe restart actions
|
||||
- Secure Director, File Browser, PostgreSQL, and PgHero access through local SSH tunnels
|
||||
- Bundled `dune-server-service` daemon for on-host scheduled maintenance (daily restarts with in-game warnings, automated backups, server update check + apply) — installed over SSH straight from the Management card
|
||||
- Admin console for in-game actions: item grants, vehicle spawns, skill/journey/XP tags, player lookup with live pawn location, and a logged history of every published command
|
||||
- Automated tasks tab with editable schedule settings (daily restart time, warning lead/frequency, update apply lead, IANA timezone) — saving auto-restarts the service so changes apply immediately
|
||||
- Welcome Package automation: a per-player onboarding chain (item grants, water refill, welcome whisper) driven by Postgres player detection, tracked in the management service's SQLite ledger, and configurable from the Welcome Package tab with both a visual editor and a raw JSON mode
|
||||
|
||||

|
||||
|
||||
More management features coming soon.
|
||||
|
||||
## Install
|
||||
|
||||
Download the latest release for your operating system from GitHub Releases.
|
||||
|
||||
- Windows: run the NSIS installer.
|
||||
- Linux: use the AppImage or Debian package.
|
||||
- macOS: use the DMG for your Mac architecture.
|
||||
|
||||
After launching the app, add an existing server profile with its host, SSH user,
|
||||
and private key path, then refresh it to detect BattleGroups and management
|
||||
endpoints.
|
||||
|
||||
## Managed Server Assumptions
|
||||
|
||||
The target server must already be installed and reachable over SSH. The app
|
||||
expects the Dune Kubernetes resources and vendor management scripts to exist on
|
||||
the server before you add it.
|
||||
|
||||
Required player-facing/server ports depend on your own server deployment. A
|
||||
typical dedicated-server deployment uses:
|
||||
|
||||
- UDP 7777-7810 for game servers
|
||||
- TCP 31982 for RMQ
|
||||
|
||||
If you found a bug or are having other issues, please create an issue here:
|
||||
https://github.com/adainrivers/dune-dedicated-server-manager/issues
|
||||
|
||||
## Building From Source
|
||||
|
||||
See [Building From Source](docs/building-from-source.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE).
|
||||
15
docs/reference-repos/adainrivers/app/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dune Dedicated Server Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3897
docs/reference-repos/adainrivers/app/package-lock.json
generated
Normal file
32
docs/reference-repos/adainrivers/app/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "dune-dedicated-server-manager-app",
|
||||
"private": true,
|
||||
"version": "0.3.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 1420",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
26
docs/reference-repos/adainrivers/app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "dune-dedicated-server-manager-app"
|
||||
version = "0.2.0"
|
||||
description = "Desktop shell for Dune Dedicated Server Manager"
|
||||
authors = ["Dune Dedicated Server Manager"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "dune_dedicated_server_manager_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
dune-manager-core = { path = "../../crates/dune-manager-core" }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
||||
6
docs/reference-repos/adainrivers/app/src-tauri/binaries/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Populated by CI from the `linux-service-binary` job artifact, or locally
|
||||
# via `cargo zigbuild -p dune-server-service --release --target
|
||||
# x86_64-unknown-linux-musl` + manual copy. Not tracked.
|
||||
dune-server-service
|
||||
dune-server-service.service
|
||||
dune-server-service.openrc
|
||||
@@ -0,0 +1,23 @@
|
||||
# Bundled service binaries
|
||||
|
||||
This directory holds the Linux `dune-server-service` binary (musl-static), its
|
||||
systemd unit, and its OpenRC init script. They are populated by the
|
||||
`linux-service-binary` job in `.github/workflows/release.yml` and bundled into
|
||||
the desktop installer as Tauri resources.
|
||||
|
||||
For local debug builds the directory can be empty — the `install_management_service`
|
||||
Tauri command surfaces a friendly error when the resource is missing.
|
||||
|
||||
For a local end-to-end test, build the service yourself:
|
||||
|
||||
```powershell
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
cargo install --locked cargo-zigbuild
|
||||
cargo zigbuild -p dune-server-service --release --target x86_64-unknown-linux-musl
|
||||
Copy-Item target\x86_64-unknown-linux-musl\release\dune-server-service `
|
||||
app\src-tauri\binaries\dune-server-service
|
||||
Copy-Item crates\dune-server-service\systemd\dune-server-service.service `
|
||||
app\src-tauri\binaries\dune-server-service.service
|
||||
Copy-Item crates\dune-server-service\openrc\dune-server-service `
|
||||
app\src-tauri\binaries\dune-server-service.openrc
|
||||
```
|
||||
67
docs/reference-repos/adainrivers/app/src-tauri/build.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
fn main() {
|
||||
expose_dune_server_service_version();
|
||||
rerun_if_bundled_binaries_change();
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
/// Tauri's resource-copy step only fires when Cargo decides build.rs needs to
|
||||
/// re-run, which by default doesn't watch arbitrary files. Without these
|
||||
/// `rerun-if-changed` lines, refreshing the bundled `dune-server-service`
|
||||
/// binary or its systemd/openrc units in `binaries/` after a previous build
|
||||
/// produces a stale `target/release/binaries/` copy — the running exe then
|
||||
/// pushes the OLD binary on Install/Update, with no visible signal.
|
||||
fn rerun_if_bundled_binaries_change() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
|
||||
// Watch the directory itself so file additions/deletions also trigger a rerun.
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Skip README, .gitignore, and similar bookkeeping files.
|
||||
if matches!(
|
||||
path.file_name().and_then(|n| n.to_str()),
|
||||
Some("README.md") | Some(".gitignore")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expose_dune_server_service_version() {
|
||||
let cargo_toml = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/dune-server-service/Cargo.toml");
|
||||
println!("cargo:rerun-if-changed={}", cargo_toml.display());
|
||||
let contents = std::fs::read_to_string(&cargo_toml)
|
||||
.unwrap_or_else(|err| panic!("reading {}: {err}", cargo_toml.display()));
|
||||
let version = parse_package_version(&contents).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"could not find [package].version in {}",
|
||||
cargo_toml.display()
|
||||
)
|
||||
});
|
||||
println!("cargo:rustc-env=DUNE_SERVER_SERVICE_VERSION={version}");
|
||||
}
|
||||
|
||||
fn parse_package_version(toml: &str) -> Option<String> {
|
||||
let mut in_package = false;
|
||||
for line in toml.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') {
|
||||
in_package = trimmed == "[package]";
|
||||
continue;
|
||||
}
|
||||
if !in_package {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("version") {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix('=')?.trim_start();
|
||||
let rest = rest.trim_start_matches('"');
|
||||
let end = rest.find('"')?;
|
||||
return Some(rest[..end].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default desktop app permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:allow-open", "process:default", "shell:allow-open", "updater:default"]
|
||||
}
|
||||
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
docs/reference-repos/adainrivers/app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 3.9 KiB |