@goodandready/dsh-key-rotation 0.8.66 → 0.8.67
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/README.md +4 -0
- package/README.ru.md +4 -0
- package/README.zh.md +4 -0
- package/lib/client.js +298 -19
- package/lib/ops-config-io.js +325 -0
- package/lib/ops-paths.js +3 -0
- package/lib/ops-quick-action.js +167 -0
- package/lib/ops-status.js +2 -0
- package/lib/routes-ops.js +7 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.67 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Full Pool Configuration Export & Import (#504)**:
|
|
7
|
+
- Masked export: exports topology and pools with masked credentials (`sk-...cdef`) and hidden webhook secrets, safe for bug reports and sharing.
|
|
8
|
+
- Full backup export with AES-256-GCM encryption: optional passphrase encrypts configuration with PBKDF2 (100,000 iterations) using standard `node:crypto`.
|
|
9
|
+
- Config import route `POST /dsh-key-rotation/config-import`: supports file upload, raw JSON, or encrypted payload with passphrase decryption.
|
|
10
|
+
- Diff preview mode: `dryRun: true` returns summary of created/updated/removed providers and keys count before applying.
|
|
11
|
+
- Interactive UI in Settings card: modal drawers for Export (masked vs full, optional passphrase) and Import (file upload, merge vs replace modes, diff preview, apply).
|
|
12
|
+
- **Quick Key Actions in Chat Header Popover (#499)**:
|
|
13
|
+
- Extended `KeyRotationHeaderChip` popover: displays active provider and key tail (`...abcd`) for each configured pool.
|
|
14
|
+
- Interactive inline action buttons:
|
|
15
|
+
- ⏭️ **Skip**: advances active key cursor to the next healthy candidate in the pool without leaving chat.
|
|
16
|
+
- 🔄 **Reset**: immediately resets failure counts, cooldowns, and circuit breaker for the active provider.
|
|
17
|
+
- ⏸️ **Pause**: toggles pause state on the currently active key for immediate fault isolation.
|
|
18
|
+
- Endpoint `POST /dsh-key-rotation/quick-action`: secure loopback-validated operational route executing skip, reset, and pause commands with audit telemetry events.
|
|
19
|
+
|
|
3
20
|
## 0.8.66 - 2026-10-19
|
|
4
21
|
|
|
5
22
|
### Features
|
package/README.md
CHANGED
|
@@ -67,6 +67,10 @@
|
|
|
67
67
|
- **📡 Status extras**: per-provider `circuit` plus `meta.expectedClones` / `meta.notifyQueue`.
|
|
68
68
|
- **🧪 Smoke harness**: scripted 429 → next-key → success path in `test/smoke-rotation-080.test.mjs`.
|
|
69
69
|
|
|
70
|
+
### 🚀 What's New in v0.8.67
|
|
71
|
+
- **📥 Full Pool Config Export & Import (#504)**: Masked export for safe sharing, full backup export with AES-256-GCM encryption (PBKDF2), diff preview mode, and import with merge/replace strategies.
|
|
72
|
+
- **⚡ Quick Actions in Chat Header Popover (#499)**: Instant key management directly from the conversation toolbar — skip to next key, reset cooldowns, or pause problematic keys via `/dsh-key-rotation/quick-action`.
|
|
73
|
+
|
|
70
74
|
### 🚀 What's New in v0.8.66
|
|
71
75
|
- **⏳ Dynamic Reset Scheduler (#498)**: Reads upstream `retry-after` and `x-ratelimit-reset-*` headers to set exact cooldowns (+250ms safety jitter) and schedules immediate unref wake-up timers when the upstream reset window rolls over.
|
|
72
76
|
- **💰 Upstream Balance Inspector & Credit Tracking (#495)**: Live balance checks for DeepSeek, SiliconFlow, OpenRouter, and OpenAI via `/dsh-key-rotation/balance`, pre-emptive empty balance protection (`EMPTY_BALANCE_PAUSED`), persistent balance cache, and WebUI `💰 Balance` pills.
|
package/README.ru.md
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
+
### 🚀 Что нового в v0.8.67
|
|
40
|
+
- **📥 Экспорт и импорт полной конфигурации пулов (#504)**: Безопасный маскированный экспорт для баг-репортов, полный бэкап с AES-256-GCM шифрованием (PBKDF2), интерактивный diff-предпросмотр и режимы объединения/замены.
|
|
41
|
+
- **⚡ Быстрые действия в поповере шапки чата (#499)**: Управление ключами прямо из тулбара диалога — принудительный пропуск (Skip) на следующий ключ, мгновенный сброс кулдаунов (Reset) и изоляция сбойного ключа (Pause).
|
|
42
|
+
|
|
39
43
|
### 🚀 Что нового в v0.8.66
|
|
40
44
|
- **⏳ Динамический планировщик сброса кулдауна (#498)**: Точное вычисление времени восстановления ключей по заголовкам `retry-after` и `x-ratelimit-reset-*` (+250мс джиттер) с запуском точного unref-таймера восстановления сразу по истечении окна, не дожидаясь 30-секундного периодического свипа.
|
|
41
45
|
- **💰 Инспектор баланса и отслеживание кредитов (#495)**: Запрос реального баланса провайдеров (DeepSeek, SiliconFlow, OpenRouter, OpenAI) через маршрут `/dsh-key-rotation/balance`, автоматическая мягкая пауза при нулевом балансе (`EMPTY_BALANCE_PAUSED`), персистентное сохранение и плашки баланса в WebUI карточках ключей.
|
package/README.zh.md
CHANGED
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
|
|
39
39
|
## ⚡ 概述与核心痛点
|
|
40
40
|
|
|
41
|
+
### 🚀 v0.8.67 新特性
|
|
42
|
+
- **📥 密钥池完整配置导入与导出 (#504)**: 支持脱敏导出安全分享、AES-256-GCM 密码加密完整备份、导入差异预览 (Diff Preview) 以及合并/全量替换模式。
|
|
43
|
+
- **⚡ 聊天头部快捷键操作 (#499)**: 直接在对话工具栏气泡中管理密钥 — 快速跳过当前密钥 (Skip)、一键重置冷却与断路器 (Reset)、暂停故障密钥 (Pause)。
|
|
44
|
+
|
|
41
45
|
### 🚀 v0.8.66 新特性
|
|
42
46
|
- **⏳ 基于限流响应头的动态重置调度器 (#498)**: 解析上游 `retry-after` 与 `x-ratelimit-reset-*` 响应头,设定精确冷却时间(+250ms抖动保护),并通过独立 unref 定时器在窗口重置瞬间立刻唤醒可用密钥,无需等待30秒周期性轮询。
|
|
43
47
|
- **💰 上游余额与信用额度巡检 (#495)**: 支持 DeepSeek、SiliconFlow、OpenRouter、OpenAI 实时余额查询 (`/dsh-key-rotation/balance`),零余额前置软暂停 (`EMPTY_BALANCE_PAUSED`),持久化余额状态以及 WebUI 密钥卡片实时余额标签 (`💰 15.50 CNY`)。
|
package/lib/client.js
CHANGED
|
@@ -261,6 +261,28 @@ window.__ModuleLoader__.load({
|
|
|
261
261
|
autoImportBtn: '1-Click Auto-Import',
|
|
262
262
|
autoImportSuccess: 'Imported {n} keys into rotation pools',
|
|
263
263
|
autoImporting: 'Importing…',
|
|
264
|
+
quickSkip: 'Skip',
|
|
265
|
+
quickSkipTitle: 'Skip to next key in pool',
|
|
266
|
+
quickReset: 'Reset',
|
|
267
|
+
quickResetTitle: 'Reset cooldowns and circuit breaker for active provider',
|
|
268
|
+
quickPause: 'Pause',
|
|
269
|
+
quickPauseTitle: 'Pause or resume active key',
|
|
270
|
+
exportConfig: 'Export Pools',
|
|
271
|
+
importConfig: 'Import Pools',
|
|
272
|
+
exportSafe: 'Masked (Safe for sharing)',
|
|
273
|
+
exportFull: 'Full Backup (Includes secrets)',
|
|
274
|
+
exportWithPassphrase: 'Encrypted with passphrase',
|
|
275
|
+
importMode: 'Import Mode',
|
|
276
|
+
importMerge: 'Merge with existing pools',
|
|
277
|
+
importReplace: 'Replace entire pool configuration',
|
|
278
|
+
importPreview: 'Preview Diff',
|
|
279
|
+
importApply: 'Apply Configuration',
|
|
280
|
+
importSuccess: 'Configuration successfully imported and applied',
|
|
281
|
+
exportSuccess: 'Configuration exported successfully',
|
|
282
|
+
passphraseLabel: 'Passphrase (optional)',
|
|
283
|
+
passphrasePlaceholder: 'Enter master passphrase for encryption/decryption',
|
|
284
|
+
jsonConfigPlaceholder: 'Paste JSON configuration here or choose a file...',
|
|
285
|
+
previewSummary: 'Diff: {created} new providers, {updated} updated providers, {keys} keys ({added} new).',
|
|
264
286
|
};
|
|
265
287
|
|
|
266
288
|
const zh = {
|
|
@@ -472,6 +494,28 @@ window.__ModuleLoader__.load({
|
|
|
472
494
|
autoImportBtn: '一键自动导入',
|
|
473
495
|
autoImportSuccess: '成功导入 {n} 个密钥到轮换池',
|
|
474
496
|
autoImporting: '正在导入…',
|
|
497
|
+
quickSkip: '跳过',
|
|
498
|
+
quickSkipTitle: '跳过并轮换到池中下一个密钥',
|
|
499
|
+
quickReset: '重置',
|
|
500
|
+
quickResetTitle: '重置当前提供商的冷却时间与断路器',
|
|
501
|
+
quickPause: '暂停',
|
|
502
|
+
quickPauseTitle: '暂停或恢复当前正在使用的密钥',
|
|
503
|
+
exportConfig: '导出密钥池',
|
|
504
|
+
importConfig: '导入密钥池',
|
|
505
|
+
exportSafe: '脱敏导出 (安全分享)',
|
|
506
|
+
exportFull: '完整备份 (包含密钥)',
|
|
507
|
+
exportWithPassphrase: '使用密码加密导出',
|
|
508
|
+
importMode: '导入模式',
|
|
509
|
+
importMerge: '与现有密钥池合并',
|
|
510
|
+
importReplace: '覆盖现有全部密钥池配置',
|
|
511
|
+
importPreview: '预览差异',
|
|
512
|
+
importApply: '应用配置',
|
|
513
|
+
importSuccess: '配置已成功导入并生效',
|
|
514
|
+
exportSuccess: '配置导出成功',
|
|
515
|
+
passphraseLabel: '加密密码 (可选)',
|
|
516
|
+
passphrasePlaceholder: '输入用于加密/解密的主密码',
|
|
517
|
+
jsonConfigPlaceholder: '在此粘贴 JSON 配置或选择文件...',
|
|
518
|
+
previewSummary: '差异: 新增 {created} 个提供商, 更新 {updated} 个提供商, 包含 {keys} 个密钥 (新增 {added} 个)。',
|
|
475
519
|
};
|
|
476
520
|
|
|
477
521
|
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
@@ -1186,6 +1230,111 @@ window.__ModuleLoader__.load({
|
|
|
1186
1230
|
|
|
1187
1231
|
// Test Matrix State (#490)
|
|
1188
1232
|
const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
|
|
1233
|
+
const [exportModal, setExportModal] = React.useState(false);
|
|
1234
|
+
const [exportMasked, setExportMasked] = React.useState(true);
|
|
1235
|
+
const [exportPassphrase, setExportPassphrase] = React.useState('');
|
|
1236
|
+
const [exportError, setExportError] = React.useState('');
|
|
1237
|
+
const [importModal, setImportModal] = React.useState(false);
|
|
1238
|
+
const [importText, setImportText] = React.useState('');
|
|
1239
|
+
const [importPassphrase, setImportPassphrase] = React.useState('');
|
|
1240
|
+
const [importMode, setImportMode] = React.useState('merge');
|
|
1241
|
+
const [importPreview, setImportPreview] = React.useState(null);
|
|
1242
|
+
const [importError, setImportError] = React.useState('');
|
|
1243
|
+
const [importRunning, setImportRunning] = React.useState(false);
|
|
1244
|
+
|
|
1245
|
+
const runExport = async () => {
|
|
1246
|
+
setExportError('');
|
|
1247
|
+
try {
|
|
1248
|
+
const q = new URLSearchParams();
|
|
1249
|
+
q.set('masked', exportMasked ? 'true' : 'false');
|
|
1250
|
+
if (exportPassphrase.trim()) q.set('passphrase', exportPassphrase.trim());
|
|
1251
|
+
const res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
|
|
1252
|
+
headers: { accept: 'application/json' },
|
|
1253
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1254
|
+
});
|
|
1255
|
+
const data = await res.json();
|
|
1256
|
+
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
|
|
1257
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
1258
|
+
const url = URL.createObjectURL(blob);
|
|
1259
|
+
const a = document.createElement('a');
|
|
1260
|
+
a.href = url;
|
|
1261
|
+
a.download = `dsh-key-rotation-${exportMasked ? 'masked' : 'full'}-${Date.now()}.json`;
|
|
1262
|
+
a.click();
|
|
1263
|
+
URL.revokeObjectURL(url);
|
|
1264
|
+
setExportModal(false);
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
setExportError(String(err?.message ?? err));
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const runImportPreview = async () => {
|
|
1271
|
+
setImportError('');
|
|
1272
|
+
setImportPreview(null);
|
|
1273
|
+
setImportRunning(true);
|
|
1274
|
+
try {
|
|
1275
|
+
let parsed;
|
|
1276
|
+
try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
|
|
1277
|
+
const res = await fetch('/dsh-key-rotation/config-import', {
|
|
1278
|
+
method: 'POST',
|
|
1279
|
+
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
1280
|
+
body: JSON.stringify({
|
|
1281
|
+
config: parsed.config || (parsed.providers ? parsed : undefined),
|
|
1282
|
+
ciphertext: parsed.ciphertext,
|
|
1283
|
+
salt: parsed.salt,
|
|
1284
|
+
iv: parsed.iv,
|
|
1285
|
+
tag: parsed.tag,
|
|
1286
|
+
encrypted: parsed.encrypted,
|
|
1287
|
+
passphrase: importPassphrase.trim() || undefined,
|
|
1288
|
+
mode: importMode,
|
|
1289
|
+
dryRun: true,
|
|
1290
|
+
}),
|
|
1291
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1292
|
+
});
|
|
1293
|
+
const data = await res.json();
|
|
1294
|
+
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
|
|
1295
|
+
setImportPreview(data.preview);
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
setImportError(String(err?.message ?? err));
|
|
1298
|
+
} finally {
|
|
1299
|
+
setImportRunning(false);
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const runImportApply = async () => {
|
|
1304
|
+
setImportError('');
|
|
1305
|
+
setImportRunning(true);
|
|
1306
|
+
try {
|
|
1307
|
+
let parsed;
|
|
1308
|
+
try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
|
|
1309
|
+
const res = await fetch('/dsh-key-rotation/config-import', {
|
|
1310
|
+
method: 'POST',
|
|
1311
|
+
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
1312
|
+
body: JSON.stringify({
|
|
1313
|
+
config: parsed.config || (parsed.providers ? parsed : undefined),
|
|
1314
|
+
ciphertext: parsed.ciphertext,
|
|
1315
|
+
salt: parsed.salt,
|
|
1316
|
+
iv: parsed.iv,
|
|
1317
|
+
tag: parsed.tag,
|
|
1318
|
+
encrypted: parsed.encrypted,
|
|
1319
|
+
passphrase: importPassphrase.trim() || undefined,
|
|
1320
|
+
mode: importMode,
|
|
1321
|
+
dryRun: false,
|
|
1322
|
+
}),
|
|
1323
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1324
|
+
});
|
|
1325
|
+
const data = await res.json();
|
|
1326
|
+
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import apply failed');
|
|
1327
|
+
setImportModal(false);
|
|
1328
|
+
setImportPreview(null);
|
|
1329
|
+
setImportText('');
|
|
1330
|
+
void load();
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
setImportError(String(err?.message ?? err));
|
|
1333
|
+
} finally {
|
|
1334
|
+
setImportRunning(false);
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1189
1338
|
const [testMatrixResults, setTestMatrixResults] = React.useState(null);
|
|
1190
1339
|
const [testMatrixModal, setTestMatrixModal] = React.useState(false);
|
|
1191
1340
|
|
|
@@ -2782,7 +2931,91 @@ window.__ModuleLoader__.load({
|
|
|
2782
2931
|
h('span', null, '💾 ' + t('sectionBackup')),
|
|
2783
2932
|
),
|
|
2784
2933
|
h('div', { className: 'krot-section-desc' }, t('sectionBackupDesc')),
|
|
2785
|
-
h('div', { className: 'krot-
|
|
2934
|
+
exportModal ? h('div', { className: 'krot-box', style: { marginBottom: '12px', padding: '12px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', background: 'var(--dsw-alias-bg-hover)' } },
|
|
2935
|
+
h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📥 ' + (t('exportConfig') || 'Export Pools')),
|
|
2936
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer', marginBottom: '8px' } },
|
|
2937
|
+
h('input', { type: 'checkbox', checked: exportMasked, onChange: (e) => setExportMasked(e.target.checked) }),
|
|
2938
|
+
t('exportSafe') || 'Masked (Safe for sharing)'
|
|
2939
|
+
),
|
|
2940
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
|
|
2941
|
+
h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (optional)'),
|
|
2942
|
+
h('input', {
|
|
2943
|
+
type: 'password',
|
|
2944
|
+
className: 'krot-input',
|
|
2945
|
+
value: exportPassphrase,
|
|
2946
|
+
onChange: (e) => setExportPassphrase(e.target.value),
|
|
2947
|
+
placeholder: t('passphrasePlaceholder') || 'Enter passphrase for AES-256-GCM encryption',
|
|
2948
|
+
style: { maxWidth: '360px' }
|
|
2949
|
+
})
|
|
2950
|
+
),
|
|
2951
|
+
exportError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, exportError) : null,
|
|
2952
|
+
h('div', { style: { display: 'flex', gap: '8px' } },
|
|
2953
|
+
btn('⬇️ ' + (t('exportConfig') || 'Download JSON'), runExport, { primary: true }),
|
|
2954
|
+
btn(t('cancel'), () => setExportModal(false))
|
|
2955
|
+
)
|
|
2956
|
+
) : null,
|
|
2957
|
+
importModal ? h('div', { className: 'krot-box', style: { marginBottom: '12px', padding: '12px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', background: 'var(--dsw-alias-bg-hover)' } },
|
|
2958
|
+
h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📤 ' + (t('importConfig') || 'Import Pools')),
|
|
2959
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' } },
|
|
2960
|
+
h('label', { className: 'krot-btn', style: { cursor: 'pointer', fontSize: '12px' } },
|
|
2961
|
+
'📁 Choose File',
|
|
2962
|
+
h('input', {
|
|
2963
|
+
type: 'file',
|
|
2964
|
+
accept: '.json',
|
|
2965
|
+
style: { display: 'none' },
|
|
2966
|
+
onChange: (e) => {
|
|
2967
|
+
const file = e.target.files?.[0];
|
|
2968
|
+
if (!file) return;
|
|
2969
|
+
const reader = new FileReader();
|
|
2970
|
+
reader.onload = (ev) => { setImportText(String(ev.target?.result || '')); };
|
|
2971
|
+
reader.readAsText(file);
|
|
2972
|
+
}
|
|
2973
|
+
})
|
|
2974
|
+
),
|
|
2975
|
+
h('span', { style: { fontSize: '12px', opacity: 0.7 } }, 'or paste JSON below')
|
|
2976
|
+
),
|
|
2977
|
+
h('textarea', {
|
|
2978
|
+
className: 'krot-input',
|
|
2979
|
+
rows: 6,
|
|
2980
|
+
value: importText,
|
|
2981
|
+
onChange: (e) => { setImportText(e.target.value); setImportPreview(null); },
|
|
2982
|
+
placeholder: t('jsonConfigPlaceholder') || 'Paste JSON configuration here...',
|
|
2983
|
+
style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', marginBottom: '8px' }
|
|
2984
|
+
}),
|
|
2985
|
+
h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', marginBottom: '8px' } },
|
|
2986
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
|
|
2987
|
+
h('input', { type: 'radio', name: 'importMode', checked: importMode === 'merge', onChange: () => setImportMode('merge') }),
|
|
2988
|
+
t('importMerge') || 'Merge'
|
|
2989
|
+
),
|
|
2990
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
|
|
2991
|
+
h('input', { type: 'radio', name: 'importMode', checked: importMode === 'replace', onChange: () => setImportMode('replace') }),
|
|
2992
|
+
t('importReplace') || 'Replace'
|
|
2993
|
+
)
|
|
2994
|
+
),
|
|
2995
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
|
|
2996
|
+
h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (if encrypted)'),
|
|
2997
|
+
h('input', {
|
|
2998
|
+
type: 'password',
|
|
2999
|
+
className: 'krot-input',
|
|
3000
|
+
value: importPassphrase,
|
|
3001
|
+
onChange: (e) => setImportPassphrase(e.target.value),
|
|
3002
|
+
placeholder: t('passphrasePlaceholder') || 'Enter passphrase for decryption',
|
|
3003
|
+
style: { maxWidth: '360px' }
|
|
3004
|
+
})
|
|
3005
|
+
),
|
|
3006
|
+
importError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, importError) : null,
|
|
3007
|
+
importPreview ? h('div', { className: 'krot-alert krot-alert-ok', style: { marginBottom: '8px', fontSize: '12px' } },
|
|
3008
|
+
`Preview: ${importPreview.createdProviders?.length || 0} created, ${importPreview.updatedProviders?.length || 0} updated, ${importPreview.keysAdded || 0} keys added, ${importPreview.totalKeys || 0} total keys.`
|
|
3009
|
+
) : null,
|
|
3010
|
+
h('div', { style: { display: 'flex', gap: '8px' } },
|
|
3011
|
+
btn('🔍 ' + (t('importPreview') || 'Preview Diff'), runImportPreview, { disabled: !importText.trim() || importRunning }),
|
|
3012
|
+
btn('✅ ' + (t('importApply') || 'Apply Configuration'), runImportApply, { primary: true, disabled: !importText.trim() || importRunning }),
|
|
3013
|
+
btn(t('cancel'), () => { setImportModal(false); setImportPreview(null); })
|
|
3014
|
+
)
|
|
3015
|
+
) : null,
|
|
3016
|
+
h('div', { className: 'krot-row', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
|
|
3017
|
+
btn('📥 ' + (t('exportConfig') || 'Export Pools'), () => { setExportModal((v) => !v); setImportModal(false); }, { primary: exportModal }),
|
|
3018
|
+
btn('📤 ' + (t('importConfig') || 'Import Pools'), () => { setImportModal((v) => !v); setExportModal(false); }, { primary: importModal }),
|
|
2786
3019
|
btn(t('exportPools'), () => {
|
|
2787
3020
|
const data = JSON.stringify(val.providers ?? [], null, 2);
|
|
2788
3021
|
const blob = new Blob([data], { type: 'application/json' });
|
|
@@ -3001,31 +3234,34 @@ window.__ModuleLoader__.load({
|
|
|
3001
3234
|
|
|
3002
3235
|
|
|
3003
3236
|
|
|
3004
|
-
// (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
|
|
3005
|
-
// amber = some keys cooling, red = a pool fully exhausted. Click opens
|
|
3006
|
-
// same summary the floating dashboard shows.
|
|
3237
|
+
// (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
|
|
3238
|
+
// amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
|
|
3007
3239
|
function KeyRotationHeaderChip(props) {
|
|
3008
3240
|
const t = resolveT(props);
|
|
3009
3241
|
const [snap, setSnap] = React.useState(null);
|
|
3010
3242
|
const [open, setOpen] = React.useState(false);
|
|
3011
3243
|
const [, setTick] = React.useState(0);
|
|
3244
|
+
const [busy, setBusy] = React.useState(false);
|
|
3012
3245
|
const ref = React.useRef(null);
|
|
3013
3246
|
|
|
3247
|
+
const load = () => {
|
|
3248
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
3249
|
+
fetch('/dsh-key-rotation/health', {
|
|
3250
|
+
headers: { accept: 'application/json' },
|
|
3251
|
+
credentials: 'same-origin',
|
|
3252
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined
|
|
3253
|
+
})
|
|
3254
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3255
|
+
.then((d) => { if (d) setSnap(d); })
|
|
3256
|
+
.catch((e) => { void e; });
|
|
3257
|
+
};
|
|
3258
|
+
|
|
3014
3259
|
React.useEffect(() => {
|
|
3015
|
-
let alive = true;
|
|
3016
|
-
const load = () => {
|
|
3017
|
-
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
3018
|
-
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
|
|
3019
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
3020
|
-
.then((d) => { if (alive) setSnap(d); })
|
|
3021
|
-
.catch((e) => { void e; });
|
|
3022
|
-
};
|
|
3023
3260
|
load();
|
|
3024
3261
|
const id = setInterval(load, 5000);
|
|
3025
3262
|
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
|
|
3026
3263
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
3027
3264
|
return () => {
|
|
3028
|
-
alive = false;
|
|
3029
3265
|
clearInterval(id);
|
|
3030
3266
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
3031
3267
|
};
|
|
@@ -3047,6 +3283,21 @@ window.__ModuleLoader__.load({
|
|
|
3047
3283
|
return () => document.removeEventListener('click', onDocClick);
|
|
3048
3284
|
}, [open]);
|
|
3049
3285
|
|
|
3286
|
+
const runAction = (action, provider, keyRef) => {
|
|
3287
|
+
setBusy(true);
|
|
3288
|
+
fetch('/dsh-key-rotation/quick-action', {
|
|
3289
|
+
method: 'POST',
|
|
3290
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
3291
|
+
body: JSON.stringify({ action, provider, ref: keyRef }),
|
|
3292
|
+
credentials: 'same-origin',
|
|
3293
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
|
|
3294
|
+
})
|
|
3295
|
+
.then((r) => r.json())
|
|
3296
|
+
.then(() => { load(); })
|
|
3297
|
+
.catch((err) => { void err; })
|
|
3298
|
+
.finally(() => { setBusy(false); });
|
|
3299
|
+
};
|
|
3300
|
+
|
|
3050
3301
|
let settingsVal = null;
|
|
3051
3302
|
try {
|
|
3052
3303
|
const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
|
|
@@ -3076,17 +3327,45 @@ window.__ModuleLoader__.load({
|
|
|
3076
3327
|
label,
|
|
3077
3328
|
h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
|
|
3078
3329
|
),
|
|
3079
|
-
open ? h('div', { className: 'krot-popover' },
|
|
3330
|
+
open ? h('div', { className: 'krot-popover', style: { minWidth: '220px' } },
|
|
3080
3331
|
h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
|
|
3081
3332
|
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
|
|
3082
3333
|
poolEntries.map(([name, p]) => {
|
|
3083
3334
|
const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3335
|
+
const provName = p.providerName || name;
|
|
3336
|
+
const tail = p.lastUsedTail;
|
|
3337
|
+
return h('div', { key: name, className: 'krot-pop-row', style: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' } },
|
|
3338
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
3339
|
+
h('div', { className: 'krot-pop-name' },
|
|
3340
|
+
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
|
|
3341
|
+
name,
|
|
3342
|
+
tail ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' } }, `...${tail}`) : null
|
|
3343
|
+
),
|
|
3344
|
+
h('div', { className: 'krot-pop-count', style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)
|
|
3088
3345
|
),
|
|
3089
|
-
h('div', {
|
|
3346
|
+
h('div', { style: { display: 'flex', gap: '4px', marginTop: '2px' } },
|
|
3347
|
+
h('button', {
|
|
3348
|
+
type: 'button',
|
|
3349
|
+
disabled: busy,
|
|
3350
|
+
onClick: (e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); },
|
|
3351
|
+
title: t('quickSkipTitle'),
|
|
3352
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
3353
|
+
}, `⏭️ ${t('quickSkip')}`),
|
|
3354
|
+
h('button', {
|
|
3355
|
+
type: 'button',
|
|
3356
|
+
disabled: busy,
|
|
3357
|
+
onClick: (e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); },
|
|
3358
|
+
title: t('quickResetTitle'),
|
|
3359
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
3360
|
+
}, `🔄 ${t('quickReset')}`),
|
|
3361
|
+
h('button', {
|
|
3362
|
+
type: 'button',
|
|
3363
|
+
disabled: busy || !p.lastUsed,
|
|
3364
|
+
onClick: (e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); },
|
|
3365
|
+
title: t('quickPauseTitle'),
|
|
3366
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
3367
|
+
}, `⏸️ ${t('quickPause')}`)
|
|
3368
|
+
)
|
|
3090
3369
|
);
|
|
3091
3370
|
})
|
|
3092
3371
|
) : null
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// lib/ops-config-io.js — full configuration export and import (#504).
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { json, readJson, isTrustedBridgeRequest, NS } from './http-bridge.js';
|
|
4
|
+
import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths.js';
|
|
5
|
+
import { keyTail } from './pool.js';
|
|
6
|
+
import { plainConfig } from './index.js';
|
|
7
|
+
|
|
8
|
+
export function encryptConfig(plainText, passphrase) {
|
|
9
|
+
const salt = crypto.randomBytes(16);
|
|
10
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
|
|
11
|
+
const iv = crypto.randomBytes(12);
|
|
12
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
13
|
+
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
|
|
14
|
+
const tag = cipher.getAuthTag();
|
|
15
|
+
return {
|
|
16
|
+
encrypted: true,
|
|
17
|
+
algorithm: 'aes-256-gcm',
|
|
18
|
+
kdf: 'pbkdf2',
|
|
19
|
+
iterations: 100000,
|
|
20
|
+
salt: salt.toString('hex'),
|
|
21
|
+
iv: iv.toString('hex'),
|
|
22
|
+
tag: tag.toString('hex'),
|
|
23
|
+
ciphertext: encrypted.toString('hex'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decryptConfig(payload, passphrase) {
|
|
28
|
+
if (!payload || !payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
|
|
29
|
+
throw new Error('Invalid encrypted config payload structure');
|
|
30
|
+
}
|
|
31
|
+
const salt = Buffer.from(payload.salt, 'hex');
|
|
32
|
+
const iv = Buffer.from(payload.iv, 'hex');
|
|
33
|
+
const tag = Buffer.from(payload.tag, 'hex');
|
|
34
|
+
const ciphertext = Buffer.from(payload.ciphertext, 'hex');
|
|
35
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, payload.iterations || 100000, 32, 'sha256');
|
|
36
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
37
|
+
decipher.setAuthTag(tag);
|
|
38
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
39
|
+
return decrypted.toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function maskConfig(config) {
|
|
43
|
+
if (!config || typeof config !== 'object') return config;
|
|
44
|
+
const clone = JSON.parse(JSON.stringify(config));
|
|
45
|
+
if (Array.isArray(clone.providers)) {
|
|
46
|
+
for (const prov of clone.providers) {
|
|
47
|
+
if (Array.isArray(prov.keys)) {
|
|
48
|
+
prov.keys = prov.keys.map((k) => {
|
|
49
|
+
if (typeof k === 'string') return k.length > 8 ? `sk-...${k.slice(-4)}` : '***';
|
|
50
|
+
if (k && typeof k === 'object') {
|
|
51
|
+
return {
|
|
52
|
+
...k,
|
|
53
|
+
value: typeof k.value === 'string' ? (k.value.length > 8 ? `sk-...${k.value.slice(-4)}` : '***') : '***',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return '***';
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (clone.webhookUrl && typeof clone.webhookUrl === 'string') {
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(clone.webhookUrl);
|
|
64
|
+
const parts = u.pathname.split('/');
|
|
65
|
+
if (parts.length > 2) {
|
|
66
|
+
parts[parts.length - 1] = '***';
|
|
67
|
+
u.pathname = parts.join('/');
|
|
68
|
+
}
|
|
69
|
+
clone.webhookUrl = u.toString();
|
|
70
|
+
} catch (_) {
|
|
71
|
+
clone.webhookUrl = '***';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (clone.webhookActionToken) clone.webhookActionToken = '***';
|
|
75
|
+
return clone;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
|
|
79
|
+
const currentProviders = Array.isArray(currentConfig?.providers) ? currentConfig.providers : [];
|
|
80
|
+
const incomingProviders = Array.isArray(incomingConfig?.providers) ? incomingConfig.providers : [];
|
|
81
|
+
|
|
82
|
+
const currentMap = new Map();
|
|
83
|
+
for (const p of currentProviders) {
|
|
84
|
+
const name = p.provider || p.name || p.id;
|
|
85
|
+
if (name) currentMap.set(name, p);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const createdProviders = [];
|
|
89
|
+
const updatedProviders = [];
|
|
90
|
+
let keysAdded = 0;
|
|
91
|
+
let keysUpdated = 0;
|
|
92
|
+
|
|
93
|
+
for (const p of incomingProviders) {
|
|
94
|
+
const name = p.provider || p.name || p.id;
|
|
95
|
+
if (!name) continue;
|
|
96
|
+
const inKeys = Array.isArray(p.keys) ? p.keys : [];
|
|
97
|
+
if (!currentMap.has(name)) {
|
|
98
|
+
createdProviders.push(name);
|
|
99
|
+
keysAdded += inKeys.length;
|
|
100
|
+
} else {
|
|
101
|
+
updatedProviders.push(name);
|
|
102
|
+
const curP = currentMap.get(name);
|
|
103
|
+
const curKeys = new Set(Array.isArray(curP.keys) ? curP.keys.map((k) => (typeof k === 'string' ? k : k?.value)) : []);
|
|
104
|
+
for (const k of inKeys) {
|
|
105
|
+
const val = typeof k === 'string' ? k : k?.value;
|
|
106
|
+
if (!curKeys.has(val)) keysAdded++;
|
|
107
|
+
else keysUpdated++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const removedProviders = [];
|
|
113
|
+
if (mode === 'replace') {
|
|
114
|
+
const incomingNames = new Set(incomingProviders.map((p) => p.provider || p.name || p.id).filter(Boolean));
|
|
115
|
+
for (const [name] of currentMap.entries()) {
|
|
116
|
+
if (!incomingNames.has(name)) removedProviders.push(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const totalKeys = incomingProviders.reduce((acc, p) => acc + (Array.isArray(p.keys) ? p.keys.length : 0), 0);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
mode,
|
|
124
|
+
createdProviders,
|
|
125
|
+
updatedProviders,
|
|
126
|
+
removedProviders,
|
|
127
|
+
keysAdded,
|
|
128
|
+
keysUpdated,
|
|
129
|
+
totalKeys,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function registerConfigIoRoutes(ctx, deps) {
|
|
134
|
+
// ── GET /dsh-key-rotation/export ──
|
|
135
|
+
ctx.effect(() => ctx.webServer.register({
|
|
136
|
+
kind: 'exact',
|
|
137
|
+
path: CONFIG_EXPORT_PATH,
|
|
138
|
+
handler: async (req, res) => {
|
|
139
|
+
if (req.method !== 'GET') {
|
|
140
|
+
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
144
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: export is local-only' } });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
149
|
+
const maskedParam = urlObj.searchParams.get('masked');
|
|
150
|
+
const masked = maskedParam === null ? true : maskedParam !== 'false';
|
|
151
|
+
const passphrase = urlObj.searchParams.get('passphrase') || null;
|
|
152
|
+
|
|
153
|
+
const settings = ctx.get('settings');
|
|
154
|
+
let currentConfig = {};
|
|
155
|
+
try {
|
|
156
|
+
if (settings && typeof settings.get === 'function') {
|
|
157
|
+
currentConfig = settings.get(NS) || {};
|
|
158
|
+
} else if (settings && typeof settings.describe === 'function') {
|
|
159
|
+
const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
|
|
160
|
+
currentConfig = desc?.value || {};
|
|
161
|
+
} else {
|
|
162
|
+
currentConfig = deps.buildRuntime().cfg0 || {};
|
|
163
|
+
}
|
|
164
|
+
} catch (_) {
|
|
165
|
+
currentConfig = deps.buildRuntime().cfg0 || {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const rawConfig = plainConfig(currentConfig);
|
|
169
|
+
const exportData = masked ? maskConfig(rawConfig) : rawConfig;
|
|
170
|
+
|
|
171
|
+
if (passphrase) {
|
|
172
|
+
const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
|
|
173
|
+
json(res, 200, {
|
|
174
|
+
ok: true,
|
|
175
|
+
version: '0.8.67',
|
|
176
|
+
exportedAt: new Date().toISOString(),
|
|
177
|
+
masked,
|
|
178
|
+
encrypted: true,
|
|
179
|
+
...encryptedPayload,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
json(res, 200, {
|
|
185
|
+
ok: true,
|
|
186
|
+
version: '0.8.67',
|
|
187
|
+
exportedAt: new Date().toISOString(),
|
|
188
|
+
masked,
|
|
189
|
+
encrypted: false,
|
|
190
|
+
config: exportData,
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
// ── POST /dsh-key-rotation/config-import ──
|
|
196
|
+
const handleImport = async (req, res) => {
|
|
197
|
+
if (req.method !== 'POST') {
|
|
198
|
+
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
202
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let body;
|
|
207
|
+
try {
|
|
208
|
+
body = await readJson(req);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let incomingConfig = null;
|
|
215
|
+
const passphrase = body.passphrase || null;
|
|
216
|
+
|
|
217
|
+
if (body.encrypted && body.ciphertext) {
|
|
218
|
+
if (!passphrase) {
|
|
219
|
+
json(res, 400, { error: { code: 'passphrase-required', message: 'Passphrase is required for encrypted configuration' } });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const decryptedStr = decryptConfig(body, passphrase);
|
|
224
|
+
incomingConfig = JSON.parse(decryptedStr);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
} else if (body.config) {
|
|
230
|
+
incomingConfig = body.config;
|
|
231
|
+
} else if (body.providers) {
|
|
232
|
+
incomingConfig = body;
|
|
233
|
+
} else {
|
|
234
|
+
json(res, 400, { error: { code: 'invalid-payload', message: 'Missing config or providers in request' } });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!incomingConfig || typeof incomingConfig !== 'object') {
|
|
239
|
+
json(res, 400, { error: { code: 'invalid-config', message: 'Imported configuration must be an object' } });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const mode = body.mode === 'replace' ? 'replace' : 'merge';
|
|
244
|
+
const dryRun = Boolean(body.dryRun);
|
|
245
|
+
|
|
246
|
+
const settings = ctx.get('settings');
|
|
247
|
+
let currentConfig = {};
|
|
248
|
+
try {
|
|
249
|
+
if (settings && typeof settings.get === 'function') {
|
|
250
|
+
currentConfig = settings.get(NS) || {};
|
|
251
|
+
} else if (settings && typeof settings.describe === 'function') {
|
|
252
|
+
const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
|
|
253
|
+
currentConfig = desc?.value || {};
|
|
254
|
+
} else {
|
|
255
|
+
currentConfig = deps.buildRuntime().cfg0 || {};
|
|
256
|
+
}
|
|
257
|
+
} catch (_) {
|
|
258
|
+
currentConfig = deps.buildRuntime().cfg0 || {};
|
|
259
|
+
}
|
|
260
|
+
const rawCurrent = plainConfig(currentConfig);
|
|
261
|
+
|
|
262
|
+
const preview = diffConfigs(rawCurrent, incomingConfig, mode);
|
|
263
|
+
|
|
264
|
+
if (dryRun) {
|
|
265
|
+
json(res, 200, {
|
|
266
|
+
ok: true,
|
|
267
|
+
dryRun: true,
|
|
268
|
+
preview,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apply configuration
|
|
274
|
+
let mergedConfig;
|
|
275
|
+
if (mode === 'replace') {
|
|
276
|
+
mergedConfig = { ...rawCurrent, ...incomingConfig };
|
|
277
|
+
if (Array.isArray(incomingConfig.providers)) {
|
|
278
|
+
mergedConfig.providers = incomingConfig.providers;
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
// Merge mode
|
|
282
|
+
mergedConfig = { ...rawCurrent, ...incomingConfig };
|
|
283
|
+
const curProviders = Array.isArray(rawCurrent.providers) ? [...rawCurrent.providers] : [];
|
|
284
|
+
const curMap = new Map();
|
|
285
|
+
for (let i = 0; i < curProviders.length; i++) {
|
|
286
|
+
const name = curProviders[i].provider || curProviders[i].name || curProviders[i].id;
|
|
287
|
+
if (name) curMap.set(name, i);
|
|
288
|
+
}
|
|
289
|
+
const inProviders = Array.isArray(incomingConfig.providers) ? incomingConfig.providers : [];
|
|
290
|
+
for (const p of inProviders) {
|
|
291
|
+
const name = p.provider || p.name || p.id;
|
|
292
|
+
if (!name) continue;
|
|
293
|
+
if (curMap.has(name)) {
|
|
294
|
+
const idx = curMap.get(name);
|
|
295
|
+
curProviders[idx] = { ...curProviders[idx], ...p };
|
|
296
|
+
} else {
|
|
297
|
+
curProviders.push(p);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
mergedConfig.providers = curProviders;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (settings && typeof settings.replace === 'function') {
|
|
304
|
+
try {
|
|
305
|
+
await settings.replace(NS, mergedConfig);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
json(res, 200, {
|
|
313
|
+
ok: true,
|
|
314
|
+
applied: true,
|
|
315
|
+
mode,
|
|
316
|
+
preview,
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
ctx.effect(() => ctx.webServer.register({
|
|
321
|
+
kind: 'exact',
|
|
322
|
+
path: CONFIG_IMPORT_PATH,
|
|
323
|
+
handler: handleImport,
|
|
324
|
+
}));
|
|
325
|
+
}
|
package/lib/ops-paths.js
CHANGED
|
@@ -19,3 +19,6 @@ export const DISCOVER_PATH = '/dsh-key-rotation/discover';
|
|
|
19
19
|
export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
|
|
20
20
|
export const PRESET_LIMITS_PATH = '/dsh-key-rotation/preset-limits';
|
|
21
21
|
export const BALANCE_PATH = '/dsh-key-rotation/balance';
|
|
22
|
+
export const CONFIG_EXPORT_PATH = '/dsh-key-rotation/export';
|
|
23
|
+
export const CONFIG_IMPORT_PATH = '/dsh-key-rotation/config-import';
|
|
24
|
+
export const QUICK_ACTION_PATH = '/dsh-key-rotation/quick-action';
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { json, readJson, isTrustedBridgeRequest } from './http-bridge.js';
|
|
2
|
+
import { QUICK_ACTION_PATH } from './ops-paths.js';
|
|
3
|
+
import { keyTail, isKeyPaused } from './pool.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} ctx cordis context
|
|
7
|
+
* @param {object} deps live dependencies from apply()
|
|
8
|
+
*/
|
|
9
|
+
export function registerQuickActionRoutes(ctx, deps) {
|
|
10
|
+
const { buildRuntime, pushEvent, circuitBreaker } = deps;
|
|
11
|
+
|
|
12
|
+
ctx.effect(() => ctx.webServer.register({
|
|
13
|
+
kind: 'exact',
|
|
14
|
+
path: QUICK_ACTION_PATH,
|
|
15
|
+
handler: async (req, res) => {
|
|
16
|
+
if (req.method !== 'POST') {
|
|
17
|
+
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
21
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: quick-action is local-only' } });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = await readJson(req);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const action = typeof body?.action === 'string' ? body.action.trim().toLowerCase() : '';
|
|
34
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
35
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : null;
|
|
36
|
+
|
|
37
|
+
if (!action || !['skip', 'reset', 'pause'].includes(action)) {
|
|
38
|
+
json(res, 400, { error: { code: 'invalid-action', message: 'action must be skip, reset, or pause' } });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!provider) {
|
|
43
|
+
json(res, 400, { error: { code: 'missing-provider', message: 'provider is required' } });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const runtime = buildRuntime();
|
|
48
|
+
let targetPool = null;
|
|
49
|
+
|
|
50
|
+
const pools = runtime.pools ?? Array.from(runtime.poolByRef?.values() || []);
|
|
51
|
+
for (const pool of pools) {
|
|
52
|
+
if (pool.provider === provider || pool.base === provider) {
|
|
53
|
+
targetPool = pool;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!targetPool) {
|
|
59
|
+
json(res, 404, { error: { code: 'pool-not-found', message: `Pool not found for provider: ${provider}` } });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (action === 'skip') {
|
|
64
|
+
const total = targetPool.refs?.length || 0;
|
|
65
|
+
if (total === 0) {
|
|
66
|
+
json(res, 400, { error: { code: 'no-keys', message: 'Pool has no keys to rotate' } });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const currentCursor = targetPool.state.cursor ?? 0;
|
|
70
|
+
const nextCursor = (currentCursor + 1) % total;
|
|
71
|
+
targetPool.state.cursor = nextCursor;
|
|
72
|
+
const nextRef = targetPool.refs[nextCursor];
|
|
73
|
+
targetPool.state.lastUsed = nextRef;
|
|
74
|
+
|
|
75
|
+
if (typeof pushEvent === 'function') {
|
|
76
|
+
pushEvent(targetPool, nextRef, 'QUICK_ACTION_SKIP', 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
json(res, 200, {
|
|
80
|
+
ok: true,
|
|
81
|
+
action: 'skip',
|
|
82
|
+
provider,
|
|
83
|
+
cursor: nextCursor,
|
|
84
|
+
activeRef: nextRef,
|
|
85
|
+
activeTail: keyTail(nextRef),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (action === 'reset') {
|
|
91
|
+
targetPool.state.failedUntil?.clear();
|
|
92
|
+
targetPool.state.failCounts?.clear();
|
|
93
|
+
targetPool.state.resetReasons?.clear();
|
|
94
|
+
targetPool.state.headerResetTimes?.clear();
|
|
95
|
+
targetPool.state.switches = 0;
|
|
96
|
+
targetPool.state.lastReason = null;
|
|
97
|
+
|
|
98
|
+
const breaker = circuitBreaker ?? runtime.breaker;
|
|
99
|
+
if (breaker) {
|
|
100
|
+
if (typeof breaker.reset === 'function') breaker.reset(provider);
|
|
101
|
+
else if (typeof breaker.onSuccess === 'function') breaker.onSuccess(provider);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (typeof pushEvent === 'function') {
|
|
105
|
+
pushEvent(targetPool, '*', 'QUICK_ACTION_RESET', 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
json(res, 200, {
|
|
109
|
+
ok: true,
|
|
110
|
+
action: 'reset',
|
|
111
|
+
provider,
|
|
112
|
+
healthyKeys: targetPool.refs?.length || 0,
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (action === 'pause') {
|
|
118
|
+
const targetRef = ref || targetPool.state.lastUsed || targetPool.refs?.[0];
|
|
119
|
+
if (!targetRef) {
|
|
120
|
+
json(res, 400, { error: { code: 'no-ref', message: 'No key specified or active in pool' } });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let isPaused = false;
|
|
125
|
+
if (!targetPool.state.pausedRefs) targetPool.state.pausedRefs = new Set();
|
|
126
|
+
|
|
127
|
+
if (targetPool.state.pausedRefs instanceof Set) {
|
|
128
|
+
if (targetPool.state.pausedRefs.has(targetRef)) {
|
|
129
|
+
targetPool.state.pausedRefs.delete(targetRef);
|
|
130
|
+
targetPool.state.pauseReasons?.delete(targetRef);
|
|
131
|
+
isPaused = false;
|
|
132
|
+
} else {
|
|
133
|
+
targetPool.state.pausedRefs.add(targetRef);
|
|
134
|
+
if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
|
|
135
|
+
targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
|
|
136
|
+
isPaused = true;
|
|
137
|
+
}
|
|
138
|
+
} else if (Array.isArray(targetPool.state.pausedRefs)) {
|
|
139
|
+
const idx = targetPool.state.pausedRefs.indexOf(targetRef);
|
|
140
|
+
if (idx >= 0) {
|
|
141
|
+
targetPool.state.pausedRefs.splice(idx, 1);
|
|
142
|
+
targetPool.state.pauseReasons?.delete(targetRef);
|
|
143
|
+
isPaused = false;
|
|
144
|
+
} else {
|
|
145
|
+
targetPool.state.pausedRefs.push(targetRef);
|
|
146
|
+
if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
|
|
147
|
+
targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
|
|
148
|
+
isPaused = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof pushEvent === 'function') {
|
|
153
|
+
pushEvent(targetPool, targetRef, isPaused ? 'KEY_PAUSED' : 'KEY_RESUMED', 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
json(res, 200, {
|
|
157
|
+
ok: true,
|
|
158
|
+
action: 'pause',
|
|
159
|
+
provider,
|
|
160
|
+
ref: targetRef,
|
|
161
|
+
paused: isPaused,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
}
|
package/lib/ops-status.js
CHANGED
|
@@ -242,6 +242,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
242
242
|
providerName: pool.provider ?? providerOfBase(pool.base),
|
|
243
243
|
basePool: basePoolH?.base ?? null,
|
|
244
244
|
modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
|
|
245
|
+
lastUsed: pool.state.lastUsed ?? null,
|
|
246
|
+
lastUsedTail: pool.state.lastUsed ? keyTail(pool.state.lastUsed) : null,
|
|
245
247
|
};
|
|
246
248
|
}
|
|
247
249
|
let circuitOpenAny = false;
|
package/lib/routes-ops.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { registerBalanceRoutes } from './ops-balance.js';
|
|
3
|
-
// lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
|
|
1
|
+
// lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312/#504/#499).
|
|
4
2
|
// Thin orchestrator: each route family lives in its own lib/ops-*.js module.
|
|
5
3
|
// Registration is injected with the live apply() dependencies.
|
|
6
4
|
|
|
@@ -9,6 +7,10 @@ import { registerTelemetryRoutes } from './ops-telemetry.js';
|
|
|
9
7
|
import { registerKeyRoutes } from './ops-keys.js';
|
|
10
8
|
import { registerTestRoutes } from './ops-test.js';
|
|
11
9
|
import { registerWebhookActionRoute } from './ops-webhook.js';
|
|
10
|
+
import { registerMetricsRoutes } from './ops-metrics.js';
|
|
11
|
+
import { registerBalanceRoutes } from './ops-balance.js';
|
|
12
|
+
import { registerConfigIoRoutes } from './ops-config-io.js';
|
|
13
|
+
import { registerQuickActionRoutes } from './ops-quick-action.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* @param {object} ctx cordis context
|
|
@@ -22,4 +24,6 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
22
24
|
registerWebhookActionRoute(ctx, deps);
|
|
23
25
|
registerMetricsRoutes(ctx, deps);
|
|
24
26
|
registerBalanceRoutes(ctx, deps);
|
|
27
|
+
registerConfigIoRoutes(ctx, deps);
|
|
28
|
+
registerQuickActionRoutes(ctx, deps);
|
|
25
29
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.67",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|