feat(nats): per-license auth mechanism — agent user/password, scoped broker, generator (non-breaking)
All checks were successful
All checks were successful
Closes the open broker (anonymous publish to any tenant's corrosion.*).
Per-license isolation via NATS user/password + subject permissions:
each license -> user=license_id, password=HMAC-SHA256(license_id,
NATS_TOKEN_SECRET), scoped to corrosion.{license_id}.> + _INBOX. Backend
uses a privileged internal user.
- Agent (alpha.5): nats_user/nats_password config + env, user_and_password
auth; falls back to token/anonymous (transition-safe)
- Backend: connects with NATS_INTERNAL_USER/PASSWORD when set, else anon
- scripts/generate-nats-auth.mjs: regenerates nats-auth.conf from the
licenses table; NATS_AUTH_STAGE=open keeps a no_auth_user fallback
(verify creds first), =enforce rejects anonymous
- committed nats-auth.conf is the SAFE OPEN default (no secrets); the
host copy carries real users and is not committed
- compose: NATS_INTERNAL_USER/PASSWORD/NATS_TOKEN_SECRET, mount nats-auth.conf
Entirely non-breaking until secrets+config deployed; staged cutover next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,13 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
nats: {
|
nats: {
|
||||||
url: process.env.NATS_URL || 'nats://localhost:4222',
|
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: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET || 'change-me',
|
secret: process.env.JWT_SECRET || 'change-me',
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
try {
|
try {
|
||||||
const url = this.config.get<string>('nats.url') || 'nats://localhost:4222';
|
const url = this.config.get<string>('nats.url') || 'nats://localhost:4222';
|
||||||
this.nc = await connect({ servers: url });
|
const user = this.config.get<string>('nats.internalUser');
|
||||||
this.logger.log(`Connected to NATS at ${url}`);
|
const pass = this.config.get<string>('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) {
|
} catch (err) {
|
||||||
this.logger.warn(`NATS connection failed — running in offline mode: ${(err as Error).message}`);
|
this.logger.warn(`NATS connection failed — running in offline mode: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
2
corrosion-host-agent/Cargo.lock
generated
2
corrosion-host-agent/Cargo.lock
generated
@@ -264,7 +264,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.4"
|
version = "2.0.0-alpha.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "corrosion-host-agent"
|
name = "corrosion-host-agent"
|
||||||
version = "2.0.0-alpha.4"
|
version = "2.0.0-alpha.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
[agent]
|
[agent]
|
||||||
license_id = "your-license-uuid"
|
license_id = "your-license-uuid"
|
||||||
nats_url = "nats://nats.corrosionmgmt.com:4222"
|
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
|
heartbeat_seconds = 60
|
||||||
log_level = "info"
|
log_level = "info"
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ pub async fn connect(cfg: &Settings) -> Result<async_nats::Client> {
|
|||||||
if force_tls {
|
if force_tls {
|
||||||
opts = opts.require_tls(true);
|
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());
|
opts = opts.token(token.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ pub struct AgentSection {
|
|||||||
pub license_id: Option<String>,
|
pub license_id: Option<String>,
|
||||||
pub nats_url: Option<String>,
|
pub nats_url: Option<String>,
|
||||||
pub nats_token: Option<String>,
|
pub nats_token: Option<String>,
|
||||||
|
/// NATS username for per-license auth. Defaults to license_id when a
|
||||||
|
/// password is set but no user is given.
|
||||||
|
pub nats_user: Option<String>,
|
||||||
|
/// NATS password (the per-license token). When set, the agent authenticates
|
||||||
|
/// with user+password instead of a bare token.
|
||||||
|
pub nats_password: Option<String>,
|
||||||
#[serde(default = "default_heartbeat_seconds")]
|
#[serde(default = "default_heartbeat_seconds")]
|
||||||
pub heartbeat_seconds: u64,
|
pub heartbeat_seconds: u64,
|
||||||
#[serde(default = "default_log_level")]
|
#[serde(default = "default_log_level")]
|
||||||
@@ -122,6 +128,8 @@ pub struct Settings {
|
|||||||
pub license_id: String,
|
pub license_id: String,
|
||||||
pub nats_url: String,
|
pub nats_url: String,
|
||||||
pub nats_token: Option<String>,
|
pub nats_token: Option<String>,
|
||||||
|
pub nats_user: Option<String>,
|
||||||
|
pub nats_password: Option<String>,
|
||||||
pub heartbeat_seconds: u64,
|
pub heartbeat_seconds: u64,
|
||||||
pub log_level: String,
|
pub log_level: String,
|
||||||
pub instances: Vec<InstanceConfig>,
|
pub instances: Vec<InstanceConfig>,
|
||||||
@@ -167,6 +175,16 @@ fn resolve(file: ConfigFile) -> Result<Settings> {
|
|||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.or(file.agent.nats_token);
|
.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)?;
|
validate_subject_segment("license_id", &license_id)?;
|
||||||
|
|
||||||
let mut seen: HashSet<&str> = HashSet::new();
|
let mut seen: HashSet<&str> = HashSet::new();
|
||||||
@@ -196,6 +214,8 @@ fn resolve(file: ConfigFile) -> Result<Settings> {
|
|||||||
license_id,
|
license_id,
|
||||||
nats_url,
|
nats_url,
|
||||||
nats_token,
|
nats_token,
|
||||||
|
nats_user,
|
||||||
|
nats_password,
|
||||||
heartbeat_seconds: file.agent.heartbeat_seconds,
|
heartbeat_seconds: file.agent.heartbeat_seconds,
|
||||||
log_level: file.agent.log_level,
|
log_level: file.agent.log_level,
|
||||||
instances: file.instances,
|
instances: file.instances,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nats_data:/data
|
- nats_data:/data
|
||||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
- ./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:
|
ports:
|
||||||
- "8089:4222" # Client connections
|
- "8089:4222" # Client connections
|
||||||
|
|
||||||
@@ -43,6 +46,12 @@ services:
|
|||||||
DATABASE_URL: postgres://corrosion:${DB_PASSWORD:-corrosion_dev}@postgres:5432/corrosion
|
DATABASE_URL: postgres://corrosion:${DB_PASSWORD:-corrosion_dev}@postgres:5432/corrosion
|
||||||
DATABASE_MAX_CONNECTIONS: "20"
|
DATABASE_MAX_CONNECTIONS: "20"
|
||||||
NATS_URL: nats://nats:4222
|
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_SECRET: ${JWT_SECRET}
|
||||||
JWT_ACCESS_EXPIRY_SECONDS: "14400"
|
JWT_ACCESS_EXPIRY_SECONDS: "14400"
|
||||||
JWT_REFRESH_EXPIRY_SECONDS: "604800"
|
JWT_REFRESH_EXPIRY_SECONDS: "604800"
|
||||||
|
|||||||
12
docker/nats-auth.conf
Normal file
12
docker/nats-auth.conf
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -28,8 +28,11 @@ logtime: true
|
|||||||
max_payload: 8MB # Support map file transfer metadata
|
max_payload: 8MB # Support map file transfer metadata
|
||||||
max_connections: 10000
|
max_connections: 10000
|
||||||
|
|
||||||
# Authorization — tokens validated per-connection
|
# Authorization — per-license isolation.
|
||||||
# Plugin and companion agents authenticate with license-specific tokens
|
# The committed nats-auth.conf is the SAFE OPEN default (anonymous full access,
|
||||||
authorization {
|
# no secrets — same as before). On deploy, scripts/generate-nats-auth.mjs
|
||||||
timeout: 5
|
# 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"
|
||||||
|
|||||||
85
scripts/generate-nats-auth.mjs
Normal file
85
scripts/generate-nats-auth.mjs
Normal file
@@ -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); });
|
||||||
Reference in New Issue
Block a user