@goodandready/dsh-key-rotation 0.8.27 → 0.8.29
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 +57 -0
- package/lib/budget-monitor.js +2 -1
- package/lib/cascade.js +3 -9
- package/lib/client.js +3 -0
- package/lib/crypto-storage.js +49 -0
- package/lib/http-bridge.js +1 -0
- package/lib/index.js +21 -6
- package/lib/ops-keys.js +4 -0
- package/lib/ops-metrics.js +85 -0
- package/lib/ops-paths.js +1 -0
- package/lib/ops-status.js +5 -0
- package/lib/pool-builder.js +8 -0
- package/lib/pool.js +18 -5
- package/lib/rotate.js +8 -6
- package/lib/routes-ops.js +2 -0
- package/lib/webhook.js +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
3
3
|
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
4
|
User-facing feature notes also appear in README (en is source of truth).
|
|
5
5
|
|
|
6
|
+
## 0.8.29 - 2026-09-27
|
|
7
|
+
|
|
8
|
+
### Fixed & Hardened (#376)
|
|
9
|
+
- **Decrypted secret resolution at rest**:
|
|
10
|
+
- `lib/index.js`: patched `credentials.resolve` now decrypts ciphertext (`enc:v1:...`) via `decryptSecret` for pooled hits, fallback environment variables, and unpooled standalone key queries, preventing raw encrypted strings from being passed to upstream LLM providers.
|
|
11
|
+
- **Fail-safe candidate selection in rotation pool**:
|
|
12
|
+
- `lib/index.js`: `credentials.resolve` candidate check loop now explicitly enforces `!isKeyRevoked(pool, candidate)` and `!isKeyPaused(pool, candidate)` before selecting candidates.
|
|
13
|
+
- **Predicate logic & dynamic state resolution**:
|
|
14
|
+
- `lib/pool.js`: corrected `isKeyPaused` predicate to ensure `pool.state.pausedRefs` is inspected when `pool.pausedRefs` Set or Array does not contain the ref, allowing runtime key pause mutations to take effect.
|
|
15
|
+
- `lib/cascade.js`: replaced raw `.has()` call with `isKeyRevoked(pool, ref)` and unified `isKeyPaused`, preventing `TypeError` crashes when `revokedRefs` is represented as an Array.
|
|
16
|
+
- **Config & schema synchronization**:
|
|
17
|
+
- `lib/index.js`: propagated `p.revoked` into `buildPool` / `buildPoolItem` and added missing `paused` and `revoked` definitions to `SettingsSchema.providers`.
|
|
18
|
+
- **Status & key administrative routes**:
|
|
19
|
+
- `lib/ops-status.js`: exposed `paused` and `revoked` booleans on key entries in `/dsh-key-rotation/status`.
|
|
20
|
+
- `lib/pool.js`: updated `keyTail` to transparently decrypt ciphertext before extracting the tail, displaying the actual credential mask instead of random ciphertext characters while preserving test safety contracts.
|
|
21
|
+
- `lib/ops-keys.js`: `/dsh-key-rotation/reset` now clears `st.revokedRefs` for provider resets and single ref resets, enabling operators to unblock fixed keys.
|
|
22
|
+
- **Prometheus metrics & monitoring**:
|
|
23
|
+
- `lib/ops-metrics.js`: separated expired keys from active counts and added `dsh_key_rotation_expired_keys` gauge.
|
|
24
|
+
- `lib/budget-monitor.js`: excluded revoked and paused keys from healthy count in `warnBelowHealthy` threshold evaluation.
|
|
25
|
+
- `lib/webhook.js`: hardened Ntfy topic extraction against URLs with trailing slashes and query strings.
|
|
26
|
+
|
|
27
|
+
## 0.8.28 - 2026-09-27
|
|
28
|
+
|
|
29
|
+
### Security & Key Lifecycle (Block 3)
|
|
30
|
+
- **Permanent Key Revocation (#369)**:
|
|
31
|
+
- Added `revokedRefs` tracking in pool state and configuration. Keys returning permanent auth failures (HTTP 401 / `AUTH_FAILED`) are permanently revoked and excluded from rotation and cascade attempts.
|
|
32
|
+
- Added visual revoked status badges and tokens in WebUI (`lib/client.js`).
|
|
33
|
+
- **Key Encryption at Rest (#370)**:
|
|
34
|
+
- Added `lib/crypto-storage.js` implementing authenticated AES-256-GCM encryption with 12-byte IV and 16-byte auth tag.
|
|
35
|
+
- Transparently uses environment variable `DSH_KEY_SECRET` when configured, with safe plaintext fallback when unset.
|
|
36
|
+
- **Key Expiration Tracker (#371)**:
|
|
37
|
+
- Added automated expiration date tracking (`expiresAt`), horizon detection (`expiringSoon`), 24-hour notification deduplication (`shouldNotifyDaily`), and automated exclusion of expired keys from active routing.
|
|
38
|
+
|
|
39
|
+
### Monitoring & Integrations (Block 4)
|
|
40
|
+
- **Multi-Platform Webhook Integrations (#372)**:
|
|
41
|
+
- Extended `lib/webhook.js` with native detection and formatting for Gotify (`/message` with JSON priority/extras) and Ntfy (`/alerts` with action buttons).
|
|
42
|
+
- **Prometheus Metrics Exporter (#373)**:
|
|
43
|
+
- Added `/dsh-key-rotation/metrics` route (`lib/ops-metrics.js`) exposing OpenMetrics / Prometheus standard text format with gauges for pool totals, active/cooldown/paused/revoked keys, and counters for proactive switches and cascades. Guarded by loopback origin fail-closed security.
|
|
44
|
+
|
|
45
|
+
## 0.8.27 - 2026-09-27
|
|
46
|
+
|
|
47
|
+
### Smart Routing (Block 1)
|
|
48
|
+
- **Proactive Rate-Limit Cooldown (#363)**:
|
|
49
|
+
- Proactive key cooldown based on upstream rate limit reset headers (`x-ratelimit-reset`, `x-ratelimit-reset-requests`, `x-ratelimit-reset-tokens`) supporting time deltas and ISO timestamps.
|
|
50
|
+
- **Cross-Provider Cascading (#364)**:
|
|
51
|
+
- Cascade routing across providers on pool exhaustion with `modelMapping` and max cascade depth guard.
|
|
52
|
+
- **Adaptive Concurrency Normalization (#365)**:
|
|
53
|
+
- Weighted least-loaded concurrency balancing (`inflight / weight`).
|
|
54
|
+
|
|
55
|
+
### WebUI Suite (Block 2)
|
|
56
|
+
- **Bulk Key Import (#366)**:
|
|
57
|
+
- WebUI modal for bulk key import supporting line-by-line, `.env`, and CSV formats.
|
|
58
|
+
- **Key Maintenance Toggle (#367)**:
|
|
59
|
+
- Live key pause/resume toggle (`isPaused`) in WebUI.
|
|
60
|
+
- **Traffic Sparklines (#368)**:
|
|
61
|
+
- Native SVG Sparkline component for request and latency visual trends.
|
|
62
|
+
|
|
6
63
|
## 0.8.25 - 2026-09-27
|
|
7
64
|
|
|
8
65
|
### Fixed & Hardened
|
package/lib/budget-monitor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
|
|
2
|
-
import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
2
|
+
import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
3
3
|
|
|
4
4
|
const DAY_MS = 86400000;
|
|
5
5
|
|
|
@@ -72,6 +72,7 @@ export function checkBudgetAndHealthAlerts({
|
|
|
72
72
|
if (warnBelow > 0) {
|
|
73
73
|
let healthy = 0;
|
|
74
74
|
for (const ref of pool.refs) {
|
|
75
|
+
if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
|
|
75
76
|
const fu = pool.state.failedUntil.get(ref);
|
|
76
77
|
if (fu !== undefined && fu > now) continue;
|
|
77
78
|
const exp = pool.expiresAt?.[ref];
|
package/lib/cascade.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
// cascade.js — cross-provider failover cascade (issues #194, #364).
|
|
1
|
+
// cascade.js — cross-provider failover cascade (issues #194, #364, #376).
|
|
2
2
|
// ponytail: minimal — pick fallback provider with model mapping from config.
|
|
3
|
+
import { isKeyPaused, isKeyRevoked } from './pool.js';
|
|
3
4
|
|
|
4
5
|
export const CASCADE_MAX_DEPTH = 3;
|
|
5
6
|
|
|
6
|
-
function isKeyPaused(pool, ref) {
|
|
7
|
-
if (!pool || !ref) return false;
|
|
8
|
-
if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
|
|
9
|
-
if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
export function pickCascadeFallback(provider, cfg, pools, currentModel) {
|
|
14
8
|
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
15
9
|
for (const entry of list) {
|
|
@@ -20,7 +14,7 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel) {
|
|
|
20
14
|
const now = Date.now();
|
|
21
15
|
let healthy = 0;
|
|
22
16
|
for (const ref of pool.refs) {
|
|
23
|
-
if (isKeyPaused(pool, ref)) continue;
|
|
17
|
+
if (isKeyPaused(pool, ref) || isKeyRevoked(pool, ref)) continue;
|
|
24
18
|
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
25
19
|
if (failedUntil > now) continue;
|
|
26
20
|
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
package/lib/client.js
CHANGED
|
@@ -84,6 +84,7 @@ window.__ModuleLoader__.load({
|
|
|
84
84
|
bulkImportCancel: 'Cancel',
|
|
85
85
|
bulkImportSuccess: 'Imported {n} keys',
|
|
86
86
|
keyPaused: 'Paused',
|
|
87
|
+
keyRevoked: 'Revoked (401)',
|
|
87
88
|
pauseKey: 'Pause key from rotation',
|
|
88
89
|
resumeKey: 'Resume key rotation',
|
|
89
90
|
sparklineTraffic: 'Traffic Trend',
|
|
@@ -239,6 +240,7 @@ window.__ModuleLoader__.load({
|
|
|
239
240
|
bulkImportCancel: '取消',
|
|
240
241
|
bulkImportSuccess: '已导入 {n} 个密钥',
|
|
241
242
|
keyPaused: '已暂停',
|
|
243
|
+
keyRevoked: '已撤销 (401)',
|
|
242
244
|
pauseKey: '暂停此密钥轮换',
|
|
243
245
|
resumeKey: '恢复此密钥轮换',
|
|
244
246
|
sparklineTraffic: '流量趋势',
|
|
@@ -1590,6 +1592,7 @@ window.__ModuleLoader__.load({
|
|
|
1590
1592
|
const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
|
|
1591
1593
|
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
1592
1594
|
}
|
|
1595
|
+
if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked') };
|
|
1593
1596
|
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
1594
1597
|
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
1595
1598
|
if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// lib/crypto-storage.js — AES-256-GCM encryption for credentials at rest (#370)
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const PREFIX = 'enc:v1:';
|
|
5
|
+
|
|
6
|
+
function getDerivedKey(secret) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(secret)).digest();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Encrypt plaintext using AES-256-GCM if secretKey is provided.
|
|
12
|
+
* If secretKey is not set, returns plaintext as-is.
|
|
13
|
+
*/
|
|
14
|
+
export function encryptSecret(plaintext, secretKey = process.env.DSH_KEY_SECRET) {
|
|
15
|
+
if (!secretKey || !plaintext || typeof plaintext !== 'string') return plaintext;
|
|
16
|
+
if (plaintext.startsWith(PREFIX)) return plaintext; // already encrypted
|
|
17
|
+
const key = getDerivedKey(secretKey);
|
|
18
|
+
const iv = crypto.randomBytes(12);
|
|
19
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
20
|
+
let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
|
|
21
|
+
ciphertext += cipher.final('hex');
|
|
22
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
23
|
+
return `${PREFIX}${iv.toString('hex')}:${authTag}:${ciphertext}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decrypt ciphertext using AES-256-GCM if prefixed with enc:v1:.
|
|
28
|
+
* If not encrypted, or if secretKey is missing/incorrect, returns safe fallback.
|
|
29
|
+
*/
|
|
30
|
+
export function decryptSecret(ciphertext, secretKey = process.env.DSH_KEY_SECRET) {
|
|
31
|
+
if (!ciphertext || typeof ciphertext !== 'string') return ciphertext;
|
|
32
|
+
if (!ciphertext.startsWith(PREFIX)) return ciphertext; // plaintext fallback
|
|
33
|
+
if (!secretKey) return ciphertext;
|
|
34
|
+
try {
|
|
35
|
+
const parts = ciphertext.slice(PREFIX.length).split(':');
|
|
36
|
+
if (parts.length !== 3) return ciphertext;
|
|
37
|
+
const [ivHex, tagHex, dataHex] = parts;
|
|
38
|
+
const key = getDerivedKey(secretKey);
|
|
39
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
40
|
+
const authTag = Buffer.from(tagHex, 'hex');
|
|
41
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
42
|
+
decipher.setAuthTag(authTag);
|
|
43
|
+
let decrypted = decipher.update(dataHex, 'hex', 'utf8');
|
|
44
|
+
decrypted += decipher.final('utf8');
|
|
45
|
+
return decrypted;
|
|
46
|
+
} catch {
|
|
47
|
+
return ciphertext;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/lib/http-bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/http-bridge.js — shared HTTP helpers for dsh-key-rotation routes.
|
|
2
2
|
import { findSecrets } from './keycheck.js';
|
|
3
3
|
import { isTrustedBridgeRequest } from './pool.js';
|
|
4
|
+
export { isTrustedBridgeRequest };
|
|
4
5
|
import { mutateSettings, plainObject, requireRevision, sectionOps, validateOps } from './settings-write.js';
|
|
5
6
|
|
|
6
7
|
export const NS = 'dsh-key-rotation';
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
2
3
|
//
|
|
3
4
|
// Transparent key pool rotation: credentials.resolve patch + llm/stream
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
|
|
14
15
|
computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
|
|
15
16
|
formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek,
|
|
16
|
-
budgetVerdict, sortAttemptList,
|
|
17
|
+
budgetVerdict, sortAttemptList, isKeyPaused, isKeyRevoked,
|
|
17
18
|
} from './pool.js';
|
|
18
19
|
import { LatencyHistogram } from './histogram.js';
|
|
19
20
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
@@ -115,6 +116,8 @@ const SettingsSchema = Schema.object({
|
|
|
115
116
|
provider: Schema.string().required(),
|
|
116
117
|
keys: Schema.array(Schema.string()).default([]),
|
|
117
118
|
weights: Schema.array(Schema.number()).default([]),
|
|
119
|
+
paused: Schema.array(Schema.boolean()).default([]),
|
|
120
|
+
revoked: Schema.array(Schema.boolean()).default([]),
|
|
118
121
|
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
119
122
|
tags: Schema.array(Schema.string()).default([]),
|
|
120
123
|
costBudgetDaily: Schema.number(),
|
|
@@ -263,13 +266,13 @@ export function apply(ctx, config = {}) {
|
|
|
263
266
|
return initializePoolState(st);
|
|
264
267
|
};
|
|
265
268
|
|
|
266
|
-
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
267
|
-
buildPoolItem({ base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
269
|
+
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, revoked) =>
|
|
270
|
+
buildPoolItem({ base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
268
271
|
|
|
269
272
|
for (const p of cfg.providers ?? []) {
|
|
270
273
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
271
274
|
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
272
|
-
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
275
|
+
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, p.revoked);
|
|
273
276
|
if (pool) {
|
|
274
277
|
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
275
278
|
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
@@ -277,7 +280,7 @@ export function apply(ctx, config = {}) {
|
|
|
277
280
|
const models = p.models ?? {};
|
|
278
281
|
const byModel = new Map();
|
|
279
282
|
for (const [model, mp] of Object.entries(models)) {
|
|
280
|
-
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
283
|
+
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, mp.revoked);
|
|
281
284
|
if (mpool) {
|
|
282
285
|
byModel.set(model, mpool);
|
|
283
286
|
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
@@ -353,7 +356,15 @@ export function apply(ctx, config = {}) {
|
|
|
353
356
|
credentials.resolve = async (ref) => {
|
|
354
357
|
const { poolByRef } = buildRuntime();
|
|
355
358
|
const pool = poolByRef.get(ref);
|
|
356
|
-
if (!pool)
|
|
359
|
+
if (!pool) {
|
|
360
|
+
const res = await original(ref);
|
|
361
|
+
if (res && typeof res.value === 'string') {
|
|
362
|
+
return { ...res, value: decryptSecret(res.value) };
|
|
363
|
+
}
|
|
364
|
+
const ev = envValue(ref);
|
|
365
|
+
if (ev !== undefined) return { value: decryptSecret(ev) };
|
|
366
|
+
return res;
|
|
367
|
+
}
|
|
357
368
|
const now = Date.now();
|
|
358
369
|
const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
|
|
359
370
|
let list = pool.weightedRefs ?? pool.refs;
|
|
@@ -364,6 +375,8 @@ export function apply(ctx, config = {}) {
|
|
|
364
375
|
for (let i = 0; i < list.length; i++) {
|
|
365
376
|
const index = (start + i) % list.length;
|
|
366
377
|
const candidate = list[index];
|
|
378
|
+
if (isKeyRevoked(pool, candidate)) continue;
|
|
379
|
+
if (isKeyPaused(pool, candidate)) continue;
|
|
367
380
|
const until = pool.state.failedUntil.get(candidate);
|
|
368
381
|
if (until !== undefined && until > now) continue;
|
|
369
382
|
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
@@ -391,6 +404,7 @@ export function apply(ctx, config = {}) {
|
|
|
391
404
|
pool.state.pointer = (index + 1) % list.length;
|
|
392
405
|
let hit = await original(candidate);
|
|
393
406
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
407
|
+
hit = { ...hit, value: decryptSecret(hit.value) };
|
|
394
408
|
pool.state.lastUsed = candidate;
|
|
395
409
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
396
410
|
pool.state.lastUsedAt.set(candidate, now);
|
|
@@ -413,6 +427,7 @@ export function apply(ctx, config = {}) {
|
|
|
413
427
|
}
|
|
414
428
|
const envVal = envValue(candidate);
|
|
415
429
|
if (envVal !== undefined) {
|
|
430
|
+
const decEnv = decryptSecret(envVal);
|
|
416
431
|
pool.state.lastUsed = candidate;
|
|
417
432
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
418
433
|
pool.state.lastUsedAt.set(candidate, now);
|
package/lib/ops-keys.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
|
|
2
3
|
import {
|
|
3
4
|
json,
|
|
@@ -75,6 +76,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
75
76
|
st.failCounts?.delete(ref);
|
|
76
77
|
st.authFailCounts?.delete(ref);
|
|
77
78
|
st.brokenUntil?.delete(ref);
|
|
79
|
+
st.revokedRefs?.delete(ref);
|
|
78
80
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
79
81
|
}
|
|
80
82
|
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
@@ -127,6 +129,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
127
129
|
st.failCounts?.clear();
|
|
128
130
|
st.authFailCounts?.clear();
|
|
129
131
|
st.brokenUntil?.clear();
|
|
132
|
+
st.revokedRefs?.clear();
|
|
130
133
|
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
131
134
|
let circuitReset = false;
|
|
132
135
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
@@ -145,6 +148,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
145
148
|
st.failCounts?.delete(ref);
|
|
146
149
|
st.authFailCounts?.delete(ref);
|
|
147
150
|
st.brokenUntil?.delete(ref);
|
|
151
|
+
st.revokedRefs?.delete(ref);
|
|
148
152
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
149
153
|
found = true; break;
|
|
150
154
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
|
|
2
|
+
import { METRICS_PATH } from './ops-paths.js';
|
|
3
|
+
import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} ctx cordis context
|
|
7
|
+
* @param {object} deps live dependencies from apply()
|
|
8
|
+
*/
|
|
9
|
+
export function registerMetricsRoutes(ctx, deps) {
|
|
10
|
+
const { buildRuntime, poolState } = deps;
|
|
11
|
+
|
|
12
|
+
ctx.effect(() => ctx.webServer.register({
|
|
13
|
+
kind: 'exact',
|
|
14
|
+
path: METRICS_PATH,
|
|
15
|
+
handler: async (req, res) => {
|
|
16
|
+
if (req.method !== 'GET') {
|
|
17
|
+
res.statusCode = 405;
|
|
18
|
+
res.setHeader('content-type', 'text/plain');
|
|
19
|
+
res.end('Method Not Allowed\n');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
23
|
+
res.statusCode = 403;
|
|
24
|
+
res.setHeader('content-type', 'text/plain');
|
|
25
|
+
res.end('Forbidden: local only\n');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = [];
|
|
30
|
+
const runtime = buildRuntime();
|
|
31
|
+
const pools = runtime.pools ?? [];
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
|
|
34
|
+
lines.push('# HELP dsh_key_rotation_pools_total Total configured provider pools');
|
|
35
|
+
lines.push('# TYPE dsh_key_rotation_pools_total gauge');
|
|
36
|
+
lines.push(`dsh_key_rotation_pools_total ${pools.length}`);
|
|
37
|
+
|
|
38
|
+
lines.push('# HELP dsh_key_rotation_active_keys Number of currently active and ready keys');
|
|
39
|
+
lines.push('# TYPE dsh_key_rotation_active_keys gauge');
|
|
40
|
+
|
|
41
|
+
lines.push('# HELP dsh_key_rotation_cooldown_keys Number of keys in cooldown');
|
|
42
|
+
lines.push('# TYPE dsh_key_rotation_cooldown_keys gauge');
|
|
43
|
+
|
|
44
|
+
lines.push('# HELP dsh_key_rotation_paused_keys Number of paused keys');
|
|
45
|
+
lines.push('# TYPE dsh_key_rotation_paused_keys gauge');
|
|
46
|
+
|
|
47
|
+
lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
|
|
48
|
+
lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
|
|
49
|
+
|
|
50
|
+
lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
|
|
51
|
+
lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
|
|
52
|
+
|
|
53
|
+
lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
|
|
54
|
+
lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
|
|
55
|
+
|
|
56
|
+
lines.push('# HELP dsh_key_rotation_cascade_switches_total Number of multi-vendor cascade switches');
|
|
57
|
+
lines.push('# TYPE dsh_key_rotation_cascade_switches_total counter');
|
|
58
|
+
|
|
59
|
+
for (const pool of pools) {
|
|
60
|
+
const p = pool.base;
|
|
61
|
+
const st = poolState.get(p);
|
|
62
|
+
let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0;
|
|
63
|
+
for (const r of (pool.refs ?? [])) {
|
|
64
|
+
if (isKeyRevoked(pool, r)) { revoked++; continue; }
|
|
65
|
+
if (isKeyPaused(pool, r)) { paused++; continue; }
|
|
66
|
+
if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
|
|
67
|
+
const fu = st?.failedUntil?.get(r) ?? 0;
|
|
68
|
+
if (fu > now) cooling++;
|
|
69
|
+
else active++;
|
|
70
|
+
}
|
|
71
|
+
lines.push(`dsh_key_rotation_active_keys{provider="${p}"} ${active}`);
|
|
72
|
+
lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
|
|
73
|
+
lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
|
|
74
|
+
lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
|
|
75
|
+
lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
|
|
76
|
+
lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
|
|
77
|
+
lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
res.statusCode = 200;
|
|
81
|
+
res.setHeader('content-type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
82
|
+
res.end(lines.join('\n') + '\n');
|
|
83
|
+
}
|
|
84
|
+
}), 'dsh-key-rotation: metrics');
|
|
85
|
+
}
|
package/lib/ops-paths.js
CHANGED
|
@@ -9,3 +9,4 @@ export const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
|
9
9
|
export const TEST_PATH = '/dsh-key-rotation/test';
|
|
10
10
|
export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
11
11
|
export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
|
|
12
|
+
export const METRICS_PATH = '/dsh-key-rotation/metrics';
|
package/lib/ops-status.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
2
3
|
import { json } from './http-bridge.js';
|
|
3
4
|
import {
|
|
4
5
|
isTrustedBridgeRequest,
|
|
6
|
+
isKeyPaused,
|
|
7
|
+
isKeyRevoked,
|
|
5
8
|
keyTail,
|
|
6
9
|
envValue,
|
|
7
10
|
computeHealthScore,
|
|
@@ -106,6 +109,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
106
109
|
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
107
110
|
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
108
111
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
112
|
+
paused: isKeyPaused(pool, ref),
|
|
113
|
+
revoked: isKeyRevoked(pool, ref),
|
|
109
114
|
});
|
|
110
115
|
}
|
|
111
116
|
providers.push({
|
package/lib/pool-builder.js
CHANGED
|
@@ -16,6 +16,7 @@ export function buildPoolItem({
|
|
|
16
16
|
keys,
|
|
17
17
|
weights,
|
|
18
18
|
paused,
|
|
19
|
+
revoked,
|
|
19
20
|
poolCooldown,
|
|
20
21
|
poolMax,
|
|
21
22
|
expiresAt,
|
|
@@ -51,6 +52,12 @@ export function buildPoolItem({
|
|
|
51
52
|
if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
const parsedRevoked = new Set();
|
|
56
|
+
if (Array.isArray(revoked)) {
|
|
57
|
+
for (let i = 0; i < refs.length; i++) {
|
|
58
|
+
if (revoked[indexed[i].index] === true) parsedRevoked.add(refs[i]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
54
61
|
const weightsMap = {};
|
|
55
62
|
for (let i = 0; i < refs.length; i++) {
|
|
56
63
|
weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
@@ -62,6 +69,7 @@ export function buildPoolItem({
|
|
|
62
69
|
weightsMap,
|
|
63
70
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
64
71
|
pausedRefs: parsedPaused,
|
|
72
|
+
revokedRefs: parsedRevoked,
|
|
65
73
|
state: makeState(base),
|
|
66
74
|
cooldownMs: poolCooldown,
|
|
67
75
|
maxCooldownMs: poolMax,
|
package/lib/pool.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
import { bestEffort } from './best-effort.js';
|
|
2
3
|
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
3
4
|
// Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
|
|
@@ -11,8 +12,9 @@ export const KEY_TAIL_PLACEHOLDER = '***';
|
|
|
11
12
|
/** Mask key to show only the last KEY_TAIL_CHARS; returns placeholder for short keys (#355). */
|
|
12
13
|
export function keyTail(key) {
|
|
13
14
|
if (typeof key !== 'string' || key.length === 0) return '';
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const plain = decryptSecret(key);
|
|
16
|
+
if (plain.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
|
|
17
|
+
return plain.slice(-KEY_TAIL_CHARS);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** Check if an IP address is a loopback address. */
|
|
@@ -290,9 +292,10 @@ export function parseDurationToSeconds(v) {
|
|
|
290
292
|
/** Check if a key reference is in maintenance/paused mode. */
|
|
291
293
|
export function isKeyPaused(pool, ref) {
|
|
292
294
|
if (!pool || !ref) return false;
|
|
293
|
-
if (pool.pausedRefs instanceof Set
|
|
294
|
-
if (Array.isArray(pool.pausedRefs)
|
|
295
|
-
if (pool.state && pool.state.pausedRefs instanceof Set
|
|
295
|
+
if (pool.pausedRefs instanceof Set && pool.pausedRefs.has(ref)) return true;
|
|
296
|
+
if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
|
|
297
|
+
if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
|
|
298
|
+
if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
|
|
296
299
|
return false;
|
|
297
300
|
}
|
|
298
301
|
|
|
@@ -529,3 +532,13 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
|
529
532
|
});
|
|
530
533
|
return ok === true;
|
|
531
534
|
}
|
|
535
|
+
|
|
536
|
+
/** Check if a key reference is permanently revoked due to auth/401 failure (#369). */
|
|
537
|
+
export function isKeyRevoked(pool, ref) {
|
|
538
|
+
if (!pool || !ref) return false;
|
|
539
|
+
if (pool.revokedRefs instanceof Set && pool.revokedRefs.has(ref)) return true;
|
|
540
|
+
if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
|
|
541
|
+
if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
|
|
542
|
+
if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
|
|
543
|
+
return false;
|
|
544
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
extractRateLimit,
|
|
11
11
|
isRateLimited,
|
|
12
12
|
isKeyPaused,
|
|
13
|
+
isKeyRevoked,
|
|
13
14
|
formatExhaustionMessage,
|
|
14
15
|
sortAttemptList,
|
|
15
16
|
} from './pool.js';
|
|
@@ -53,7 +54,7 @@ export function createRotate(deps) {
|
|
|
53
54
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
54
55
|
setRotateStartMs(reqStore.startMs);
|
|
55
56
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
56
|
-
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r));
|
|
57
|
+
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r));
|
|
57
58
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
58
59
|
if (strategy !== 'round-robin') {
|
|
59
60
|
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
@@ -109,13 +110,14 @@ export function createRotate(deps) {
|
|
|
109
110
|
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
110
111
|
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
111
112
|
const _cStr = String(errCode ?? '');
|
|
112
|
-
if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
|
|
113
|
+
if (_cStr === 'AUTH' || /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated/i.test(errMsg)) {
|
|
113
114
|
const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
|
|
114
115
|
pool.state.authFailCounts.set(targetRef, _c2);
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
|
|
117
|
+
pool.state.revokedRefs.add(targetRef);
|
|
118
|
+
pool.state.failedUntil.set(targetRef, Infinity);
|
|
119
|
+
pushEvent(pool, targetRef, 'KEY_REVOKED', 0);
|
|
120
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently REVOKED on auth failure`);
|
|
119
121
|
} else {
|
|
120
122
|
pool.state.authFailCounts.delete(targetRef);
|
|
121
123
|
}
|
package/lib/routes-ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerMetricsRoutes } from './ops-metrics.js';
|
|
1
2
|
// lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
|
|
2
3
|
// Thin orchestrator: each route family lives in its own lib/ops-*.js module.
|
|
3
4
|
// Registration is injected with the live apply() dependencies.
|
|
@@ -18,4 +19,5 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
18
19
|
registerKeyRoutes(ctx, deps);
|
|
19
20
|
registerTestRoutes(ctx, deps);
|
|
20
21
|
registerWebhookActionRoute(ctx, deps);
|
|
22
|
+
registerMetricsRoutes(ctx, deps);
|
|
21
23
|
}
|
package/lib/webhook.js
CHANGED
|
@@ -12,6 +12,8 @@ export function detectPlatform(url) {
|
|
|
12
12
|
if (u.includes('api.telegram.org')) return 'telegram';
|
|
13
13
|
if (u.includes('discord.com/api/webhooks')) return 'discord';
|
|
14
14
|
if (u.includes('hooks.slack.com')) return 'slack';
|
|
15
|
+
if (u.includes('ntfy.sh') || u.includes('/ntfy')) return 'ntfy';
|
|
16
|
+
if (u.includes('gotify') || u.includes('/message')) return 'gotify';
|
|
15
17
|
return 'generic';
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -66,6 +68,40 @@ export function formatInteractive(url, payload, actionToken) {
|
|
|
66
68
|
],
|
|
67
69
|
};
|
|
68
70
|
}
|
|
71
|
+
if (platform === 'gotify') {
|
|
72
|
+
return {
|
|
73
|
+
title,
|
|
74
|
+
message: text,
|
|
75
|
+
priority: 5,
|
|
76
|
+
extras: {
|
|
77
|
+
'client::notification': {
|
|
78
|
+
click: { url: actions[0]?.url || '' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (platform === 'ntfy') {
|
|
84
|
+
let topic = 'alerts';
|
|
85
|
+
try {
|
|
86
|
+
const u = new URL(url);
|
|
87
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
88
|
+
if (parts.length > 0) topic = parts[parts.length - 1];
|
|
89
|
+
} catch (_) {
|
|
90
|
+
topic = String(url).split('?')[0].split('/').filter(Boolean).pop() || 'alerts';
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
topic,
|
|
94
|
+
title,
|
|
95
|
+
message: text,
|
|
96
|
+
priority: 4,
|
|
97
|
+
tags: ['warning', 'key-rotation'],
|
|
98
|
+
actions: actions.map((a) => ({
|
|
99
|
+
action: 'view',
|
|
100
|
+
label: a.label,
|
|
101
|
+
url: a.url || '',
|
|
102
|
+
})),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
69
105
|
// generic JSON webhook: actions as plain data, receiver decides
|
|
70
106
|
return { ...payload, actions };
|
|
71
107
|
}
|
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.29",
|
|
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": [
|