@lifeaitools/clauth 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/serve.js +476 -2
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -53,9 +53,347 @@ ALTER TABLE clauth_machines ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEF
|
|
|
53
53
|
);
|
|
54
54
|
ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
version: 4,
|
|
58
|
+
name: "004_key_rotation",
|
|
59
|
+
description: "Key rotation tracking — expiry dates, rotation policy, rotation log",
|
|
60
|
+
type: "safe",
|
|
61
|
+
sql: `ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS expires_at timestamptz;
|
|
62
|
+
ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS rotation_policy text NOT NULL DEFAULT 'none';
|
|
63
|
+
ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS rotation_days integer NOT NULL DEFAULT 30;
|
|
64
|
+
ALTER TABLE clauth_services ADD COLUMN IF NOT EXISTS last_rotated_at timestamptz;
|
|
65
|
+
CREATE TABLE IF NOT EXISTS clauth_rotation_log (
|
|
66
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
67
|
+
service text NOT NULL,
|
|
68
|
+
action text NOT NULL,
|
|
69
|
+
status text NOT NULL,
|
|
70
|
+
detail text,
|
|
71
|
+
created_at timestamptz DEFAULT now()
|
|
72
|
+
);
|
|
73
|
+
ALTER TABLE clauth_rotation_log ENABLE ROW LEVEL SECURITY;`,
|
|
74
|
+
},
|
|
56
75
|
];
|
|
57
76
|
|
|
58
|
-
const CURRENT_SCHEMA_VERSION =
|
|
77
|
+
const CURRENT_SCHEMA_VERSION = 4;
|
|
78
|
+
|
|
79
|
+
// ── Key Rotation Config ─────────────────────────────────────────
|
|
80
|
+
// Per-service rotation capabilities. "auto" services can be rotated programmatically.
|
|
81
|
+
// "manual" services require dashboard/UI action. "none" means static keys.
|
|
82
|
+
const ROTATION_CONFIG = {
|
|
83
|
+
"cloudflare": {
|
|
84
|
+
policy: "auto",
|
|
85
|
+
defaultDays: 30,
|
|
86
|
+
description: "Cloudflare API Token",
|
|
87
|
+
async rotate(currentKey) {
|
|
88
|
+
// Step 1: List tokens to find current token's ID and permissions
|
|
89
|
+
const listResp = await fetch("https://api.cloudflare.com/client/v4/user/tokens", {
|
|
90
|
+
headers: { "Authorization": `Bearer ${currentKey}`, "Content-Type": "application/json" },
|
|
91
|
+
});
|
|
92
|
+
const listData = await listResp.json();
|
|
93
|
+
if (!listData.success) throw new Error(`CF list tokens: ${listData.errors?.[0]?.message || "failed"}`);
|
|
94
|
+
|
|
95
|
+
const currentToken = listData.result?.find(t => t.status === "active");
|
|
96
|
+
if (!currentToken) throw new Error("No active CF token found");
|
|
97
|
+
|
|
98
|
+
// Step 2: Create a new token with same policies
|
|
99
|
+
const createResp = await fetch("https://api.cloudflare.com/client/v4/user/tokens", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Authorization": `Bearer ${currentKey}`, "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
name: `${currentToken.name} (rotated ${new Date().toISOString().slice(0, 10)})`,
|
|
104
|
+
policies: currentToken.policies,
|
|
105
|
+
expires_on: new Date(Date.now() + 30 * 86400000).toISOString(),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
const createData = await createResp.json();
|
|
109
|
+
if (!createData.success) throw new Error(`CF create token: ${createData.errors?.[0]?.message || "failed"}`);
|
|
110
|
+
|
|
111
|
+
const newKey = createData.result?.value;
|
|
112
|
+
if (!newKey) throw new Error("CF token created but no value returned");
|
|
113
|
+
|
|
114
|
+
// Step 3: Verify new token works
|
|
115
|
+
const verifyResp = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
116
|
+
headers: { "Authorization": `Bearer ${newKey}` },
|
|
117
|
+
});
|
|
118
|
+
const verifyData = await verifyResp.json();
|
|
119
|
+
if (!verifyData.success) throw new Error("New CF token failed verification");
|
|
120
|
+
|
|
121
|
+
// Step 4: Delete old token
|
|
122
|
+
await fetch(`https://api.cloudflare.com/client/v4/user/tokens/${currentToken.id}`, {
|
|
123
|
+
method: "DELETE",
|
|
124
|
+
headers: { "Authorization": `Bearer ${newKey}` },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return { newKey, expiresAt: createData.result.expires_on };
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"cloudfare-dns": {
|
|
131
|
+
policy: "auto",
|
|
132
|
+
defaultDays: 30,
|
|
133
|
+
description: "Cloudflare DNS Token",
|
|
134
|
+
// Same rotation logic as cloudflare — they're both CF API tokens
|
|
135
|
+
async rotate(currentKey) {
|
|
136
|
+
return ROTATION_CONFIG["cloudflare"].rotate(currentKey);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
"npm": {
|
|
140
|
+
policy: "auto",
|
|
141
|
+
defaultDays: 30,
|
|
142
|
+
description: "npm Granular Token",
|
|
143
|
+
async rotate(currentKey) {
|
|
144
|
+
// Step 1: Create new token
|
|
145
|
+
const createResp = await fetch("https://registry.npmjs.org/-/npm/v1/tokens", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
"Authorization": `Bearer ${currentKey}`,
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
password: "", // granular tokens don't need password for automation tokens
|
|
153
|
+
cidr_whitelist: [],
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
if (!createResp.ok) throw new Error(`npm create token: HTTP ${createResp.status}`);
|
|
157
|
+
const createData = await createResp.json();
|
|
158
|
+
const newKey = createData.token;
|
|
159
|
+
if (!newKey) throw new Error("npm token created but no value returned");
|
|
160
|
+
|
|
161
|
+
// Step 2: Delete old token (get its key hash first)
|
|
162
|
+
const listResp = await fetch("https://registry.npmjs.org/-/npm/v1/tokens", {
|
|
163
|
+
headers: { "Authorization": `Bearer ${newKey}` },
|
|
164
|
+
});
|
|
165
|
+
if (listResp.ok) {
|
|
166
|
+
const listData = await listResp.json();
|
|
167
|
+
// The old token shows as a different key from the new one
|
|
168
|
+
const oldToken = listData.objects?.find(t => t.key !== createData.key);
|
|
169
|
+
if (oldToken) {
|
|
170
|
+
await fetch(`https://registry.npmjs.org/-/npm/v1/tokens/token/${oldToken.key}`, {
|
|
171
|
+
method: "DELETE",
|
|
172
|
+
headers: { "Authorization": `Bearer ${newKey}` },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { newKey, expiresAt: null }; // npm tokens don't have built-in expiry
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
"vultr-api": {
|
|
181
|
+
policy: "auto",
|
|
182
|
+
defaultDays: 90,
|
|
183
|
+
description: "Vultr API Key",
|
|
184
|
+
async rotate(currentKey) {
|
|
185
|
+
const expiresAt = new Date(Date.now() + 90 * 86400000).toISOString();
|
|
186
|
+
const createResp = await fetch("https://api.vultr.com/v2/apikeys", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
"Authorization": `Bearer ${currentKey}`,
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
name: `clauth-rotated-${new Date().toISOString().slice(0, 10)}`,
|
|
194
|
+
expire: true,
|
|
195
|
+
date_expire: expiresAt,
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
if (!createResp.ok) throw new Error(`Vultr create key: HTTP ${createResp.status}`);
|
|
199
|
+
const createData = await createResp.json();
|
|
200
|
+
const newKey = createData.api_key?.api_key;
|
|
201
|
+
if (!newKey) throw new Error("Vultr key created but no value returned");
|
|
202
|
+
|
|
203
|
+
// Delete old key
|
|
204
|
+
const listResp = await fetch("https://api.vultr.com/v2/apikeys", {
|
|
205
|
+
headers: { "Authorization": `Bearer ${newKey}` },
|
|
206
|
+
});
|
|
207
|
+
if (listResp.ok) {
|
|
208
|
+
const listData = await listResp.json();
|
|
209
|
+
const oldKey = listData.api_keys?.find(k => k.id !== createData.api_key?.id);
|
|
210
|
+
if (oldKey) {
|
|
211
|
+
await fetch(`https://api.vultr.com/v2/apikeys/${oldKey.id}`, {
|
|
212
|
+
method: "DELETE",
|
|
213
|
+
headers: { "Authorization": `Bearer ${newKey}` },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { newKey, expiresAt };
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
// Manual-only services — track expiry but can't auto-rotate
|
|
222
|
+
"github": { policy: "manual", defaultDays: 90, description: "GitHub PAT (rotate in Settings > Developer settings)" },
|
|
223
|
+
"supabase-anon": { policy: "manual", defaultDays: 365, description: "Supabase anon key (rotate via Management API or dashboard)" },
|
|
224
|
+
"supabase-service": { policy: "manual", defaultDays: 365, description: "Supabase service key (rotate via Management API or dashboard)" },
|
|
225
|
+
"supabase-db": { policy: "none", defaultDays: 0, description: "Supabase DB connection string" },
|
|
226
|
+
"coolify-api": { policy: "manual", defaultDays: 365, description: "Coolify API token (rotate in dashboard)" },
|
|
227
|
+
"rocketreach": { policy: "manual", defaultDays: 365, description: "RocketReach API key" },
|
|
228
|
+
"namecheap": { policy: "manual", defaultDays: 0, description: "Namecheap API (no expiry, dashboard-only)" },
|
|
229
|
+
"neo4j": { policy: "none", defaultDays: 0, description: "Neo4j connection string" },
|
|
230
|
+
"r2": { policy: "manual", defaultDays: 365, description: "Cloudflare R2 access key" },
|
|
231
|
+
"r2-bucket": { policy: "none", defaultDays: 0, description: "R2 bucket endpoint" },
|
|
232
|
+
"google-client-id": { policy: "none", defaultDays: 0, description: "Google OAuth client ID (static)" },
|
|
233
|
+
"google-client-secret": { policy: "none", defaultDays: 0, description: "Google OAuth client secret (static)" },
|
|
234
|
+
"google-redirect-uri": { policy: "none", defaultDays: 0, description: "Google OAuth redirect URI (static)" },
|
|
235
|
+
"google-refresh-token": { policy: "none", defaultDays: 0, description: "Google OAuth refresh token (auto-refreshes)" },
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// ── Rotation Engine ─────────────────────────────────────────────
|
|
239
|
+
// Runs inside createServer() as a background interval.
|
|
240
|
+
// Checks all services for upcoming expiry, auto-rotates if policy=auto.
|
|
241
|
+
|
|
242
|
+
function createRotationEngine(password, machineHash, logFile) {
|
|
243
|
+
const CHECK_INTERVAL_MS = 6 * 3600 * 1000; // check every 6 hours
|
|
244
|
+
const WARN_DAYS = 7; // warn when within 7 days of expiry
|
|
245
|
+
let rotationState = new Map(); // service → { expires_at, policy, rotation_days, last_rotated_at, status }
|
|
246
|
+
let rotationLog = []; // in-memory log of recent rotations (also persisted to DB)
|
|
247
|
+
let intervalHandle = null;
|
|
248
|
+
|
|
249
|
+
function getExpiryStatus(expiresAt) {
|
|
250
|
+
if (!expiresAt) return { status: "unknown", daysLeft: null, color: "gray" };
|
|
251
|
+
const days = Math.ceil((new Date(expiresAt) - Date.now()) / 86400000);
|
|
252
|
+
if (days < 0) return { status: "expired", daysLeft: days, color: "red" };
|
|
253
|
+
if (days <= WARN_DAYS) return { status: "expiring", daysLeft: days, color: "orange" };
|
|
254
|
+
if (days <= 30) return { status: "ok", daysLeft: days, color: "yellow" };
|
|
255
|
+
return { status: "ok", daysLeft: days, color: "green" };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function loadState() {
|
|
259
|
+
// Load expiry metadata from clauth_config
|
|
260
|
+
try {
|
|
261
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
262
|
+
const result = await api.status(password, machineHash, token, timestamp);
|
|
263
|
+
if (!result.services) return;
|
|
264
|
+
|
|
265
|
+
for (const svc of result.services) {
|
|
266
|
+
const config = ROTATION_CONFIG[svc.name] || { policy: "none", defaultDays: 0 };
|
|
267
|
+
rotationState.set(svc.name, {
|
|
268
|
+
expires_at: svc.expires_at || null,
|
|
269
|
+
policy: svc.rotation_policy || config.policy,
|
|
270
|
+
rotation_days: svc.rotation_days || config.defaultDays,
|
|
271
|
+
last_rotated_at: svc.last_rotated_at || null,
|
|
272
|
+
status: getExpiryStatus(svc.expires_at).status,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
const msg = `[${new Date().toISOString()}] Rotation engine: failed to load state: ${err.message}\n`;
|
|
277
|
+
try { fs.appendFileSync(logFile, msg); } catch {}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function rotateService(serviceName) {
|
|
282
|
+
const config = ROTATION_CONFIG[serviceName];
|
|
283
|
+
if (!config || !config.rotate) {
|
|
284
|
+
return { ok: false, error: "No auto-rotation available for this service" };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const logEntry = { service: serviceName, action: "rotate", status: "started", detail: null, created_at: new Date().toISOString() };
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
// Get current key
|
|
291
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
292
|
+
const retrieved = await api.retrieve(password, machineHash, token, timestamp, serviceName);
|
|
293
|
+
if (retrieved.error) throw new Error(retrieved.error);
|
|
294
|
+
const currentKey = retrieved.value;
|
|
295
|
+
|
|
296
|
+
// Execute rotation
|
|
297
|
+
const result = await config.rotate(currentKey);
|
|
298
|
+
if (!result.newKey) throw new Error("Rotation returned no new key");
|
|
299
|
+
|
|
300
|
+
// Write new key to vault
|
|
301
|
+
const { token: wt, timestamp: wts } = deriveToken(password, machineHash);
|
|
302
|
+
const writeResult = await api.write(password, machineHash, wt, wts, serviceName, result.newKey);
|
|
303
|
+
if (writeResult.error) throw new Error(`Write failed: ${writeResult.error}`);
|
|
304
|
+
|
|
305
|
+
// Update expiry in state
|
|
306
|
+
const newExpiry = result.expiresAt || new Date(Date.now() + (config.defaultDays || 30) * 86400000).toISOString();
|
|
307
|
+
rotationState.set(serviceName, {
|
|
308
|
+
...rotationState.get(serviceName),
|
|
309
|
+
expires_at: newExpiry,
|
|
310
|
+
last_rotated_at: new Date().toISOString(),
|
|
311
|
+
status: "ok",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
logEntry.status = "success";
|
|
315
|
+
logEntry.detail = `Rotated. New expiry: ${newExpiry}`;
|
|
316
|
+
rotationLog.unshift(logEntry);
|
|
317
|
+
if (rotationLog.length > 100) rotationLog.length = 100;
|
|
318
|
+
|
|
319
|
+
const msg = `[${new Date().toISOString()}] Rotation: ${serviceName} rotated successfully. Expires: ${newExpiry}\n`;
|
|
320
|
+
try { fs.appendFileSync(logFile, msg); } catch {}
|
|
321
|
+
|
|
322
|
+
return { ok: true, expiresAt: newExpiry };
|
|
323
|
+
} catch (err) {
|
|
324
|
+
logEntry.status = "failed";
|
|
325
|
+
logEntry.detail = err.message;
|
|
326
|
+
rotationLog.unshift(logEntry);
|
|
327
|
+
if (rotationLog.length > 100) rotationLog.length = 100;
|
|
328
|
+
|
|
329
|
+
const msg = `[${new Date().toISOString()}] Rotation: ${serviceName} FAILED: ${err.message}\n`;
|
|
330
|
+
try { fs.appendFileSync(logFile, msg); } catch {}
|
|
331
|
+
|
|
332
|
+
return { ok: false, error: err.message };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function checkAndRotate() {
|
|
337
|
+
if (!password) return; // vault locked — skip
|
|
338
|
+
await loadState();
|
|
339
|
+
|
|
340
|
+
for (const [name, state] of rotationState) {
|
|
341
|
+
const config = ROTATION_CONFIG[name];
|
|
342
|
+
if (!config || config.policy !== "auto" || !config.rotate) continue;
|
|
343
|
+
if (!state.expires_at) continue;
|
|
344
|
+
|
|
345
|
+
const { daysLeft } = getExpiryStatus(state.expires_at);
|
|
346
|
+
if (daysLeft !== null && daysLeft <= WARN_DAYS) {
|
|
347
|
+
const msg = `[${new Date().toISOString()}] Rotation: ${name} expires in ${daysLeft} days — auto-rotating\n`;
|
|
348
|
+
try { fs.appendFileSync(logFile, msg); } catch {}
|
|
349
|
+
await rotateService(name);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function start() {
|
|
355
|
+
// Initial check after 30s (let daemon fully start)
|
|
356
|
+
setTimeout(() => {
|
|
357
|
+
checkAndRotate().catch(() => {});
|
|
358
|
+
}, 30000);
|
|
359
|
+
// Then every 6 hours
|
|
360
|
+
intervalHandle = setInterval(() => {
|
|
361
|
+
checkAndRotate().catch(() => {});
|
|
362
|
+
}, CHECK_INTERVAL_MS);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function stop() {
|
|
366
|
+
if (intervalHandle) clearInterval(intervalHandle);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getState() {
|
|
370
|
+
const result = {};
|
|
371
|
+
for (const [name, state] of rotationState) {
|
|
372
|
+
const expiry = getExpiryStatus(state.expires_at);
|
|
373
|
+
result[name] = { ...state, ...expiry };
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getLog() {
|
|
379
|
+
return rotationLog;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Set expiry for a service manually (from dashboard or API)
|
|
383
|
+
function setExpiry(serviceName, expiresAt, rotationDays) {
|
|
384
|
+
const existing = rotationState.get(serviceName) || {};
|
|
385
|
+
const config = ROTATION_CONFIG[serviceName] || { policy: "none", defaultDays: 0 };
|
|
386
|
+
rotationState.set(serviceName, {
|
|
387
|
+
...existing,
|
|
388
|
+
expires_at: expiresAt,
|
|
389
|
+
rotation_days: rotationDays || config.defaultDays,
|
|
390
|
+
policy: config.policy,
|
|
391
|
+
status: getExpiryStatus(expiresAt).status,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { start, stop, getState, getLog, rotateService, setExpiry, loadState, checkAndRotate };
|
|
396
|
+
}
|
|
59
397
|
|
|
60
398
|
const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
|
|
61
399
|
const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
|
|
@@ -801,6 +1139,7 @@ function renderServiceGrid(services) {
|
|
|
801
1139
|
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
|
802
1140
|
<div class="card-type">\${s.key_type || "secret"}</div>
|
|
803
1141
|
<span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
|
|
1142
|
+
<span class="expiry-badge" id="expiry-\${s.name}" style="font-size:.65rem;border-radius:3px;padding:1px 6px;display:none"></span>
|
|
804
1143
|
\${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${s.project}</span>\` : ""}
|
|
805
1144
|
</div>
|
|
806
1145
|
\${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
|
|
@@ -815,6 +1154,7 @@ function renderServiceGrid(services) {
|
|
|
815
1154
|
<button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
|
|
816
1155
|
<button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
|
|
817
1156
|
<button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
|
|
1157
|
+
<button class="btn-rotate" id="rotbtn-\${s.name}" style="display:none;background:#0e7490;border:1px solid #06b6d4;color:#cffafe;font-size:.75rem;padding:3px 8px;border-radius:4px;cursor:pointer" onclick="rotateKey('\${s.name}')">↻ Rotate</button>
|
|
818
1158
|
</div>
|
|
819
1159
|
<div class="project-edit" id="pe-\${s.name}">
|
|
820
1160
|
<input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
|
|
@@ -843,6 +1183,8 @@ async function loadServices() {
|
|
|
843
1183
|
|
|
844
1184
|
renderProjectTabs(allServices);
|
|
845
1185
|
renderServiceGrid(allServices);
|
|
1186
|
+
// Load expiry data after grid renders
|
|
1187
|
+
loadExpiry();
|
|
846
1188
|
} catch (e) {
|
|
847
1189
|
err.textContent = "⚠ " + e.message;
|
|
848
1190
|
err.style.display = "block";
|
|
@@ -851,6 +1193,92 @@ async function loadServices() {
|
|
|
851
1193
|
}
|
|
852
1194
|
}
|
|
853
1195
|
|
|
1196
|
+
// ── Expiry & Rotation ───────────────────────
|
|
1197
|
+
const ROTATION_CAPABLE = ["cloudflare","cloudfare-dns","npm","vultr-api"];
|
|
1198
|
+
|
|
1199
|
+
async function loadExpiry() {
|
|
1200
|
+
try {
|
|
1201
|
+
const data = await fetch(BASE + "/expiry").then(r => r.json());
|
|
1202
|
+
if (!data.services) return;
|
|
1203
|
+
for (const [name, info] of Object.entries(data.services)) {
|
|
1204
|
+
const el = document.getElementById("expiry-" + name);
|
|
1205
|
+
const rotBtn = document.getElementById("rotbtn-" + name);
|
|
1206
|
+
if (!el) continue;
|
|
1207
|
+
|
|
1208
|
+
// Show rotate button for auto-capable services
|
|
1209
|
+
if (rotBtn && ROTATION_CAPABLE.includes(name)) {
|
|
1210
|
+
rotBtn.style.display = "inline-block";
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (!info.expires_at) {
|
|
1214
|
+
// Show policy type for services with rotation config
|
|
1215
|
+
if (info.policy === "auto") {
|
|
1216
|
+
el.style.display = "inline";
|
|
1217
|
+
el.style.color = "#94a3b8";
|
|
1218
|
+
el.style.background = "rgba(148,163,184,0.1)";
|
|
1219
|
+
el.style.border = "1px solid rgba(148,163,184,0.2)";
|
|
1220
|
+
el.textContent = "no expiry set";
|
|
1221
|
+
}
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
el.style.display = "inline";
|
|
1226
|
+
const colors = { green: { c: "#4ade80", bg: "rgba(74,222,128,0.1)", b: "rgba(74,222,128,0.2)" },
|
|
1227
|
+
yellow: { c: "#facc15", bg: "rgba(250,204,21,0.1)", b: "rgba(250,204,21,0.2)" },
|
|
1228
|
+
orange: { c: "#fb923c", bg: "rgba(251,146,60,0.1)", b: "rgba(251,146,60,0.2)" },
|
|
1229
|
+
red: { c: "#f87171", bg: "rgba(248,113,113,0.15)", b: "rgba(248,113,113,0.3)" },
|
|
1230
|
+
gray: { c: "#94a3b8", bg: "rgba(148,163,184,0.1)", b: "rgba(148,163,184,0.2)" } };
|
|
1231
|
+
const clr = colors[info.color] || colors.gray;
|
|
1232
|
+
el.style.color = clr.c;
|
|
1233
|
+
el.style.background = clr.bg;
|
|
1234
|
+
el.style.border = "1px solid " + clr.b;
|
|
1235
|
+
|
|
1236
|
+
if (info.daysLeft === null) {
|
|
1237
|
+
el.textContent = "unknown";
|
|
1238
|
+
} else if (info.daysLeft < 0) {
|
|
1239
|
+
el.textContent = "EXPIRED " + Math.abs(info.daysLeft) + "d ago";
|
|
1240
|
+
} else if (info.daysLeft === 0) {
|
|
1241
|
+
el.textContent = "EXPIRES TODAY";
|
|
1242
|
+
} else {
|
|
1243
|
+
el.textContent = info.daysLeft + "d left";
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
} catch {}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function rotateKey(name) {
|
|
1250
|
+
if (!confirm("Rotate " + name + " key?\\n\\nA new key will be created and the old one deleted.")) return;
|
|
1251
|
+
const rotBtn = document.getElementById("rotbtn-" + name);
|
|
1252
|
+
if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
|
|
1253
|
+
try {
|
|
1254
|
+
const r = await fetch(BASE + "/rotate/" + name, { method: "POST" }).then(r => r.json());
|
|
1255
|
+
if (r.ok) {
|
|
1256
|
+
if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
|
|
1257
|
+
loadExpiry(); // refresh badges
|
|
1258
|
+
} else {
|
|
1259
|
+
alert("Rotation failed: " + (r.error || "unknown error"));
|
|
1260
|
+
if (rotBtn) { rotBtn.disabled = false; rotBtn.textContent = "↻ Rotate"; }
|
|
1261
|
+
}
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
alert("Rotation error: " + err.message);
|
|
1264
|
+
if (rotBtn) { rotBtn.disabled = false; rotBtn.textContent = "↻ Rotate"; }
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function setExpiry(name) {
|
|
1269
|
+
const days = prompt("Set expiry for " + name + " (days from now):", "30");
|
|
1270
|
+
if (!days) return;
|
|
1271
|
+
const expiresAt = new Date(Date.now() + parseInt(days) * 86400000).toISOString();
|
|
1272
|
+
try {
|
|
1273
|
+
await fetch(BASE + "/set-expiry/" + name, {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: { "Content-Type": "application/json" },
|
|
1276
|
+
body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
|
|
1277
|
+
});
|
|
1278
|
+
loadExpiry();
|
|
1279
|
+
} catch {}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
854
1282
|
// ── Reveal ──────────────────────────────────
|
|
855
1283
|
async function reveal(name, btn) {
|
|
856
1284
|
const valEl = document.getElementById("val-" + name);
|
|
@@ -1854,6 +2282,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
1854
2282
|
let password = initPassword || null; // null = locked; set via POST /auth
|
|
1855
2283
|
const machineHash = getMachineHash();
|
|
1856
2284
|
|
|
2285
|
+
// Rotation engine — starts after unlock
|
|
2286
|
+
const rotationEngine = createRotationEngine(initPassword, machineHash, LOG_FILE);
|
|
2287
|
+
|
|
1857
2288
|
const CORS = {
|
|
1858
2289
|
"Access-Control-Allow-Origin": "*",
|
|
1859
2290
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
@@ -2040,9 +2471,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2040
2471
|
}
|
|
2041
2472
|
}
|
|
2042
2473
|
|
|
2043
|
-
// Auto-start tunnel if vault is already unlocked (--pw flag)
|
|
2474
|
+
// Auto-start tunnel and rotation engine if vault is already unlocked (--pw flag)
|
|
2044
2475
|
if (password) {
|
|
2045
2476
|
startTunnel().catch(() => {});
|
|
2477
|
+
rotationEngine.start();
|
|
2046
2478
|
}
|
|
2047
2479
|
|
|
2048
2480
|
function strike(res, code, message) {
|
|
@@ -2859,6 +3291,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2859
3291
|
password = pw; // unlock — store in process memory only
|
|
2860
3292
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
2861
3293
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
3294
|
+
// Start rotation engine on unlock
|
|
3295
|
+
rotationEngine.start();
|
|
2862
3296
|
// Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
|
|
2863
3297
|
// Only on Windows; only if autostart dir exists (i.e., install was run)
|
|
2864
3298
|
if (os.platform() === "win32") {
|
|
@@ -2997,6 +3431,46 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2997
3431
|
}
|
|
2998
3432
|
}
|
|
2999
3433
|
|
|
3434
|
+
// ── Key Rotation Endpoints ──────────────────────────────────
|
|
3435
|
+
|
|
3436
|
+
// GET /expiry — expiry status for all services
|
|
3437
|
+
if (method === "GET" && reqPath === "/expiry") {
|
|
3438
|
+
if (lockedGuard(res)) return;
|
|
3439
|
+
const state = rotationEngine.getState();
|
|
3440
|
+
return ok(res, { services: state });
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
// POST /rotate/:service — manually trigger rotation for a service
|
|
3444
|
+
if (method === "POST" && reqPath.startsWith("/rotate/")) {
|
|
3445
|
+
if (lockedGuard(res)) return;
|
|
3446
|
+
const service = reqPath.slice("/rotate/".length);
|
|
3447
|
+
if (!service) {
|
|
3448
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3449
|
+
return res.end(JSON.stringify({ error: "Service name required" }));
|
|
3450
|
+
}
|
|
3451
|
+
const result = await rotationEngine.rotateService(service);
|
|
3452
|
+
return ok(res, result);
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// POST /set-expiry/:service — set expiry date for a service
|
|
3456
|
+
if (method === "POST" && reqPath.startsWith("/set-expiry/")) {
|
|
3457
|
+
if (lockedGuard(res)) return;
|
|
3458
|
+
const service = reqPath.slice("/set-expiry/".length);
|
|
3459
|
+
let body;
|
|
3460
|
+
try { body = await readBody(req); } catch {
|
|
3461
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3462
|
+
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
3463
|
+
}
|
|
3464
|
+
rotationEngine.setExpiry(service, body.expires_at, body.rotation_days);
|
|
3465
|
+
return ok(res, { ok: true, service, expires_at: body.expires_at });
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
// GET /rotation-log — recent rotation history
|
|
3469
|
+
if (method === "GET" && reqPath === "/rotation-log") {
|
|
3470
|
+
if (lockedGuard(res)) return;
|
|
3471
|
+
return ok(res, { log: rotationEngine.getLog() });
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3000
3474
|
// GET /tunnel/test — end-to-end tunnel health check (hits /ping through the tunnel)
|
|
3001
3475
|
if (method === "GET" && reqPath === "/tunnel/test") {
|
|
3002
3476
|
if (lockedGuard(res)) return;
|