Provision instance bank credentials automatically (Refs #146)
This commit is contained in:
+104
-18
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { routePermission } from '../../src/lib/server/auth/access.js';
|
||||
@@ -5,20 +6,38 @@ import { routePermission } from '../../src/lib/server/auth/access.js';
|
||||
let fixture: Fixture;
|
||||
const server = 't5_zombie_theater_1';
|
||||
const other = 't6_zm_transit_1';
|
||||
const secret = 'ec_bank_' + 'a'.repeat(40);
|
||||
const otherSecret = 'ec_bank_' + 'b'.repeat(40);
|
||||
let secret: string;
|
||||
let otherSecret: string;
|
||||
let instanceId: string;
|
||||
let hostId: string;
|
||||
let hostToken: string;
|
||||
let serial = 0;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([server, other]);
|
||||
vi.stubEnv('EC_BANK_TOKENS', JSON.stringify({ [server]: secret, [other]: otherSecret }));
|
||||
vi.stubEnv('EC_TELEMETRY_TOKEN', 'telemetry_' + 't'.repeat(40));
|
||||
vi.stubEnv('EC_BANK_MAX_BALANCE', '250000');
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
await seedServerRegistry();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { bankCredential } = await import('../../src/lib/server/auth/bank.js');
|
||||
const { createHost, createEnrollment, enrollHost } =
|
||||
await import('../../src/lib/server/hosts/index.js');
|
||||
hostId = createHost({ name: 'Bank host' }).id;
|
||||
hostToken = enrollHost(createEnrollment(hostId).token, {}).credential;
|
||||
for (const source of [server, other]) {
|
||||
const id = randomUUID();
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)`
|
||||
)
|
||||
.run(id, source, hostId, new Date().toISOString());
|
||||
if (source === server) {
|
||||
instanceId = id;
|
||||
secret = bankCredential(id);
|
||||
} else otherSecret = bankCredential(id);
|
||||
saveTelemetry({
|
||||
server: source,
|
||||
map: source === server ? 'zombie_theater' : 'zm_transit',
|
||||
@@ -84,21 +103,88 @@ it('routes only bank POSTs as machine requests, authenticating before reading bo
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for missing, duplicate, telemetry-reused and invalid credentials', async () => {
|
||||
for (const configured of [
|
||||
'',
|
||||
'{}',
|
||||
'[]',
|
||||
'{',
|
||||
JSON.stringify({ [server]: secret, [other]: secret }),
|
||||
JSON.stringify({ [server]: 'short' })
|
||||
]) {
|
||||
vi.stubEnv('EC_BANK_TOKENS', configured);
|
||||
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 503 });
|
||||
}
|
||||
vi.stubEnv('EC_BANK_TOKENS', JSON.stringify({ [server]: secret }));
|
||||
vi.stubEnv('EC_TELEMETRY_TOKEN', secret);
|
||||
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 503 });
|
||||
it('owns generated secrets by immutable instance, preserving rebuilds and revoking retired names', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { bankCredential } = await import('../../src/lib/server/auth/bank.js');
|
||||
const data = transfer();
|
||||
await post('deposit', data);
|
||||
expect(secret).toMatch(/^ec_bank_[A-Za-z0-9_-]{43}$/);
|
||||
expect(otherSecret).not.toBe(secret);
|
||||
closeDb();
|
||||
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
|
||||
expect(bankCredential(instanceId)).toBe(secret);
|
||||
db()
|
||||
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
|
||||
.run(new Date().toISOString(), instanceId);
|
||||
expect(await post('balance', { server, guid: 'ace' })).toMatchObject({ status: 401 });
|
||||
expect(() => bankCredential(instanceId)).toThrow('active native instance');
|
||||
const next = randomUUID();
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)`
|
||||
)
|
||||
.run(next, server, hostId, new Date().toISOString());
|
||||
const replacement = bankCredential(next);
|
||||
const { migrateBankCredentials } = await import('../../src/lib/server/db/bank-credentials.js');
|
||||
migrateBankCredentials(db()); // A legacy migration rerun must not rebind old requests.
|
||||
expect(replacement).not.toBe(secret);
|
||||
expect(await post('commit', data, replacement)).toMatchObject({
|
||||
status: 409,
|
||||
code: 'request_conflict'
|
||||
});
|
||||
expect(await post('cancel', data, replacement)).toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it('settles journals only for the host assigned to an unfinished teardown, even after retirement', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { queueHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
||||
const { POST } = await import('../../src/routes/api/agent/bank/settle/+server.js');
|
||||
const data = transfer();
|
||||
await post('deposit', data);
|
||||
const removal = queueHostOperation(
|
||||
hostId,
|
||||
'instance.remove',
|
||||
{},
|
||||
{ instanceId, generation: 1 }
|
||||
);
|
||||
db()
|
||||
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
|
||||
.run(new Date().toISOString(), instanceId);
|
||||
const settle = (
|
||||
action = 'commit',
|
||||
operationId = removal.id,
|
||||
credential = hostToken,
|
||||
value = data
|
||||
) =>
|
||||
POST({
|
||||
request: new Request('https://test/api/agent/bank/settle', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${credential}` },
|
||||
body: JSON.stringify({ operationId, action, transfer: value })
|
||||
})
|
||||
});
|
||||
await expect(settle('commit', removal.id, secret)).rejects.toMatchObject({ status: 401 });
|
||||
const { createHost, createEnrollment, enrollHost } =
|
||||
await import('../../src/lib/server/hosts/index.js');
|
||||
const stranger = createHost({ name: 'Unassigned host' });
|
||||
const strangerToken = enrollHost(createEnrollment(stranger.id).token, {}).credential;
|
||||
await expect(settle('commit', removal.id, strangerToken)).rejects.toMatchObject({
|
||||
status: 403
|
||||
});
|
||||
await expect(settle('deposit')).rejects.toMatchObject({ status: 400 });
|
||||
await expect(settle('commit', randomUUID())).rejects.toMatchObject({ status: 403 });
|
||||
expect(
|
||||
await (await settle('commit', removal.id, hostToken, { ...data, server: other })).json()
|
||||
).toMatchObject({ code: 'wrong_server' });
|
||||
const first = await (await settle()).json();
|
||||
expect(first).toMatchObject({ state: 'committed', balance: 2000 });
|
||||
expect(await (await settle()).json()).toEqual(first);
|
||||
const cancelled = transfer();
|
||||
expect(await (await settle('cancel', removal.id, hostToken, cancelled)).json()).toMatchObject({
|
||||
state: 'cancelled'
|
||||
});
|
||||
db().prepare("UPDATE ec_host_operations SET state='completed' WHERE id=?").run(removal.id);
|
||||
await expect(settle()).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('persists one credit and one ledger row through lost replies, retries and database reopen', async () => {
|
||||
|
||||
@@ -416,3 +416,47 @@ describe('asynchronous native launch preparation', () => {
|
||||
expect(await operationFor(second.deploymentId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('injects unique persistent bank credentials only into private runtime files', async () => {
|
||||
const job = await queue();
|
||||
await runPass();
|
||||
const op = await operationFor(job.deploymentId);
|
||||
const { getHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
||||
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
|
||||
const { bankCredential, authenticateBank } = await import('../../src/lib/server/auth/bank.js');
|
||||
const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||
const instance = nativeInstance(job.name)!;
|
||||
const credential = bankCredential(instance.id);
|
||||
const payload = getHostOperation(op!.id)!.payload as {
|
||||
bank: { token: string; configPath: string };
|
||||
files: { path: string }[];
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(payload.bank).toEqual({
|
||||
token: credential,
|
||||
configPath: 'configs/electric_cherry_bank.cfg'
|
||||
});
|
||||
expect(payload.files.some((file) => file.path.endsWith('electric_cherry_bank.cfg'))).toBe(
|
||||
false
|
||||
);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(
|
||||
db()
|
||||
.prepare('SELECT count(*) AS n FROM file_blobs WHERE instr(content,?) > 0')
|
||||
.get(Buffer.from(credential))
|
||||
).toEqual({ n: 0 });
|
||||
expect(payload.env.PLUTAINER_EXTRA_ARGS).toContain(
|
||||
'+exec electric_cherry_bank.cfg +set tws_server_id'
|
||||
);
|
||||
expect(
|
||||
authenticateBank(
|
||||
new Request('https://test', { headers: { Authorization: `Bearer ${credential}` } })
|
||||
)
|
||||
).toMatchObject({ ok: true, server: job.name, instanceId: instance.id });
|
||||
expect(JSON.stringify(recipeForDeployment(job.deploymentId))).not.toContain(credential);
|
||||
expect(
|
||||
recipeForDeployment(job.deploymentId)!.recipe.files.some(
|
||||
(file) => file.path === 'electric_cherry_bank.cfg'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -149,6 +149,7 @@ it.each([
|
||||
instance: backend.nativeInstance(a.server)!,
|
||||
serverKey: 'test-key',
|
||||
rconPassword: password,
|
||||
bankToken: 'ec_bank_' + 'a'.repeat(43),
|
||||
rebuild: false
|
||||
});
|
||||
const actualConfig = prepared.recipe.files.find((file) => file.path === target)!;
|
||||
|
||||
Reference in New Issue
Block a user