Provision instance bank credentials automatically (Refs #146)
This commit is contained in:
+38
-33
@@ -1,41 +1,46 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest();
|
||||
const valid = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^ec_bank_[A-Za-z0-9_-]{32,128}$/.test(value);
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
/** Dedicated, unique credentials; telemetry and browser credentials never authorize spending. */
|
||||
/** Private controller state, reused only by the same immutable native instance. */
|
||||
export function bankCredential(instanceId: string): string {
|
||||
return db()
|
||||
.transaction(() => {
|
||||
const instance = db()
|
||||
.prepare('SELECT server FROM native_instances WHERE id=? AND retired_at IS NULL')
|
||||
.get(instanceId) as { server: string } | undefined;
|
||||
if (!instance) throw new Error('Bank credentials require an active native instance.');
|
||||
const saved = db()
|
||||
.prepare('SELECT token FROM bank_credentials WHERE instance_id=?')
|
||||
.get(instanceId) as { token: string } | undefined;
|
||||
if (saved) return saved.token;
|
||||
const token = 'ec_bank_' + randomBytes(32).toString('base64url');
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO bank_credentials(instance_id,server,token,token_hash,created_at) VALUES (?,?,?,?,?)'
|
||||
)
|
||||
.run(instanceId, instance.server, token, digest(token), new Date().toISOString());
|
||||
return token;
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
|
||||
/** Retiring an instance immediately revokes its game credential, including reads. */
|
||||
export function authenticateBank(
|
||||
request: Request
|
||||
): { ok: true; server: string } | { ok: false; status: 401 | 503; code: string } {
|
||||
let entries: [string, string][];
|
||||
try {
|
||||
const config: unknown = JSON.parse(process.env.EC_BANK_TOKENS ?? '{}');
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error();
|
||||
const values = Object.entries(config);
|
||||
if (
|
||||
!values.length ||
|
||||
values.some(
|
||||
([server, token]) =>
|
||||
!server || !valid(token) || token === process.env.EC_TELEMETRY_TOKEN
|
||||
)
|
||||
)
|
||||
throw new Error();
|
||||
entries = values as [string, string][];
|
||||
if (new Set(entries.map(([, token]) => token)).size !== entries.length) throw new Error();
|
||||
} catch {
|
||||
return { ok: false, status: 503, code: 'unconfigured' };
|
||||
}
|
||||
): { ok: true; server: string; instanceId: string } | { ok: false; status: 401; code: string } {
|
||||
const token = request.headers
|
||||
.get('authorization')
|
||||
?.match(/^Bearer (ec_bank_[A-Za-z0-9_-]{32,128})$/i)?.[1];
|
||||
if (!token) return { ok: false, status: 401, code: 'unauthorized' };
|
||||
const supplied = digest(token);
|
||||
let matched: string | undefined;
|
||||
for (const [server, expected] of entries) {
|
||||
if (timingSafeEqual(supplied, digest(expected))) matched = server;
|
||||
}
|
||||
return matched
|
||||
? { ok: true, server: matched }
|
||||
: { ok: false, status: 401, code: 'unauthorized' };
|
||||
const matched = token
|
||||
? (db()
|
||||
.prepare(
|
||||
`SELECT c.server, c.instance_id AS instanceId
|
||||
FROM bank_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.server=c.server
|
||||
WHERE c.token_hash=? AND n.retired_at IS NULL`
|
||||
)
|
||||
.get(digest(token)) as { server: string; instanceId: string } | undefined)
|
||||
: undefined;
|
||||
return matched ? { ok: true, ...matched } : { ok: false, status: 401, code: 'unauthorized' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function migrateBankCredentials(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bank_credentials (
|
||||
instance_id TEXT PRIMARY KEY, server TEXT NOT NULL,
|
||||
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
if (
|
||||
!(handle.pragma('table_info(bank_requests)') as { name: string }[]).some(
|
||||
(column) => column.name === 'instance_id'
|
||||
)
|
||||
) {
|
||||
handle.exec(`
|
||||
ALTER TABLE bank_requests ADD COLUMN instance_id TEXT;
|
||||
UPDATE bank_requests SET instance_id = (
|
||||
SELECT id FROM native_instances WHERE server = bank_requests.server AND retired_at IS NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||
import { migrateBank } from './bank.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import type { Migration } from './migration-runner.js';
|
||||
|
||||
// Append only: never renumber entries or change an applied migration to add an
|
||||
@@ -111,7 +112,8 @@ export const migrations: readonly Migration[] = [
|
||||
name: 'cumulative player score per match',
|
||||
run: migratePlayerScoreTotal
|
||||
},
|
||||
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank }
|
||||
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
|
||||
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials }
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Result {
|
||||
balance: number;
|
||||
}
|
||||
interface Saved {
|
||||
instance_id: string | null;
|
||||
fingerprint: string;
|
||||
state: string;
|
||||
amount: number;
|
||||
@@ -79,7 +80,8 @@ export function bankProfile(guid: string) {
|
||||
export function bankRequest(
|
||||
action: BankAction,
|
||||
value: unknown,
|
||||
authenticatedServer: string
|
||||
authenticatedServer: string,
|
||||
instanceId: string
|
||||
): { status: number; body: Result } {
|
||||
const result = (status: number, code: string, request_id = '') => ({
|
||||
status,
|
||||
@@ -121,7 +123,7 @@ export function bankRequest(
|
||||
const saved = handle
|
||||
.prepare('SELECT * FROM bank_requests WHERE server = ? AND request_id = ?')
|
||||
.get(server, request_id) as Saved | undefined;
|
||||
if (saved && saved.fingerprint !== fingerprint)
|
||||
if (saved && (saved.instance_id !== instanceId || saved.fingerprint !== fingerprint))
|
||||
return result(409, 'request_conflict', request_id);
|
||||
const balance =
|
||||
(
|
||||
@@ -145,8 +147,8 @@ export function bankRequest(
|
||||
const insert = (body: Result, matchId: number | null = null) => {
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`
|
||||
`INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at,instance_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
server,
|
||||
@@ -158,7 +160,8 @@ export function bankRequest(
|
||||
body.amount,
|
||||
body.state,
|
||||
JSON.stringify(body),
|
||||
now
|
||||
now,
|
||||
instanceId
|
||||
);
|
||||
return { status: 200, body };
|
||||
};
|
||||
@@ -200,6 +203,14 @@ export function bankRequest(
|
||||
return { status: 200, body };
|
||||
}
|
||||
if (saved) return { status: 200, body: JSON.parse(saved.result) as Result };
|
||||
if (
|
||||
!handle
|
||||
.prepare(
|
||||
'SELECT 1 FROM native_instances WHERE id=? AND server=? AND retired_at IS NULL'
|
||||
)
|
||||
.get(instanceId, server)
|
||||
)
|
||||
return result(403, 'retired_instance', request_id);
|
||||
const member = handle
|
||||
.prepare(
|
||||
`SELECT m.id FROM leaderboard_matches m
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface NativeLaunchInput {
|
||||
instance: NativeInstance;
|
||||
serverKey: string;
|
||||
rconPassword: string;
|
||||
bankToken: string;
|
||||
rebuild: boolean;
|
||||
safetyOverride?: boolean;
|
||||
unhealthyRecovery?: boolean;
|
||||
|
||||
@@ -71,6 +71,19 @@ parentPort!.on(
|
||||
};
|
||||
})();
|
||||
const instance = input.instance;
|
||||
// Credential files travel only in this host's private operation payload, never
|
||||
// shared recipes, promoted files, or the content-addressed download store.
|
||||
const bankPath = posix.join(
|
||||
relDestForKind('config', recipe.game),
|
||||
'electric_cherry_bank.cfg'
|
||||
);
|
||||
if (
|
||||
recipe.files.some(
|
||||
(file) =>
|
||||
posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath
|
||||
)
|
||||
)
|
||||
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.');
|
||||
const payload = {
|
||||
name: instance.server,
|
||||
game: recipe.game,
|
||||
@@ -79,13 +92,14 @@ parentPort!.on(
|
||||
port: instance.port,
|
||||
bindAddress: instance.bind_address,
|
||||
rconPassword: password,
|
||||
bank: { token: input.bankToken, configPath: bankPath },
|
||||
env: {
|
||||
PLUTAINER_SERVER_NAME: instance.server,
|
||||
PLUTAINER_PORT: String(instance.port),
|
||||
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
|
||||
PLUTAINER_CONFIG_FILE: recipe.configFile,
|
||||
PLUTAINER_MAP_ROTATE: 'false',
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`,
|
||||
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg +set tws_server_id ${instance.server} +map_rotate`,
|
||||
PLUTO_SERVER_KEY: input.serverKey
|
||||
},
|
||||
files: recipe.files.map((file) => ({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isIP } from 'node:net';
|
||||
import { setImmediate as yieldToController } from 'node:timers/promises';
|
||||
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { bankCredential } from '../auth/bank.js';
|
||||
import { posix } from 'node:path';
|
||||
import { db } from '../db/index.js';
|
||||
import { unsettledCancelledCommand } from './cancellation.js';
|
||||
@@ -1936,6 +1937,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
instance,
|
||||
serverKey: preparedKey,
|
||||
rconPassword: preparedPassword,
|
||||
bankToken: bankCredential(instance.id),
|
||||
rebuild: job.action === 'rebuild' && !migration,
|
||||
safetyOverride: overrideSafety,
|
||||
unhealthyRecovery: host.inventory.capabilities?.includes(
|
||||
|
||||
Reference in New Issue
Block a user