Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4501eba873 | ||
|
|
f3b9699317 | ||
|
|
b9bc897d6e |
@@ -1,5 +1,14 @@
|
||||
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
|
||||
EC_PUBLIC_URL=https://electric-cherry.example.com
|
||||
# Client addresses for login throttling behind a trusted reverse proxy.
|
||||
# Leave ADDRESS_HEADER empty for direct connections. Enable only when all access
|
||||
# goes through a proxy that replaces untrusted forwarding headers.
|
||||
# For one trusted proxy using X-Forwarded-For:
|
||||
# ADDRESS_HEADER=x-forwarded-for
|
||||
# XFF_DEPTH=1
|
||||
# Set XFF_DEPTH to the number of trusted proxy hops; see the access guide.
|
||||
ADDRESS_HEADER=
|
||||
XFF_DEPTH=1
|
||||
# Generate separate secrets with: openssl rand -hex 32
|
||||
# Setup token authorizes creation of the first administrator only. Remove it
|
||||
# after setup. Never reuse the telemetry token as a setup or host credential.
|
||||
|
||||
@@ -17,6 +17,9 @@ services:
|
||||
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
||||
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
||||
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
||||
# Enable only behind a trusted proxy that sets the client-address header.
|
||||
ADDRESS_HEADER: ${ADDRESS_HEADER:-}
|
||||
XFF_DEPTH: ${XFF_DEPTH:-1}
|
||||
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
|
||||
BODY_SIZE_LIMIT: 'Infinity'
|
||||
DB_PATH: /data/cherry.db
|
||||
|
||||
@@ -113,9 +113,22 @@ revocation every 15 seconds and then close.
|
||||
|
||||
Login and password changes for enabled Administrators and Super Viewers are exempt
|
||||
from per-account and per-address rate limits, including existing exhausted windows.
|
||||
Their attempts do not consume either limit. Other accounts and initial setup use
|
||||
persistent 15-minute windows. Exemptions use the account's current database role;
|
||||
password verification is still required. The database stores audit entries for login
|
||||
Their attempts do not consume either limit. Other accounts use persistent
|
||||
15-minute login windows of 10 attempts per username-and-address pair and 30 per
|
||||
address across accounts. Attempts from one address do not exhaust another
|
||||
address's allowance for the same username. Initial setup and password changes
|
||||
retain their limits of 10 per account and 30 per address. Exemptions use the
|
||||
account's current database role; password verification is still required.
|
||||
|
||||
A successful password login also sets a separate, HTTP-only login-device cookie
|
||||
for 30 days. This cookie lets that browser skip its username-and-address limit,
|
||||
while the password and shared address limit still apply. It does not sign you in,
|
||||
and is independent of **Keep me signed in**. It survives ordinary sign-out;
|
||||
password changes/resets, role changes, disabling an account, and revoking all of
|
||||
its sessions invalidate its login-device cookies. Only token hashes are stored in
|
||||
SQLite. Reusing a valid cookie does not extend its original expiry.
|
||||
|
||||
The database stores audit entries for login
|
||||
results, account changes and successful management requests without recording
|
||||
passwords, tokens or request bodies. Administrators and Super Viewers can review
|
||||
recent entries in Users.
|
||||
@@ -138,8 +151,10 @@ DB_PATH=./data/cherry.db node scripts/reset-admin.mjs admin
|
||||
|
||||
Replace `admin` with the existing administrator's username. The command prompts
|
||||
without echoing the password, resets that enabled administrator only, revokes
|
||||
their sessions and records the recovery. It does not expose a remote recovery
|
||||
endpoint or accept passwords as command arguments.
|
||||
their sessions and trusted login devices, clears their account-specific login and
|
||||
password-change rate limits, and records the recovery. Other users' limits and
|
||||
shared address limits remain intact. It does not expose a remote recovery endpoint
|
||||
or accept passwords as command arguments.
|
||||
|
||||
## Agent and telemetry connections
|
||||
|
||||
@@ -181,9 +196,27 @@ JSON requests and multipart uploads. Do not configure a proxy to replace arbitra
|
||||
incoming origins with your trusted origin. Machine endpoints use their own token
|
||||
checks and do not require a browser Origin.
|
||||
|
||||
Use the adapter's real client-address configuration only with a trusted proxy
|
||||
that strips spoofed forwarding headers; login throttling otherwise uses the direct
|
||||
peer address. Forwarded host or user headers do not authenticate users.
|
||||
Login throttling uses the address returned by SvelteKit's adapter. Without
|
||||
`ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
|
||||
share the proxy's address limit. Compose passes `ADDRESS_HEADER` and `XFF_DEPTH`
|
||||
from `.env` to the controller; direct Node deployments must set them in the
|
||||
server's environment. For one trusted proxy that sets `X-Forwarded-For`, use:
|
||||
|
||||
```dotenv
|
||||
ADDRESS_HEADER=x-forwarded-for
|
||||
XFF_DEPTH=1
|
||||
```
|
||||
|
||||
Recreate the Compose controller after changing these values with
|
||||
`docker compose up -d`. For a proxy chain, set `XFF_DEPTH` to the number of trusted
|
||||
proxies: the adapter selects that many positions from the right of the header.
|
||||
For example, `client, proxy1` with two trusted proxies requires `XFF_DEPTH=2`.
|
||||
The public-facing proxy must replace untrusted forwarding headers, and every
|
||||
trusted hop must supply the expected address chain. Restrict direct access to
|
||||
port 3000 so requests cannot bypass those proxies. Do not enable these settings
|
||||
for an untrusted proxy or copy a depth that does not match your deployment.
|
||||
See the [adapter's client-address settings](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ADDRESS_HEADER-and-XFF_DEPTH).
|
||||
Forwarded host or user headers do not authenticate users.
|
||||
|
||||
Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback
|
||||
hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
|
||||
|
||||
@@ -56,6 +56,11 @@ including server-rendered pages, page data, and API responses. The build already
|
||||
precompresses static JavaScript and CSS assets; dynamically generated responses
|
||||
need compression at the proxy.
|
||||
|
||||
Configure `ADDRESS_HEADER` and `XFF_DEPTH` for your trusted proxy so login limits
|
||||
use each client's address. Leave `ADDRESS_HEADER` empty for direct connections;
|
||||
see the [proxy configuration guide](authentication.md#proxy-and-local-development)
|
||||
before enabling forwarded addresses.
|
||||
|
||||
Open the dashboard and follow `/setup` to create the first administrator. Then
|
||||
remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
|
||||
controller. Setup stays closed once an account exists.
|
||||
|
||||
@@ -130,8 +130,11 @@ Agent 1.8.5 adds incremental game-log reads for chat collection. After the initi
|
||||
tail, the controller requests only appended bytes, handles rotation and truncation,
|
||||
and buffers incomplete lines. Unchanged logs need no content transfer or chat
|
||||
parsing. Background collection skips offline hosts, stopped servers and known
|
||||
empty servers, with one final read after players leave. Opening chat still reads
|
||||
on demand. Older agents use tail reads until they update.
|
||||
empty servers, with one final read after players leave. Opening chat reuses the
|
||||
worker's saved reader state and only starts an on-demand read when the previous
|
||||
read completed at least 15 seconds ago. This limit is shared across viewers and also
|
||||
applies to missing logs and failed reads. Older agents use tail reads until they
|
||||
update.
|
||||
|
||||
### New game files and cached versions
|
||||
|
||||
|
||||
+80
-24
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
// Local recovery only: requires filesystem/container access to the controller.
|
||||
import Database from 'better-sqlite3';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { emitKeypressEvents } from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||||
|
||||
/** @param {string} prompt @returns {Promise<string>} */
|
||||
function secret(prompt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = '';
|
||||
@@ -17,6 +21,7 @@ function secret(prompt) {
|
||||
process.stdin.pause();
|
||||
process.stdout.write('\n');
|
||||
};
|
||||
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
|
||||
const listener = (text, key) => {
|
||||
if (key?.ctrl && key.name === 'c') {
|
||||
cleanup();
|
||||
@@ -39,7 +44,67 @@ function secret(prompt) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
/** @param {import('better-sqlite3').Database} database @param {string} username */
|
||||
function administrator(database, username) {
|
||||
const user = /** @type {{ id: string, username: string } | undefined} */ (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0"
|
||||
)
|
||||
.get(username)
|
||||
);
|
||||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local recovery shared with regression tests; the CLI only accepts passwords on its TTY.
|
||||
* @param {import('better-sqlite3').Database} database
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
*/
|
||||
export async function recoverAdministrator(database, username, password) {
|
||||
const user = administrator(database, username);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const digest = (/** @type {string} */ value) =>
|
||||
createHash('sha256').update(value).digest('hex');
|
||||
database
|
||||
.transaction(() => {
|
||||
const changed = database
|
||||
.prepare(
|
||||
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
|
||||
)
|
||||
.run(passwordHash, Date.now(), user.id);
|
||||
if (changed.changes !== 1)
|
||||
throw new Error('Administrator changed during recovery. Try again.');
|
||||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||||
// Older databases may not have device credentials yet; recovery still works.
|
||||
if (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'"
|
||||
)
|
||||
.get()
|
||||
) {
|
||||
database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id);
|
||||
}
|
||||
database
|
||||
.prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)')
|
||||
.run(
|
||||
`login:account:${digest(user.username.toLowerCase())}:ip:*`,
|
||||
digest(`login:account:${user.username.toLowerCase()}`),
|
||||
digest(`password:account:${user.id.toLowerCase()}`)
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
|
||||
)
|
||||
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const username = process.argv[2]?.toLowerCase();
|
||||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
|
||||
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||||
@@ -51,34 +116,25 @@ try {
|
||||
fileMustExist: true
|
||||
});
|
||||
database.pragma('busy_timeout=5000');
|
||||
const user = database
|
||||
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0")
|
||||
.get(username);
|
||||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
||||
try {
|
||||
administrator(database, username);
|
||||
const password = await secret('New administrator password (8–128 characters): ');
|
||||
const confirmation = await secret('Repeat password: ');
|
||||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||||
const passwordHash = await hashPassword(password);
|
||||
database
|
||||
.transaction(() => {
|
||||
const changed = database
|
||||
.prepare(
|
||||
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
|
||||
)
|
||||
.run(passwordHash, Date.now(), user.id);
|
||||
if (changed.changes !== 1)
|
||||
throw new Error('Administrator changed during recovery. Try again.');
|
||||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
|
||||
)
|
||||
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
|
||||
})
|
||||
.immediate();
|
||||
await recoverAdministrator(database, username, password);
|
||||
console.log(
|
||||
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
console.log('Administrator password reset. Existing sessions have been revoked.');
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
await main();
|
||||
} catch (cause) {
|
||||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
return 'admin';
|
||||
|
||||
// These reads initiate fleet file scans or spend external API quota.
|
||||
if (routeId === '/api/drift' || routeId === '/api/plugins/github/release') return 'operator';
|
||||
if (
|
||||
routeId === '/api/drift' ||
|
||||
routeId === '/api/plugins/github/release' ||
|
||||
routeId === '/api/plugins/updates'
|
||||
)
|
||||
return 'operator';
|
||||
|
||||
// Summaries remain visible to viewers; raw configuration, historical file
|
||||
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
|
||||
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
|
||||
import { AuthError, LOGIN_DEVICE_COOKIE, SESSION_COOKIE, type AuthResult } from './index.js';
|
||||
import { PasswordServiceBusyError } from './passwords.js';
|
||||
import type { AuthUser } from '../../auth.js';
|
||||
|
||||
export function sessionCookie(url: URL): string {
|
||||
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev';
|
||||
}
|
||||
export function loginDeviceCookie(url: URL): string {
|
||||
return url.protocol === 'https:' ? LOGIN_DEVICE_COOKIE : 'ec_login_device_dev';
|
||||
}
|
||||
export function requireUserTransport(url: URL): void {
|
||||
if (url.protocol === 'https:') return;
|
||||
if (
|
||||
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
|
||||
sameSite: 'lax',
|
||||
expires: new Date(result.session.expiresAt)
|
||||
});
|
||||
if (result.loginDevice)
|
||||
cookies.set(loginDeviceCookie(url), result.loginDevice.token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: url.protocol === 'https:',
|
||||
sameSite: 'lax',
|
||||
expires: new Date(result.loginDevice.expiresAt)
|
||||
});
|
||||
return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
export function requireUser(user: AuthUser | null): AuthUser {
|
||||
|
||||
@@ -7,6 +7,8 @@ export const SESSION_COOKIE = '__Host-ec_session';
|
||||
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
|
||||
export const SESSION_IDLE_MS = 30 * 60_000;
|
||||
export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000;
|
||||
export const LOGIN_DEVICE_COOKIE = '__Host-ec_login_device';
|
||||
export const LOGIN_DEVICE_LIFETIME_MS = 30 * 24 * 60 * 60_000;
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
function database() {
|
||||
return db();
|
||||
@@ -111,7 +113,8 @@ export function listAuthAudit() {
|
||||
export function consumeAuthAttempt(
|
||||
clientAddress: string,
|
||||
account: string,
|
||||
kind: 'login' | 'setup' | 'password' = 'login'
|
||||
kind: 'login' | 'setup' | 'password' = 'login',
|
||||
loginDevice?: string
|
||||
): void {
|
||||
// Resolve the current role from storage; setup never has an existing account exemption.
|
||||
if (
|
||||
@@ -127,16 +130,22 @@ export function consumeAuthAttempt(
|
||||
const now = Date.now();
|
||||
const windowMs = 15 * 60_000;
|
||||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
||||
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`);
|
||||
// Keep the hashed account prefix identifiable for local account recovery.
|
||||
const accountKey =
|
||||
kind === 'login'
|
||||
? `login:account:${digest(account.toLowerCase())}:ip:${digest(clientAddress)}`
|
||||
: digest(`${kind}:account:${account.toLowerCase()}`);
|
||||
// A prior successful login permits another password check even when this
|
||||
// account/address pair is exhausted. The shared address limit still applies.
|
||||
const knownDevice = kind === 'login' && readLoginDevice(account, loginDevice);
|
||||
const limits: [string, number][] = [[ipKey, 30]];
|
||||
if (!knownDevice) limits.push([accountKey, 10]);
|
||||
database()
|
||||
.transaction(() => {
|
||||
database()
|
||||
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
|
||||
.run(now - 24 * 60 * 60_000);
|
||||
for (const [key, limit] of [
|
||||
[ipKey, 30],
|
||||
[accountKey, 10]
|
||||
] as const) {
|
||||
for (const [key, limit] of limits) {
|
||||
const previous = database()
|
||||
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
|
||||
.get(key) as { window_start: number; attempts: number } | undefined;
|
||||
@@ -147,7 +156,7 @@ export function consumeAuthAttempt(
|
||||
)
|
||||
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
||||
}
|
||||
for (const key of [ipKey, accountKey])
|
||||
for (const [key] of limits)
|
||||
database()
|
||||
.prepare(
|
||||
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
||||
@@ -189,6 +198,7 @@ function insertUser(input: {
|
||||
export interface AuthResult {
|
||||
user: AuthUser;
|
||||
session: { token: string; expiresAt: number };
|
||||
loginDevice?: { token: string; expiresAt: number };
|
||||
}
|
||||
export async function bootstrapAdmin(
|
||||
input: Record<string, unknown>,
|
||||
@@ -230,13 +240,14 @@ export async function bootstrapAdmin(
|
||||
}
|
||||
export async function authenticatePassword(
|
||||
input: Record<string, unknown>,
|
||||
clientAddress: string
|
||||
clientAddress: string,
|
||||
loginDevice?: string
|
||||
): Promise<AuthResult> {
|
||||
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
|
||||
throw new AuthError('Keep me signed in must be true or false.');
|
||||
const name =
|
||||
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : '';
|
||||
consumeAuthAttempt(clientAddress, name);
|
||||
consumeAuthAttempt(clientAddress, name, 'login', loginDevice);
|
||||
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
|
||||
UserRow | undefined;
|
||||
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
||||
@@ -258,7 +269,8 @@ export async function authenticatePassword(
|
||||
recordAudit(current.id, 'login', current.id);
|
||||
return {
|
||||
user: getUser(current.id)!,
|
||||
session: createSession(current.id, input.rememberMe === true)
|
||||
session: createSession(current.id, input.rememberMe === true),
|
||||
loginDevice: createLoginDevice(current, loginDevice)
|
||||
};
|
||||
})
|
||||
.immediate();
|
||||
@@ -266,6 +278,32 @@ export async function authenticatePassword(
|
||||
return result;
|
||||
}
|
||||
|
||||
function readLoginDevice(account: string, token: string | undefined) {
|
||||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
return database()
|
||||
.prepare(
|
||||
`SELECT d.expires_at FROM auth_login_devices d JOIN auth_users u ON u.id=d.user_id
|
||||
WHERE d.token_hash=? AND d.expires_at>? AND u.username=? AND u.disabled=0`
|
||||
)
|
||||
.get(digest(token), Date.now(), account) as { expires_at: number } | undefined;
|
||||
}
|
||||
|
||||
/** Proof of a previous password login only; this token never authenticates a session. */
|
||||
function createLoginDevice(user: UserRow, previousToken?: string) {
|
||||
const now = Date.now();
|
||||
database().prepare('DELETE FROM auth_login_devices WHERE expires_at<=?').run(now);
|
||||
const previous = readLoginDevice(user.username, previousToken);
|
||||
if (previous && previousToken) return { token: previousToken, expiresAt: previous.expires_at };
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const expiresAt = now + LOGIN_DEVICE_LIFETIME_MS;
|
||||
database()
|
||||
.prepare(
|
||||
'INSERT INTO auth_login_devices(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)'
|
||||
)
|
||||
.run(digest(token), user.id, now, expiresAt);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
userId: string,
|
||||
remembered = false
|
||||
@@ -327,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
|
||||
requireAdministrator(actorId);
|
||||
if (!userRow(userId)) throw new AuthError('User not found.', 404);
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
|
||||
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
|
||||
recordAudit(actorId, 'sessions.revoked', userId);
|
||||
})();
|
||||
}
|
||||
@@ -427,8 +466,10 @@ export async function updateUser(
|
||||
Date.now(),
|
||||
id
|
||||
);
|
||||
if (passwordHash || disabled !== current.disabled || nextRole !== current.role)
|
||||
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) {
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
|
||||
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
|
||||
}
|
||||
recordAudit(actorId, 'user.updated', id, {
|
||||
role: nextRole,
|
||||
disabled: !!disabled,
|
||||
@@ -469,6 +510,7 @@ export async function changePassword(
|
||||
)
|
||||
.run(passwordHash, Date.now(), userId);
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
|
||||
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
|
||||
recordAudit(userId, 'password.changed', userId);
|
||||
return {
|
||||
user: getUser(userId)!,
|
||||
|
||||
@@ -33,6 +33,17 @@ export function migrateRememberedSessions(handle: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
|
||||
export function migrateLoginDevices(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_login_devices (
|
||||
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS auth_login_devices_user ON auth_login_devices(user_id);
|
||||
CREATE INDEX IF NOT EXISTS auth_login_devices_expires_at ON auth_login_devices(expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
/** Run with foreign keys disabled by the migration runner before its transaction. */
|
||||
export function migrateSuperViewerRole(handle: Database.Database): void {
|
||||
const schema = handle
|
||||
|
||||
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
|
||||
import { migrateHostSchema } from '../hosts/schema.js';
|
||||
import {
|
||||
migrateAuthSchema,
|
||||
migrateLoginDevices,
|
||||
migrateRememberedSessions,
|
||||
migrateSuperViewerRole,
|
||||
migrateModeratorRole
|
||||
@@ -160,7 +161,8 @@ export const migrations: readonly Migration[] = [
|
||||
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0'
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ version: 28, name: 'known login devices', run: migrateLoginDevices }
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -209,12 +209,38 @@ interface ChatReadState {
|
||||
}
|
||||
const readers = new Map<string, ChatReadState>();
|
||||
const failures = new Map<string, number>();
|
||||
const CHAT_READ_MIN_AGE_MS = 15_000;
|
||||
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
|
||||
const lastReads = new Map<string, LastChatRead>();
|
||||
|
||||
function chatReadKey(server: ServerRow): string {
|
||||
return `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
|
||||
}
|
||||
|
||||
/** Live viewers reuse the worker's reader state instead of adding a read per tab. */
|
||||
export async function readServerChat(
|
||||
server: ServerRow
|
||||
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
|
||||
const key = chatReadKey(server);
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing;
|
||||
const last = lastReads.get(key);
|
||||
if (!last || Date.now() - last.completedAt >= CHAT_READ_MIN_AGE_MS)
|
||||
return collectServerChat(server);
|
||||
if ('error' in last) throw last.error;
|
||||
const state = readers.get(server.name);
|
||||
return {
|
||||
// Telemetry may have started a new match since the last log read.
|
||||
entries: currentChatEntries(server, state?.key === key ? state.entries : []),
|
||||
missing: last.missing
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by the live API and the worker; collection does not require a viewer. */
|
||||
export function collectServerChat(
|
||||
server: ServerRow
|
||||
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
|
||||
const key = `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
|
||||
const key = chatReadKey(server);
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing;
|
||||
const work = (async () => {
|
||||
@@ -319,7 +345,19 @@ export function collectServerChat(
|
||||
state.signature = signature;
|
||||
readers.set(server.name, state);
|
||||
return { entries: currentChatEntries(current, state.entries), missing: false };
|
||||
})().finally(() => pending.delete(key));
|
||||
})()
|
||||
.then(
|
||||
(result) => {
|
||||
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
|
||||
return result;
|
||||
},
|
||||
(error: unknown) => {
|
||||
// Failed reads also observe the cooldown, without advancing the cursor.
|
||||
lastReads.set(key, { completedAt: Date.now(), error });
|
||||
throw error;
|
||||
}
|
||||
)
|
||||
.finally(() => pending.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
@@ -329,8 +367,10 @@ export async function collectFleetChat(): Promise<void> {
|
||||
const observe = observationReader();
|
||||
const live = listServers().filter((server) => !server.destroyed_at);
|
||||
const names = new Set(live.map((server) => server.name));
|
||||
const keys = new Set(live.map(chatReadKey));
|
||||
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
|
||||
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
|
||||
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
|
||||
const servers = live.filter((server) => {
|
||||
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
||||
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
><span class="concise-reason"
|
||||
>{operationIssue ? operation.label : status.shortReason}</span
|
||||
>{#if status.since}<span class="evidence-time" title={status.since}
|
||||
><span class="evidence-separator"> · </span>Evidence {elapsedSince(
|
||||
><span class="evidence-separator">{' · '}</span>Evidence {elapsedSince(
|
||||
status.since,
|
||||
$uiNow
|
||||
)} ago</span
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
|
||||
import {
|
||||
authBody,
|
||||
authError,
|
||||
loginDeviceCookie,
|
||||
sessionCookie,
|
||||
sessionResponse
|
||||
} from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||
try {
|
||||
const result = await authenticatePassword(await authBody(request), getClientAddress());
|
||||
const result = await authenticatePassword(
|
||||
await authBody(request),
|
||||
getClientAddress(),
|
||||
cookies.get(loginDeviceCookie(url))
|
||||
);
|
||||
revokeSession(cookies.get(sessionCookie(url)));
|
||||
return sessionResponse(result, cookies, url);
|
||||
} catch (cause) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { gameLogKey, resolveLogPath } from '$lib/server/fleet/logs.js';
|
||||
import { collectServerChat } from '$lib/server/fleet/chat.js';
|
||||
import { readServerChat } from '$lib/server/fleet/chat.js';
|
||||
|
||||
export async function GET({ params }: { params: { name: string } }) {
|
||||
const headers = { 'Cache-Control': 'no-store' };
|
||||
@@ -14,7 +14,7 @@ export async function GET({ params }: { params: { name: string } }) {
|
||||
return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
|
||||
|
||||
try {
|
||||
return json({ ok: true, ...(await collectServerChat(server)) }, { headers });
|
||||
return json({ ok: true, ...(await readServerChat(server)) }, { headers });
|
||||
} catch (error) {
|
||||
console.error('Could not read server chat', error);
|
||||
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
{#if tab === 'active'}<p class="queue-summary">
|
||||
<strong
|
||||
>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong
|
||||
>{#if queueSummary}<span> · {queueSummary}</span>{/if}
|
||||
>{#if queueSummary}<span>{' · '}{queueSummary}</span>{/if}
|
||||
</p>
|
||||
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
|
||||
<div class="queue-tools">
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('management route permissions', () => {
|
||||
expect(allows('super_viewer', '/api/settings/retention/preview', 'POST')).toBe(true);
|
||||
expect(allows('operator', '/api/settings/retention/preview', 'POST')).toBe(false);
|
||||
expect(allows('super_viewer', '/api/settings/retention', 'POST')).toBe(false);
|
||||
for (const route of ['/api/drift', '/api/plugins/github/release'])
|
||||
for (const route of ['/api/drift', '/api/plugins/github/release', '/api/plugins/updates'])
|
||||
for (const method of ['GET', 'HEAD'])
|
||||
expect(allows('super_viewer', route, method)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -339,13 +339,15 @@ describe('browser authentication guard', () => {
|
||||
['GET', '/api/drift'],
|
||||
['HEAD', '/api/drift'],
|
||||
['GET', '/api/plugins/github/release'],
|
||||
['HEAD', '/api/plugins/github/release']
|
||||
['HEAD', '/api/plugins/github/release'],
|
||||
['GET', '/api/plugins/updates'],
|
||||
['HEAD', '/api/plugins/updates']
|
||||
])('restricts expensive %s %s requests before handler execution', async (method, route) => {
|
||||
const anonymous = requestFor(route, { method, cookie: null });
|
||||
expect((await anonymous.invoke()).status).toBe(401);
|
||||
expect(anonymous.resolve).not.toHaveBeenCalled();
|
||||
|
||||
for (const role of ['viewer', 'super_viewer', 'operator', 'admin'] as const) {
|
||||
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor(route, { method });
|
||||
const allowed = role === 'operator' || role === 'admin';
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthUser, UserRole } from '../../src/lib/auth.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
// Exercise real password verification without weakening production scrypt costs.
|
||||
vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
|
||||
const password = 'testpass';
|
||||
const replacement = 'a replacement account passphrase';
|
||||
const firstAddress = '192.0.2.1';
|
||||
const limitedAddress = '192.0.2.2';
|
||||
let passwordHash: string;
|
||||
let fixture: Fixture;
|
||||
let auth: typeof import('../../src/lib/server/auth/index.js');
|
||||
let database: typeof import('../../src/lib/server/db/index.js');
|
||||
|
||||
beforeAll(async () => {
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
passwordHash = await passwords.hashPassword(password);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
auth = await import('../../src/lib/server/auth/index.js');
|
||||
database = await import('../../src/lib/server/db/index.js');
|
||||
auth.setupRequired();
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
function seedUser(username = 'viewer', role: UserRole = 'viewer', disabled = false): AuthUser {
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
database
|
||||
.db()
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(id, username, username, passwordHash, role, Number(disabled), now, now);
|
||||
return auth.getUser(id)!;
|
||||
}
|
||||
|
||||
function exhaustPair(username: string, address = limitedAddress) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt(address, username);
|
||||
}
|
||||
|
||||
function devices() {
|
||||
return database.db().prepare('SELECT * FROM auth_login_devices ORDER BY rowid').all();
|
||||
}
|
||||
|
||||
async function loginRoute(
|
||||
input: Record<string, unknown>,
|
||||
options: { cookie?: string; address?: string; origin?: string } = {}
|
||||
) {
|
||||
const { POST } = await import('../../src/routes/api/auth/login/+server.js');
|
||||
const url = new URL('/api/auth/login', options.origin ?? 'https://ec.test');
|
||||
const deviceName = url.protocol === 'https:' ? '__Host-ec_login_device' : 'ec_login_device_dev';
|
||||
const cookies = {
|
||||
get: vi.fn((name: string) => (name === deviceName ? options.cookie : undefined)),
|
||||
set: vi.fn()
|
||||
};
|
||||
const response = await POST({
|
||||
request: new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
cookies,
|
||||
url,
|
||||
getClientAddress: () => options.address ?? firstAddress
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
return { response, cookies };
|
||||
}
|
||||
|
||||
describe('known login device proof', () => {
|
||||
it('creates an account-bound, hashed proof after password verification that cannot authenticate a session', async () => {
|
||||
const now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
|
||||
const result = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
|
||||
const device = result.loginDevice!;
|
||||
expect(device.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(device.token).not.toBe(result.session.token);
|
||||
expect(device.expiresAt).toBe(now + 30 * 24 * 60 * 60_000);
|
||||
expect(devices()).toEqual([
|
||||
{
|
||||
token_hash: createHash('sha256').update(device.token).digest('hex'),
|
||||
user_id: user.id,
|
||||
created_at: now,
|
||||
expires_at: device.expiresAt
|
||||
}
|
||||
]);
|
||||
expect(auth.readSession(device.token)).toBeNull();
|
||||
expect(auth.readSession(result.session.token)?.user.id).toBe(user.id);
|
||||
const persisted = JSON.stringify(devices()) + JSON.stringify(auth.listAuthAudit());
|
||||
expect(persisted).not.toContain(device.token);
|
||||
expect(persisted).not.toContain(result.session.token);
|
||||
expect(persisted).not.toContain(password);
|
||||
});
|
||||
|
||||
it('allows a proven account past an exhausted pair while still rejecting a wrong password', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword({ username: user.username, password }, limitedAddress)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
const original = devices();
|
||||
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password: 'wrong password' },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
expect(devices()).toEqual(original);
|
||||
const result = await auth.authenticatePassword(
|
||||
{ username: user.username.toUpperCase(), password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
);
|
||||
|
||||
expect(result.user.id).toBe(user.id);
|
||||
expect(result.loginDevice).toEqual(initial.loginDevice);
|
||||
expect(devices()).toEqual(original);
|
||||
});
|
||||
|
||||
it.each(['other account', 'malformed', 'unknown', 'expired', 'session token'] as const)(
|
||||
'does not bypass an exhausted pair with %s proof',
|
||||
async (kind) => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
const target = kind === 'other account' ? seedUser('another-viewer') : user;
|
||||
let token = initial.loginDevice!.token;
|
||||
if (kind === 'malformed') token += '!';
|
||||
if (kind === 'unknown') token = 'x'.repeat(43);
|
||||
if (kind === 'session token') token = initial.session.token;
|
||||
if (kind === 'expired')
|
||||
database.db().prepare('UPDATE auth_login_devices SET expires_at=?').run(Date.now());
|
||||
exhaustPair(target.username);
|
||||
const original = devices();
|
||||
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: target.username, password },
|
||||
limitedAddress,
|
||||
token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
|
||||
expect(devices()).toEqual(original);
|
||||
}
|
||||
);
|
||||
|
||||
it('continues enforcing the shared IP ceiling for a valid device', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
for (let attempt = 0; attempt < 30; attempt++)
|
||||
auth.consumeAuthAttempt(limitedAddress, `missing-${attempt}`);
|
||||
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
expect(devices()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accepts proof only from the request cookie and ignores body-only trust assertions', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
exhaustPair(user.username);
|
||||
const input = {
|
||||
username: user.username,
|
||||
password,
|
||||
loginDevice: initial.loginDevice!.token,
|
||||
trusted: true,
|
||||
knownDevice: true
|
||||
};
|
||||
|
||||
const rejected = await loginRoute(input, { address: limitedAddress });
|
||||
expect(rejected.response.status).toBe(429);
|
||||
expect(rejected.cookies.set).not.toHaveBeenCalled();
|
||||
const accepted = await loginRoute(input, {
|
||||
address: limitedAddress,
|
||||
cookie: initial.loginDevice!.token
|
||||
});
|
||||
expect(accepted.response.status).toBe(200);
|
||||
expect(accepted.cookies.get).toHaveBeenCalledWith('__Host-ec_login_device');
|
||||
expect(devices()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('retains the original absolute expiry when the database is reopened and the device logs in again', async () => {
|
||||
const now = 2_000_000_000_000;
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
const original = devices();
|
||||
database.closeDb();
|
||||
clock.mockReturnValue(now + 5 * 24 * 60 * 60_000);
|
||||
exhaustPair(user.username);
|
||||
|
||||
const next = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
);
|
||||
|
||||
expect(next.loginDevice).toEqual(initial.loginDevice);
|
||||
expect(devices()).toEqual(original);
|
||||
clock.mockReturnValue(initial.loginDevice!.expiresAt);
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it('does not issue device cookies or store proof for failed password logins', async () => {
|
||||
seedUser();
|
||||
seedUser('disabled', 'viewer', true);
|
||||
for (const input of [
|
||||
{ username: 'viewer', password: 'wrong password' },
|
||||
{ username: 'missing', password },
|
||||
{ username: 'disabled', password }
|
||||
]) {
|
||||
const result = await loginRoute(input);
|
||||
expect(result.response.status).toBe(401);
|
||||
expect(result.cookies.set).not.toHaveBeenCalled();
|
||||
expect(devices()).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('known login device lifecycle', () => {
|
||||
it('keeps device proof through logout while removing the session cookie and session', async () => {
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
const original = devices();
|
||||
const { POST } = await import('../../src/routes/api/auth/logout/+server.js');
|
||||
const cookies = {
|
||||
get: vi.fn((name: string) =>
|
||||
name === '__Host-ec_session' ? initial.session.token : initial.loginDevice!.token
|
||||
),
|
||||
delete: vi.fn()
|
||||
};
|
||||
const response = await POST({
|
||||
cookies,
|
||||
url: new URL('https://ec.test/api/auth/logout'),
|
||||
locals: { user }
|
||||
} as unknown as Parameters<typeof POST>[0]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cookies.delete.mock.calls).toEqual([['__Host-ec_session', { path: '/' }]]);
|
||||
expect(auth.readSession(initial.session.token)).toBeNull();
|
||||
expect(devices()).toEqual(original);
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).resolves.toMatchObject({ user: { id: user.id }, loginDevice: initial.loginDevice });
|
||||
});
|
||||
|
||||
it.each(['role', 'disable', 'password reset', 'password change', 'revoke sessions'] as const)(
|
||||
'revokes device proof after an account %s operation',
|
||||
async (operation) => {
|
||||
const admin = seedUser('administrator', 'admin');
|
||||
const user = seedUser();
|
||||
const initial = await auth.authenticatePassword(
|
||||
{ username: user.username, password },
|
||||
firstAddress
|
||||
);
|
||||
if (operation === 'role')
|
||||
await auth.updateUser(admin.id, user.id, { role: 'operator' });
|
||||
if (operation === 'disable')
|
||||
await auth.updateUser(admin.id, user.id, { disabled: true });
|
||||
if (operation === 'password reset')
|
||||
await auth.updateUser(admin.id, user.id, { password: replacement });
|
||||
if (operation === 'password change')
|
||||
await auth.changePassword(
|
||||
user.id,
|
||||
password,
|
||||
replacement,
|
||||
auth.readSession(initial.session.token)!.tokenHash
|
||||
);
|
||||
if (operation === 'revoke sessions') auth.revokeUserSessions(admin.id, user.id);
|
||||
|
||||
expect(devices()).toEqual([]);
|
||||
expect(auth.readSession(initial.session.token)).toBeNull();
|
||||
exhaustPair(user.username);
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{
|
||||
username: user.username,
|
||||
password: operation.startsWith('password') ? replacement : password
|
||||
},
|
||||
limitedAddress,
|
||||
initial.loginDevice!.token
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ origin: 'https://ec.test', rememberMe: false },
|
||||
{ origin: 'https://ec.test', rememberMe: true },
|
||||
{ origin: 'http://localhost', rememberMe: false }
|
||||
])('sets a separate device cookie for $origin with rememberMe=$rememberMe', async (options) => {
|
||||
const now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const user = seedUser();
|
||||
|
||||
const { response, cookies } = await loginRoute(
|
||||
{ username: user.username, password, rememberMe: options.rememberMe },
|
||||
options
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cookies.set).toHaveBeenCalledTimes(2);
|
||||
const secure = options.origin.startsWith('https:');
|
||||
const deviceName = secure ? '__Host-ec_login_device' : 'ec_login_device_dev';
|
||||
const sessionName = secure ? '__Host-ec_session' : 'ec_session_dev';
|
||||
const [, deviceToken, deviceOptions] = cookies.set.mock.calls.find(
|
||||
([name]) => name === deviceName
|
||||
)!;
|
||||
const [, sessionToken, sessionOptions] = cookies.set.mock.calls.find(
|
||||
([name]) => name === sessionName
|
||||
)!;
|
||||
expect(deviceOptions).toEqual({
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
expires: new Date(now + 30 * 24 * 60 * 60_000)
|
||||
});
|
||||
expect(sessionOptions.expires).toEqual(
|
||||
new Date(
|
||||
now +
|
||||
(options.rememberMe
|
||||
? auth.REMEMBERED_SESSION_LIFETIME_MS
|
||||
: auth.SESSION_LIFETIME_MS)
|
||||
)
|
||||
);
|
||||
expect(deviceToken).not.toBe(sessionToken);
|
||||
expect(auth.readSession(deviceToken)).toBeNull();
|
||||
expect(auth.readSession(sessionToken)?.remembered).toBe(options.rememberMe);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(await response.json()).toEqual({ user: expect.objectContaining({ id: user.id }) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { recoverAdministrator } from '../../scripts/reset-admin.mjs';
|
||||
import { verifyPassword } from '../../src/lib/server/auth/passwords.js';
|
||||
import { migrateAuthSchema } from '../../src/lib/server/auth/schema.js';
|
||||
|
||||
vi.setConfig({ testTimeout: 20_000 });
|
||||
let database: Database.Database;
|
||||
const password = 'replacement administrator passphrase';
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
const pairKey = (kind: string, account: string, ip: string) =>
|
||||
`${kind}:account:${digest(account.toLowerCase())}:ip:${digest(ip)}`;
|
||||
|
||||
beforeEach(() => {
|
||||
database = new Database(':memory:');
|
||||
database.pragma('foreign_keys = ON');
|
||||
migrateAuthSchema(database);
|
||||
database.exec(`
|
||||
INSERT INTO auth_users VALUES
|
||||
('admin-id','Admin_One','Admin','original-hash','admin',0,1,10,20,NULL),
|
||||
('other-id','Other_Admin','Other','other-hash','admin',0,0,10,20,NULL),
|
||||
('viewer-id','Viewer','Viewer','viewer-hash','viewer',0,0,10,20,NULL),
|
||||
('disabled-id','Disabled_Admin','Disabled','disabled-hash','admin',1,0,10,20,NULL);
|
||||
INSERT INTO auth_sessions VALUES
|
||||
('admin-session','admin-id',10,1000,100),
|
||||
('other-session','other-id',10,1000,100);
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => database.close());
|
||||
|
||||
function rateKeys() {
|
||||
return database.prepare('SELECT key FROM auth_rate_limits ORDER BY key').all();
|
||||
}
|
||||
|
||||
function addRates(keys: string[]) {
|
||||
const insert = database.prepare('INSERT INTO auth_rate_limits VALUES (?,100,30)');
|
||||
for (const key of keys) insert.run(key);
|
||||
}
|
||||
|
||||
function addDevices() {
|
||||
database.exec(`
|
||||
CREATE TABLE auth_login_devices (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO auth_login_devices VALUES
|
||||
('admin-device','admin-id',1000,10),
|
||||
('other-device','other-id',1000,10);
|
||||
`);
|
||||
}
|
||||
|
||||
describe('local administrator recovery', () => {
|
||||
it('resets only the target administrator and clears their sessions, devices and account limits', async () => {
|
||||
addDevices();
|
||||
const cleared = [
|
||||
pairKey('login', 'admin_one', '192.0.2.1'),
|
||||
pairKey('login', 'admin_one', '192.0.2.2'),
|
||||
digest('login:account:admin_one'),
|
||||
digest('password:account:admin-id')
|
||||
];
|
||||
const preserved = [
|
||||
pairKey('login', 'other_admin', '192.0.2.1'),
|
||||
pairKey('setup', 'admin_one', '192.0.2.1'),
|
||||
digest('login:account:other_admin'),
|
||||
digest('password:account:other-id'),
|
||||
digest('login:ip:192.0.2.1'),
|
||||
digest('password:ip:192.0.2.1'),
|
||||
digest('setup:ip:192.0.2.1')
|
||||
];
|
||||
addRates([...cleared, ...preserved]);
|
||||
const others = database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all();
|
||||
|
||||
await recoverAdministrator(database, 'ADMIN_ONE', password);
|
||||
|
||||
const user = database
|
||||
.prepare(
|
||||
"SELECT password_hash,must_change_password,updated_at FROM auth_users WHERE id='admin-id'"
|
||||
)
|
||||
.get() as { password_hash: string; must_change_password: number; updated_at: number };
|
||||
expect(await verifyPassword(password, user.password_hash)).toBe(true);
|
||||
expect(user.must_change_password).toBe(0);
|
||||
expect(user.updated_at).toBeGreaterThan(20);
|
||||
expect(database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all()).toEqual(
|
||||
others
|
||||
);
|
||||
expect(database.prepare('SELECT token_hash,user_id FROM auth_sessions').all()).toEqual([
|
||||
{ token_hash: 'other-session', user_id: 'other-id' }
|
||||
]);
|
||||
expect(database.prepare('SELECT token_hash,user_id FROM auth_login_devices').all()).toEqual(
|
||||
[{ token_hash: 'other-device', user_id: 'other-id' }]
|
||||
);
|
||||
expect(rateKeys()).toEqual(preserved.sort().map((key) => ({ key })));
|
||||
expect(
|
||||
database.prepare('SELECT actor_id,action,target_id,details FROM auth_audit').all()
|
||||
).toEqual([
|
||||
{
|
||||
actor_id: null,
|
||||
action: 'password.recovered',
|
||||
target_id: 'admin-id',
|
||||
details: '{"source":"local-cli"}'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports older databases without trusted-device tables and clears their legacy account limits', async () => {
|
||||
addRates([digest('login:account:admin_one'), digest('password:account:admin-id')]);
|
||||
|
||||
await recoverAdministrator(database, 'admin_one', password);
|
||||
|
||||
expect(rateKeys()).toEqual([]);
|
||||
expect(
|
||||
database.prepare("SELECT * FROM auth_sessions WHERE user_id='admin-id'").all()
|
||||
).toEqual([]);
|
||||
expect(
|
||||
database.prepare("SELECT name FROM sqlite_master WHERE name='auth_login_devices'").get()
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects disabled, non-administrator and missing accounts without making changes', async () => {
|
||||
const users = database.prepare('SELECT * FROM auth_users').all();
|
||||
const sessions = database.prepare('SELECT * FROM auth_sessions').all();
|
||||
for (const username of ['disabled_admin', 'viewer', 'missing']) {
|
||||
await expect(recoverAdministrator(database, username, password)).rejects.toThrow(
|
||||
'An enabled administrator with that username was not found.'
|
||||
);
|
||||
}
|
||||
expect(database.prepare('SELECT * FROM auth_users').all()).toEqual(users);
|
||||
expect(database.prepare('SELECT * FROM auth_sessions').all()).toEqual(sessions);
|
||||
expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rolls back the password, session, device and rate changes when recovery cannot be audited', async () => {
|
||||
addDevices();
|
||||
addRates([pairKey('login', 'admin_one', '192.0.2.1')]);
|
||||
database.exec(`
|
||||
CREATE TRIGGER reject_recovery BEFORE INSERT ON auth_audit
|
||||
BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END;
|
||||
`);
|
||||
const tables = ['auth_users', 'auth_sessions', 'auth_login_devices', 'auth_rate_limits'];
|
||||
const before = tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all());
|
||||
|
||||
await expect(recoverAdministrator(database, 'admin_one', password)).rejects.toThrow(
|
||||
'audit unavailable'
|
||||
);
|
||||
|
||||
expect(tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all())).toEqual(
|
||||
before
|
||||
);
|
||||
expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the command restricted to interactive password entry', () => {
|
||||
const command = spawnSync(process.execPath, ['scripts/reset-admin.mjs', 'admin_one'], {
|
||||
encoding: 'utf8',
|
||||
input: password
|
||||
});
|
||||
expect(command.status).toBe(1);
|
||||
expect(command.stderr).toContain('Run this command in an interactive terminal.');
|
||||
expect(command.stdout).not.toContain(password);
|
||||
const argument = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/reset-admin.mjs', 'admin_one', password],
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
);
|
||||
expect(argument.status).toBe(1);
|
||||
expect(argument.stderr).toContain('Usage: node scripts/reset-admin.mjs');
|
||||
expect(argument.stderr).not.toContain(password);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
migrateLoginDevices,
|
||||
migrateModeratorRole,
|
||||
migrateRememberedSessions,
|
||||
migrateSuperViewerRole
|
||||
@@ -9,6 +13,7 @@ import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
|
||||
import { migrations } from '../../src/lib/server/db/migrations.js';
|
||||
|
||||
let handle: Database.Database;
|
||||
const temporaryDirectories: string[] = [];
|
||||
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -30,7 +35,11 @@ beforeEach(() => {
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => handle.close());
|
||||
afterEach(() => {
|
||||
handle.close();
|
||||
for (const directory of temporaryDirectories.splice(0))
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function rows(table: string) {
|
||||
// Compare the original session fields across migrations that add policy columns.
|
||||
@@ -155,6 +164,86 @@ describe('remembered session migration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('known login device migration', () => {
|
||||
it('preserves existing authentication data and cascades device removal when an account is deleted', () => {
|
||||
const upgrade = migrations.find((entry) => entry.name === 'known login devices')!;
|
||||
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
|
||||
handle.exec("UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'");
|
||||
const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers'];
|
||||
const original = tables.map(rows);
|
||||
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
|
||||
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(tables.map(rows)).toEqual(original);
|
||||
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
|
||||
sessions
|
||||
);
|
||||
expect(rows('auth_login_devices')).toEqual([]);
|
||||
expect(handle.pragma('foreign_key_list(auth_login_devices)')).toEqual([
|
||||
expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' })
|
||||
]);
|
||||
expect(handle.pragma('index_info(auth_login_devices_user)')).toEqual([
|
||||
expect.objectContaining({ name: 'user_id' })
|
||||
]);
|
||||
expect(handle.pragma('index_info(auth_login_devices_expires_at)')).toEqual([
|
||||
expect.objectContaining({ name: 'expires_at' })
|
||||
]);
|
||||
handle.exec(`
|
||||
INSERT INTO auth_login_devices VALUES
|
||||
('admin-device-hash','admin',100,3000),
|
||||
('operator-device-hash','operator',200,4000);
|
||||
`);
|
||||
expect(() =>
|
||||
handle.exec("INSERT INTO auth_login_devices VALUES ('missing-device','missing',0,1)")
|
||||
).toThrow('FOREIGN KEY constraint failed');
|
||||
expect(() =>
|
||||
handle.exec("INSERT INTO auth_login_devices VALUES ('admin-device-hash','admin',0,1)")
|
||||
).toThrow('UNIQUE constraint failed');
|
||||
handle.exec("DELETE FROM auth_users WHERE id='operator'");
|
||||
expect(rows('auth_login_devices')).toEqual([
|
||||
{ token_hash: 'admin-device-hash', user_id: 'admin', created_at: 100, expires_at: 3000 }
|
||||
]);
|
||||
expect(handle.prepare('SELECT user_id FROM auth_sessions').all()).toEqual([
|
||||
{ user_id: 'admin' }
|
||||
]);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps device hashes and existing authentication data when replayed or reopened', () => {
|
||||
const upgrade = migrations.find((entry) => entry.name === 'known login devices')!;
|
||||
runMigrations(handle, migrations);
|
||||
handle.exec("INSERT INTO auth_login_devices VALUES ('device-hash','admin',100,3000)");
|
||||
const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'auth_login_devices'];
|
||||
const original = tables.map(rows);
|
||||
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
|
||||
const schemaVersion = handle.pragma('schema_version', { simple: true });
|
||||
|
||||
migrateLoginDevices(handle);
|
||||
handle.pragma(`user_version = ${upgrade.version - 1}`);
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(tables.map(rows)).toEqual(original);
|
||||
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||
const directory = mkdtempSync(join(tmpdir(), 'electric-cherry-auth-schema-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const databasePath = join(directory, 'auth.sqlite');
|
||||
writeFileSync(databasePath, handle.serialize());
|
||||
handle.close();
|
||||
handle = new Database(databasePath);
|
||||
handle.pragma('foreign_keys = ON');
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
|
||||
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||
expect(tables.map(rows)).toEqual(original);
|
||||
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
|
||||
sessions
|
||||
);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moderator role migration', () => {
|
||||
it('preserves all account roles and remembered sessions while extending the role constraint', () => {
|
||||
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// These tests intentionally use production-strength scrypt while the complete
|
||||
@@ -871,6 +871,28 @@ describe('remembered administrator account changes', () => {
|
||||
});
|
||||
|
||||
describe('persistent authentication throttling', () => {
|
||||
it('blocks an exhausted account/address pair without locking out the account at another address', async () => {
|
||||
const user = seedUser('target', 'viewer');
|
||||
const input = { username: user.username.toUpperCase(), password };
|
||||
for (let attempt = 0; attempt < 10; attempt++)
|
||||
auth.consumeAuthAttempt('192.0.2.70', input.username);
|
||||
// A pre-upgrade account-wide bucket must not continue a global lockout.
|
||||
database
|
||||
.db()
|
||||
.prepare('INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,?)')
|
||||
.run(
|
||||
createHash('sha256').update(`login:account:${user.username}`).digest('hex'),
|
||||
Date.now(),
|
||||
10
|
||||
);
|
||||
await expect(auth.authenticatePassword(input, '192.0.2.70')).rejects.toMatchObject({
|
||||
status: 429
|
||||
});
|
||||
await expect(auth.authenticatePassword(input, '192.0.2.71')).resolves.toMatchObject({
|
||||
user: { id: user.id }
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['admin', 'super_viewer'] as const)(
|
||||
'does not consume login or password-change limits for an enabled %s',
|
||||
(role) => {
|
||||
@@ -959,6 +981,12 @@ describe('persistent authentication throttling', () => {
|
||||
const account = kind === 'login' ? user.username : user.id;
|
||||
for (let attempt = 0; attempt < 10; attempt++)
|
||||
auth.consumeAuthAttempt('192.0.2.52', account, kind);
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.52', account, kind)).toThrow(
|
||||
'Too many attempts'
|
||||
);
|
||||
if (kind === 'login')
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).not.toThrow();
|
||||
else
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow(
|
||||
'Too many attempts'
|
||||
);
|
||||
@@ -966,25 +994,26 @@ describe('persistent authentication throttling', () => {
|
||||
await expect(
|
||||
auth.authenticatePassword(
|
||||
{ username: user.username, password, role: 'admin' },
|
||||
'192.0.2.53'
|
||||
'192.0.2.52'
|
||||
)
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => {
|
||||
it('retains case-insensitive account/address limits across database reopen and resets after the window', () => {
|
||||
let now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
for (let attempt = 0; attempt < 10; attempt++)
|
||||
auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
|
||||
database.closeDb();
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).toThrow('Too many attempts');
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).toThrow('Too many attempts');
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow();
|
||||
const persisted = JSON.stringify(
|
||||
database.db().prepare('SELECT * FROM auth_rate_limits').all()
|
||||
);
|
||||
expect(persisted).not.toContain('192.0.2.30');
|
||||
expect(persisted).not.toContain('admin');
|
||||
now += 15 * 60_000;
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow();
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).not.toThrow();
|
||||
});
|
||||
|
||||
it('limits attempts from one address across many accounts and separates setup from login windows', () => {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
|
||||
import type { TailResult } from '../../src/lib/server/fleet/logs.js';
|
||||
|
||||
const SERVER = 't5_zombie_theater_1';
|
||||
const OTHER_SERVER = 't6_zombie_town_1';
|
||||
let fixture: Fixture;
|
||||
let baseline: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
baseline = Date.now() - 600_000;
|
||||
fixture = await newFixture([SERVER, OTHER_SERVER]);
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
await seedServerRegistry();
|
||||
db()
|
||||
.prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'")
|
||||
.run(new Date(baseline - 120_000).toISOString());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
function tail(message: string): TailResult {
|
||||
const line = `1:00 say;abc;0;Player;${message}`;
|
||||
return {
|
||||
lines: [line],
|
||||
size: Buffer.byteLength(line) + 1,
|
||||
mtime: new Date(baseline + 60_000).toISOString(),
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
function messages(body: { entries: ContextualChatEntry[] }): string[] {
|
||||
return body.entries.flatMap((entry) =>
|
||||
entry.type === 'message' ? [entry.message.message] : []
|
||||
);
|
||||
}
|
||||
|
||||
function advance(milliseconds: number) {
|
||||
vi.setSystemTime(Date.now() + milliseconds);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const logs = await import('../../src/lib/server/fleet/logs.js');
|
||||
const read = vi.spyOn(logs, 'readLogChunk').mockResolvedValue(tail('first'));
|
||||
const { GET } = await import('../../src/routes/api/servers/[name]/chat/+server.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
|
||||
return {
|
||||
read,
|
||||
db: db(),
|
||||
collectFleetChat,
|
||||
get: (server = SERVER) => GET({ params: { name: server } })
|
||||
};
|
||||
}
|
||||
|
||||
async function addInstance() {
|
||||
const { createHost } = await import('../../src/lib/server/hosts/index.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const host = createHost({ name: 'Chat host' });
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('chat-instance',?,?,'library','image',28960,?)`
|
||||
)
|
||||
.run(SERVER, host.id, new Date(baseline).toISOString());
|
||||
}
|
||||
|
||||
describe('server chat refresh sharing', () => {
|
||||
it('serves worker state to repeated viewers and still collects after players leave', async () => {
|
||||
const s = await setup();
|
||||
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
const responses = await Promise.all(Array.from({ length: 5 }, () => s.get()));
|
||||
for (const response of responses)
|
||||
expect(messages(await response.json())).toEqual(['first']);
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
|
||||
s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
|
||||
s.read.mockResolvedValue(tail('last message'));
|
||||
await s.collectFleetChat();
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['last message']);
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refreshes exactly when the completed read becomes fifteen seconds old', async () => {
|
||||
const s = await setup();
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
s.read.mockResolvedValue(tail('second message'));
|
||||
advance(14_999);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
advance(1);
|
||||
const responses = await Promise.all([s.get(), s.get(), s.get()]);
|
||||
for (const response of responses)
|
||||
expect(messages(await response.json())).toEqual(['second message']);
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares slow worker reads and starts the cooldown when they complete', async () => {
|
||||
const s = await setup();
|
||||
const slow = deferred<TailResult>();
|
||||
s.read.mockImplementationOnce(() => slow.promise);
|
||||
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
|
||||
const collection = s.collectFleetChat();
|
||||
const first = s.get();
|
||||
advance(60_000);
|
||||
const second = s.get();
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
slow.resolve(tail('slow message'));
|
||||
await collection;
|
||||
for (const response of await Promise.all([first, second]))
|
||||
expect(messages(await response.json())).toEqual(['slow message']);
|
||||
|
||||
advance(14_999);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['slow message']);
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
advance(1);
|
||||
await s.get();
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches missing logs until the next allowed attempt', async () => {
|
||||
const s = await setup();
|
||||
s.read.mockResolvedValueOnce(null);
|
||||
const missing = { ok: true, entries: [], missing: true };
|
||||
expect(await (await s.get()).json()).toEqual(missing);
|
||||
advance(14_999);
|
||||
for (const response of await Promise.all([s.get(), s.get()]))
|
||||
expect(await response.json()).toEqual(missing);
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
advance(1);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('backs off failed reads and retries without leaking the original error', async () => {
|
||||
const s = await setup();
|
||||
s.read.mockRejectedValueOnce(new Error('read failed at /private/server/log'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const first = await s.get();
|
||||
expect(first.status).toBe(500);
|
||||
expect(await first.json()).toEqual({ ok: false, reason: 'Could not read server chat' });
|
||||
advance(14_999);
|
||||
for (const response of await Promise.all([s.get(), s.get()])) {
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
reason: 'Could not read server chat'
|
||||
});
|
||||
}
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
advance(1);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps each server and every replacement identity on its own cooldown', async () => {
|
||||
const s = await setup();
|
||||
await addInstance();
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
s.read.mockResolvedValue(tail('other server'));
|
||||
expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']);
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
|
||||
s.db
|
||||
.prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?')
|
||||
.run(SERVER);
|
||||
s.read.mockResolvedValue(tail('new generation'));
|
||||
expect(messages(await (await s.get()).json())).toEqual(['new generation']);
|
||||
s.db
|
||||
.prepare("UPDATE native_instances SET id='replacement-instance' WHERE server=?")
|
||||
.run(SERVER);
|
||||
s.read.mockResolvedValue(tail('new instance'));
|
||||
expect(messages(await (await s.get()).json())).toEqual(['new instance']);
|
||||
s.db
|
||||
.prepare('UPDATE servers SET created_at=? WHERE name=?')
|
||||
.run(new Date(baseline - 60_000).toISOString(), SERVER);
|
||||
s.read.mockResolvedValue(tail('new registration'));
|
||||
expect(messages(await (await s.get()).json())).toEqual(['new registration']);
|
||||
expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']);
|
||||
expect(s.read).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('does not let an old in-flight generation overwrite its replacement cache', async () => {
|
||||
const s = await setup();
|
||||
await addInstance();
|
||||
const oldRead = deferred<TailResult>();
|
||||
s.read.mockImplementationOnce(() => oldRead.promise);
|
||||
const oldResponse = s.get();
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
s.db
|
||||
.prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?')
|
||||
.run(SERVER);
|
||||
s.read.mockResolvedValue(tail('replacement'));
|
||||
expect(messages(await (await s.get()).json())).toEqual(['replacement']);
|
||||
advance(10_000);
|
||||
oldRead.resolve(tail('old generation'));
|
||||
expect(await (await oldResponse).json()).toEqual({ ok: true, entries: [], missing: true });
|
||||
expect(messages(await (await s.get()).json())).toEqual(['replacement']);
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
|
||||
advance(5_000);
|
||||
s.read.mockResolvedValue(tail('replacement refreshed'));
|
||||
expect(messages(await (await s.get()).json())).toEqual(['replacement refreshed']);
|
||||
expect(s.read).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ const at = (seconds: number) => new Date(baseline + seconds * 1000).toISOString(
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
baseline = Math.floor(Date.now() / 1000) * 1000 - 600_000;
|
||||
fixture = await newFixture([SERVER, T6_SERVER]);
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
@@ -26,6 +27,7 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
@@ -70,6 +72,10 @@ async function get(server = SERVER) {
|
||||
return GET({ params: { name: server } });
|
||||
}
|
||||
|
||||
function advanceTime(milliseconds = 15_000) {
|
||||
vi.setSystemTime(Date.now() + milliseconds);
|
||||
}
|
||||
|
||||
describe('server chat API', () => {
|
||||
it('buffers partial UTF-8 lines and avoids context queries when the log is unchanged', async () => {
|
||||
await report('game_start', 0);
|
||||
@@ -78,6 +84,7 @@ describe('server chat API', () => {
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const prepare = vi.spyOn(db(), 'prepare');
|
||||
advanceTime();
|
||||
await get();
|
||||
expect(prepare.mock.calls.some(([sql]) => sql.includes('FROM telemetry_requests'))).toBe(
|
||||
false
|
||||
@@ -90,9 +97,11 @@ describe('server chat API', () => {
|
||||
const line = Buffer.from('61:00 say;abc;0;Player;café\n');
|
||||
await appendFile(path, line.subarray(0, -2)); // Split inside é, before the newline.
|
||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||
advanceTime();
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
await appendFile(path, line.subarray(-2));
|
||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||
advanceTime();
|
||||
const body = await (await get()).json();
|
||||
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
||||
'first',
|
||||
@@ -110,6 +119,7 @@ describe('server chat API', () => {
|
||||
const path = join(fixture.t5, SERVER, 'logs/games_sp.log');
|
||||
await appendFile(path, '61:00 say;abc;0;Player;retry me\n');
|
||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||
advanceTime();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db(),
|
||||
original = handle.prepare.bind(handle);
|
||||
@@ -121,6 +131,7 @@ describe('server chat API', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect((await get()).status).toBe(500);
|
||||
prepare.mockRestore();
|
||||
advanceTime();
|
||||
expect(
|
||||
(await (await get()).json()).entries.map(
|
||||
(entry: ChatMessageEntry) => entry.message.message
|
||||
@@ -142,9 +153,14 @@ describe('server chat API', () => {
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
|
||||
await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']);
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
await collectFleetChat();
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect((await (await get()).json()).entries).toHaveLength(2);
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
db()
|
||||
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
|
||||
.run(SERVER);
|
||||
@@ -280,6 +296,8 @@ describe('server chat API', () => {
|
||||
});
|
||||
|
||||
it('clears at a new match before it writes to the log and keeps the old chat with its match', async () => {
|
||||
const logs = await import('../../src/lib/server/fleet/logs.js');
|
||||
const read = vi.spyOn(logs, 'readLogChunk');
|
||||
await report('game_start', 0);
|
||||
await report('round_change', 20, 4);
|
||||
await log(['60:30 say;abc;0;Player;old match'], 30);
|
||||
@@ -287,8 +305,10 @@ describe('server chat API', () => {
|
||||
await report('game_end', 60, 4);
|
||||
await report('game_start', 80);
|
||||
expect((await (await get()).json()).entries).toEqual([]);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
await report('heartbeat', 90, 2);
|
||||
await log(['60:30 say;abc;0;Player;old match', '61:40 say;abc;0;Player;new match'], 100);
|
||||
advanceTime();
|
||||
const body = await (await get()).json();
|
||||
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
||||
'new match'
|
||||
@@ -311,8 +331,10 @@ describe('server chat API', () => {
|
||||
await report('game_start', 0);
|
||||
await report('heartbeat', 20, 4);
|
||||
await log(['60:30 say;abc;0;Player;old match'], 30);
|
||||
advanceTime();
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
await log(['60:30 say;abc;0;Player;old match', boundary], 60);
|
||||
advanceTime();
|
||||
expect((await (await get()).json()).entries, boundary).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -333,6 +355,7 @@ describe('server chat API', () => {
|
||||
.map((entry: ChatMessageEntry) => entry.message.message)
|
||||
).toEqual(['new match']);
|
||||
await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:'], 60);
|
||||
advanceTime();
|
||||
expect(
|
||||
(await (await get()).json()).entries.some(
|
||||
(entry: ContextualChatEntry) => entry.type === 'message'
|
||||
|
||||
Reference in New Issue
Block a user