195 lines
7.4 KiB
TypeScript
195 lines
7.4 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { db } from '../db/index.js';
|
|
|
|
type KeyGame = 't5' | 't6';
|
|
export interface ServerKey {
|
|
id: string;
|
|
game: KeyGame;
|
|
label: string;
|
|
updated_at: string;
|
|
assigned_servers: string[];
|
|
}
|
|
|
|
function validGame(game: unknown): asserts game is KeyGame {
|
|
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 for the server key');
|
|
}
|
|
|
|
function normalizedKey(key: unknown): string {
|
|
if (
|
|
typeof key !== 'string' ||
|
|
!key.trim() ||
|
|
key.trim().length > 512 ||
|
|
/\s/.test(key.trim())
|
|
) {
|
|
throw new Error('Enter a server key without whitespace (maximum 512 characters)');
|
|
}
|
|
return key.trim();
|
|
}
|
|
|
|
function normalizedLabel(label: unknown): string {
|
|
if (typeof label !== 'string' || label.trim().length > 160 || /[\r\n]/.test(label)) {
|
|
throw new Error('Enter a key label with at most 160 characters');
|
|
}
|
|
return label.trim();
|
|
}
|
|
|
|
export function listServerKeys(): ServerKey[] {
|
|
const rows = db()
|
|
.prepare('SELECT id,game,label,updated_at FROM server_keys ORDER BY game,label,id')
|
|
.all() as Omit<ServerKey, 'assigned_servers'>[];
|
|
const assignments = db()
|
|
.prepare(
|
|
`SELECT DISTINCT k.id AS key_id,l.server_id FROM server_keys k
|
|
JOIN server_keys occupied ON occupied.pluto_key=k.pluto_key JOIN server_key_leases l ON l.key_id=occupied.id
|
|
ORDER BY l.server_id`
|
|
)
|
|
.all() as { key_id: string; server_id: string }[];
|
|
const byKey = new Map<string, string[]>();
|
|
for (const lease of assignments) {
|
|
const servers = byKey.get(lease.key_id) ?? [];
|
|
servers.push(lease.server_id);
|
|
byKey.set(lease.key_id, servers);
|
|
}
|
|
return rows.map((row) => ({ ...row, assigned_servers: byKey.get(row.id) ?? [] }));
|
|
}
|
|
|
|
export function getServerKey(serverId: string): string | undefined {
|
|
return (
|
|
db()
|
|
.prepare(
|
|
`SELECT k.pluto_key FROM server_key_leases l
|
|
JOIN server_keys k ON k.id=l.key_id WHERE l.server_id=? AND l.is_current=1`
|
|
)
|
|
.get(serverId) as { pluto_key: string } | undefined
|
|
)?.pluto_key;
|
|
}
|
|
|
|
/** Saving a credential twice cannot increase available capacity. */
|
|
export function saveServerKey(game: unknown, key: unknown, label?: unknown): string {
|
|
validGame(game);
|
|
const credential = normalizedKey(key);
|
|
const keyLabel = label === undefined ? undefined : normalizedLabel(label);
|
|
return db()
|
|
.transaction(() => {
|
|
const existing = db()
|
|
.prepare('SELECT id FROM server_keys WHERE game=? AND pluto_key=?')
|
|
.get(game, credential) as { id: string } | undefined;
|
|
if (existing) {
|
|
if (keyLabel !== undefined)
|
|
db()
|
|
.prepare(
|
|
"UPDATE server_keys SET label=?,updated_at=datetime('now') WHERE id=?"
|
|
)
|
|
.run(keyLabel, existing.id);
|
|
return existing.id;
|
|
}
|
|
const id = randomUUID();
|
|
db()
|
|
.prepare('INSERT INTO server_keys(id,game,label,pluto_key) VALUES(?,?,?,?)')
|
|
.run(id, game, keyLabel ?? '', credential);
|
|
return id;
|
|
})
|
|
.immediate();
|
|
}
|
|
|
|
// Compare the credential as well as its row: a mistakenly imported copy in
|
|
// another game's pool must not permit simultaneous use of the same key.
|
|
const availableKey = `NOT EXISTS (SELECT 1 FROM server_key_leases l
|
|
JOIN server_keys occupied ON occupied.id=l.key_id
|
|
WHERE occupied.pluto_key=k.pluto_key AND (l.server_id!=@server OR l.is_current=0))`;
|
|
|
|
export function serverKeyPool(
|
|
game: KeyGame,
|
|
serverId?: string
|
|
): { total: number; available: number } {
|
|
validGame(game);
|
|
return db()
|
|
.prepare(
|
|
`SELECT COUNT(*) AS total,
|
|
COALESCE(SUM(CASE WHEN ${availableKey} THEN 1 ELSE 0 END),0) AS available
|
|
FROM server_keys k WHERE k.game=@game`
|
|
)
|
|
.get({ game, server: serverId ?? '' }) as { total: number; available: number };
|
|
}
|
|
|
|
export function availableServerKeyCount(game: KeyGame): number {
|
|
return serverKeyPool(game).available;
|
|
}
|
|
|
|
/** Called within launch admission; the write lock prevents competing launches sharing a key. */
|
|
export function reserveServerKey(serverId: string, game: KeyGame, suppliedKey?: unknown): string {
|
|
validGame(game);
|
|
if (!serverId) throw new Error('Choose a server for the key reservation');
|
|
const credential =
|
|
suppliedKey === undefined ||
|
|
suppliedKey === null ||
|
|
(typeof suppliedKey === 'string' && !suppliedKey.trim())
|
|
? undefined
|
|
: normalizedKey(suppliedKey);
|
|
return db()
|
|
.transaction(() => {
|
|
const existing = db()
|
|
.prepare(
|
|
`SELECT k.game,k.pluto_key FROM server_key_leases l
|
|
JOIN server_keys k ON k.id=l.key_id WHERE l.server_id=? AND l.is_current=1`
|
|
)
|
|
.get(serverId) as { game: KeyGame; pluto_key: string } | undefined;
|
|
if (existing) {
|
|
if (existing.game !== game)
|
|
throw new Error('This server already has a key reserved for another game');
|
|
if (credential && credential !== existing.pluto_key)
|
|
throw new Error(
|
|
'This server already has a reserved key; destroy it before changing its key'
|
|
);
|
|
return existing.pluto_key;
|
|
}
|
|
const requestedId = credential ? saveServerKey(game, credential) : null;
|
|
const key = db()
|
|
.prepare(
|
|
`SELECT k.id,k.pluto_key FROM server_keys k WHERE k.game=@game
|
|
AND (@requestedId IS NULL OR k.id=@requestedId) AND ${availableKey}
|
|
ORDER BY k.updated_at,k.id LIMIT 1`
|
|
)
|
|
.get({ game, server: serverId, requestedId }) as
|
|
{ id: string; pluto_key: string } | undefined;
|
|
if (!key)
|
|
throw new Error(
|
|
credential
|
|
? 'This server key is already reserved by another server'
|
|
: `No available ${game.toUpperCase()} server keys. Add a key or destroy a server to release one.`
|
|
);
|
|
db()
|
|
.prepare('INSERT INTO server_key_leases(server_id,key_id) VALUES(?,?)')
|
|
.run(serverId, key.id);
|
|
return key.pluto_key;
|
|
})
|
|
.immediate();
|
|
}
|
|
|
|
/** Only after confirmed destruction or a creation cancelled before any host mutation. */
|
|
export function releaseServerKey(serverId: string): boolean {
|
|
return (
|
|
db().prepare('DELETE FROM server_key_leases WHERE server_id=?').run(serverId).changes > 0
|
|
);
|
|
}
|
|
|
|
export function removeServerKey(id: string): boolean {
|
|
return db()
|
|
.transaction(() => {
|
|
if (
|
|
db()
|
|
.prepare(
|
|
`SELECT 1 FROM server_keys k JOIN server_keys occupied ON occupied.pluto_key=k.pluto_key
|
|
JOIN server_key_leases l ON l.key_id=occupied.id WHERE k.id=?`
|
|
)
|
|
.get(id)
|
|
) {
|
|
throw new Error(
|
|
'This key is reserved by a server. Destroy that server before removing its key.'
|
|
);
|
|
}
|
|
return db().prepare('DELETE FROM server_keys WHERE id=?').run(id).changes > 0;
|
|
})
|
|
.immediate();
|
|
}
|