@goodandready/dsh-key-rotation 0.8.11 → 0.8.13
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 +196 -0
- package/lib/budget-monitor.js +123 -0
- package/lib/client.js +22 -13
- package/lib/index.js +199 -612
- package/lib/lifecycle.js +122 -0
- package/lib/logger.js +13 -0
- package/lib/notify-events.js +64 -0
- package/lib/ops-keys.js +189 -0
- package/lib/ops-paths.js +11 -0
- package/lib/ops-status.js +202 -0
- package/lib/ops-telemetry.js +113 -0
- package/lib/ops-test.js +106 -0
- package/lib/ops-webhook.js +115 -0
- package/lib/pool-builder.js +86 -0
- package/lib/rotate.js +10 -7
- package/lib/routes-ops.js +12 -631
- package/lib/sandbox-service.js +55 -0
- package/package.json +4 -1
package/lib/lifecycle.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// lib/lifecycle.js — background timers and lifecycle effects
|
|
2
|
+
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
|
+
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
|
|
7
|
+
return ctx.effect(() => {
|
|
8
|
+
const cfg = getConfig();
|
|
9
|
+
if (!cfg || cfg.selfHealCooldown === false) return () => {};
|
|
10
|
+
const timer = setInterval(() => {
|
|
11
|
+
try {
|
|
12
|
+
const c = getConfig();
|
|
13
|
+
if (!c || c.selfHealCooldown === false) return;
|
|
14
|
+
const idle = Number.isFinite(c.selfHealIdleMs) && c.selfHealIdleMs > 0 ? c.selfHealIdleMs : 3600000;
|
|
15
|
+
const providers = Array.isArray(c.providers) ? c.providers : [];
|
|
16
|
+
const pools = providers
|
|
17
|
+
.map((p) => buildRuntime().providerToPool.get(p.provider))
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
healIdleCooldowns(pools, idle);
|
|
20
|
+
} catch (_) { /* ponytail: never crash the timer */ }
|
|
21
|
+
}, 60000);
|
|
22
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
23
|
+
return () => clearInterval(timer);
|
|
24
|
+
}, 'dsh-key-rotation: self-healing idle');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger) {
|
|
28
|
+
return ctx.effect(() => {
|
|
29
|
+
const cfg = getConfig();
|
|
30
|
+
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
31
|
+
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
32
|
+
const intervalMs = intervalMin * 60 * 1000;
|
|
33
|
+
const timer = setInterval(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const c = getConfig();
|
|
36
|
+
if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
|
|
37
|
+
const { pools } = buildRuntime();
|
|
38
|
+
const runner = ensureSandboxRunner(ctx);
|
|
39
|
+
await autoUnbreakBrokenKeys(pools, async (ref) => {
|
|
40
|
+
let val = (await ctx.credentials?.resolve?.(ref))?.value;
|
|
41
|
+
if (!val) return { ok: false };
|
|
42
|
+
return runner.probeModels(ref, val);
|
|
43
|
+
});
|
|
44
|
+
} catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
45
|
+
}, intervalMs);
|
|
46
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
47
|
+
return () => clearInterval(timer);
|
|
48
|
+
}, 'dsh-key-rotation: auto-unbreak');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseLoggingOn, logger }) {
|
|
52
|
+
let statePersistence = null;
|
|
53
|
+
try {
|
|
54
|
+
const hostDirs = [
|
|
55
|
+
process.env.DSH_HOME,
|
|
56
|
+
process.cwd(),
|
|
57
|
+
].filter((d) => typeof d === 'string' && d.length > 0);
|
|
58
|
+
const resolvedPath = resolveStatePath({
|
|
59
|
+
configuredPath: cfg0.persistencePath,
|
|
60
|
+
dataDir: hostDirs[0],
|
|
61
|
+
});
|
|
62
|
+
if (cfg0.persistenceEnabled !== false && resolvedPath) {
|
|
63
|
+
statePersistence = new StatePersistence({ filePath: resolvedPath });
|
|
64
|
+
statePersistence.load().then((snap) => {
|
|
65
|
+
if (!snap) return;
|
|
66
|
+
try {
|
|
67
|
+
StatePersistence.restorePools(poolState, snap);
|
|
68
|
+
if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
|
|
69
|
+
if (verboseLoggingOn?.()) {
|
|
70
|
+
logger?.warn?.(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
logger?.warn?.('[dsh-key-rotation] persistence restore failed', e?.message ?? e);
|
|
74
|
+
}
|
|
75
|
+
}).catch(() => {});
|
|
76
|
+
} else if (cfg0.persistenceEnabled !== false && !resolvedPath) {
|
|
77
|
+
logger?.warn?.('[dsh-key-rotation] persistence disabled: no data directory');
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
logger?.warn?.('[dsh-key-rotation] persistence init failed', e?.message ?? e);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function persistenceSnapshot() {
|
|
84
|
+
if (!statePersistence) return null;
|
|
85
|
+
return StatePersistence.serialize({
|
|
86
|
+
poolState,
|
|
87
|
+
circuitSnapshot: moduleBreaker ? moduleBreaker.snapshot() : {},
|
|
88
|
+
quotaSnapshot: {},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function schedulePersist() {
|
|
93
|
+
if (!statePersistence) return;
|
|
94
|
+
const snap = persistenceSnapshot();
|
|
95
|
+
if (snap) statePersistence.save(snap);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ctx.effect(() => {
|
|
99
|
+
const timer = setInterval(() => {
|
|
100
|
+
try { schedulePersist(); }
|
|
101
|
+
catch (e) { logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
|
|
102
|
+
}, 15000);
|
|
103
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
104
|
+
return () => {
|
|
105
|
+
clearInterval(timer);
|
|
106
|
+
try {
|
|
107
|
+
if (statePersistence) {
|
|
108
|
+
const snap = persistenceSnapshot();
|
|
109
|
+
if (snap) {
|
|
110
|
+
statePersistence.save(snap);
|
|
111
|
+
void statePersistence.flush();
|
|
112
|
+
}
|
|
113
|
+
statePersistence.dispose();
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}, 'dsh-key-rotation: state persistence');
|
|
120
|
+
|
|
121
|
+
return { schedulePersist, persistenceSnapshot };
|
|
122
|
+
}
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// lib/logger.js — safe Cordis logger wrapper
|
|
2
|
+
const noop = () => {};
|
|
3
|
+
const noopLogger = { warn: noop, info: noop, error: noop, debug: noop, log: noop };
|
|
4
|
+
|
|
5
|
+
export function getLogger(ctx, scope = 'dsh-key-rotation') {
|
|
6
|
+
if (typeof ctx?.logger === 'function') {
|
|
7
|
+
return ctx.logger(scope);
|
|
8
|
+
}
|
|
9
|
+
if (ctx?.logger && typeof ctx.logger.warn === 'function') {
|
|
10
|
+
return ctx.logger;
|
|
11
|
+
}
|
|
12
|
+
return noopLogger;
|
|
13
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/notify-events.js — switch/exhaustion notify helpers and pool event ring (#312).
|
|
2
|
+
// Extracted from index.js for size and testability; behavior unchanged.
|
|
3
|
+
|
|
4
|
+
import { WebhookSender } from './webhook.js';
|
|
5
|
+
|
|
6
|
+
const switchNotifiedAt = new Map();
|
|
7
|
+
const MAX_EVENTS = 50;
|
|
8
|
+
// Default sender for callers that omit hooks (matches former index.js module scope).
|
|
9
|
+
const defaultWebhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
10
|
+
|
|
11
|
+
export function notifySwitch(runtime, pool, info, hooks = { webhookSender: defaultWebhookSender, now: () => Date.now() }) {
|
|
12
|
+
if (!runtime?.notifyWebhook) return;
|
|
13
|
+
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
14
|
+
const last = switchNotifiedAt.get(info.provider) ?? 0;
|
|
15
|
+
const now = hooks.now();
|
|
16
|
+
if (now - last < throttle) return;
|
|
17
|
+
switchNotifiedAt.set(info.provider, now);
|
|
18
|
+
// #263: non-blocking — enqueue never awaits webhook I/O
|
|
19
|
+
const send = hooks.notifyQueue
|
|
20
|
+
? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
|
|
21
|
+
: (url, payload) => hooks.webhookSender.send(url, payload);
|
|
22
|
+
send(runtime.notifyWebhook, {
|
|
23
|
+
title: `Key switched: ${info.provider}`,
|
|
24
|
+
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
25
|
+
provider: info.provider,
|
|
26
|
+
kind: 'switch',
|
|
27
|
+
from: info.from,
|
|
28
|
+
code: info.code,
|
|
29
|
+
at: info.at,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
34
|
+
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
35
|
+
pool.state.events.push(ev);
|
|
36
|
+
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender: defaultWebhookSender }) {
|
|
40
|
+
if (!runtime || !pool) return;
|
|
41
|
+
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
42
|
+
if (count <= 0) return;
|
|
43
|
+
try {
|
|
44
|
+
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
45
|
+
const token = runtime.webhookActionToken ?? '';
|
|
46
|
+
const payload = {
|
|
47
|
+
title: `Key pool exhausted: ${options.provider}`,
|
|
48
|
+
text: `${count} exhaustion(s); keys: ${(pool.refs ?? []).join(', ')}`,
|
|
49
|
+
provider: options.provider,
|
|
50
|
+
exhaustionCount: count,
|
|
51
|
+
at: pool.state.lastExhaustionAt,
|
|
52
|
+
keys: pool.refs,
|
|
53
|
+
actionToken: token || undefined,
|
|
54
|
+
actions: token ? [
|
|
55
|
+
{ id: `reset-${options.provider}`, label: 'Reset cooldown' },
|
|
56
|
+
{ id: `pause-${options.provider}`, label: 'Pause 1h' },
|
|
57
|
+
] : undefined,
|
|
58
|
+
};
|
|
59
|
+
if (hooks.notifyQueue) hooks.notifyQueue.enqueue(runtime.notifyWebhook, payload);
|
|
60
|
+
else hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
61
|
+
}
|
|
62
|
+
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
63
|
+
}
|
|
64
|
+
|
package/lib/ops-keys.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
|
|
2
|
+
import {
|
|
3
|
+
json,
|
|
4
|
+
readJson,
|
|
5
|
+
NS,
|
|
6
|
+
} from './http-bridge.js';
|
|
7
|
+
import {
|
|
8
|
+
isTrustedBridgeRequest,
|
|
9
|
+
isValidRef,
|
|
10
|
+
keyTail,
|
|
11
|
+
} from './pool.js';
|
|
12
|
+
import { looksLikeApiSecret } from './keycheck.js';
|
|
13
|
+
import { bestEffort } from './best-effort.js';
|
|
14
|
+
import {
|
|
15
|
+
KEY_PATH,
|
|
16
|
+
RESET_PATH,
|
|
17
|
+
IMPORT_PATH,
|
|
18
|
+
} from './ops-paths.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} ctx cordis context
|
|
22
|
+
* @param {object} deps live dependencies from apply()
|
|
23
|
+
*/
|
|
24
|
+
export function registerKeyRoutes(ctx, deps) {
|
|
25
|
+
const {
|
|
26
|
+
lastTestCache,
|
|
27
|
+
poolState,
|
|
28
|
+
buildRuntime,
|
|
29
|
+
circuitBreaker,
|
|
30
|
+
} = deps;
|
|
31
|
+
|
|
32
|
+
// ── key route: store a key value without leaving the rotation card ──
|
|
33
|
+
//
|
|
34
|
+
// Adding a key used to mean two screens: create the credential elsewhere,
|
|
35
|
+
// then type its env name here. The value is write-only from the browser —
|
|
36
|
+
// it is never sent back, only its last few characters are (see the status
|
|
37
|
+
// route) — and the route is loopback- and same-origin-gated like the config
|
|
38
|
+
// bridge next to it.
|
|
39
|
+
ctx.effect(() => ctx.webServer.register({
|
|
40
|
+
kind: 'exact',
|
|
41
|
+
path: KEY_PATH,
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
44
|
+
json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
48
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: keys are local-only' } });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const credentialsService = ctx.get('credentials');
|
|
52
|
+
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
|
53
|
+
json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
body = await readJson(req);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
json(res, 400, { error: { code: 'bad-request', message: String(error?.message ?? error) } });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
64
|
+
if (!isValidRef(ref)) {
|
|
65
|
+
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
if (req.method === 'DELETE') {
|
|
70
|
+
await credentialsService.unset(ref);
|
|
71
|
+
for (const st of poolState.values()) {
|
|
72
|
+
st.failedUntil?.delete(ref);
|
|
73
|
+
st.failCounts?.delete(ref);
|
|
74
|
+
st.authFailCounts?.delete(ref);
|
|
75
|
+
st.brokenUntil?.delete(ref);
|
|
76
|
+
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
77
|
+
}
|
|
78
|
+
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
79
|
+
json(res, 200, { ok: true, ref });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const value = typeof body?.value === 'string' ? body.value.trim() : '';
|
|
83
|
+
if (value.length === 0) {
|
|
84
|
+
json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await credentialsService.set(ref, value);
|
|
88
|
+
// #200: leak-detector hint - stored value should look like a credential
|
|
89
|
+
const secretShape = looksLikeApiSecret(value);
|
|
90
|
+
json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// A ref supplied by the launching environment is read-only, and the
|
|
93
|
+
// service says so in plain words — pass that through to the card.
|
|
94
|
+
json(res, 409, { error: { code: 'write-rejected', message: String(error?.message ?? error) } });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}), 'dsh-key-rotation: key route');
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// ── reset route: clear cooldown for a provider (or a single ref) ──
|
|
101
|
+
ctx.effect(() => ctx.webServer.register({
|
|
102
|
+
kind: 'exact',
|
|
103
|
+
path: RESET_PATH,
|
|
104
|
+
handler: async (req, res) => {
|
|
105
|
+
if (req.method !== 'POST') {
|
|
106
|
+
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
110
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let body;
|
|
114
|
+
try { body = await readJson(req); } catch (e) {
|
|
115
|
+
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
119
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
120
|
+
if (provider) {
|
|
121
|
+
const st = poolState.get(provider);
|
|
122
|
+
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
123
|
+
const cleared = st.failedUntil.size;
|
|
124
|
+
st.failedUntil.clear();
|
|
125
|
+
st.failCounts?.clear();
|
|
126
|
+
st.authFailCounts?.clear();
|
|
127
|
+
st.brokenUntil?.clear();
|
|
128
|
+
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
129
|
+
let circuitReset = false;
|
|
130
|
+
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
131
|
+
if (br) {
|
|
132
|
+
if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
|
|
133
|
+
else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
|
|
134
|
+
}
|
|
135
|
+
json(res, 200, { ok: true, provider, cleared, circuitReset });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (ref) {
|
|
139
|
+
let found = false;
|
|
140
|
+
for (const st of poolState.values()) {
|
|
141
|
+
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.authFailCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
142
|
+
st.failedUntil?.delete(ref);
|
|
143
|
+
st.failCounts?.delete(ref);
|
|
144
|
+
st.authFailCounts?.delete(ref);
|
|
145
|
+
st.brokenUntil?.delete(ref);
|
|
146
|
+
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
147
|
+
found = true; break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
|
|
151
|
+
if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
152
|
+
json(res, 200, { ok: true, ref });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
|
|
156
|
+
},
|
|
157
|
+
}), 'dsh-key-rotation: reset route');
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
ctx.effect(() => ctx.webServer.register({
|
|
161
|
+
kind: 'exact',
|
|
162
|
+
path: IMPORT_PATH,
|
|
163
|
+
handler: async (req, res) => {
|
|
164
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
165
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } }); return; }
|
|
166
|
+
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
167
|
+
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
168
|
+
if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
|
|
169
|
+
try {
|
|
170
|
+
const resp = await fetch(url);
|
|
171
|
+
if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
|
|
172
|
+
const data = await resp.json();
|
|
173
|
+
if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
|
|
174
|
+
const settings = ctx.get('settings');
|
|
175
|
+
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
176
|
+
const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
|
|
177
|
+
const cur = desc?.value?.providers ?? [];
|
|
178
|
+
const merged = new Map();
|
|
179
|
+
for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
|
|
180
|
+
for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
|
|
181
|
+
const mergedArr = [...merged.values()];
|
|
182
|
+
await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
|
|
183
|
+
json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
|
|
184
|
+
} catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
|
|
185
|
+
},
|
|
186
|
+
}), 'dsh-key-rotation: import route');
|
|
187
|
+
|
|
188
|
+
// Health for external panels (Beszel/Uptime)
|
|
189
|
+
}
|
package/lib/ops-paths.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// lib/ops-paths.js — shared HTTP path constants for operational routes (#312).
|
|
2
|
+
export const STATUS_PATH = '/dsh-key-rotation/status';
|
|
3
|
+
export const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
|
|
4
|
+
export const KEY_PATH = '/dsh-key-rotation/key';
|
|
5
|
+
export const RESET_PATH = '/dsh-key-rotation/reset';
|
|
6
|
+
export const IMPORT_PATH = '/dsh-key-rotation/import';
|
|
7
|
+
export const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
8
|
+
export const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
9
|
+
export const TEST_PATH = '/dsh-key-rotation/test';
|
|
10
|
+
export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
11
|
+
export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
2
|
+
import { json } from './http-bridge.js';
|
|
3
|
+
import {
|
|
4
|
+
isTrustedBridgeRequest,
|
|
5
|
+
keyTail,
|
|
6
|
+
envValue,
|
|
7
|
+
computeHealthScore,
|
|
8
|
+
costForDay,
|
|
9
|
+
costForWeek,
|
|
10
|
+
isLoopbackAddress,
|
|
11
|
+
} from './pool.js';
|
|
12
|
+
import { bucketInfo } from './bucket.js';
|
|
13
|
+
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
14
|
+
import {
|
|
15
|
+
STATUS_PATH,
|
|
16
|
+
HEALTH_PATH,
|
|
17
|
+
} from './ops-paths.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} ctx cordis context
|
|
21
|
+
* @param {object} deps live dependencies from apply()
|
|
22
|
+
*/
|
|
23
|
+
export function registerStatusRoutes(ctx, deps) {
|
|
24
|
+
const {
|
|
25
|
+
buildRuntime,
|
|
26
|
+
latencyHistogram,
|
|
27
|
+
circuitBreaker,
|
|
28
|
+
quotaStore,
|
|
29
|
+
} = deps;
|
|
30
|
+
|
|
31
|
+
// ── status route: what the settings card cannot know on its own ──
|
|
32
|
+
//
|
|
33
|
+
// Reports, per configured provider, which key is in use, which are cooling
|
|
34
|
+
// down and until when, whether an env name resolves to a credential at all
|
|
35
|
+
// (a typo is otherwise silent), and how often rotation has fired.
|
|
36
|
+
//
|
|
37
|
+
// Key VALUES never leave the host — only the boolean fact that one exists.
|
|
38
|
+
ctx.effect(() => ctx.webServer.register({
|
|
39
|
+
kind: 'exact',
|
|
40
|
+
path: STATUS_PATH,
|
|
41
|
+
handler: async (req, res) => {
|
|
42
|
+
if (req.method !== 'GET') {
|
|
43
|
+
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
47
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const runtime = buildRuntime();
|
|
51
|
+
const { poolByRef, providerTags, providerBudgets, latencySloMs } = runtime;
|
|
52
|
+
const base = ctx.get('credentials');
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const providers = [];
|
|
56
|
+
for (const pool of poolByRef.values()) {
|
|
57
|
+
if (seen.has(pool.base)) continue;
|
|
58
|
+
seen.add(pool.base);
|
|
59
|
+
try {
|
|
60
|
+
const keys = [];
|
|
61
|
+
for (const ref of pool.refs) {
|
|
62
|
+
let present = false;
|
|
63
|
+
let tail = '';
|
|
64
|
+
let source = null;
|
|
65
|
+
let writable = true;
|
|
66
|
+
try {
|
|
67
|
+
// The resolve patch is installed on this same service, so ask for
|
|
68
|
+
// the exact ref: a pool ref would otherwise round-robin to another
|
|
69
|
+
// key and report a missing name as present.
|
|
70
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
71
|
+
present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
72
|
+
if (present) tail = keyTail(hit.value);
|
|
73
|
+
// fallback: env var bootstrapping (issue #7)
|
|
74
|
+
if (!present) {
|
|
75
|
+
const ev = envValue(ref);
|
|
76
|
+
if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
present = false;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const described = await base?.describe?.(ref);
|
|
83
|
+
source = described?.source ?? null;
|
|
84
|
+
writable = described?.writable !== false;
|
|
85
|
+
} catch {
|
|
86
|
+
/* describe is optional — the card falls back to editable */
|
|
87
|
+
}
|
|
88
|
+
const until = pool.state.failedUntil.get(ref);
|
|
89
|
+
keys.push({
|
|
90
|
+
ref,
|
|
91
|
+
present,
|
|
92
|
+
tail,
|
|
93
|
+
source,
|
|
94
|
+
writable,
|
|
95
|
+
active: pool.state.lastUsed === ref,
|
|
96
|
+
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
97
|
+
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
98
|
+
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
99
|
+
// #215: effective round-robin weight of this key
|
|
100
|
+
weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
|
|
101
|
+
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
102
|
+
byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
|
|
103
|
+
usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
|
|
104
|
+
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
105
|
+
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
106
|
+
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
107
|
+
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
108
|
+
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
providers.push({
|
|
112
|
+
provider: pool.base,
|
|
113
|
+
keys,
|
|
114
|
+
tags: providerTags.get(pool.base) ?? [],
|
|
115
|
+
// #260 circuit breaker state (may be null if not yet tripped)
|
|
116
|
+
circuit: (() => {
|
|
117
|
+
const br = runtime.breaker;
|
|
118
|
+
if (!br) return null;
|
|
119
|
+
const st = br.state(pool.base);
|
|
120
|
+
return { state: st, threshold: br.threshold, openMs: br.openMs };
|
|
121
|
+
})(),
|
|
122
|
+
switches: pool.state.switches ?? 0,
|
|
123
|
+
lastReason: pool.state.lastReason ?? null,
|
|
124
|
+
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
125
|
+
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
126
|
+
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
127
|
+
totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
|
|
128
|
+
// #225: aggregate p95 across the pool's keys
|
|
129
|
+
p95: (() => {
|
|
130
|
+
const vals = (pool.refs ?? []).map((r) => (typeof latencyHistogram?.snapshot === 'function' ? latencyHistogram.snapshot(r) : null)).filter((s) => s && s.p95 != null).map((s) => s.p95);
|
|
131
|
+
return vals.length ? Math.round(Math.max(...vals)) : null;
|
|
132
|
+
})(),
|
|
133
|
+
latencySloMs,
|
|
134
|
+
events: (pool.state.events ?? []).slice(-50),
|
|
135
|
+
healthScore: computeHealthScore(pool.state),
|
|
136
|
+
// #208: today/week spend + configured budget for the card
|
|
137
|
+
todayCost: costForDay(pool.state.costDays),
|
|
138
|
+
weeklyCost: costForWeek(pool.state.costDays, now),
|
|
139
|
+
budgetDaily: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetDaily ?? 0,
|
|
140
|
+
budgetWeekly: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetWeekly ?? 0,
|
|
141
|
+
pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
|
|
142
|
+
routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
|
|
143
|
+
proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
|
|
144
|
+
});
|
|
145
|
+
} catch (e) {
|
|
146
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
|
147
|
+
providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
json(res, 200, sanitizeSnapshot({
|
|
151
|
+
providers,
|
|
152
|
+
// #266/#263 operational extras (additive)
|
|
153
|
+
meta: {
|
|
154
|
+
expectedClones: [...(runtime.expectedClones ?? [])],
|
|
155
|
+
notifyQueue: runtime.notifyQueue?.stats?.() ?? null,
|
|
156
|
+
breakerEnabled: runtime.circuitBreakerEnabled !== false,
|
|
157
|
+
at: now,
|
|
158
|
+
},
|
|
159
|
+
}, now));
|
|
160
|
+
},
|
|
161
|
+
}), 'dsh-key-rotation: status route');
|
|
162
|
+
|
|
163
|
+
// #209: usage report - per-key requests/cost over the last N days.
|
|
164
|
+
// ?format=csv returns text/csv; ?days=N window (1..90, default 7).
|
|
165
|
+
|
|
166
|
+
ctx.effect(() => ctx.webServer.register({
|
|
167
|
+
kind: 'exact',
|
|
168
|
+
path: HEALTH_PATH,
|
|
169
|
+
handler: async (req, res) => {
|
|
170
|
+
if (!isTrustedBridgeRequest(req) && req.socket?.remoteAddress !== '127.0.0.1' && req.socket?.remoteAddress !== '::1') { } // allow same-origin already checked
|
|
171
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
172
|
+
// also allow plain loopback without Origin
|
|
173
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) { res.writeHead(403); res.end(); return; }
|
|
174
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end(); return; }
|
|
175
|
+
}
|
|
176
|
+
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const pools = {};
|
|
179
|
+
let exhaustedAny = false;
|
|
180
|
+
const { poolByRef: pr, providerTags } = buildRuntime();
|
|
181
|
+
const seenH = new Set();
|
|
182
|
+
for (const pool of pr.values()) {
|
|
183
|
+
if (seenH.has(pool.base)) continue;
|
|
184
|
+
seenH.add(pool.base);
|
|
185
|
+
let healthy = 0;
|
|
186
|
+
for (const ref of pool.refs) {
|
|
187
|
+
const until = pool.state.failedUntil.get(ref);
|
|
188
|
+
if (until !== undefined && until > now) continue;
|
|
189
|
+
const exp = pool.expiresAt?.[ref];
|
|
190
|
+
if (exp !== undefined && now >= exp) continue;
|
|
191
|
+
healthy++;
|
|
192
|
+
}
|
|
193
|
+
const total = pool.refs.length;
|
|
194
|
+
const exhausted = healthy === 0 && total > 0;
|
|
195
|
+
if (exhausted) exhaustedAny = true;
|
|
196
|
+
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
197
|
+
}
|
|
198
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
|
|
199
|
+
},
|
|
200
|
+
}), 'dsh-key-rotation: health');
|
|
201
|
+
|
|
202
|
+
}
|