@goodandready/dsh-key-rotation 0.8.74 → 0.8.76
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 +17 -0
- package/lib/atomic-io.js +47 -4
- package/lib/client.js +191 -1
- package/lib/concurrency.js +25 -5
- package/lib/index.js +2 -0
- package/lib/lifecycle.js +2 -0
- package/lib/ops-config-io.js +32 -5
- package/lib/ops-telemetry.js +3 -2
- package/lib/pool-builder.js +14 -0
- package/lib/pool.js +20 -5
- package/lib/request-budget.js +8 -1
- package/lib/rotate.js +628 -597
- package/lib/safe-fetch.js +188 -53
- package/package.json +2 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.76 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
### Security Hardening, DNS Pinning & Mutation Integrity (Block 4 Release)
|
|
6
|
+
- **SSRF DNS Rebinding Prevention (Issue #557)**: Replaced single-shot DNS precheck with connect-time socket IP validation (`createSafeLookup`) via native HTTPS request handling (`fetchNativeJson`). Enforces loopback and private IP rejection at the exact moment the socket establishes connection across all attempts and redirects, completely neutralizing TOCTOU DNS rebinding with zero runtime dependencies.
|
|
7
|
+
- **Write-Origin Guard & Desktop Spoofing Elimination (Issue #516)**: Added `hasNonLoopbackForwarding` to detect requests proxied through LAN reverse proxies (`x-forwarded-for`, `x-real-ip`). Refused mutating requests (POST/PUT/DELETE/PATCH) lacking Origin or attempting to spoof Desktop headers (`@deepseek-ai/dsh-desktop/` User-Agent, `dsh-auth-` cookies, or `dsh-app://app` Origin) over LAN; ensured `/auto-import` strictly validates bodies and fails closed.
|
|
8
|
+
- **Config Import Optimistic Concurrency & Revision Integrity (Issue #553)**: End-to-end optimistic revision tracking in config import; dry-run preview returns settings revision, WebUI client (`50-card-main.js`) stores preview revision and transmits `expectedRevision` upon apply; backend validates revision match and converts `SETTINGS_CONFLICT` during `settings.replace` into HTTP 409 Conflict with clear preview invalidation guidance instead of generic 500 errors.
|
|
9
|
+
|
|
10
|
+
## 0.8.75 (2026-10-30)
|
|
11
|
+
|
|
12
|
+
### Dependencies, Cleanup & Audit Finalization (Block 3 Release)
|
|
13
|
+
- **Zero Runtime Dependencies & Security Hardening (Issues #520, #535)**: Completely removed `undici` dependency and eliminated 5 high/moderate vulnerabilities (`pnpm audit --prod` reports 0 vulnerabilities); `safe-fetch.js` rewritten using standard `globalThis.fetch` and pure Node.js DNS/IP validation.
|
|
14
|
+
- **Atomic I/O Cleanup (Issue #521)**: Crash-safe file writes now safely clean up `.tmp` files on write/sync/rename errors, and `cleanupStaleTmpFiles` purges abandoned temporary files on startup.
|
|
15
|
+
- **Request Budget Inheritance & Signal Management (Issue #464)**: `maxAttempts` and `maxDurationMs` properly propagated from settings to `buildRuntime()` and `pool-builder`; `createRequestBudget` removes caller `AbortSignal` listeners upon `dispose()`; guaranteed cleanup in `rotate.js` `finally` block; traffic smoother now receives `budget.signal`.
|
|
16
|
+
- **Selection Trace Diagnosability (Issue #461)**: Candidate evaluation now logs detailed `SKIPPED` steps (`PAUSED`, `REVOKED`, `BROKEN`, `DRAINING`, `COOLDOWN`, `CAPABILITY_MISMATCH`, `QUOTA_EXHAUSTED`); added interactive Selection Trace UI inspector in settings card.
|
|
17
|
+
- **Managed Key Drain UI & Concurrency Robustness (Issue #463)**: Added interactive Managed Key Drain section in settings card; enhanced `ConcurrencyTracker` with `touch()` and prolonged 30m stale threshold for long-running streaming generation.
|
|
18
|
+
- **Offline Simulator Parity (Issue #462)**: Audit verification finalized following Block 2 quarantine cloning, Map lookup, and per-Context isolation fixes.
|
|
19
|
+
|
|
3
20
|
## 0.8.74 (2026-10-30)
|
|
4
21
|
|
|
5
22
|
### Rotation & Routing Reliability (Block 2 Release)
|
package/lib/atomic-io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264).
|
|
1
|
+
// lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264, #521).
|
|
2
2
|
// Never overwrite a non-empty destination with empty/corrupt parse results.
|
|
3
|
+
// Clean up orphaned temporary files on failure or during startup.
|
|
3
4
|
|
|
4
5
|
import { promises as fs } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -8,6 +9,7 @@ import crypto from 'node:crypto';
|
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Atomically write data to filePath (temp + rename in same directory).
|
|
12
|
+
* Cleans up temporary file if open, write, sync, or rename fails (#521).
|
|
11
13
|
* @param {string} filePath
|
|
12
14
|
* @param {string|Buffer} data
|
|
13
15
|
*/
|
|
@@ -15,14 +17,55 @@ export async function atomicWriteFile(filePath, data) {
|
|
|
15
17
|
const dir = path.dirname(filePath);
|
|
16
18
|
await fs.mkdir(dir, { recursive: true });
|
|
17
19
|
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
18
|
-
|
|
20
|
+
let fh = null;
|
|
19
21
|
try {
|
|
22
|
+
fh = await fs.open(tmp, 'w');
|
|
20
23
|
await fh.writeFile(data);
|
|
21
24
|
await fh.sync();
|
|
22
|
-
} finally {
|
|
23
25
|
await fh.close();
|
|
26
|
+
fh = null;
|
|
27
|
+
await fs.rename(tmp, filePath);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (fh) {
|
|
30
|
+
try { await fh.close(); } catch {}
|
|
31
|
+
}
|
|
32
|
+
try { await fs.unlink(tmp); } catch {}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Clean up stale temporary files left behind by crashed or interrupted writes (#521).
|
|
39
|
+
* @param {string} filePath
|
|
40
|
+
* @param {number} [maxAgeMs=3600000]
|
|
41
|
+
* @returns {Promise<number>} number of files unlinked
|
|
42
|
+
*/
|
|
43
|
+
export async function cleanupStaleTmpFiles(filePath, maxAgeMs = 3600000) {
|
|
44
|
+
if (!filePath || typeof filePath !== 'string') return 0;
|
|
45
|
+
try {
|
|
46
|
+
const dir = path.dirname(filePath);
|
|
47
|
+
const base = path.basename(filePath);
|
|
48
|
+
const prefix = `.${base}.`;
|
|
49
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
let cleaned = 0;
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isFile()) continue;
|
|
54
|
+
if (entry.name.startsWith(prefix) && entry.name.endsWith('.tmp')) {
|
|
55
|
+
const fullPath = path.join(dir, entry.name);
|
|
56
|
+
try {
|
|
57
|
+
const st = await fs.stat(fullPath);
|
|
58
|
+
if (now - st.mtimeMs > maxAgeMs) {
|
|
59
|
+
await fs.unlink(fullPath);
|
|
60
|
+
cleaned++;
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return cleaned;
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
24
68
|
}
|
|
25
|
-
await fs.rename(tmp, filePath);
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
/**
|
package/lib/client.js
CHANGED
|
@@ -61,6 +61,24 @@ window.__ModuleLoader__.load({
|
|
|
61
61
|
sectionStatsDesc: 'Real-time telemetry of active key pools, healthy credentials, and failover status.',
|
|
62
62
|
sectionFailover: 'Failover Triggers & Error Codes',
|
|
63
63
|
sectionFailoverDesc: 'Select error conditions that trigger automatic switching to the next available key.',
|
|
64
|
+
maxAttemptsLabel: 'Max Attempts (Budget)',
|
|
65
|
+
maxDurationLabel: 'Max Duration ms (Budget)',
|
|
66
|
+
traceTitle: 'Key Selection Traces',
|
|
67
|
+
traceDesc: 'Candidate evaluation history, skips and selection decisions for requests.',
|
|
68
|
+
traceRefresh: 'Refresh Traces',
|
|
69
|
+
traceClear: 'Clear History',
|
|
70
|
+
traceEmpty: 'No selection traces recorded yet.',
|
|
71
|
+
traceSelected: 'Selected Key',
|
|
72
|
+
traceOutcome: 'Outcome',
|
|
73
|
+
drainTitle: 'Managed Key Drain',
|
|
74
|
+
drainDesc: 'Safely transition a key out of rotation waiting for active requests to finish.',
|
|
75
|
+
drainKeyPlaceholder: 'Key ref (e.g. DEEPSEEK_API_KEY_2)',
|
|
76
|
+
drainStart: 'Start Drain',
|
|
77
|
+
drainCancel: 'Cancel Drain',
|
|
78
|
+
drainActiveStreams: 'Active requests',
|
|
79
|
+
drainCanComplete: 'Ready to remove',
|
|
80
|
+
drainDraining: 'Draining Keys',
|
|
81
|
+
drainEmpty: 'No keys currently draining.',
|
|
64
82
|
sectionTiming: 'Timing & Rotation Schedule',
|
|
65
83
|
sectionTimingDesc: 'Configure cooldown durations and scheduled periodic rotation windows.',
|
|
66
84
|
sectionBackup: 'Backup & Snapshots',
|
|
@@ -317,6 +335,24 @@ window.__ModuleLoader__.load({
|
|
|
317
335
|
sectionStatsDesc: '活跃密钥池、健康凭证和故障转移状态的实时遥测。',
|
|
318
336
|
sectionFailover: '故障转移触发器与错误代码',
|
|
319
337
|
sectionFailoverDesc: '选择触发自动切换至下一个可用密钥的错误条件。',
|
|
338
|
+
maxAttemptsLabel: '最大重试次数 (预算)',
|
|
339
|
+
maxDurationLabel: '最大超时毫秒 (预算)',
|
|
340
|
+
traceTitle: '密钥选择追踪 (Trace)',
|
|
341
|
+
traceDesc: '查看请求的候选密钥评估、跳过原因和最终选择决策。',
|
|
342
|
+
traceRefresh: '刷新追踪',
|
|
343
|
+
traceClear: '清空历史',
|
|
344
|
+
traceEmpty: '暂无选择追踪记录。',
|
|
345
|
+
traceSelected: '所选密钥',
|
|
346
|
+
traceOutcome: '结果',
|
|
347
|
+
drainTitle: '安全下线密钥 (Drain)',
|
|
348
|
+
drainDesc: '平滑下线密钥并等待当前在途请求完成,避免中途断流。',
|
|
349
|
+
drainKeyPlaceholder: '密钥标识 (例如 DEEPSEEK_API_KEY_2)',
|
|
350
|
+
drainStart: '开始下线',
|
|
351
|
+
drainCancel: '取消下线',
|
|
352
|
+
drainActiveStreams: '在途请求数',
|
|
353
|
+
drainCanComplete: '可安全移除',
|
|
354
|
+
drainDraining: '下线中的密钥',
|
|
355
|
+
drainEmpty: '当前没有正在下线的密钥。',
|
|
320
356
|
sectionTiming: '时间与轮换计划',
|
|
321
357
|
sectionTimingDesc: '配置冷却持续时间和定期轮换计划窗口。',
|
|
322
358
|
sectionBackup: '备份与快照',
|
|
@@ -1257,6 +1293,16 @@ window.__ModuleLoader__.load({
|
|
|
1257
1293
|
// Auto-Discovery State (#491)
|
|
1258
1294
|
const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
|
|
1259
1295
|
const [autoImporting, setAutoImporting] = React.useState(false);
|
|
1296
|
+
|
|
1297
|
+
// Selection Trace State (#461)
|
|
1298
|
+
const [selectionTraces, setSelectionTraces] = React.useState([]);
|
|
1299
|
+
const [loadingTraces, setLoadingTraces] = React.useState(false);
|
|
1300
|
+
|
|
1301
|
+
// Managed Key Drain State (#463)
|
|
1302
|
+
const [drainingKeys, setDrainingKeys] = React.useState([]);
|
|
1303
|
+
const [drainRefInput, setDrainRefInput] = React.useState('');
|
|
1304
|
+
const [loadingDrain, setLoadingDrain] = React.useState(false);
|
|
1305
|
+
const [drainActionError, setDrainActionError] = React.useState('');
|
|
1260
1306
|
const [autoImportMsg, setAutoImportMsg] = React.useState('');
|
|
1261
1307
|
|
|
1262
1308
|
// Test Matrix State (#490)
|
|
@@ -1270,6 +1316,7 @@ window.__ModuleLoader__.load({
|
|
|
1270
1316
|
const [importPassphrase, setImportPassphrase] = React.useState('');
|
|
1271
1317
|
const [importMode, setImportMode] = React.useState('merge');
|
|
1272
1318
|
const [importPreview, setImportPreview] = React.useState(null);
|
|
1319
|
+
const [importRevision, setImportRevision] = React.useState(null);
|
|
1273
1320
|
const [importError, setImportError] = React.useState('');
|
|
1274
1321
|
const [importRunning, setImportRunning] = React.useState(false);
|
|
1275
1322
|
|
|
@@ -1312,6 +1359,7 @@ window.__ModuleLoader__.load({
|
|
|
1312
1359
|
const runImportPreview = async () => {
|
|
1313
1360
|
setImportError('');
|
|
1314
1361
|
setImportPreview(null);
|
|
1362
|
+
setImportRevision(null);
|
|
1315
1363
|
setImportRunning(true);
|
|
1316
1364
|
try {
|
|
1317
1365
|
let parsed;
|
|
@@ -1335,6 +1383,7 @@ window.__ModuleLoader__.load({
|
|
|
1335
1383
|
const data = await res.json();
|
|
1336
1384
|
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
|
|
1337
1385
|
setImportPreview(data.preview);
|
|
1386
|
+
setImportRevision(data.revision ?? null);
|
|
1338
1387
|
} catch (err) {
|
|
1339
1388
|
setImportError(String(err?.message ?? err));
|
|
1340
1389
|
} finally {
|
|
@@ -1360,14 +1409,23 @@ window.__ModuleLoader__.load({
|
|
|
1360
1409
|
encrypted: parsed.encrypted,
|
|
1361
1410
|
passphrase: importPassphrase.trim() || undefined,
|
|
1362
1411
|
mode: importMode,
|
|
1412
|
+
expectedRevision: importRevision !== null ? importRevision : undefined,
|
|
1363
1413
|
dryRun: false,
|
|
1364
1414
|
}),
|
|
1365
1415
|
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1366
1416
|
});
|
|
1367
1417
|
const data = await res.json();
|
|
1368
|
-
if (!res.ok || !data.ok)
|
|
1418
|
+
if (!res.ok || !data.ok) {
|
|
1419
|
+
if (res.status === 409 || data?.error?.code === 'conflict') {
|
|
1420
|
+
setImportPreview(null);
|
|
1421
|
+
setImportRevision(null);
|
|
1422
|
+
throw new Error(data?.error?.message || 'Configuration was modified concurrently. Please preview again before applying.');
|
|
1423
|
+
}
|
|
1424
|
+
throw new Error(data?.error?.message || 'Import apply failed');
|
|
1425
|
+
}
|
|
1369
1426
|
setImportModal(false);
|
|
1370
1427
|
setImportPreview(null);
|
|
1428
|
+
setImportRevision(null);
|
|
1371
1429
|
setImportText('');
|
|
1372
1430
|
void load();
|
|
1373
1431
|
} catch (err) {
|
|
@@ -1472,6 +1530,70 @@ window.__ModuleLoader__.load({
|
|
|
1472
1530
|
}
|
|
1473
1531
|
};
|
|
1474
1532
|
|
|
1533
|
+
const loadTraces = async () => {
|
|
1534
|
+
setLoadingTraces(true);
|
|
1535
|
+
try {
|
|
1536
|
+
const res = await fetch('/dsh-key-rotation/trace?limit=20');
|
|
1537
|
+
const data = await res.json();
|
|
1538
|
+
if (data && data.ok) {
|
|
1539
|
+
setSelectionTraces(data.traces || []);
|
|
1540
|
+
}
|
|
1541
|
+
} catch (_) {}
|
|
1542
|
+
finally {
|
|
1543
|
+
setLoadingTraces(false);
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
const clearTraces = async () => {
|
|
1548
|
+
try {
|
|
1549
|
+
await fetch('/dsh-key-rotation/trace', { method: 'DELETE' });
|
|
1550
|
+
setSelectionTraces([]);
|
|
1551
|
+
} catch (_) {}
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
const loadDrainStatus = async () => {
|
|
1555
|
+
setLoadingDrain(true);
|
|
1556
|
+
try {
|
|
1557
|
+
const res = await fetch('/dsh-key-rotation/drain');
|
|
1558
|
+
const data = await res.json();
|
|
1559
|
+
if (data && data.ok) {
|
|
1560
|
+
setDrainingKeys(data.draining || []);
|
|
1561
|
+
}
|
|
1562
|
+
} catch (_) {}
|
|
1563
|
+
finally {
|
|
1564
|
+
setLoadingDrain(false);
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
const startKeyDrain = async (ref) => {
|
|
1569
|
+
if (!ref || !ref.trim()) return;
|
|
1570
|
+
setDrainActionError('');
|
|
1571
|
+
try {
|
|
1572
|
+
const res = await fetch('/dsh-key-rotation/drain', {
|
|
1573
|
+
method: 'POST',
|
|
1574
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1575
|
+
body: JSON.stringify({ ref: ref.trim() }),
|
|
1576
|
+
});
|
|
1577
|
+
const data = await res.json();
|
|
1578
|
+
if (data && data.ok) {
|
|
1579
|
+
setDrainRefInput('');
|
|
1580
|
+
await loadDrainStatus();
|
|
1581
|
+
} else {
|
|
1582
|
+
setDrainActionError(data?.error?.message || 'Drain request failed');
|
|
1583
|
+
}
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
setDrainActionError(String(e?.message || e));
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
const cancelKeyDrain = async (ref) => {
|
|
1590
|
+
if (!ref) return;
|
|
1591
|
+
try {
|
|
1592
|
+
await fetch(`/dsh-key-rotation/drain?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' });
|
|
1593
|
+
await loadDrainStatus();
|
|
1594
|
+
} catch (_) {}
|
|
1595
|
+
};
|
|
1596
|
+
|
|
1475
1597
|
const [optimisticReset, setOptimisticReset] = React.useState({});
|
|
1476
1598
|
const [selected, setSelected] = React.useState(new Set());
|
|
1477
1599
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
@@ -2928,6 +3050,10 @@ window.__ModuleLoader__.load({
|
|
|
2928
3050
|
field(t('rpmLimitLabel'), textInput(String(val.rpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, rpmLimit: Number(v) || 0 })))),
|
|
2929
3051
|
field(t('tpmLimitLabel'), textInput(String(val.tpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, tpmLimit: Number(v) || 0 }))))
|
|
2930
3052
|
),
|
|
3053
|
+
h('div', { className: 'krot-grid-2', style: { marginTop: '10px' } },
|
|
3054
|
+
field(t('maxAttemptsLabel'), textInput(String(val.maxAttempts ?? 0), (v) => setField((cur) => ({ ...cur, maxAttempts: Number(v) || 0 })))),
|
|
3055
|
+
field(t('maxDurationLabel'), textInput(String(val.maxDurationMs ?? 0), (v) => setField((cur) => ({ ...cur, maxDurationMs: Number(v) || 0 }))))
|
|
3056
|
+
),
|
|
2931
3057
|
h('div', { className: 'krot-grid-3', style: { marginTop: '10px' } },
|
|
2932
3058
|
field(t('routingStrategyLabel'), h('select', {
|
|
2933
3059
|
className: 'krot-in',
|
|
@@ -3017,6 +3143,68 @@ window.__ModuleLoader__.load({
|
|
|
3017
3143
|
) : null
|
|
3018
3144
|
);
|
|
3019
3145
|
|
|
3146
|
+
const drainSection = h('div', { className: 'krot-section-card' },
|
|
3147
|
+
h('div', { className: 'krot-section-title' },
|
|
3148
|
+
h('span', null, '⏳ ' + t('drainTitle')),
|
|
3149
|
+
h('span', { className: 'krot-badge ' + (drainingKeys.length > 0 ? 'krot-badge-warn' : 'krot-badge-ok') },
|
|
3150
|
+
drainingKeys.length > 0 ? (drainingKeys.length + ' ' + t('drainDraining')) : t('drainEmpty')
|
|
3151
|
+
)
|
|
3152
|
+
),
|
|
3153
|
+
h('div', { className: 'krot-section-desc' }, t('drainDesc')),
|
|
3154
|
+
h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '10px' } },
|
|
3155
|
+
h('input', {
|
|
3156
|
+
className: 'krot-in',
|
|
3157
|
+
placeholder: t('drainKeyPlaceholder'),
|
|
3158
|
+
value: drainRefInput,
|
|
3159
|
+
onChange: (e) => setDrainRefInput(e.target.value),
|
|
3160
|
+
style: { maxWidth: '240px' },
|
|
3161
|
+
}),
|
|
3162
|
+
btn(t('drainStart'), () => startKeyDrain(drainRefInput), { primary: true, disabled: !drainRefInput.trim() }),
|
|
3163
|
+
btn(t('traceRefresh'), loadDrainStatus, { disabled: loadingDrain })
|
|
3164
|
+
),
|
|
3165
|
+
drainActionError ? h('div', { className: 'krot-warn', style: { marginTop: '8px' } }, '⚠ ' + drainActionError) : null,
|
|
3166
|
+
drainingKeys.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
3167
|
+
drainingKeys.map((dk) => h('div', { key: dk.ref, className: 'krot-stat-box', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 12px' } },
|
|
3168
|
+
h('span', { style: { fontWeight: 600 } }, dk.ref),
|
|
3169
|
+
h('span', { className: 'krot-badge ' + (dk.canComplete ? 'krot-badge-ok' : 'krot-badge-warn') },
|
|
3170
|
+
dk.canComplete ? t('drainCanComplete') : (t('drainActiveStreams') + ': ' + dk.inFlight)
|
|
3171
|
+
),
|
|
3172
|
+
btn(t('drainCancel'), () => cancelKeyDrain(dk.ref), { danger: true })
|
|
3173
|
+
))
|
|
3174
|
+
) : null
|
|
3175
|
+
);
|
|
3176
|
+
|
|
3177
|
+
const traceSection = h('div', { className: 'krot-section-card' },
|
|
3178
|
+
h('div', { className: 'krot-section-title' },
|
|
3179
|
+
h('span', null, '🧭 ' + t('traceTitle')),
|
|
3180
|
+
h('span', { className: 'krot-badge krot-badge-ok' }, selectionTraces.length + ' traces')
|
|
3181
|
+
),
|
|
3182
|
+
h('div', { className: 'krot-section-desc' }, t('traceDesc')),
|
|
3183
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '10px' } },
|
|
3184
|
+
btn(loadingTraces ? '...' : t('traceRefresh'), loadTraces, { disabled: loadingTraces }),
|
|
3185
|
+
btn(t('traceClear'), clearTraces, { disabled: selectionTraces.length === 0 })
|
|
3186
|
+
),
|
|
3187
|
+
selectionTraces.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '280px', overflowY: 'auto' } },
|
|
3188
|
+
selectionTraces.slice().reverse().map((tr) => h('div', { key: tr.requestId, style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', padding: '8px', fontSize: '11px' } },
|
|
3189
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px' } },
|
|
3190
|
+
h('span', { style: { fontWeight: 600 } }, (tr.provider || 'default') + (tr.model ? ' / ' + tr.model : '')),
|
|
3191
|
+
h('span', { className: 'krot-badge ' + (tr.outcome === 'SUCCESS' ? 'krot-badge-ok' : 'krot-badge-bad') }, tr.outcome)
|
|
3192
|
+
),
|
|
3193
|
+
h('div', { style: { color: 'var(--dsw-alias-label-secondary)', marginBottom: '4px' } },
|
|
3194
|
+
tr.selectedRef ? (t('traceSelected') + ': ' + tr.selectedRef) : ''
|
|
3195
|
+
),
|
|
3196
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', paddingLeft: '8px', borderLeft: '2px solid var(--dsw-alias-border-l2)' } },
|
|
3197
|
+
(tr.steps || []).map((st, i) => h('div', { key: i, style: { display: 'flex', gap: '6px' } },
|
|
3198
|
+
h('span', { style: { color: st.decision === 'SELECTED' ? 'var(--dsw-alias-state-success-primary)' : st.decision === 'SKIPPED' ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-state-danger-primary)', fontWeight: 600 } }, st.decision),
|
|
3199
|
+
h('span', { style: { fontWeight: 600 } }, st.ref || '-'),
|
|
3200
|
+
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, st.reasonCode ? ('[' + st.reasonCode + ']') : ''),
|
|
3201
|
+
h('span', null, st.details || '')
|
|
3202
|
+
))
|
|
3203
|
+
)
|
|
3204
|
+
))
|
|
3205
|
+
) : h('div', { className: 'krot-hint', style: { marginTop: '8px' } }, t('traceEmpty'))
|
|
3206
|
+
);
|
|
3207
|
+
|
|
3020
3208
|
const backupSection = h('div', { className: 'krot-section-card' },
|
|
3021
3209
|
h('div', { className: 'krot-section-title' },
|
|
3022
3210
|
h('span', null, '💾 ' + t('sectionBackup')),
|
|
@@ -3305,6 +3493,8 @@ window.__ModuleLoader__.load({
|
|
|
3305
3493
|
poolsSection,
|
|
3306
3494
|
failoverSection,
|
|
3307
3495
|
timingSection,
|
|
3496
|
+
drainSection,
|
|
3497
|
+
traceSection,
|
|
3308
3498
|
backupSection,
|
|
3309
3499
|
simulatorSection,
|
|
3310
3500
|
),
|
package/lib/concurrency.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407).
|
|
1
|
+
// concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
|
|
2
2
|
|
|
3
3
|
const CONCURRENCY_DEFAULT_LIMIT = 0;
|
|
4
|
-
|
|
4
|
+
// Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
|
|
5
|
+
const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
|
|
5
6
|
|
|
6
7
|
export class ConcurrencyTracker {
|
|
7
8
|
constructor(opts) {
|
|
@@ -42,6 +43,14 @@ export class ConcurrencyTracker {
|
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
touch(ref, now) {
|
|
47
|
+
now = now || Date.now();
|
|
48
|
+
const e = this._inFlight.get(ref);
|
|
49
|
+
if (e && e.count > 0) {
|
|
50
|
+
e.lastAcquired = now;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
release(ref, now) {
|
|
46
55
|
now = now || Date.now();
|
|
47
56
|
const e = this._inFlight.get(ref);
|
|
@@ -55,13 +64,24 @@ export class ConcurrencyTracker {
|
|
|
55
64
|
if (!ref) return 0;
|
|
56
65
|
const e = this._inFlight.get(ref);
|
|
57
66
|
if (!e) return 0;
|
|
58
|
-
if (now - e.lastAcquired > this._staleMs)
|
|
67
|
+
if (now - e.lastAcquired > this._staleMs) {
|
|
68
|
+
e.count = 0;
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
59
71
|
return e.count;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
snapshot() {
|
|
74
|
+
snapshot(now) {
|
|
75
|
+
now = now || Date.now();
|
|
63
76
|
const out = {};
|
|
64
|
-
for (const [k, v] of this._inFlight)
|
|
77
|
+
for (const [k, v] of this._inFlight) {
|
|
78
|
+
let count = v.count;
|
|
79
|
+
if (now - v.lastAcquired > this._staleMs) {
|
|
80
|
+
count = 0;
|
|
81
|
+
v.count = 0;
|
|
82
|
+
}
|
|
83
|
+
out[k] = { count };
|
|
84
|
+
}
|
|
65
85
|
return out;
|
|
66
86
|
}
|
|
67
87
|
|
package/lib/index.js
CHANGED
|
@@ -373,6 +373,8 @@ export function apply(ctx, config = {}) {
|
|
|
373
373
|
showChatHeaderChip: cfg.showChatHeaderChip !== false,
|
|
374
374
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
375
375
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
376
|
+
maxAttempts: typeof cfg.maxAttempts === 'number' && cfg.maxAttempts > 0 ? Math.floor(cfg.maxAttempts) : 0,
|
|
377
|
+
maxDurationMs: typeof cfg.maxDurationMs === 'number' && cfg.maxDurationMs > 0 ? Math.floor(cfg.maxDurationMs) : 0,
|
|
376
378
|
index, pools,
|
|
377
379
|
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram, trafficSmoother,
|
|
378
380
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
package/lib/lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
3
|
import { decryptSecret } from './crypto-storage.js';
|
|
4
4
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
5
|
+
import { cleanupStaleTmpFiles } from './atomic-io.js';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { envValue } from './pool.js';
|
|
7
8
|
import { clearAllDraining } from './key-drain.js';
|
|
@@ -97,6 +98,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
|
|
|
97
98
|
dataDir: hostDirs[0],
|
|
98
99
|
});
|
|
99
100
|
if (cfg0.persistenceEnabled !== false && resolvedPath) {
|
|
101
|
+
cleanupStaleTmpFiles(resolvedPath).catch(() => {});
|
|
100
102
|
statePersistence = new StatePersistence({ filePath: resolvedPath });
|
|
101
103
|
statePersistence.load().then((snap) => {
|
|
102
104
|
if (!snap) return;
|
package/lib/ops-config-io.js
CHANGED
|
@@ -349,15 +349,24 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
349
349
|
}
|
|
350
350
|
const rawCurrent = plainConfig(currentConfig);
|
|
351
351
|
|
|
352
|
-
// Concurrency control:
|
|
353
|
-
if (
|
|
354
|
-
if (body.expectedRevision
|
|
352
|
+
// Concurrency control: validate expectedRevision format and match (#553)
|
|
353
|
+
if (body.expectedRevision !== undefined && body.expectedRevision !== null) {
|
|
354
|
+
if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
|
|
355
|
+
json(res, 400, {
|
|
356
|
+
error: {
|
|
357
|
+
code: 'bad-request',
|
|
358
|
+
message: 'expectedRevision must be a non-negative integer',
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (currentRevision !== null && body.expectedRevision !== currentRevision) {
|
|
355
364
|
json(res, 409, {
|
|
356
365
|
error: {
|
|
357
366
|
code: 'conflict',
|
|
358
367
|
message: `Configuration has been modified concurrently (expected revision ${body.expectedRevision}, current is ${currentRevision})`,
|
|
359
368
|
currentRevision,
|
|
360
|
-
}
|
|
369
|
+
},
|
|
361
370
|
});
|
|
362
371
|
return;
|
|
363
372
|
}
|
|
@@ -406,8 +415,26 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
try {
|
|
409
|
-
await settings.replace(NS, mergedConfig, currentRevision ?? undefined);
|
|
418
|
+
await settings.replace(NS, mergedConfig, body.expectedRevision ?? currentRevision ?? undefined);
|
|
410
419
|
} catch (err) {
|
|
420
|
+
const isConflict = err?.code === 'SETTINGS_CONFLICT' ||
|
|
421
|
+
err?.status === 409 ||
|
|
422
|
+
err?.code === 'CONFLICT' ||
|
|
423
|
+
/conflict|revision|stale/i.test(err?.message || '');
|
|
424
|
+
if (isConflict) {
|
|
425
|
+
let latestRevision = null;
|
|
426
|
+
try {
|
|
427
|
+
latestRevision = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS)?.revision ?? null;
|
|
428
|
+
} catch (_) {}
|
|
429
|
+
json(res, 409, {
|
|
430
|
+
error: {
|
|
431
|
+
code: 'conflict',
|
|
432
|
+
message: `Configuration has been modified concurrently: ${err.message}`,
|
|
433
|
+
currentRevision: latestRevision ?? currentRevision,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
411
438
|
json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
|
|
412
439
|
return;
|
|
413
440
|
}
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -174,14 +174,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
174
174
|
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: trace is local-only' } });
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
|
+
const traceStore = deps.selectionTraceStore || null;
|
|
177
178
|
if (req.method === 'DELETE') {
|
|
178
|
-
clearSelectionTraces();
|
|
179
|
+
clearSelectionTraces(traceStore);
|
|
179
180
|
json(res, 200, { ok: true, message: 'trace history cleared' });
|
|
180
181
|
return;
|
|
181
182
|
}
|
|
182
183
|
const url = new URL(req.url ?? TRACE_PATH, 'http://localhost');
|
|
183
184
|
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
|
184
|
-
json(res, 200, { ok: true, traces: getSelectionTraces(limit) });
|
|
185
|
+
json(res, 200, { ok: true, traces: getSelectionTraces(limit, traceStore) });
|
|
185
186
|
},
|
|
186
187
|
}), 'dsh-key-rotation: trace route');
|
|
187
188
|
|
package/lib/pool-builder.js
CHANGED
|
@@ -39,6 +39,8 @@ export function buildPoolItem({
|
|
|
39
39
|
useKeyRef = null,
|
|
40
40
|
trafficSmoothingBurst = null,
|
|
41
41
|
maxQueueWaitMs = null,
|
|
42
|
+
maxAttempts = null,
|
|
43
|
+
maxDurationMs = null,
|
|
42
44
|
}) {
|
|
43
45
|
const seen = new Set();
|
|
44
46
|
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
@@ -143,6 +145,12 @@ const weightsMap = {};
|
|
|
143
145
|
maxQueueWaitMs: typeof maxQueueWaitMs === 'number' && Number.isFinite(maxQueueWaitMs) && maxQueueWaitMs >= 0
|
|
144
146
|
? Math.floor(maxQueueWaitMs)
|
|
145
147
|
: 800,
|
|
148
|
+
maxAttempts: typeof maxAttempts === 'number' && Number.isFinite(maxAttempts) && maxAttempts > 0
|
|
149
|
+
? Math.floor(maxAttempts)
|
|
150
|
+
: 0,
|
|
151
|
+
maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
|
|
152
|
+
? Math.floor(maxDurationMs)
|
|
153
|
+
: 0,
|
|
146
154
|
};
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -178,6 +186,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
178
186
|
const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
|
|
179
187
|
const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
|
|
180
188
|
const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
|
|
189
|
+
const poolAttempts = typeof p.maxAttempts === 'number' ? p.maxAttempts : (cfg.maxAttempts ?? 0);
|
|
190
|
+
const poolDuration = typeof p.maxDurationMs === 'number' ? p.maxDurationMs : (cfg.maxDurationMs ?? 0);
|
|
181
191
|
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
182
192
|
provider: p.provider,
|
|
183
193
|
model: null,
|
|
@@ -186,6 +196,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
186
196
|
pinnedRef: p.pinnedRef ?? p.useKeyRef,
|
|
187
197
|
trafficSmoothingBurst: p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
188
198
|
maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
|
199
|
+
maxAttempts: poolAttempts,
|
|
200
|
+
maxDurationMs: poolDuration,
|
|
189
201
|
}, provRpm, provTpm, provConcurrency);
|
|
190
202
|
if (pool) {
|
|
191
203
|
for (const ref of pool.refs) {
|
|
@@ -216,6 +228,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
216
228
|
pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
|
|
217
229
|
trafficSmoothingBurst: mp.trafficSmoothingBurst ?? p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
218
230
|
maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
|
231
|
+
maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
|
|
232
|
+
maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
|
|
219
233
|
}, modelRpm, modelTpm, modelConcurrency);
|
|
220
234
|
if (!mpool) continue;
|
|
221
235
|
mpool.basePool = pool;
|
package/lib/pool.js
CHANGED
|
@@ -39,8 +39,23 @@ export function isLoopbackAddress(ip) {
|
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Check if
|
|
42
|
+
/** Check if request contains non-loopback forwarding headers (e.g. proxied from LAN/Tailscale) (#516). */
|
|
43
|
+
export function hasNonLoopbackForwarding(req) {
|
|
44
|
+
const xff = req?.headers?.['x-forwarded-for'];
|
|
45
|
+
if (xff && typeof xff === 'string') {
|
|
46
|
+
const ips = xff.split(',').map((s) => s.trim());
|
|
47
|
+
if (ips.some((ip) => ip && !isLoopbackAddress(ip))) return true;
|
|
48
|
+
}
|
|
49
|
+
const xRealIp = req?.headers?.['x-real-ip'];
|
|
50
|
+
if (xRealIp && typeof xRealIp === 'string' && !isLoopbackAddress(xRealIp.trim())) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check if an incoming HTTP request is from an authentic local Desktop shell (#516). */
|
|
43
57
|
export function isDesktopShellRequest(req) {
|
|
58
|
+
if (hasNonLoopbackForwarding(req)) return false;
|
|
44
59
|
const agent = req?.headers?.['user-agent'] || '';
|
|
45
60
|
const cookie = req?.headers?.cookie || '';
|
|
46
61
|
return typeof agent === 'string' && agent.includes('@deepseek-ai/dsh-desktop/') &&
|
|
@@ -69,9 +84,9 @@ export function isTrustedBridgeRequest(req) {
|
|
|
69
84
|
if (!origin) return false; // Explicit empty string or null-like falsy
|
|
70
85
|
try {
|
|
71
86
|
const originUrl = new URL(origin);
|
|
72
|
-
// Official Desktop build shell origin (#22, #465, #516)
|
|
87
|
+
// Official Desktop build shell origin (#22, #465, #516) - requires authentic local Desktop shell (rejects LAN forwarding)
|
|
73
88
|
if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
|
|
74
|
-
return
|
|
89
|
+
return !hasNonLoopbackForwarding(req);
|
|
75
90
|
}
|
|
76
91
|
if (originUrl.host !== hostHeader) return false;
|
|
77
92
|
const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
|
|
@@ -89,7 +104,7 @@ export function isTrustedBridgeRequest(req) {
|
|
|
89
104
|
return true;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
// Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465)
|
|
107
|
+
// Mutating requests without Origin: allow authentic local Desktop shell requests (#516, #465)
|
|
93
108
|
if (isDesktopShellRequest(req)) {
|
|
94
109
|
return true;
|
|
95
110
|
}
|
|
@@ -98,7 +113,7 @@ export function isTrustedBridgeRequest(req) {
|
|
|
98
113
|
try {
|
|
99
114
|
const refUrl = new URL(referer);
|
|
100
115
|
if (refUrl.protocol === 'dsh-app:' && (refUrl.host === 'app' || refUrl.hostname === 'app' || refUrl.host === 'localhost')) {
|
|
101
|
-
return
|
|
116
|
+
return !hasNonLoopbackForwarding(req);
|
|
102
117
|
}
|
|
103
118
|
if (refUrl.host !== hostHeader) return false;
|
|
104
119
|
const hostname = refUrl.hostname.replace(/^\[|\]$/g, '');
|
package/lib/request-budget.js
CHANGED
|
@@ -22,13 +22,14 @@ export function createRequestBudget(options = {}) {
|
|
|
22
22
|
|
|
23
23
|
const controller = new AbortController();
|
|
24
24
|
let timerId = null;
|
|
25
|
+
let onCallerAbort = null;
|
|
25
26
|
|
|
26
27
|
// Chain caller signal if provided
|
|
27
28
|
if (options.signal) {
|
|
28
29
|
if (options.signal.aborted) {
|
|
29
30
|
controller.abort(options.signal.reason);
|
|
30
31
|
} else {
|
|
31
|
-
|
|
32
|
+
onCallerAbort = () => {
|
|
32
33
|
controller.abort(options.signal.reason);
|
|
33
34
|
cleanup();
|
|
34
35
|
};
|
|
@@ -52,6 +53,12 @@ export function createRequestBudget(options = {}) {
|
|
|
52
53
|
clearTimeout(timerId);
|
|
53
54
|
timerId = null;
|
|
54
55
|
}
|
|
56
|
+
if (options.signal && onCallerAbort) {
|
|
57
|
+
try {
|
|
58
|
+
options.signal.removeEventListener('abort', onCallerAbort);
|
|
59
|
+
} catch {}
|
|
60
|
+
onCallerAbort = null;
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
return {
|