diff --git a/backend-nest/src/config/configuration.ts b/backend-nest/src/config/configuration.ts index f521e16..a642d63 100644 --- a/backend-nest/src/config/configuration.ts +++ b/backend-nest/src/config/configuration.ts @@ -6,6 +6,13 @@ export default () => ({ }, nats: { url: process.env.NATS_URL || 'nats://localhost:4222', + // Privileged internal credentials for the backend's own NATS connection + // (full corrosion.> access). Empty = anonymous (transition period). + internalUser: process.env.NATS_INTERNAL_USER || '', + internalPassword: process.env.NATS_INTERNAL_PASSWORD || '', + // Secret used to derive a per-license agent password: + // HMAC-SHA256(license_id, secret). Shared with the nats.conf generator. + tokenSecret: process.env.NATS_TOKEN_SECRET || '', }, jwt: { secret: process.env.JWT_SECRET || 'change-me', diff --git a/backend-nest/src/services/nats.service.ts b/backend-nest/src/services/nats.service.ts index 3f14f14..0fa1e70 100644 --- a/backend-nest/src/services/nats.service.ts +++ b/backend-nest/src/services/nats.service.ts @@ -13,8 +13,13 @@ export class NatsService implements OnModuleInit, OnModuleDestroy { async onModuleInit() { try { const url = this.config.get('nats.url') || 'nats://localhost:4222'; - this.nc = await connect({ servers: url }); - this.logger.log(`Connected to NATS at ${url}`); + const user = this.config.get('nats.internalUser'); + const pass = this.config.get('nats.internalPassword'); + // Authenticate with the privileged internal user when configured; + // otherwise connect anonymously (broker hasn't enforced auth yet). + const opts = user && pass ? { servers: url, user, pass } : { servers: url }; + this.nc = await connect(opts); + this.logger.log(`Connected to NATS at ${url}${user ? ` as ${user}` : ' (anonymous)'}`); } catch (err) { this.logger.warn(`NATS connection failed — running in offline mode: ${(err as Error).message}`); } diff --git a/corrosion-host-agent/Cargo.lock b/corrosion-host-agent/Cargo.lock index 8841a96..20f2df6 100644 --- a/corrosion-host-agent/Cargo.lock +++ b/corrosion-host-agent/Cargo.lock @@ -264,7 +264,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corrosion-host-agent" -version = "2.0.0-alpha.4" +version = "2.0.0-alpha.5" dependencies = [ "anyhow", "async-nats", diff --git a/corrosion-host-agent/Cargo.toml b/corrosion-host-agent/Cargo.toml index b24cfc4..6d7a990 100644 --- a/corrosion-host-agent/Cargo.toml +++ b/corrosion-host-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corrosion-host-agent" -version = "2.0.0-alpha.4" +version = "2.0.0-alpha.5" edition = "2021" description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers" license = "UNLICENSED" diff --git a/corrosion-host-agent/agent.example.toml b/corrosion-host-agent/agent.example.toml index 6339f62..1f82e61 100644 --- a/corrosion-host-agent/agent.example.toml +++ b/corrosion-host-agent/agent.example.toml @@ -9,7 +9,11 @@ [agent] license_id = "your-license-uuid" nats_url = "nats://nats.corrosionmgmt.com:4222" -# nats_token = "set-me-or-use-CORROSION_NATS_TOKEN" +# Per-license auth (preferred): user = license id, password = the token shown +# on the panel Server page. The broker scopes you to corrosion.{license}.> +# nats_user = "your-license-uuid" # defaults to license_id if omitted +# nats_password = "set-me-or-use-CORROSION_NATS_PASSWORD" +# nats_token = "legacy token-only auth; use nats_password instead" heartbeat_seconds = 60 log_level = "info" diff --git a/corrosion-host-agent/src/bus.rs b/corrosion-host-agent/src/bus.rs index 03f441b..c7ff880 100644 --- a/corrosion-host-agent/src/bus.rs +++ b/corrosion-host-agent/src/bus.rs @@ -33,7 +33,15 @@ pub async fn connect(cfg: &Settings) -> Result { if force_tls { opts = opts.require_tls(true); } - if let Some(token) = &cfg.nats_token { + + // Per-license auth: the broker maps user=license_id, password=derived + // token to permissions scoped to corrosion.{license_id}.>. Falls back to + // token-only or anonymous so the agent still works against a broker that + // hasn't enforced auth yet (transition period). + if let Some(password) = &cfg.nats_password { + let user = cfg.nats_user.clone().unwrap_or_else(|| cfg.license_id.clone()); + opts = opts.user_and_password(user, password.clone()); + } else if let Some(token) = &cfg.nats_token { opts = opts.token(token.clone()); } diff --git a/corrosion-host-agent/src/config.rs b/corrosion-host-agent/src/config.rs index db2c951..930f4cd 100644 --- a/corrosion-host-agent/src/config.rs +++ b/corrosion-host-agent/src/config.rs @@ -34,6 +34,12 @@ pub struct AgentSection { pub license_id: Option, pub nats_url: Option, pub nats_token: Option, + /// NATS username for per-license auth. Defaults to license_id when a + /// password is set but no user is given. + pub nats_user: Option, + /// NATS password (the per-license token). When set, the agent authenticates + /// with user+password instead of a bare token. + pub nats_password: Option, #[serde(default = "default_heartbeat_seconds")] pub heartbeat_seconds: u64, #[serde(default = "default_log_level")] @@ -122,6 +128,8 @@ pub struct Settings { pub license_id: String, pub nats_url: String, pub nats_token: Option, + pub nats_user: Option, + pub nats_password: Option, pub heartbeat_seconds: u64, pub log_level: String, pub instances: Vec, @@ -167,6 +175,16 @@ fn resolve(file: ConfigFile) -> Result { .filter(|v| !v.is_empty()) .or(file.agent.nats_token); + let nats_user = std::env::var("CORROSION_NATS_USER") + .ok() + .filter(|v| !v.is_empty()) + .or(file.agent.nats_user); + + let nats_password = std::env::var("CORROSION_NATS_PASSWORD") + .ok() + .filter(|v| !v.is_empty()) + .or(file.agent.nats_password); + validate_subject_segment("license_id", &license_id)?; let mut seen: HashSet<&str> = HashSet::new(); @@ -196,6 +214,8 @@ fn resolve(file: ConfigFile) -> Result { license_id, nats_url, nats_token, + nats_user, + nats_password, heartbeat_seconds: file.agent.heartbeat_seconds, log_level: file.agent.log_level, instances: file.instances, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9752fb7..ad28612 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -31,6 +31,9 @@ services: volumes: - nats_data:/data - ./nats.conf:/etc/nats/nats.conf:ro + # Per-license authorization (generated on the host; carries secrets, not + # committed with real users — see scripts/generate-nats-auth.mjs). + - ./nats-auth.conf:/etc/nats/nats-auth.conf:ro ports: - "8089:4222" # Client connections @@ -43,6 +46,12 @@ services: DATABASE_URL: postgres://corrosion:${DB_PASSWORD:-corrosion_dev}@postgres:5432/corrosion DATABASE_MAX_CONNECTIONS: "20" NATS_URL: nats://nats:4222 + # Privileged internal NATS user (full corrosion.> access). Empty = anonymous. + NATS_INTERNAL_USER: ${NATS_INTERNAL_USER:-} + NATS_INTERNAL_PASSWORD: ${NATS_INTERNAL_PASSWORD:-} + # Secret for deriving per-license agent passwords (shared with the + # nats-auth generator). HMAC-SHA256(license_id, secret). + NATS_TOKEN_SECRET: ${NATS_TOKEN_SECRET:-} JWT_SECRET: ${JWT_SECRET} JWT_ACCESS_EXPIRY_SECONDS: "14400" JWT_REFRESH_EXPIRY_SECONDS: "604800" diff --git a/docker/nats-auth.conf b/docker/nats-auth.conf new file mode 100644 index 0000000..590c900 --- /dev/null +++ b/docker/nats-auth.conf @@ -0,0 +1,12 @@ +# SAFE OPEN DEFAULT — anonymous full access, no secrets. Same behavior as the +# pre-auth broker so fresh deploys and the repo stay valid. +# +# Regenerated on deploy by scripts/generate-nats-auth.mjs with the privileged +# internal user + per-license scoped users (those carry secrets and must NOT be +# committed — mark the host copy with `git update-index --assume-unchanged`). +authorization { + users: [ + { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } } + ] + no_auth_user: "anonymous" +} diff --git a/docker/nats.conf b/docker/nats.conf index f2691a1..178035b 100644 --- a/docker/nats.conf +++ b/docker/nats.conf @@ -28,8 +28,11 @@ logtime: true max_payload: 8MB # Support map file transfer metadata max_connections: 10000 -# Authorization — tokens validated per-connection -# Plugin and companion agents authenticate with license-specific tokens -authorization { - timeout: 5 -} +# Authorization — per-license isolation. +# The committed nats-auth.conf is the SAFE OPEN default (anonymous full access, +# no secrets — same as before). On deploy, scripts/generate-nats-auth.mjs +# regenerates this file from the licenses table with the privileged internal +# user + per-license scoped users; flip NATS_AUTH_STAGE=enforce to reject +# anonymous. The host copy carries secrets and is NOT committed +# (git update-index --assume-unchanged docker/nats-auth.conf). +include "nats-auth.conf" diff --git a/scripts/generate-nats-auth.mjs b/scripts/generate-nats-auth.mjs new file mode 100644 index 0000000..27897bf --- /dev/null +++ b/scripts/generate-nats-auth.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Generate corrosion-nats authorization config from the licenses table. +// +// Per-license isolation without auth-callout: each license maps to a NATS user +// (user = license UUID, password = HMAC-SHA256(license_id, NATS_TOKEN_SECRET)) +// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX +// for request-reply). The backend uses a privileged internal user. +// +// STAGING (NATS_AUTH_STAGE env): +// "open" (default) — defines a full-access `anonymous` user and sets +// no_auth_user, so unauthenticated clients still work. +// Non-breaking; lets you verify real creds first. +// "enforce" — omits no_auth_user; anonymous connections are rejected. +// +// Usage: +// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \ +// NATS_TOKEN_SECRET=... NATS_AUTH_STAGE=open node scripts/generate-nats-auth.mjs > docker/nats-auth.conf +// +// Re-run and reload NATS (`docker exec corrosion-nats nats-server --signal reload`) +// whenever licenses change. + +import { createRequire } from 'node:module'; +import { createHmac } from 'node:crypto'; + +const require = createRequire(new URL('../backend-nest/x.js', import.meta.url)); +const { Client } = require('pg'); + +const { + DATABASE_URL, + NATS_INTERNAL_USER, + NATS_INTERNAL_PASSWORD, + NATS_TOKEN_SECRET, + NATS_AUTH_STAGE = 'open', +} = process.env; + +for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) { + if (!v) { console.error(`Missing required env: ${k}`); process.exit(2); } +} + +/** Per-license agent password — must match the backend's derivation. */ +export function licensePassword(licenseId, secret) { + return createHmac('sha256', secret).update(licenseId).digest('hex'); +} + +const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +const main = async () => { + const pg = new Client({ connectionString: DATABASE_URL }); + await pg.connect(); + const { rows } = await pg.query('SELECT id FROM licenses ORDER BY created_at'); + await pg.end(); + + const lines = []; + lines.push('# GENERATED by scripts/generate-nats-auth.mjs — do not edit by hand.'); + lines.push(`# stage=${NATS_AUTH_STAGE} licenses=${rows.length}`); + lines.push('authorization {'); + lines.push(' users: ['); + // Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS). + lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`); + + // Per-license scoped users. + for (const { id } of rows) { + const pw = licensePassword(id, NATS_TOKEN_SECRET); + const scope = `corrosion.${id}.>`; + lines.push( + ` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` + + `publish: { allow: ["${scope}", "_INBOX.>"] }, ` + + `subscribe: { allow: ["${scope}", "_INBOX.>"] } } }`, + ); + } + + if (NATS_AUTH_STAGE === 'open') { + // Transition: unauthenticated clients map to a full-access user so nothing + // breaks while real credentials roll out. Remove for enforcement. + lines.push(' { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }'); + } + lines.push(' ]'); + if (NATS_AUTH_STAGE === 'open') { + lines.push(' no_auth_user: "anonymous"'); + } + lines.push('}'); + process.stdout.write(lines.join('\n') + '\n'); +}; + +main().catch((e) => { console.error(e); process.exit(1); });