ldrouter 1.18.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +15 -1
- package/dist/server/backup/container.js +99 -0
- package/dist/server/backup/export.js +99 -0
- package/dist/server/backup/restore-upload.js +333 -0
- package/dist/server/backup/stats.js +15 -0
- package/dist/server/config/index.js +14 -1
- package/dist/server/db/schema.js +18 -1
- package/dist/server/gateway/runner.js +73 -59
- package/dist/server/routes/admin/api-keys.js +1 -2
- package/dist/server/routes/admin/backup.js +177 -176
- package/dist/server/routes/admin/models.js +7 -7
- package/dist/server/routes/admin/providers.js +15 -14
- package/dist/server/routes/admin/setup.js +4 -1
- package/dist/server/routes/admin/stats.js +129 -61
- package/dist/web/assets/index-BdbskFHA.js +349 -0
- package/dist/web/index.html +1 -1
- package/migrations/0011_daily_aggregate_stats.sql +60 -0
- package/package.json +1 -1
- package/dist/web/assets/index-C868AUvW.js +0 -349
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.19.0] - 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Compact and full database backup profiles, exported as a v2 `.ldb` binary container (manifest + gzip payload + encrypted key envelope).
|
|
12
|
+
- Compact backups keep all gateway configuration and daily statistics (by model, provider and API key) while excluding request/attempt/audit logs, the gateway response cache, admin sessions, CSRF tokens and previous backups.
|
|
13
|
+
- Streamed multipart restore: the upload is written to disk, then validated (checksum, gzip, SQLite integrity, schema, key envelope) before an atomic swap with rollback, with real upload progress and cancel in the admin UI.
|
|
14
|
+
- `LATEDEV_MAX_BACKUP_UPLOAD_BYTES` (default 256 MiB) and a `GET /api/admin/backup/capabilities` preflight.
|
|
15
|
+
- Daily aggregate statistics tables (`daily_model_stats`, `daily_provider_stats`, `daily_api_key_stats`) written in the same transaction as request persistence.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Backup download extension is now `.ldb` (previously `.json`); legacy v1 JSON backups remain restorable.
|
|
20
|
+
- Statistics over a range that includes a compacted day report p95 latency and TTFT as unavailable instead of a misleading zero.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
|
|
24
|
+
- A compact restore cannot recover individual request, attempt or audit history, and the response cache is empty after a compact restore even when caching is enabled.
|
|
25
|
+
- Uploads are not resumable.
|
|
26
|
+
|
|
27
|
+
## [1.18.2] - 2026-10-02
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Restore now adopts the backup's master key, including across restarts.
|
|
32
|
+
- Admin deletion now permanently removes API keys, models, and providers with dependent routing configuration cleaned up first.
|
|
33
|
+
- `/setup` always exposes the master-key input and reports runtime key availability.
|
|
34
|
+
|
|
7
35
|
## [1.17.9] - 2026-09-22
|
|
8
36
|
|
|
9
37
|
### Fixed
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Lightweight self-hosted LLM gateway with a polished admin UI. Presents stable Op
|
|
|
16
16
|
- Request + attempt logs, statistics (Today / 7d / 30d), retention cleanup
|
|
17
17
|
- Admin TOTP 2FA, Argon2id passwords, recovery codes
|
|
18
18
|
- Immutable audit logs
|
|
19
|
-
- Consistent backup / restore
|
|
19
|
+
- Consistent backup / restore: compact `.ldb` default + full forensic profile, streamed upload with progress/cancel
|
|
20
20
|
- Prometheus `/metrics`, structured logs, graceful shutdown
|
|
21
21
|
- One distributable npm package, multi-stage Dockerfile, Docker Compose
|
|
22
22
|
|
|
@@ -52,6 +52,20 @@ The data directory defaults to `~/.latedev-router/` and can be overridden via `L
|
|
|
52
52
|
| `LATEDEV_MASTER_KEY` | 32-byte base64 key for encrypting provider credentials | _required once providers exist_ |
|
|
53
53
|
| `LATEDEV_TRUST_PROXY` | Number of reverse-proxy hops to trust for X-Forwarded-For | `0` |
|
|
54
54
|
| `LATEDEV_LOG_LEVEL` | trace / debug / info / warn / error / fatal | `info` |
|
|
55
|
+
| `LATEDEV_MAX_BACKUP_UPLOAD_BYTES` | Maximum accepted backup restore upload size (bytes) | `268435456` (256 MiB) |
|
|
56
|
+
|
|
57
|
+
## Backup & restore
|
|
58
|
+
|
|
59
|
+
Backups download from Settings > Backup (or first-run Setup). New backups use the binary `.ldb` container; legacy `.ldb.json` files remain restoreable.
|
|
60
|
+
|
|
61
|
+
- **Compact (default)** — retains all gateway configuration, encrypted provider credentials, API-key configuration, quota usage, and daily statistics by model/provider/API key. It excludes requests, attempts, audit logs, and the gateway response cache, so it is much smaller and is the right choice for routine backups.
|
|
62
|
+
- **Full (forensic)** — retains every raw row, including request/attempt/audit and cached response content. It can be much larger and contains sensitive data; treat the file as confidential.
|
|
63
|
+
|
|
64
|
+
To restore, upload the `.ldb` file (or legacy `.ldb.json`) and enter the **same six-digit passphrase** used to create it — it decrypts the encrypted master key inside the backup. Without it, restored provider credentials cannot be decrypted. The upload streams to disk with real progress and a Cancel button; **uploads are not resumable**, so an interrupted upload must be restarted. The default upload size limit is 256 MiB (`LATEDEV_MAX_BACKUP_UPLOAD_BYTES`); over-limit uploads are rejected with HTTP 413.
|
|
65
|
+
|
|
66
|
+
Restore validates the container, checksum, gzip, SQLite integrity, schema version, and key envelope before an atomic swap, and keeps a pre-restore snapshot so a failure rolls back to the previous database.
|
|
67
|
+
|
|
68
|
+
**Limitation:** a compact backup cannot restore individual request/attempt/audit history or cached responses, and after a compact restore historical p95 latency and p95 TTFT percentiles are unavailable for aggregate-only days (average latency/TTFT and top rankings are retained). Use a full backup if you need that row-level detail or percentile history.
|
|
55
69
|
|
|
56
70
|
## Public API examples
|
|
57
71
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { open as openFile } from 'node:fs/promises';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
export const BACKUP_MAGIC = Buffer.from('LDRB');
|
|
5
|
+
export const BACKUP_VERSION = 2;
|
|
6
|
+
export const MAX_BACKUP_MANIFEST_BYTES = 1024 * 1024;
|
|
7
|
+
const PREFIX_BYTES = BACKUP_MAGIC.length + 1 + 4;
|
|
8
|
+
export async function writeBackupContainer(input) {
|
|
9
|
+
const manifest = Buffer.from(JSON.stringify(input.manifest), 'utf8');
|
|
10
|
+
if (manifest.length > MAX_BACKUP_MANIFEST_BYTES)
|
|
11
|
+
throw new Error('Backup manifest exceeds maximum size');
|
|
12
|
+
const header = Buffer.alloc(PREFIX_BYTES + manifest.length);
|
|
13
|
+
BACKUP_MAGIC.copy(header);
|
|
14
|
+
header.writeUInt8(BACKUP_VERSION, BACKUP_MAGIC.length);
|
|
15
|
+
header.writeUInt32BE(manifest.length, BACKUP_MAGIC.length + 1);
|
|
16
|
+
manifest.copy(header, PREFIX_BYTES);
|
|
17
|
+
return Readable.from((async function* () {
|
|
18
|
+
yield header;
|
|
19
|
+
yield* input.payload;
|
|
20
|
+
})());
|
|
21
|
+
}
|
|
22
|
+
export async function readBackupContainerHeader(file, open = (path) => openFile(path, 'r')) {
|
|
23
|
+
const handle = await open(file);
|
|
24
|
+
try {
|
|
25
|
+
const prefix = Buffer.alloc(PREFIX_BYTES);
|
|
26
|
+
const prefixRead = await handle.read(prefix, 0, PREFIX_BYTES, 0);
|
|
27
|
+
if (prefixRead.bytesRead !== PREFIX_BYTES)
|
|
28
|
+
throw new Error('Truncated backup header');
|
|
29
|
+
if (!prefix.subarray(0, BACKUP_MAGIC.length).equals(BACKUP_MAGIC))
|
|
30
|
+
throw new Error('Invalid backup magic');
|
|
31
|
+
if (prefix.readUInt8(BACKUP_MAGIC.length) !== BACKUP_VERSION)
|
|
32
|
+
throw new Error('Unsupported backup version');
|
|
33
|
+
const manifestLength = prefix.readUInt32BE(BACKUP_MAGIC.length + 1);
|
|
34
|
+
if (manifestLength > MAX_BACKUP_MANIFEST_BYTES)
|
|
35
|
+
throw new Error('Backup manifest exceeds maximum size');
|
|
36
|
+
const manifestBytes = Buffer.alloc(manifestLength);
|
|
37
|
+
const manifestRead = await handle.read(manifestBytes, 0, manifestLength, PREFIX_BYTES);
|
|
38
|
+
if (manifestRead.bytesRead !== manifestLength)
|
|
39
|
+
throw new Error('Truncated backup header');
|
|
40
|
+
let manifest;
|
|
41
|
+
try {
|
|
42
|
+
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error('Invalid backup manifest');
|
|
46
|
+
}
|
|
47
|
+
if (!isBackupManifestV2(manifest))
|
|
48
|
+
throw new Error('Invalid backup manifest');
|
|
49
|
+
return { manifest, payloadOffset: PREFIX_BYTES + manifestLength };
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await handle.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function streamBackupPayload(file, payloadOffset) {
|
|
56
|
+
return fs.createReadStream(file, { start: payloadOffset });
|
|
57
|
+
}
|
|
58
|
+
function isBackupManifestV2(value) {
|
|
59
|
+
if (!value || typeof value !== 'object')
|
|
60
|
+
return false;
|
|
61
|
+
const manifest = value;
|
|
62
|
+
return manifest.format === 'latedev-backup'
|
|
63
|
+
&& manifest.version === BACKUP_VERSION
|
|
64
|
+
&& (manifest.profile === 'compact' || manifest.profile === 'full')
|
|
65
|
+
&& typeof manifest.appVersion === 'string'
|
|
66
|
+
&& isNonNegativeInteger(manifest.schemaVersion)
|
|
67
|
+
&& isIsoTimestamp(manifest.createdAt)
|
|
68
|
+
&& isNonNegativeInteger(manifest.compressedBytes)
|
|
69
|
+
&& isNonNegativeInteger(manifest.uncompressedBytes)
|
|
70
|
+
&& typeof manifest.checksum === 'string'
|
|
71
|
+
&& /^[a-f\d]{64}$/i.test(manifest.checksum)
|
|
72
|
+
&& (!('keyEnvelope' in manifest) || isBackupMasterKeyEnvelope(manifest.keyEnvelope))
|
|
73
|
+
&& Array.isArray(manifest.excludedTables)
|
|
74
|
+
&& manifest.excludedTables.every((table) => typeof table === 'string')
|
|
75
|
+
&& !!manifest.statistics
|
|
76
|
+
&& manifest.statistics.granularity === 'daily'
|
|
77
|
+
&& manifest.statistics.p95Available === false
|
|
78
|
+
&& Array.isArray(manifest.statistics.dimensions)
|
|
79
|
+
&& manifest.statistics.dimensions.every((dimension) => dimension === 'model' || dimension === 'provider' || dimension === 'api_key');
|
|
80
|
+
}
|
|
81
|
+
function isNonNegativeInteger(value) {
|
|
82
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
83
|
+
}
|
|
84
|
+
function isIsoTimestamp(value) {
|
|
85
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value))
|
|
86
|
+
return false;
|
|
87
|
+
const timestamp = new Date(value);
|
|
88
|
+
return !Number.isNaN(timestamp.valueOf()) && timestamp.toISOString() === value;
|
|
89
|
+
}
|
|
90
|
+
function isBackupMasterKeyEnvelope(value) {
|
|
91
|
+
if (!value || typeof value !== 'object')
|
|
92
|
+
return false;
|
|
93
|
+
const envelope = value;
|
|
94
|
+
return envelope.algorithm === 'scrypt-aes-256-gcm'
|
|
95
|
+
&& typeof envelope.salt === 'string'
|
|
96
|
+
&& typeof envelope.nonce === 'string'
|
|
97
|
+
&& typeof envelope.ciphertext === 'string'
|
|
98
|
+
&& typeof envelope.tag === 'string';
|
|
99
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Compact/full backup export: consistent snapshot → profile pruning → streamed gzip → v2 container.
|
|
2
|
+
// The SQLite payload is never read into memory: only per-chunk byte counters and the SHA-256
|
|
3
|
+
// digest state are kept while the snapshot is piped through gzip to disk.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import zlib from 'node:zlib';
|
|
8
|
+
import { Transform } from 'node:stream';
|
|
9
|
+
import { pipeline } from 'node:stream/promises';
|
|
10
|
+
import { encryptBackupMasterKey } from '../auth/backup-crypto.js';
|
|
11
|
+
import { parseMasterKey } from '../auth/crypto.js';
|
|
12
|
+
import { getSettings } from '../db/repositories/settings.js';
|
|
13
|
+
import { getAppVersion } from '../version.js';
|
|
14
|
+
import { writeBackupContainer } from './container.js';
|
|
15
|
+
/** Forensic/volatile tables removed by a compact export; retained by a full export. */
|
|
16
|
+
export const COMPACT_EXCLUDED_TABLES = [
|
|
17
|
+
'requests',
|
|
18
|
+
'request_attempts',
|
|
19
|
+
'audit_logs',
|
|
20
|
+
'response_cache',
|
|
21
|
+
'admin_sessions',
|
|
22
|
+
'csrf_tokens',
|
|
23
|
+
'backups',
|
|
24
|
+
];
|
|
25
|
+
export async function createBackupExport(input) {
|
|
26
|
+
const { profile, passphrase, cfg } = input;
|
|
27
|
+
const snapshotFile = path.join(cfg.dataDir, `.export-snapshot-${crypto.randomUUID()}.sqlite`);
|
|
28
|
+
const gzFile = path.join(cfg.dataDir, `.export-payload-${crypto.randomUUID()}.gz`);
|
|
29
|
+
let body;
|
|
30
|
+
try {
|
|
31
|
+
const Database = (await import('better-sqlite3')).default;
|
|
32
|
+
// 1. Consistent snapshot of the live DB while the gateway keeps serving.
|
|
33
|
+
const source = new Database(cfg.dbFile, { readonly: true });
|
|
34
|
+
try {
|
|
35
|
+
await source.backup(snapshotFile);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
source.close();
|
|
39
|
+
}
|
|
40
|
+
// 2. Compact prunes the snapshot in place, then reclaims its pages. Full keeps every row.
|
|
41
|
+
if (profile === 'compact') {
|
|
42
|
+
const staging = new Database(snapshotFile);
|
|
43
|
+
try {
|
|
44
|
+
const present = new Set(staging.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
|
|
45
|
+
for (const table of COMPACT_EXCLUDED_TABLES) {
|
|
46
|
+
if (present.has(table))
|
|
47
|
+
staging.prepare(`DELETE FROM "${table}"`).run();
|
|
48
|
+
}
|
|
49
|
+
staging.exec('VACUUM');
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
staging.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 3. Stream the snapshot through gzip to disk, hashing exactly the compressed bytes.
|
|
56
|
+
const checksum = crypto.createHash('sha256');
|
|
57
|
+
let uncompressedBytes = 0;
|
|
58
|
+
let compressedBytes = 0;
|
|
59
|
+
const meter = (onChunk) => new Transform({
|
|
60
|
+
transform(chunk, _encoding, callback) {
|
|
61
|
+
onChunk(chunk.length, chunk);
|
|
62
|
+
callback(null, chunk);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
await pipeline(fs.createReadStream(snapshotFile), meter((length) => { uncompressedBytes += length; }), zlib.createGzip({ level: 6 }), meter((length, chunk) => { compressedBytes += length; checksum.update(chunk); }), fs.createWriteStream(gzFile));
|
|
66
|
+
// 4. v2 manifest.
|
|
67
|
+
const masterKey = parseMasterKey(cfg.masterKey ?? fs.readFileSync(path.join(cfg.dataDir, 'master.key'), 'utf8').trim());
|
|
68
|
+
const manifest = {
|
|
69
|
+
format: 'latedev-backup',
|
|
70
|
+
version: 2,
|
|
71
|
+
profile,
|
|
72
|
+
appVersion: getAppVersion(),
|
|
73
|
+
schemaVersion: getSettings().schemaVersion,
|
|
74
|
+
createdAt: new Date().toISOString(),
|
|
75
|
+
compressedBytes,
|
|
76
|
+
uncompressedBytes,
|
|
77
|
+
checksum: checksum.digest('hex'),
|
|
78
|
+
keyEnvelope: encryptBackupMasterKey(masterKey, passphrase),
|
|
79
|
+
excludedTables: profile === 'compact' ? [...COMPACT_EXCLUDED_TABLES] : [],
|
|
80
|
+
statistics: { granularity: 'daily', dimensions: ['model', 'provider', 'api_key'], p95Available: false },
|
|
81
|
+
};
|
|
82
|
+
// 5. Container. The payload stream is lazy, so the gz file must outlive this
|
|
83
|
+
// function: it is deleted when that stream closes or errors, never before.
|
|
84
|
+
body = await writeBackupContainer({ manifest, payload: fs.createReadStream(gzFile) });
|
|
85
|
+
return { manifest, body };
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
89
|
+
fs.rmSync(`${snapshotFile}${suffix}`, { force: true });
|
|
90
|
+
if (body) {
|
|
91
|
+
const removePayload = () => fs.rmSync(gzFile, { force: true });
|
|
92
|
+
body.once('close', removePayload);
|
|
93
|
+
body.once('error', removePayload);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
fs.rmSync(gzFile, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Streamed, disk-based backup restore support.
|
|
2
|
+
//
|
|
3
|
+
// The upload is written straight to a private temp file (never buffered in memory), the v2 header
|
|
4
|
+
// is read from disk, and the payload is stream-decompressed into a second temp SQLite file that is
|
|
5
|
+
// validated (magic, integrity_check, manifest checksum, key envelope, schema version) BEFORE the
|
|
6
|
+
// caller swaps it in. Every failure path removes its temp files.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import zlib from 'node:zlib';
|
|
11
|
+
import { Readable, Transform } from 'node:stream';
|
|
12
|
+
import { pipeline } from 'node:stream/promises';
|
|
13
|
+
import { loadConfig } from '../config/index.js';
|
|
14
|
+
import { GatewayError } from '../errors.js';
|
|
15
|
+
import { decryptBackupMasterKey } from '../auth/backup-crypto.js';
|
|
16
|
+
import { parseMasterKey } from '../auth/crypto.js';
|
|
17
|
+
import { getSettings } from '../db/repositories/settings.js';
|
|
18
|
+
import { readBackupContainerHeader, streamBackupPayload, } from './container.js';
|
|
19
|
+
/** Tables a compact export must have left in place; a restore of one without them is not usable. */
|
|
20
|
+
const COMPACT_AGGREGATE_TABLES = ['daily_model_stats', 'daily_provider_stats', 'daily_api_key_stats'];
|
|
21
|
+
/** Ceiling on decompressed bytes, as a multiple of the configured upload limit. A SQLite snapshot
|
|
22
|
+
* compresses ~3-10x, so 20x is generous while still bounding a decompression bomb. */
|
|
23
|
+
const MAX_UNCOMPRESSED_MULTIPLE = 20;
|
|
24
|
+
const LEGACY_VERSION = 1;
|
|
25
|
+
function backupError(code, message, status = 400) {
|
|
26
|
+
return new GatewayError('invalid_request_error', message, { status, code });
|
|
27
|
+
}
|
|
28
|
+
const tooLarge = () => backupError('backup_too_large', 'Backup upload exceeds maximum size', 413);
|
|
29
|
+
const malformed = () => backupError('multipart_malformed', 'Malformed multipart restore body');
|
|
30
|
+
const invalidFormat = () => backupError('invalid_backup_format', 'Invalid backup file format');
|
|
31
|
+
const checksumMismatch = () => backupError('checksum_mismatch', 'Backup checksum mismatch');
|
|
32
|
+
const decompressFailed = () => backupError('decompress_failed', 'Backup decompression failed');
|
|
33
|
+
const notSqlite = () => backupError('not_sqlite', 'Backup does not contain a valid SQLite database');
|
|
34
|
+
const integrityFailed = () => backupError('integrity_failed', 'Backup database failed the integrity check');
|
|
35
|
+
const invalidPassphrase = () => backupError('invalid_backup_passphrase', 'Invalid backup passphrase');
|
|
36
|
+
async function restoreTmpDir(cfg) {
|
|
37
|
+
const dir = path.join(cfg.dataDir, 'restore-tmp');
|
|
38
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
function removeQuietly(...files) {
|
|
42
|
+
for (const file of files)
|
|
43
|
+
fs.rmSync(file, { force: true });
|
|
44
|
+
}
|
|
45
|
+
async function drain(stream) {
|
|
46
|
+
for await (const chunk of stream)
|
|
47
|
+
void chunk;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Collect the single `backup` file part into `<dataDir>/restore-tmp/<uuid>.part` (dir 0700, file
|
|
51
|
+
* 0600), counting bytes as they arrive so the configured maximum is enforced mid-stream rather
|
|
52
|
+
* than trusted from Content-Length alone.
|
|
53
|
+
*/
|
|
54
|
+
export async function receiveBackupUpload(request, options) {
|
|
55
|
+
const maxBytes = options.maxBytes;
|
|
56
|
+
const declared = Number(request.headers['content-length']);
|
|
57
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
58
|
+
throw tooLarge();
|
|
59
|
+
const tmpDir = await restoreTmpDir(options.cfg);
|
|
60
|
+
let target;
|
|
61
|
+
let out;
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
let fileDone = false;
|
|
64
|
+
let settled = false;
|
|
65
|
+
let passphrase;
|
|
66
|
+
let filename;
|
|
67
|
+
// Abort/cancel cleanup: the request may die while the body is still streaming. Node also emits
|
|
68
|
+
// 'close' when a request completes normally, so only an incomplete message counts as a cancel.
|
|
69
|
+
const cleanupFile = () => {
|
|
70
|
+
try {
|
|
71
|
+
out?.destroy();
|
|
72
|
+
}
|
|
73
|
+
catch { /* already gone */ }
|
|
74
|
+
if (target)
|
|
75
|
+
removeQuietly(target);
|
|
76
|
+
};
|
|
77
|
+
const onAbort = () => {
|
|
78
|
+
if (settled || fileDone || request.raw.complete)
|
|
79
|
+
return;
|
|
80
|
+
settled = true;
|
|
81
|
+
cleanupFile();
|
|
82
|
+
};
|
|
83
|
+
request.raw.once('aborted', onAbort);
|
|
84
|
+
request.raw.once('close', onAbort);
|
|
85
|
+
try {
|
|
86
|
+
let seenFiles = 0;
|
|
87
|
+
// The global multipart fileSize cap (2 MB, sized for JSON imports) is too small for a backup,
|
|
88
|
+
// so the limit is raised per request here; the byte counter below is the real enforcement.
|
|
89
|
+
for await (const part of request.parts({ limits: { fileSize: maxBytes + 1 } })) {
|
|
90
|
+
if (part.type === 'field') {
|
|
91
|
+
if (part.fieldname === 'passphrase')
|
|
92
|
+
passphrase = String(part.value);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (part.fieldname !== 'backup') {
|
|
96
|
+
await drain(part.file);
|
|
97
|
+
throw malformed();
|
|
98
|
+
}
|
|
99
|
+
seenFiles += 1;
|
|
100
|
+
if (seenFiles > 1) {
|
|
101
|
+
await drain(part.file);
|
|
102
|
+
throw malformed();
|
|
103
|
+
}
|
|
104
|
+
const name = part.filename ?? '';
|
|
105
|
+
if (!name) {
|
|
106
|
+
await drain(part.file);
|
|
107
|
+
throw malformed();
|
|
108
|
+
}
|
|
109
|
+
if (!name.endsWith('.ldb') && !name.endsWith('.ldb.json')) {
|
|
110
|
+
await drain(part.file);
|
|
111
|
+
throw malformed();
|
|
112
|
+
}
|
|
113
|
+
filename = name;
|
|
114
|
+
target = path.join(tmpDir, `${crypto.randomUUID()}.part`);
|
|
115
|
+
out = fs.createWriteStream(target, { mode: 0o600, flags: 'wx' });
|
|
116
|
+
const counter = new Transform({
|
|
117
|
+
transform(chunk, _encoding, callback) {
|
|
118
|
+
bytes += chunk.length;
|
|
119
|
+
if (bytes > maxBytes) {
|
|
120
|
+
callback(tooLarge());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
callback(null, chunk);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await pipeline(part.file, counter, out);
|
|
127
|
+
fileDone = true;
|
|
128
|
+
}
|
|
129
|
+
if (!target || !filename)
|
|
130
|
+
throw malformed();
|
|
131
|
+
settled = true;
|
|
132
|
+
request.raw.off('aborted', onAbort);
|
|
133
|
+
request.raw.off('close', onAbort);
|
|
134
|
+
const filePath = target;
|
|
135
|
+
return {
|
|
136
|
+
path: filePath,
|
|
137
|
+
bytes,
|
|
138
|
+
filename,
|
|
139
|
+
...(passphrase === undefined ? {} : { passphrase }),
|
|
140
|
+
cleanup: async () => { removeQuietly(filePath); },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
settled = true;
|
|
145
|
+
cleanupFile();
|
|
146
|
+
if (error instanceof GatewayError)
|
|
147
|
+
throw error;
|
|
148
|
+
throw malformed();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Write a legacy v1 JSON envelope to a private temp file so both restore paths share one reader. */
|
|
152
|
+
export async function writeLegacyEnvelopeUpload(envelope, cfg, passphrase) {
|
|
153
|
+
const tmpDir = await restoreTmpDir(cfg);
|
|
154
|
+
const target = path.join(tmpDir, `${crypto.randomUUID()}.ldb.json`);
|
|
155
|
+
const body = Buffer.from(JSON.stringify(envelope ?? {}), 'utf8');
|
|
156
|
+
if (body.length > cfg.maxBackupUploadBytes)
|
|
157
|
+
throw tooLarge();
|
|
158
|
+
await fs.promises.writeFile(target, body, { mode: 0o600 });
|
|
159
|
+
return {
|
|
160
|
+
path: target,
|
|
161
|
+
bytes: body.length,
|
|
162
|
+
filename: 'restore.ldb.json',
|
|
163
|
+
...(passphrase === undefined ? {} : { passphrase }),
|
|
164
|
+
cleanup: async () => { removeQuietly(target); },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Validate an uploaded backup from disk and return a SQLite file ready for the atomic swap.
|
|
169
|
+
* Reads only the v2 header into memory; the payload is streamed through gunzip into a second
|
|
170
|
+
* private temp file. Nothing is swapped until checksum, decompression, SQLite integrity, schema
|
|
171
|
+
* version, and the passphrase-protected key envelope have all passed.
|
|
172
|
+
*/
|
|
173
|
+
export async function validateUploadedBackup(upload, passphrase) {
|
|
174
|
+
const cfg = loadConfig();
|
|
175
|
+
const isLegacy = upload.filename.endsWith('.ldb.json');
|
|
176
|
+
const sqlitePath = path.join(path.dirname(upload.path), `${crypto.randomUUID()}.sqlite`);
|
|
177
|
+
const cleanup = async () => {
|
|
178
|
+
removeQuietly(sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`, upload.path);
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
const result = isLegacy
|
|
182
|
+
? await validateLegacyJson(upload.path, sqlitePath, cfg, passphrase)
|
|
183
|
+
: await validateV2(upload.path, sqlitePath, cfg, passphrase);
|
|
184
|
+
return { ...result, cleanup };
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
await cleanup();
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function validateV2(file, sqlitePath, cfg, passphrase) {
|
|
192
|
+
let manifest;
|
|
193
|
+
let payloadOffset;
|
|
194
|
+
try {
|
|
195
|
+
({ manifest, payloadOffset } = await readBackupContainerHeader(file));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw invalidFormat();
|
|
199
|
+
}
|
|
200
|
+
if (manifest.schemaVersion > getSettings().schemaVersion) {
|
|
201
|
+
throw backupError('future_schema', 'Backup was created by a newer schema version than this gateway supports');
|
|
202
|
+
}
|
|
203
|
+
const checksum = crypto.createHash('sha256');
|
|
204
|
+
let compressedBytes = 0;
|
|
205
|
+
let uncompressedBytes = 0;
|
|
206
|
+
const ceiling = cfg.maxBackupUploadBytes * MAX_UNCOMPRESSED_MULTIPLE;
|
|
207
|
+
try {
|
|
208
|
+
await pipeline(streamBackupPayload(file, payloadOffset), new Transform({
|
|
209
|
+
transform(chunk, _encoding, callback) {
|
|
210
|
+
compressedBytes += chunk.length;
|
|
211
|
+
checksum.update(chunk);
|
|
212
|
+
callback(null, chunk);
|
|
213
|
+
},
|
|
214
|
+
}), zlib.createGunzip(), new Transform({
|
|
215
|
+
transform(chunk, _encoding, callback) {
|
|
216
|
+
uncompressedBytes += chunk.length;
|
|
217
|
+
if (uncompressedBytes > ceiling) {
|
|
218
|
+
callback(tooLarge());
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
callback(null, chunk);
|
|
222
|
+
},
|
|
223
|
+
}), fs.createWriteStream(sqlitePath, { mode: 0o600, flags: 'wx' }));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof GatewayError)
|
|
227
|
+
throw error;
|
|
228
|
+
throw decompressFailed();
|
|
229
|
+
}
|
|
230
|
+
if (compressedBytes !== manifest.compressedBytes || checksum.digest('hex') !== manifest.checksum) {
|
|
231
|
+
throw checksumMismatch();
|
|
232
|
+
}
|
|
233
|
+
await assertUsableSqlite(sqlitePath, manifest.profile);
|
|
234
|
+
return {
|
|
235
|
+
sqlitePath,
|
|
236
|
+
schemaVersion: manifest.schemaVersion,
|
|
237
|
+
profile: manifest.profile,
|
|
238
|
+
...(manifest.keyEnvelope ? { masterKey: decryptMasterKey(manifest.keyEnvelope, passphrase) } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function validateLegacyJson(file, sqlitePath, cfg, passphrase) {
|
|
242
|
+
// Bounded read: the file was already capped at the configured maximum by the receiver.
|
|
243
|
+
let envelope;
|
|
244
|
+
try {
|
|
245
|
+
envelope = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw invalidFormat();
|
|
249
|
+
}
|
|
250
|
+
if (!envelope || envelope.format !== 'latedev-backup')
|
|
251
|
+
throw invalidFormat();
|
|
252
|
+
if (typeof envelope.version === 'number' && envelope.version > LEGACY_VERSION) {
|
|
253
|
+
throw backupError('unsupported_version', 'Backup version is not supported');
|
|
254
|
+
}
|
|
255
|
+
const compressed = Buffer.from(String(envelope.payload ?? ''), 'base64');
|
|
256
|
+
if (compressed.length > cfg.maxBackupUploadBytes)
|
|
257
|
+
throw tooLarge();
|
|
258
|
+
if (crypto.createHash('sha256').update(compressed).digest('hex') !== envelope.checksum)
|
|
259
|
+
throw checksumMismatch();
|
|
260
|
+
let uncompressedBytes = 0;
|
|
261
|
+
const ceiling = cfg.maxBackupUploadBytes * MAX_UNCOMPRESSED_MULTIPLE;
|
|
262
|
+
try {
|
|
263
|
+
await pipeline(Readable.from(compressed), zlib.createGunzip(), new Transform({
|
|
264
|
+
transform(chunk, _encoding, callback) {
|
|
265
|
+
uncompressedBytes += chunk.length;
|
|
266
|
+
if (uncompressedBytes > ceiling) {
|
|
267
|
+
callback(tooLarge());
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
callback(null, chunk);
|
|
271
|
+
},
|
|
272
|
+
}), fs.createWriteStream(sqlitePath, { mode: 0o600, flags: 'wx' }));
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error instanceof GatewayError)
|
|
276
|
+
throw error;
|
|
277
|
+
throw decompressFailed();
|
|
278
|
+
}
|
|
279
|
+
await assertUsableSqlite(sqlitePath, 'full');
|
|
280
|
+
return {
|
|
281
|
+
sqlitePath,
|
|
282
|
+
schemaVersion: typeof envelope.schemaVersion === 'number' ? envelope.schemaVersion : getSettings().schemaVersion,
|
|
283
|
+
profile: 'full',
|
|
284
|
+
...(envelope.keyEnvelope ? { masterKey: decryptMasterKey(envelope.keyEnvelope, passphrase) } : {}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function decryptMasterKey(envelope, passphrase) {
|
|
288
|
+
if (!/^\d{6}$/.test(passphrase ?? ''))
|
|
289
|
+
throw invalidPassphrase();
|
|
290
|
+
let key;
|
|
291
|
+
try {
|
|
292
|
+
key = decryptBackupMasterKey(envelope, passphrase);
|
|
293
|
+
parseMasterKey(key.toString('base64'));
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
throw invalidPassphrase();
|
|
297
|
+
}
|
|
298
|
+
return key;
|
|
299
|
+
}
|
|
300
|
+
/** Verify the SQLite header, run a read-only integrity check, and confirm the tables the profile
|
|
301
|
+
* promises are actually present. */
|
|
302
|
+
async function assertUsableSqlite(file, profile) {
|
|
303
|
+
const head = Buffer.alloc(16);
|
|
304
|
+
const fd = fs.openSync(file, 'r');
|
|
305
|
+
try {
|
|
306
|
+
if (fs.readSync(fd, head, 0, 16, 0) !== 16 || head.toString('utf8') !== 'SQLite format 3\u0000')
|
|
307
|
+
throw notSqlite();
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
fs.closeSync(fd);
|
|
311
|
+
}
|
|
312
|
+
const Database = (await import('better-sqlite3')).default;
|
|
313
|
+
let db;
|
|
314
|
+
try {
|
|
315
|
+
db = new Database(file, { readonly: true, fileMustExist: true });
|
|
316
|
+
const result = db.prepare('PRAGMA integrity_check').get();
|
|
317
|
+
if (result?.integrity_check !== 'ok')
|
|
318
|
+
throw integrityFailed();
|
|
319
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
|
|
320
|
+
if (!tables.has('app_settings'))
|
|
321
|
+
throw invalidFormat();
|
|
322
|
+
if (profile === 'compact' && !COMPACT_AGGREGATE_TABLES.every((table) => tables.has(table)))
|
|
323
|
+
throw invalidFormat();
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error instanceof GatewayError)
|
|
327
|
+
throw error;
|
|
328
|
+
throw notSqlite();
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
db?.close();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Call within the raw request-log transaction; empty IDs represent missing dimensions. */
|
|
2
|
+
export function upsertDailyAggregates(db, request, attempts) {
|
|
3
|
+
const model = db.prepare(`INSERT INTO daily_model_stats (day,model_id,requests,successful_requests,failed_requests,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,reasoning_tokens,gateway_cache_hits,fallback_requests,latency_success_sum_ms,latency_success_count,ttft_sum_ms,ttft_count)
|
|
4
|
+
VALUES (@day,@modelId,1,@success,1-@success,@inputTokens,@outputTokens,@cacheReadTokens,@cacheWriteTokens,@reasoningTokens,@cacheHit,@fallback,@latencySum,@latencyCount,@ttftSum,@ttftCount)
|
|
5
|
+
ON CONFLICT(day,model_id) DO UPDATE SET requests=requests+1,successful_requests=successful_requests+excluded.successful_requests,failed_requests=failed_requests+excluded.failed_requests,input_tokens=input_tokens+excluded.input_tokens,output_tokens=output_tokens+excluded.output_tokens,cache_read_tokens=cache_read_tokens+excluded.cache_read_tokens,cache_write_tokens=cache_write_tokens+excluded.cache_write_tokens,reasoning_tokens=reasoning_tokens+excluded.reasoning_tokens,gateway_cache_hits=gateway_cache_hits+excluded.gateway_cache_hits,fallback_requests=fallback_requests+excluded.fallback_requests,latency_success_sum_ms=latency_success_sum_ms+excluded.latency_success_sum_ms,latency_success_count=latency_success_count+excluded.latency_success_count,ttft_sum_ms=ttft_sum_ms+excluded.ttft_sum_ms,ttft_count=ttft_count+excluded.ttft_count`);
|
|
6
|
+
model.run({ day: request.day, modelId: request.modelId ?? '', success: Number(request.success), inputTokens: request.inputTokens, outputTokens: request.outputTokens, cacheReadTokens: request.cacheReadTokens, cacheWriteTokens: request.cacheWriteTokens, reasoningTokens: request.reasoningTokens, cacheHit: Number(request.cacheHit), fallback: Number(request.attemptsCount > 1), latencySum: request.success ? request.latencyMs : 0, latencyCount: Number(request.success), ttftSum: request.success ? request.ttftMs ?? 0 : 0, ttftCount: Number(request.success && request.ttftMs !== null) });
|
|
7
|
+
db.prepare(`INSERT INTO daily_api_key_stats (day,api_key_id,requests,successful_requests,failed_requests,input_tokens,output_tokens,total_tokens) VALUES (@day,@apiKeyId,1,@success,1-@success,@inputTokens,@outputTokens,@totalTokens)
|
|
8
|
+
ON CONFLICT(day,api_key_id) DO UPDATE SET requests=requests+1,successful_requests=successful_requests+excluded.successful_requests,failed_requests=failed_requests+excluded.failed_requests,input_tokens=input_tokens+excluded.input_tokens,output_tokens=output_tokens+excluded.output_tokens,total_tokens=total_tokens+excluded.total_tokens`)
|
|
9
|
+
.run({ day: request.day, apiKeyId: request.apiKeyId ?? '', success: Number(request.success), inputTokens: request.inputTokens, outputTokens: request.outputTokens, totalTokens: request.inputTokens + request.outputTokens });
|
|
10
|
+
const provider = db.prepare(`INSERT INTO daily_provider_stats (day,provider_id,attempts,successful_attempts,failed_attempts,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,reasoning_tokens,latency_success_sum_ms,latency_success_count)
|
|
11
|
+
VALUES (@day,@providerId,1,@success,1-@success,@inputTokens,@outputTokens,@cacheReadTokens,@cacheWriteTokens,@reasoningTokens,@latencySum,@latencyCount)
|
|
12
|
+
ON CONFLICT(day,provider_id) DO UPDATE SET attempts=attempts+1,successful_attempts=successful_attempts+excluded.successful_attempts,failed_attempts=failed_attempts+excluded.failed_attempts,input_tokens=input_tokens+excluded.input_tokens,output_tokens=output_tokens+excluded.output_tokens,cache_read_tokens=cache_read_tokens+excluded.cache_read_tokens,cache_write_tokens=cache_write_tokens+excluded.cache_write_tokens,reasoning_tokens=reasoning_tokens+excluded.reasoning_tokens,latency_success_sum_ms=latency_success_sum_ms+excluded.latency_success_sum_ms,latency_success_count=latency_success_count+excluded.latency_success_count`);
|
|
13
|
+
for (const attempt of attempts)
|
|
14
|
+
provider.run({ day: attempt.day, providerId: attempt.providerId, success: Number(attempt.success), inputTokens: attempt.inputTokens, outputTokens: attempt.outputTokens, cacheReadTokens: attempt.cacheReadTokens, cacheWriteTokens: attempt.cacheWriteTokens, reasoningTokens: attempt.reasoningTokens, latencySum: attempt.success ? attempt.latencyMs : 0, latencyCount: Number(attempt.success) });
|
|
15
|
+
}
|
|
@@ -14,6 +14,9 @@ const EnvSchema = z.object({
|
|
|
14
14
|
// Alias used by docs/13 debug tooling: LOG_LEVEL=debug enables lifecycle lines.
|
|
15
15
|
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).optional(),
|
|
16
16
|
LATEDEV_DB_URL: z.string().optional(),
|
|
17
|
+
// Maximum accepted database-restore upload. Default 256 MiB, matching the operator expectation
|
|
18
|
+
// that a full forensic backup of a heavily-logged instance still restores.
|
|
19
|
+
LATEDEV_MAX_BACKUP_UPLOAD_BYTES: z.coerce.number().int().positive().default(256 * 1024 * 1024),
|
|
17
20
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
|
18
21
|
});
|
|
19
22
|
let cached = null;
|
|
@@ -49,17 +52,27 @@ export function loadConfig(env = process.env, argv = process.argv) {
|
|
|
49
52
|
(parsed.NODE_ENV === 'test' ? path.resolve('../.tmp/test-data') : path.resolve(os.homedir(), '.latedev-router'));
|
|
50
53
|
const isContainer = Boolean(env.LATEDEV_DATA_DIR?.startsWith('/data') || process.env.CONTAINER === '1');
|
|
51
54
|
const dbFile = parsed.LATEDEV_DB_URL ?? path.join(dataDir, 'data.sqlite');
|
|
55
|
+
const restoredMasterKeyPath = path.join(dataDir, 'master.key.restore');
|
|
56
|
+
let restoredMasterKey = null;
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(restoredMasterKeyPath))
|
|
59
|
+
restoredMasterKey = fs.readFileSync(restoredMasterKeyPath, 'utf8').trim() || null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* ignore unreadable override — use the configured key */
|
|
63
|
+
}
|
|
52
64
|
cached = {
|
|
53
65
|
host: cliArgs.host ?? parsed.LATEDEV_HOST,
|
|
54
66
|
port: cliArgs.port ?? parsed.LATEDEV_PORT,
|
|
55
67
|
dataDir,
|
|
56
68
|
dbFile,
|
|
57
|
-
masterKey: parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
|
|
69
|
+
masterKey: restoredMasterKey ?? parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
|
|
58
70
|
trustProxyHops: parsed.LATEDEV_TRUST_PROXY,
|
|
59
71
|
logLevel: parsed.LOG_LEVEL ?? parsed.LATEDEV_LOG_LEVEL,
|
|
60
72
|
env: parsed.NODE_ENV,
|
|
61
73
|
isContainer,
|
|
62
74
|
appVersion: getAppVersion(),
|
|
75
|
+
maxBackupUploadBytes: parsed.LATEDEV_MAX_BACKUP_UPLOAD_BYTES,
|
|
63
76
|
};
|
|
64
77
|
return cached;
|
|
65
78
|
}
|