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.
|
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
|
||||||
EC_PUBLIC_URL=https://electric-cherry.example.com
|
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
|
# Generate separate secrets with: openssl rand -hex 32
|
||||||
# Setup token authorizes creation of the first administrator only. Remove it
|
# Setup token authorizes creation of the first administrator only. Remove it
|
||||||
# after setup. Never reuse the telemetry token as a setup or host credential.
|
# 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_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
||||||
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
||||||
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
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.
|
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
|
||||||
BODY_SIZE_LIMIT: 'Infinity'
|
BODY_SIZE_LIMIT: 'Infinity'
|
||||||
DB_PATH: /data/cherry.db
|
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
|
Login and password changes for enabled Administrators and Super Viewers are exempt
|
||||||
from per-account and per-address rate limits, including existing exhausted windows.
|
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
|
Their attempts do not consume either limit. Other accounts use persistent
|
||||||
persistent 15-minute windows. Exemptions use the account's current database role;
|
15-minute login windows of 10 attempts per username-and-address pair and 30 per
|
||||||
password verification is still required. The database stores audit entries for login
|
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
|
results, account changes and successful management requests without recording
|
||||||
passwords, tokens or request bodies. Administrators and Super Viewers can review
|
passwords, tokens or request bodies. Administrators and Super Viewers can review
|
||||||
recent entries in Users.
|
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
|
Replace `admin` with the existing administrator's username. The command prompts
|
||||||
without echoing the password, resets that enabled administrator only, revokes
|
without echoing the password, resets that enabled administrator only, revokes
|
||||||
their sessions and records the recovery. It does not expose a remote recovery
|
their sessions and trusted login devices, clears their account-specific login and
|
||||||
endpoint or accept passwords as command arguments.
|
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
|
## 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
|
incoming origins with your trusted origin. Machine endpoints use their own token
|
||||||
checks and do not require a browser Origin.
|
checks and do not require a browser Origin.
|
||||||
|
|
||||||
Use the adapter's real client-address configuration only with a trusted proxy
|
Login throttling uses the address returned by SvelteKit's adapter. Without
|
||||||
that strips spoofed forwarding headers; login throttling otherwise uses the direct
|
`ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
|
||||||
peer address. Forwarded host or user headers do not authenticate users.
|
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
|
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
|
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
|
precompresses static JavaScript and CSS assets; dynamically generated responses
|
||||||
need compression at the proxy.
|
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
|
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
|
remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
|
||||||
controller. Setup stays closed once an account exists.
|
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,
|
tail, the controller requests only appended bytes, handles rotation and truncation,
|
||||||
and buffers incomplete lines. Unchanged logs need no content transfer or chat
|
and buffers incomplete lines. Unchanged logs need no content transfer or chat
|
||||||
parsing. Background collection skips offline hosts, stopped servers and known
|
parsing. Background collection skips offline hosts, stopped servers and known
|
||||||
empty servers, with one final read after players leave. Opening chat still reads
|
empty servers, with one final read after players leave. Opening chat reuses the
|
||||||
on demand. Older agents use tail reads until they update.
|
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
|
### New game files and cached versions
|
||||||
|
|
||||||
|
|||||||
+80
-24
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Local recovery only: requires filesystem/container access to the controller.
|
// Local recovery only: requires filesystem/container access to the controller.
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { emitKeypressEvents } from 'node:readline';
|
import { emitKeypressEvents } from 'node:readline';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||||||
|
|
||||||
|
/** @param {string} prompt @returns {Promise<string>} */
|
||||||
function secret(prompt) {
|
function secret(prompt) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let value = '';
|
let value = '';
|
||||||
@@ -17,6 +21,7 @@ function secret(prompt) {
|
|||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
};
|
};
|
||||||
|
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
|
||||||
const listener = (text, key) => {
|
const listener = (text, key) => {
|
||||||
if (key?.ctrl && key.name === 'c') {
|
if (key?.ctrl && key.name === 'c') {
|
||||||
cleanup();
|
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();
|
const username = process.argv[2]?.toLowerCase();
|
||||||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
|
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>');
|
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||||||
@@ -51,34 +116,25 @@ try {
|
|||||||
fileMustExist: true
|
fileMustExist: true
|
||||||
});
|
});
|
||||||
database.pragma('busy_timeout=5000');
|
database.pragma('busy_timeout=5000');
|
||||||
const user = database
|
try {
|
||||||
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0")
|
administrator(database, username);
|
||||||
.get(username);
|
|
||||||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
|
||||||
const password = await secret('New administrator password (8–128 characters): ');
|
const password = await secret('New administrator password (8–128 characters): ');
|
||||||
const confirmation = await secret('Repeat password: ');
|
const confirmation = await secret('Repeat password: ');
|
||||||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||||||
const passwordHash = await hashPassword(password);
|
await recoverAdministrator(database, username, password);
|
||||||
database
|
console.log(
|
||||||
.transaction(() => {
|
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
|
||||||
const changed = database
|
);
|
||||||
.prepare(
|
} finally {
|
||||||
"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();
|
|
||||||
database.close();
|
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) {
|
} catch (cause) {
|
||||||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
return 'admin';
|
return 'admin';
|
||||||
|
|
||||||
// These reads initiate fleet file scans or spend external API quota.
|
// 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
|
// Summaries remain visible to viewers; raw configuration, historical file
|
||||||
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
|
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 { PasswordServiceBusyError } from './passwords.js';
|
||||||
import type { AuthUser } from '../../auth.js';
|
import type { AuthUser } from '../../auth.js';
|
||||||
|
|
||||||
export function sessionCookie(url: URL): string {
|
export function sessionCookie(url: URL): string {
|
||||||
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev';
|
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 {
|
export function requireUserTransport(url: URL): void {
|
||||||
if (url.protocol === 'https:') return;
|
if (url.protocol === 'https:') return;
|
||||||
if (
|
if (
|
||||||
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
expires: new Date(result.session.expiresAt)
|
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' } });
|
return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
}
|
}
|
||||||
export function requireUser(user: AuthUser | null): AuthUser {
|
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_LIFETIME_MS = 12 * 60 * 60_000;
|
||||||
export const SESSION_IDLE_MS = 30 * 60_000;
|
export const SESSION_IDLE_MS = 30 * 60_000;
|
||||||
export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 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');
|
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||||
function database() {
|
function database() {
|
||||||
return db();
|
return db();
|
||||||
@@ -111,7 +113,8 @@ export function listAuthAudit() {
|
|||||||
export function consumeAuthAttempt(
|
export function consumeAuthAttempt(
|
||||||
clientAddress: string,
|
clientAddress: string,
|
||||||
account: string,
|
account: string,
|
||||||
kind: 'login' | 'setup' | 'password' = 'login'
|
kind: 'login' | 'setup' | 'password' = 'login',
|
||||||
|
loginDevice?: string
|
||||||
): void {
|
): void {
|
||||||
// Resolve the current role from storage; setup never has an existing account exemption.
|
// Resolve the current role from storage; setup never has an existing account exemption.
|
||||||
if (
|
if (
|
||||||
@@ -127,16 +130,22 @@ export function consumeAuthAttempt(
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = 15 * 60_000;
|
const windowMs = 15 * 60_000;
|
||||||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
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()
|
database()
|
||||||
.transaction(() => {
|
.transaction(() => {
|
||||||
database()
|
database()
|
||||||
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
|
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
|
||||||
.run(now - 24 * 60 * 60_000);
|
.run(now - 24 * 60 * 60_000);
|
||||||
for (const [key, limit] of [
|
for (const [key, limit] of limits) {
|
||||||
[ipKey, 30],
|
|
||||||
[accountKey, 10]
|
|
||||||
] as const) {
|
|
||||||
const previous = database()
|
const previous = database()
|
||||||
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
|
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
|
||||||
.get(key) as { window_start: number; attempts: number } | undefined;
|
.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);
|
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
||||||
}
|
}
|
||||||
for (const key of [ipKey, accountKey])
|
for (const [key] of limits)
|
||||||
database()
|
database()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
||||||
@@ -189,6 +198,7 @@ function insertUser(input: {
|
|||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
session: { token: string; expiresAt: number };
|
session: { token: string; expiresAt: number };
|
||||||
|
loginDevice?: { token: string; expiresAt: number };
|
||||||
}
|
}
|
||||||
export async function bootstrapAdmin(
|
export async function bootstrapAdmin(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
@@ -230,13 +240,14 @@ export async function bootstrapAdmin(
|
|||||||
}
|
}
|
||||||
export async function authenticatePassword(
|
export async function authenticatePassword(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
clientAddress: string
|
clientAddress: string,
|
||||||
|
loginDevice?: string
|
||||||
): Promise<AuthResult> {
|
): Promise<AuthResult> {
|
||||||
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
|
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
|
||||||
throw new AuthError('Keep me signed in must be true or false.');
|
throw new AuthError('Keep me signed in must be true or false.');
|
||||||
const name =
|
const name =
|
||||||
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : '';
|
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
|
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
|
||||||
UserRow | undefined;
|
UserRow | undefined;
|
||||||
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
||||||
@@ -258,7 +269,8 @@ export async function authenticatePassword(
|
|||||||
recordAudit(current.id, 'login', current.id);
|
recordAudit(current.id, 'login', current.id);
|
||||||
return {
|
return {
|
||||||
user: getUser(current.id)!,
|
user: getUser(current.id)!,
|
||||||
session: createSession(current.id, input.rememberMe === true)
|
session: createSession(current.id, input.rememberMe === true),
|
||||||
|
loginDevice: createLoginDevice(current, loginDevice)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.immediate();
|
.immediate();
|
||||||
@@ -266,6 +278,32 @@ export async function authenticatePassword(
|
|||||||
return result;
|
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(
|
export function createSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
remembered = false
|
remembered = false
|
||||||
@@ -327,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
|
|||||||
requireAdministrator(actorId);
|
requireAdministrator(actorId);
|
||||||
if (!userRow(userId)) throw new AuthError('User not found.', 404);
|
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_sessions WHERE user_id=?').run(userId);
|
||||||
|
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
|
||||||
recordAudit(actorId, 'sessions.revoked', userId);
|
recordAudit(actorId, 'sessions.revoked', userId);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -427,8 +466,10 @@ export async function updateUser(
|
|||||||
Date.now(),
|
Date.now(),
|
||||||
id
|
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_sessions WHERE user_id=?').run(id);
|
||||||
|
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
|
||||||
|
}
|
||||||
recordAudit(actorId, 'user.updated', id, {
|
recordAudit(actorId, 'user.updated', id, {
|
||||||
role: nextRole,
|
role: nextRole,
|
||||||
disabled: !!disabled,
|
disabled: !!disabled,
|
||||||
@@ -469,6 +510,7 @@ export async function changePassword(
|
|||||||
)
|
)
|
||||||
.run(passwordHash, Date.now(), userId);
|
.run(passwordHash, Date.now(), userId);
|
||||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(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);
|
recordAudit(userId, 'password.changed', userId);
|
||||||
return {
|
return {
|
||||||
user: getUser(userId)!,
|
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. */
|
/** Run with foreign keys disabled by the migration runner before its transaction. */
|
||||||
export function migrateSuperViewerRole(handle: Database.Database): void {
|
export function migrateSuperViewerRole(handle: Database.Database): void {
|
||||||
const schema = handle
|
const schema = handle
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
|
|||||||
import { migrateHostSchema } from '../hosts/schema.js';
|
import { migrateHostSchema } from '../hosts/schema.js';
|
||||||
import {
|
import {
|
||||||
migrateAuthSchema,
|
migrateAuthSchema,
|
||||||
|
migrateLoginDevices,
|
||||||
migrateRememberedSessions,
|
migrateRememberedSessions,
|
||||||
migrateSuperViewerRole,
|
migrateSuperViewerRole,
|
||||||
migrateModeratorRole
|
migrateModeratorRole
|
||||||
@@ -160,7 +161,8 @@ export const migrations: readonly Migration[] = [
|
|||||||
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0'
|
'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;
|
export const databaseVersion = migrations.length;
|
||||||
|
|||||||
@@ -209,12 +209,38 @@ interface ChatReadState {
|
|||||||
}
|
}
|
||||||
const readers = new Map<string, ChatReadState>();
|
const readers = new Map<string, ChatReadState>();
|
||||||
const failures = new Map<string, number>();
|
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. */
|
/** Shared by the live API and the worker; collection does not require a viewer. */
|
||||||
export function collectServerChat(
|
export function collectServerChat(
|
||||||
server: ServerRow
|
server: ServerRow
|
||||||
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
|
): 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);
|
const existing = pending.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const work = (async () => {
|
const work = (async () => {
|
||||||
@@ -319,7 +345,19 @@ export function collectServerChat(
|
|||||||
state.signature = signature;
|
state.signature = signature;
|
||||||
readers.set(server.name, state);
|
readers.set(server.name, state);
|
||||||
return { entries: currentChatEntries(current, state.entries), missing: false };
|
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);
|
pending.set(key, work);
|
||||||
return work;
|
return work;
|
||||||
}
|
}
|
||||||
@@ -329,8 +367,10 @@ export async function collectFleetChat(): Promise<void> {
|
|||||||
const observe = observationReader();
|
const observe = observationReader();
|
||||||
const live = listServers().filter((server) => !server.destroyed_at);
|
const live = listServers().filter((server) => !server.destroyed_at);
|
||||||
const names = new Set(live.map((server) => server.name));
|
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 readers.keys()) if (!names.has(name)) readers.delete(name);
|
||||||
for (const name of failures.keys()) if (!names.has(name)) failures.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) => {
|
const servers = live.filter((server) => {
|
||||||
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
||||||
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
||||||
|
|||||||
@@ -413,7 +413,7 @@
|
|||||||
><span class="concise-reason"
|
><span class="concise-reason"
|
||||||
>{operationIssue ? operation.label : status.shortReason}</span
|
>{operationIssue ? operation.label : status.shortReason}</span
|
||||||
>{#if status.since}<span class="evidence-time" title={status.since}
|
>{#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,
|
status.since,
|
||||||
$uiNow
|
$uiNow
|
||||||
)} ago</span
|
)} ago</span
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
|
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 }) => {
|
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||||
try {
|
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)));
|
revokeSession(cookies.get(sessionCookie(url)));
|
||||||
return sessionResponse(result, cookies, url);
|
return sessionResponse(result, cookies, url);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getServer } from '$lib/server/fleet/servers.js';
|
import { getServer } from '$lib/server/fleet/servers.js';
|
||||||
import { gameLogKey, resolveLogPath } from '$lib/server/fleet/logs.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 } }) {
|
export async function GET({ params }: { params: { name: string } }) {
|
||||||
const headers = { 'Cache-Control': 'no-store' };
|
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 });
|
return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return json({ ok: true, ...(await collectServerChat(server)) }, { headers });
|
return json({ ok: true, ...(await readServerChat(server)) }, { headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Could not read server chat', error);
|
console.error('Could not read server chat', error);
|
||||||
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
|
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
|
||||||
|
|||||||
@@ -393,7 +393,7 @@
|
|||||||
{#if tab === 'active'}<p class="queue-summary">
|
{#if tab === 'active'}<p class="queue-summary">
|
||||||
<strong
|
<strong
|
||||||
>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong
|
>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong
|
||||||
>{#if queueSummary}<span> · {queueSummary}</span>{/if}
|
>{#if queueSummary}<span>{' · '}{queueSummary}</span>{/if}
|
||||||
</p>
|
</p>
|
||||||
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
|
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
|
||||||
<div class="queue-tools">
|
<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('super_viewer', '/api/settings/retention/preview', 'POST')).toBe(true);
|
||||||
expect(allows('operator', '/api/settings/retention/preview', 'POST')).toBe(false);
|
expect(allows('operator', '/api/settings/retention/preview', 'POST')).toBe(false);
|
||||||
expect(allows('super_viewer', '/api/settings/retention', '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'])
|
for (const method of ['GET', 'HEAD'])
|
||||||
expect(allows('super_viewer', route, method)).toBe(false);
|
expect(allows('super_viewer', route, method)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -339,13 +339,15 @@ describe('browser authentication guard', () => {
|
|||||||
['GET', '/api/drift'],
|
['GET', '/api/drift'],
|
||||||
['HEAD', '/api/drift'],
|
['HEAD', '/api/drift'],
|
||||||
['GET', '/api/plugins/github/release'],
|
['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) => {
|
])('restricts expensive %s %s requests before handler execution', async (method, route) => {
|
||||||
const anonymous = requestFor(route, { method, cookie: null });
|
const anonymous = requestFor(route, { method, cookie: null });
|
||||||
expect((await anonymous.invoke()).status).toBe(401);
|
expect((await anonymous.invoke()).status).toBe(401);
|
||||||
expect(anonymous.resolve).not.toHaveBeenCalled();
|
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);
|
signIn(role);
|
||||||
const call = requestFor(route, { method });
|
const call = requestFor(route, { method });
|
||||||
const allowed = role === 'operator' || role === 'admin';
|
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 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 { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
migrateLoginDevices,
|
||||||
migrateModeratorRole,
|
migrateModeratorRole,
|
||||||
migrateRememberedSessions,
|
migrateRememberedSessions,
|
||||||
migrateSuperViewerRole
|
migrateSuperViewerRole
|
||||||
@@ -9,6 +13,7 @@ import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
|
|||||||
import { migrations } from '../../src/lib/server/db/migrations.js';
|
import { migrations } from '../../src/lib/server/db/migrations.js';
|
||||||
|
|
||||||
let handle: Database.Database;
|
let handle: Database.Database;
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
|
const migration = migrations.find((entry) => entry.name === 'super viewer authentication role')!;
|
||||||
|
|
||||||
beforeEach(() => {
|
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) {
|
function rows(table: string) {
|
||||||
// Compare the original session fields across migrations that add policy columns.
|
// 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', () => {
|
describe('moderator role migration', () => {
|
||||||
it('preserves all account roles and remembered sessions while extending the role constraint', () => {
|
it('preserves all account roles and remembered sessions while extending the role constraint', () => {
|
||||||
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
|
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';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// These tests intentionally use production-strength scrypt while the complete
|
// These tests intentionally use production-strength scrypt while the complete
|
||||||
@@ -871,6 +871,28 @@ describe('remembered administrator account changes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('persistent authentication throttling', () => {
|
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)(
|
it.each(['admin', 'super_viewer'] as const)(
|
||||||
'does not consume login or password-change limits for an enabled %s',
|
'does not consume login or password-change limits for an enabled %s',
|
||||||
(role) => {
|
(role) => {
|
||||||
@@ -959,6 +981,12 @@ describe('persistent authentication throttling', () => {
|
|||||||
const account = kind === 'login' ? user.username : user.id;
|
const account = kind === 'login' ? user.username : user.id;
|
||||||
for (let attempt = 0; attempt < 10; attempt++)
|
for (let attempt = 0; attempt < 10; attempt++)
|
||||||
auth.consumeAuthAttempt('192.0.2.52', account, kind);
|
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(
|
expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow(
|
||||||
'Too many attempts'
|
'Too many attempts'
|
||||||
);
|
);
|
||||||
@@ -966,25 +994,26 @@ describe('persistent authentication throttling', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
auth.authenticatePassword(
|
auth.authenticatePassword(
|
||||||
{ username: user.username, password, role: 'admin' },
|
{ username: user.username, password, role: 'admin' },
|
||||||
'192.0.2.53'
|
'192.0.2.52'
|
||||||
)
|
)
|
||||||
).rejects.toMatchObject({ status: 429 });
|
).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;
|
let now = 2_000_000_000_000;
|
||||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
for (let attempt = 0; attempt < 10; attempt++)
|
for (let attempt = 0; attempt < 10; attempt++)
|
||||||
auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
|
auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
|
||||||
database.closeDb();
|
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(
|
const persisted = JSON.stringify(
|
||||||
database.db().prepare('SELECT * FROM auth_rate_limits').all()
|
database.db().prepare('SELECT * FROM auth_rate_limits').all()
|
||||||
);
|
);
|
||||||
expect(persisted).not.toContain('192.0.2.30');
|
expect(persisted).not.toContain('192.0.2.30');
|
||||||
expect(persisted).not.toContain('admin');
|
expect(persisted).not.toContain('admin');
|
||||||
now += 15 * 60_000;
|
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', () => {
|
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 () => {
|
beforeEach(async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
baseline = Math.floor(Date.now() / 1000) * 1000 - 600_000;
|
baseline = Math.floor(Date.now() / 1000) * 1000 - 600_000;
|
||||||
fixture = await newFixture([SERVER, T6_SERVER]);
|
fixture = await newFixture([SERVER, T6_SERVER]);
|
||||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
@@ -26,6 +27,7 @@ beforeEach(async () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
await fixture.cleanup();
|
await fixture.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +72,10 @@ async function get(server = SERVER) {
|
|||||||
return GET({ params: { name: server } });
|
return GET({ params: { name: server } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function advanceTime(milliseconds = 15_000) {
|
||||||
|
vi.setSystemTime(Date.now() + milliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
describe('server chat API', () => {
|
describe('server chat API', () => {
|
||||||
it('buffers partial UTF-8 lines and avoids context queries when the log is unchanged', async () => {
|
it('buffers partial UTF-8 lines and avoids context queries when the log is unchanged', async () => {
|
||||||
await report('game_start', 0);
|
await report('game_start', 0);
|
||||||
@@ -78,6 +84,7 @@ describe('server chat API', () => {
|
|||||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const prepare = vi.spyOn(db(), 'prepare');
|
const prepare = vi.spyOn(db(), 'prepare');
|
||||||
|
advanceTime();
|
||||||
await get();
|
await get();
|
||||||
expect(prepare.mock.calls.some(([sql]) => sql.includes('FROM telemetry_requests'))).toBe(
|
expect(prepare.mock.calls.some(([sql]) => sql.includes('FROM telemetry_requests'))).toBe(
|
||||||
false
|
false
|
||||||
@@ -90,9 +97,11 @@ describe('server chat API', () => {
|
|||||||
const line = Buffer.from('61:00 say;abc;0;Player;café\n');
|
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 appendFile(path, line.subarray(0, -2)); // Split inside é, before the newline.
|
||||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||||
|
advanceTime();
|
||||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||||
await appendFile(path, line.subarray(-2));
|
await appendFile(path, line.subarray(-2));
|
||||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||||
|
advanceTime();
|
||||||
const body = await (await get()).json();
|
const body = await (await get()).json();
|
||||||
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
||||||
'first',
|
'first',
|
||||||
@@ -110,6 +119,7 @@ describe('server chat API', () => {
|
|||||||
const path = join(fixture.t5, SERVER, 'logs/games_sp.log');
|
const path = join(fixture.t5, SERVER, 'logs/games_sp.log');
|
||||||
await appendFile(path, '61:00 say;abc;0;Player;retry me\n');
|
await appendFile(path, '61:00 say;abc;0;Player;retry me\n');
|
||||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||||
|
advanceTime();
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const handle = db(),
|
const handle = db(),
|
||||||
original = handle.prepare.bind(handle);
|
original = handle.prepare.bind(handle);
|
||||||
@@ -121,6 +131,7 @@ describe('server chat API', () => {
|
|||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
expect((await get()).status).toBe(500);
|
expect((await get()).status).toBe(500);
|
||||||
prepare.mockRestore();
|
prepare.mockRestore();
|
||||||
|
advanceTime();
|
||||||
expect(
|
expect(
|
||||||
(await (await get()).json()).entries.map(
|
(await (await get()).json()).entries.map(
|
||||||
(entry: ChatMessageEntry) => entry.message.message
|
(entry: ChatMessageEntry) => entry.message.message
|
||||||
@@ -142,9 +153,14 @@ describe('server chat API', () => {
|
|||||||
await collectFleetChat();
|
await collectFleetChat();
|
||||||
expect(read).toHaveBeenCalledTimes(1);
|
expect(read).toHaveBeenCalledTimes(1);
|
||||||
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
|
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();
|
||||||
await collectFleetChat();
|
await collectFleetChat();
|
||||||
expect(read).toHaveBeenCalledTimes(2);
|
expect(read).toHaveBeenCalledTimes(2);
|
||||||
|
expect((await (await get()).json()).entries).toHaveLength(2);
|
||||||
|
expect(read).toHaveBeenCalledTimes(2);
|
||||||
db()
|
db()
|
||||||
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
|
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
|
||||||
.run(SERVER);
|
.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 () => {
|
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('game_start', 0);
|
||||||
await report('round_change', 20, 4);
|
await report('round_change', 20, 4);
|
||||||
await log(['60:30 say;abc;0;Player;old match'], 30);
|
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_end', 60, 4);
|
||||||
await report('game_start', 80);
|
await report('game_start', 80);
|
||||||
expect((await (await get()).json()).entries).toEqual([]);
|
expect((await (await get()).json()).entries).toEqual([]);
|
||||||
|
expect(read).toHaveBeenCalledTimes(1);
|
||||||
await report('heartbeat', 90, 2);
|
await report('heartbeat', 90, 2);
|
||||||
await log(['60:30 say;abc;0;Player;old match', '61:40 say;abc;0;Player;new match'], 100);
|
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();
|
const body = await (await get()).json();
|
||||||
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
||||||
'new match'
|
'new match'
|
||||||
@@ -311,8 +331,10 @@ describe('server chat API', () => {
|
|||||||
await report('game_start', 0);
|
await report('game_start', 0);
|
||||||
await report('heartbeat', 20, 4);
|
await report('heartbeat', 20, 4);
|
||||||
await log(['60:30 say;abc;0;Player;old match'], 30);
|
await log(['60:30 say;abc;0;Player;old match'], 30);
|
||||||
|
advanceTime();
|
||||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||||
await log(['60:30 say;abc;0;Player;old match', boundary], 60);
|
await log(['60:30 say;abc;0;Player;old match', boundary], 60);
|
||||||
|
advanceTime();
|
||||||
expect((await (await get()).json()).entries, boundary).toEqual([]);
|
expect((await (await get()).json()).entries, boundary).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -333,6 +355,7 @@ describe('server chat API', () => {
|
|||||||
.map((entry: ChatMessageEntry) => entry.message.message)
|
.map((entry: ChatMessageEntry) => entry.message.message)
|
||||||
).toEqual(['new match']);
|
).toEqual(['new match']);
|
||||||
await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:'], 60);
|
await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:'], 60);
|
||||||
|
advanceTime();
|
||||||
expect(
|
expect(
|
||||||
(await (await get()).json()).entries.some(
|
(await (await get()).json()).entries.some(
|
||||||
(entry: ContextualChatEntry) => entry.type === 'message'
|
(entry: ContextualChatEntry) => entry.type === 'message'
|
||||||
|
|||||||
Reference in New Issue
Block a user