@lifeaitools/clauth 0.2.2 → 0.3.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 +378 -0
- package/cli/index.js +41 -1
- package/package.json +1 -1
- package/supabase/functions/auth-vault/index.ts +132 -235
- package/supabase/migrations/20260317_lockout.sql +26 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// cli/commands/serve.js
|
|
2
|
+
// Localhost-only credential daemon with daemon lifecycle management
|
|
3
|
+
// Binds 127.0.0.1 ONLY — unreachable from outside the machine
|
|
4
|
+
// 3 failed requests of any kind → process exits, requires manual restart
|
|
5
|
+
// Supports: start (background daemon), stop, restart, ping, foreground
|
|
6
|
+
|
|
7
|
+
import http from "http";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { getMachineHash, deriveToken } from "../fingerprint.js";
|
|
12
|
+
import * as api from "../api.js";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
|
|
15
|
+
const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
|
|
16
|
+
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
17
|
+
|
|
18
|
+
// ── PID helpers ──────────────────────────────────────────────
|
|
19
|
+
function readPid() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
|
22
|
+
const [pid, port] = raw.split(":");
|
|
23
|
+
return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writePid(pid, port) {
|
|
28
|
+
fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function removePid() {
|
|
32
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isProcessAlive(pid) {
|
|
36
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
40
|
+
function createServer(password, whitelist, port) {
|
|
41
|
+
const MAX_FAILS = 3;
|
|
42
|
+
let failCount = 0;
|
|
43
|
+
const machineHash = getMachineHash();
|
|
44
|
+
|
|
45
|
+
function strike(res, code, message) {
|
|
46
|
+
failCount++;
|
|
47
|
+
const remaining = MAX_FAILS - failCount;
|
|
48
|
+
const logLine = `[${new Date().toISOString()}] [FAIL ${failCount}/${MAX_FAILS}] ${message}\n`;
|
|
49
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
50
|
+
|
|
51
|
+
const body = JSON.stringify({
|
|
52
|
+
error: message,
|
|
53
|
+
failures: failCount,
|
|
54
|
+
failures_remaining: remaining,
|
|
55
|
+
...(failCount >= MAX_FAILS ? { shutdown: true } : {})
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
59
|
+
res.end(body);
|
|
60
|
+
|
|
61
|
+
if (failCount >= MAX_FAILS) {
|
|
62
|
+
const msg = `[${new Date().toISOString()}] Failure limit reached — shutting down\n`;
|
|
63
|
+
try { fs.appendFileSync(LOG_FILE, msg); } catch {}
|
|
64
|
+
removePid();
|
|
65
|
+
setTimeout(() => process.exit(1), 100);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ok(res, data) {
|
|
70
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
71
|
+
res.end(JSON.stringify(data));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const server = http.createServer(async (req, res) => {
|
|
75
|
+
// Hard reject anything not from loopback
|
|
76
|
+
const remote = req.socket.remoteAddress;
|
|
77
|
+
const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
78
|
+
if (!isLocal) {
|
|
79
|
+
return strike(res, 403, `Rejected non-local address: ${remote}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
83
|
+
const reqPath = url.pathname;
|
|
84
|
+
const method = req.method;
|
|
85
|
+
|
|
86
|
+
// GET /ping
|
|
87
|
+
if (method === "GET" && reqPath === "/ping") {
|
|
88
|
+
return ok(res, {
|
|
89
|
+
status: "ok",
|
|
90
|
+
pid: process.pid,
|
|
91
|
+
failures: failCount,
|
|
92
|
+
failures_remaining: MAX_FAILS - failCount,
|
|
93
|
+
services: whitelist || "all",
|
|
94
|
+
port
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// GET /shutdown (for daemon stop)
|
|
99
|
+
if (method === "GET" && reqPath === "/shutdown") {
|
|
100
|
+
ok(res, { ok: true, message: "shutting down" });
|
|
101
|
+
removePid();
|
|
102
|
+
setTimeout(() => process.exit(0), 100);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// GET /status
|
|
107
|
+
if (method === "GET" && reqPath === "/status") {
|
|
108
|
+
try {
|
|
109
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
110
|
+
const result = await api.status(password, machineHash, token, timestamp);
|
|
111
|
+
if (result.error) return strike(res, 502, result.error);
|
|
112
|
+
if (whitelist) {
|
|
113
|
+
result.services = (result.services || []).filter(
|
|
114
|
+
s => whitelist.includes(s.name.toLowerCase())
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return ok(res, result);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return strike(res, 502, err.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// GET /get/:service
|
|
124
|
+
const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_-]+)$/);
|
|
125
|
+
if (method === "GET" && getMatch) {
|
|
126
|
+
const service = getMatch[1].toLowerCase();
|
|
127
|
+
|
|
128
|
+
if (whitelist && !whitelist.includes(service)) {
|
|
129
|
+
return strike(res, 403, `Service '${service}' not in whitelist`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
134
|
+
const result = await api.retrieve(password, machineHash, token, timestamp, service);
|
|
135
|
+
if (result.error) return strike(res, 502, result.error);
|
|
136
|
+
return ok(res, { service, value: result.value, key_type: result.key_type });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return strike(res, 502, err.message);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Unknown route
|
|
143
|
+
return strike(res, 404, `Unknown endpoint: ${reqPath}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return server;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Actions ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async function verifyAuth(password) {
|
|
152
|
+
const machineHash = getMachineHash();
|
|
153
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
154
|
+
const result = await api.test(password, machineHash, token, timestamp);
|
|
155
|
+
if (result.error) throw new Error(result.error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function actionStart(opts) {
|
|
159
|
+
const port = parseInt(opts.port || "52437", 10);
|
|
160
|
+
const password = opts.pw;
|
|
161
|
+
const whitelist = opts.services
|
|
162
|
+
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
// Check for existing instance
|
|
166
|
+
const existing = readPid();
|
|
167
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
168
|
+
console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
|
|
169
|
+
console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// If we're the daemon child, run the server directly
|
|
174
|
+
if (process.env.__CLAUTH_DAEMON === "1") {
|
|
175
|
+
try {
|
|
176
|
+
await verifyAuth(password);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const msg = `[${new Date().toISOString()}] Auth failed: ${err.message}\n`;
|
|
179
|
+
fs.appendFileSync(LOG_FILE, msg);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const server = createServer(password, whitelist, port);
|
|
184
|
+
server.listen(port, "127.0.0.1", () => {
|
|
185
|
+
writePid(process.pid, port);
|
|
186
|
+
const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
187
|
+
fs.appendFileSync(LOG_FILE, msg);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
server.on("error", err => {
|
|
191
|
+
const msg = `[${new Date().toISOString()}] Server error: ${err.message}\n`;
|
|
192
|
+
fs.appendFileSync(LOG_FILE, msg);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const shutdown = () => { removePid(); process.exit(0); };
|
|
197
|
+
process.on("SIGTERM", shutdown);
|
|
198
|
+
process.on("SIGINT", shutdown);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Parent: verify auth first (show errors to user)
|
|
203
|
+
console.log(chalk.gray("\n Verifying vault credentials..."));
|
|
204
|
+
try {
|
|
205
|
+
await verifyAuth(password);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
console.log(chalk.green(" ✓ Vault auth verified"));
|
|
211
|
+
|
|
212
|
+
// Spawn detached daemon child
|
|
213
|
+
const { spawn } = await import("child_process");
|
|
214
|
+
const { fileURLToPath } = await import("url");
|
|
215
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
216
|
+
|
|
217
|
+
// Build args: node serve.js --action start --port N --pw PW [--services S]
|
|
218
|
+
const childArgs = [__filename, "--action", "start", "--port", String(port), "--pw", password];
|
|
219
|
+
if (opts.services) childArgs.push("--services", opts.services);
|
|
220
|
+
|
|
221
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
222
|
+
const child = spawn(process.execPath, childArgs, {
|
|
223
|
+
detached: true,
|
|
224
|
+
stdio: ["ignore", out, out],
|
|
225
|
+
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
226
|
+
});
|
|
227
|
+
child.unref();
|
|
228
|
+
|
|
229
|
+
// Give it a moment then verify
|
|
230
|
+
await new Promise(r => setTimeout(r, 800));
|
|
231
|
+
|
|
232
|
+
const info = readPid();
|
|
233
|
+
if (info && isProcessAlive(info.pid)) {
|
|
234
|
+
console.log(chalk.green(`\n 🔐 clauth serve started`));
|
|
235
|
+
console.log(chalk.gray(` PID: ${info.pid}`));
|
|
236
|
+
console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
|
|
237
|
+
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
238
|
+
console.log(chalk.gray(` Log: ${LOG_FILE}`));
|
|
239
|
+
console.log(chalk.gray(` Stop: clauth serve stop\n`));
|
|
240
|
+
} else {
|
|
241
|
+
console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function actionStop() {
|
|
247
|
+
const info = readPid();
|
|
248
|
+
if (!info) {
|
|
249
|
+
console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!isProcessAlive(info.pid)) {
|
|
254
|
+
console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
|
|
255
|
+
removePid();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Try HTTP shutdown first (clean)
|
|
260
|
+
try {
|
|
261
|
+
const resp = await fetch(`http://127.0.0.1:${info.port}/shutdown`);
|
|
262
|
+
if (resp.ok) {
|
|
263
|
+
await new Promise(r => setTimeout(r, 300));
|
|
264
|
+
console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
|
|
265
|
+
removePid();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
} catch {}
|
|
269
|
+
|
|
270
|
+
// Fallback: kill the process
|
|
271
|
+
try {
|
|
272
|
+
process.kill(info.pid, "SIGTERM");
|
|
273
|
+
await new Promise(r => setTimeout(r, 300));
|
|
274
|
+
console.log(chalk.green(`\n 🛑 clauth serve stopped via SIGTERM (PID ${info.pid})\n`));
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
|
|
277
|
+
}
|
|
278
|
+
removePid();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function actionPing() {
|
|
282
|
+
const info = readPid();
|
|
283
|
+
if (!info) {
|
|
284
|
+
console.log(chalk.red("\n clauth serve is not running (no PID file)\n"));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!isProcessAlive(info.pid)) {
|
|
289
|
+
console.log(chalk.red(`\n PID ${info.pid} is not alive (stale PID file)\n`));
|
|
290
|
+
removePid();
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const resp = await fetch(`http://127.0.0.1:${info.port}/ping`);
|
|
296
|
+
const data = await resp.json();
|
|
297
|
+
if (data.status === "ok") {
|
|
298
|
+
console.log(chalk.green(`\n ✅ clauth serve running`));
|
|
299
|
+
console.log(chalk.gray(` PID: ${info.pid}`));
|
|
300
|
+
console.log(chalk.gray(` Port: ${info.port}`));
|
|
301
|
+
console.log(chalk.gray(` Fails: ${data.failures}/${data.failures + data.failures_remaining}`));
|
|
302
|
+
console.log(chalk.gray(` Services: ${Array.isArray(data.services) ? data.services.join(", ") : data.services}\n`));
|
|
303
|
+
} else {
|
|
304
|
+
console.log(chalk.yellow(`\n PID alive but /ping returned unexpected response\n`));
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.log(chalk.yellow(`\n PID ${info.pid} alive but HTTP failed: ${err.message}\n`));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function actionRestart(opts) {
|
|
312
|
+
const info = readPid();
|
|
313
|
+
if (info && isProcessAlive(info.pid)) {
|
|
314
|
+
await actionStop();
|
|
315
|
+
await new Promise(r => setTimeout(r, 500));
|
|
316
|
+
}
|
|
317
|
+
await actionStart(opts);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function actionForeground(opts) {
|
|
321
|
+
const port = parseInt(opts.port || "52437", 10);
|
|
322
|
+
const password = opts.pw;
|
|
323
|
+
const whitelist = opts.services
|
|
324
|
+
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
325
|
+
: null;
|
|
326
|
+
|
|
327
|
+
console.log(chalk.gray("\n Verifying vault credentials..."));
|
|
328
|
+
try {
|
|
329
|
+
await verifyAuth(password);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(chalk.green(" ✓ Vault auth verified"));
|
|
336
|
+
console.log(chalk.gray(` Port: 127.0.0.1:${port}`));
|
|
337
|
+
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
338
|
+
console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
|
|
339
|
+
|
|
340
|
+
const server = createServer(password, whitelist, port);
|
|
341
|
+
server.listen(port, "127.0.0.1", () => {
|
|
342
|
+
writePid(process.pid, port);
|
|
343
|
+
console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
|
|
344
|
+
console.log(chalk.gray(" Ctrl+C to stop\n"));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
server.on("error", err => {
|
|
348
|
+
if (err.code === "EADDRINUSE") {
|
|
349
|
+
console.log(chalk.red(`\n Port ${port} already in use. Use --port to choose another.\n`));
|
|
350
|
+
} else {
|
|
351
|
+
console.log(chalk.red(`\n Server error: ${err.message}\n`));
|
|
352
|
+
}
|
|
353
|
+
process.exit(1);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
process.on("SIGINT", () => {
|
|
357
|
+
console.log(chalk.yellow("\n Stopping clauth serve...\n"));
|
|
358
|
+
removePid();
|
|
359
|
+
server.close(() => process.exit(0));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Export ────────────────────────────────────────────────────
|
|
364
|
+
export async function runServe(opts) {
|
|
365
|
+
const action = opts.action || "foreground";
|
|
366
|
+
|
|
367
|
+
switch (action) {
|
|
368
|
+
case "start": return actionStart(opts);
|
|
369
|
+
case "stop": return actionStop();
|
|
370
|
+
case "restart": return actionRestart(opts);
|
|
371
|
+
case "ping": return actionPing();
|
|
372
|
+
case "foreground": return actionForeground(opts);
|
|
373
|
+
default:
|
|
374
|
+
console.log(chalk.red(`\n Unknown serve action: ${action}`));
|
|
375
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground\n"));
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
}
|
package/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import * as api from "./api.js";
|
|
|
11
11
|
import os from "os";
|
|
12
12
|
|
|
13
13
|
const config = new Conf({ projectName: "clauth" });
|
|
14
|
-
const VERSION = "0.
|
|
14
|
+
const VERSION = "0.3.0";
|
|
15
15
|
|
|
16
16
|
// ============================================================
|
|
17
17
|
// Password prompt helper
|
|
@@ -53,6 +53,7 @@ program
|
|
|
53
53
|
import { runInstall } from './commands/install.js';
|
|
54
54
|
import { runUninstall } from './commands/uninstall.js';
|
|
55
55
|
import { runScrub } from './commands/scrub.js';
|
|
56
|
+
import { runServe } from './commands/serve.js';
|
|
56
57
|
|
|
57
58
|
program
|
|
58
59
|
.command('install')
|
|
@@ -457,4 +458,43 @@ program.addHelpText("beforeAll", chalk.cyan(`
|
|
|
457
458
|
v${VERSION} — LIFEAI Credential Vault
|
|
458
459
|
`));
|
|
459
460
|
|
|
461
|
+
// ──────────────────────────────────────────────
|
|
462
|
+
// clauth serve [action]
|
|
463
|
+
// ──────────────────────────────────────────────
|
|
464
|
+
program
|
|
465
|
+
.command("serve [action]")
|
|
466
|
+
.description("Manage localhost HTTP vault daemon (start|stop|restart|ping)")
|
|
467
|
+
.option("--port <n>", "Port (default: 52437)")
|
|
468
|
+
.option("-p, --pw <password>", "clauth password (required for start/restart)")
|
|
469
|
+
.option("--services <list>", "Comma-separated service whitelist (default: all)")
|
|
470
|
+
.option("--action <action>", "Internal: action override for daemon child")
|
|
471
|
+
.addHelpText("after", `
|
|
472
|
+
Actions:
|
|
473
|
+
start Start the server as a background daemon
|
|
474
|
+
stop Stop the running daemon
|
|
475
|
+
restart Stop + start
|
|
476
|
+
ping Check if the daemon is running
|
|
477
|
+
foreground Run in foreground (Ctrl+C to stop) — default if no action given
|
|
478
|
+
|
|
479
|
+
Examples:
|
|
480
|
+
clauth serve -p mypass Run in foreground (original behavior)
|
|
481
|
+
clauth serve start -p mypass Start as background daemon
|
|
482
|
+
clauth serve stop Stop the daemon
|
|
483
|
+
clauth serve ping Check status
|
|
484
|
+
clauth serve restart -p mypass Restart the daemon
|
|
485
|
+
clauth serve start --services github,vercel -p mypass
|
|
486
|
+
`)
|
|
487
|
+
.action(async (action, opts) => {
|
|
488
|
+
const resolvedAction = opts.action || action || "foreground";
|
|
489
|
+
|
|
490
|
+
// stop and ping don't need a password
|
|
491
|
+
if (!["stop", "ping"].includes(resolvedAction) && !opts.pw) {
|
|
492
|
+
console.log(chalk.red("\n --pw is required for serve mode\n"));
|
|
493
|
+
console.log(chalk.gray(" Example: clauth serve start --pw yourpassword\n"));
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
await runServe({ ...opts, action: resolvedAction });
|
|
498
|
+
});
|
|
499
|
+
|
|
460
500
|
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,326 +1,223 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Supabase Deno runtime
|
|
4
|
-
// Routes:
|
|
5
|
-
// POST /auth-vault/retrieve — validate HMAC, return key
|
|
6
|
-
// POST /auth-vault/write — write/update key in vault
|
|
7
|
-
// POST /auth-vault/enable — enable/disable service
|
|
8
|
-
// POST /auth-vault/add — add new service to registry
|
|
9
|
-
// POST /auth-vault/remove — remove service from registry
|
|
10
|
-
// POST /auth-vault/status — list all services + state
|
|
11
|
-
// POST /auth-vault/test — dry-run HMAC check only
|
|
12
|
-
// POST /auth-vault/rotate — flag a service for rotation
|
|
13
|
-
// POST /auth-vault/revoke — delete key from vault
|
|
14
|
-
// ============================================================
|
|
1
|
+
// clauth — auth-vault Edge Function v2
|
|
2
|
+
// Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
|
|
15
3
|
|
|
16
4
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
17
5
|
|
|
18
|
-
const SUPABASE_URL
|
|
19
|
-
const SERVICE_ROLE_KEY
|
|
20
|
-
const CLAUTH_HMAC_SALT
|
|
6
|
+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
|
7
|
+
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
8
|
+
const CLAUTH_HMAC_SALT = Deno.env.get("CLAUTH_HMAC_SALT")!;
|
|
9
|
+
const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
|
|
21
10
|
|
|
22
|
-
const
|
|
11
|
+
const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
|
|
12
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
async function sha256hex(input: string): Promise<string> {
|
|
29
|
-
const buf = await crypto.subtle.digest(
|
|
30
|
-
"SHA-256",
|
|
31
|
-
new TextEncoder().encode(input)
|
|
32
|
-
);
|
|
33
|
-
return Array.from(new Uint8Array(buf))
|
|
34
|
-
.map(b => b.toString(16).padStart(2, "0"))
|
|
35
|
-
.join("");
|
|
36
|
-
}
|
|
14
|
+
const RATE_LIMIT_MAX = 30;
|
|
15
|
+
const RATE_LIMIT_WINDOW = 60;
|
|
16
|
+
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
17
|
+
const MAX_FAIL_COUNT = 5;
|
|
37
18
|
|
|
38
19
|
async function hmacSha256(key: string, message: string): Promise<string> {
|
|
39
20
|
const cryptoKey = await crypto.subtle.importKey(
|
|
40
|
-
"raw",
|
|
41
|
-
|
|
42
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
43
|
-
false,
|
|
44
|
-
["sign"]
|
|
45
|
-
);
|
|
46
|
-
const sig = await crypto.subtle.sign(
|
|
47
|
-
"HMAC",
|
|
48
|
-
cryptoKey,
|
|
49
|
-
new TextEncoder().encode(message)
|
|
21
|
+
"raw", new TextEncoder().encode(key),
|
|
22
|
+
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
|
|
50
23
|
);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.join("");
|
|
24
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
|
|
25
|
+
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
54
26
|
}
|
|
55
27
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}): Promise<{ valid: boolean; reason?: string }> {
|
|
62
|
-
const now = Date.now();
|
|
28
|
+
function getClientIP(req: Request): string {
|
|
29
|
+
return req.headers.get("cf-connecting-ip") ||
|
|
30
|
+
req.headers.get("x-real-ip") ||
|
|
31
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
32
|
+
}
|
|
63
33
|
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
34
|
+
function checkIP(ip: string): { allowed: boolean; reason?: string } {
|
|
35
|
+
if (ALLOWED_IPS.length === 0) return { allowed: true };
|
|
36
|
+
if (ALLOWED_IPS.includes(ip)) return { allowed: true };
|
|
37
|
+
return { allowed: false, reason: `IP not whitelisted: ${ip}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
|
|
41
|
+
const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
|
|
42
|
+
const { count } = await sb.from("clauth_audit")
|
|
43
|
+
.select("id", { count: "exact", head: true })
|
|
44
|
+
.eq("machine_hash", machine_hash)
|
|
45
|
+
.gte("created_at", windowStart);
|
|
46
|
+
if ((count || 0) >= RATE_LIMIT_MAX) {
|
|
47
|
+
return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
|
|
67
48
|
}
|
|
49
|
+
return { allowed: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
|
|
68
55
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.from("clauth_machines")
|
|
73
|
-
.select("hmac_seed_hash, enabled")
|
|
74
|
-
.eq("machine_hash", body.machine_hash)
|
|
75
|
-
.single();
|
|
56
|
+
const { data: machine, error } = await sb.from("clauth_machines")
|
|
57
|
+
.select("hmac_seed_hash, enabled, fail_count, locked")
|
|
58
|
+
.eq("machine_hash", body.machine_hash).single();
|
|
76
59
|
|
|
77
|
-
if (error || !machine) {
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (!machine.enabled) {
|
|
81
|
-
return { valid: false, reason: "machine_disabled" };
|
|
82
|
-
}
|
|
60
|
+
if (error || !machine) return { valid: false, reason: "machine_not_found" };
|
|
61
|
+
if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
|
|
62
|
+
if (machine.locked) return { valid: false, reason: "machine_locked" };
|
|
83
63
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
|
|
87
|
-
const message = `${body.machine_hash}:${window}`;
|
|
64
|
+
const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
|
|
65
|
+
const message = `${body.machine_hash}:${window}`;
|
|
88
66
|
const expected = await hmacSha256(body.password, message);
|
|
89
67
|
|
|
90
68
|
if (expected !== body.token) {
|
|
91
|
-
|
|
69
|
+
const newCount = (machine.fail_count || 0) + 1;
|
|
70
|
+
const shouldLock = newCount >= MAX_FAIL_COUNT;
|
|
71
|
+
await sb.from("clauth_machines")
|
|
72
|
+
.update({ fail_count: newCount, locked: shouldLock })
|
|
73
|
+
.eq("machine_hash", body.machine_hash);
|
|
74
|
+
return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
|
|
92
75
|
}
|
|
93
76
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.from("clauth_machines")
|
|
97
|
-
.update({ last_seen: new Date().toISOString() })
|
|
77
|
+
await sb.from("clauth_machines")
|
|
78
|
+
.update({ last_seen: new Date().toISOString(), fail_count: 0 })
|
|
98
79
|
.eq("machine_hash", body.machine_hash);
|
|
99
|
-
|
|
100
80
|
return { valid: true };
|
|
101
81
|
}
|
|
102
82
|
|
|
103
|
-
async function auditLog(
|
|
104
|
-
sb
|
|
105
|
-
machine_hash: string,
|
|
106
|
-
service_name: string,
|
|
107
|
-
action: string,
|
|
108
|
-
result: string,
|
|
109
|
-
detail?: string
|
|
110
|
-
) {
|
|
111
|
-
await sb.from("clauth_audit").insert({
|
|
112
|
-
machine_hash, service_name, action, result, detail
|
|
113
|
-
});
|
|
83
|
+
async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
|
|
84
|
+
await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
|
|
114
85
|
}
|
|
115
86
|
|
|
116
|
-
|
|
117
|
-
// Route handlers
|
|
118
|
-
// ============================================================
|
|
119
|
-
|
|
120
|
-
async function handleRetrieve(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
|
|
87
|
+
async function handleRetrieve(sb: any, body: any, mh: string) {
|
|
121
88
|
const { service } = body;
|
|
122
89
|
if (!service) return { error: "service required" };
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!svc) {
|
|
131
|
-
await auditLog(sb, machine_hash, service, "retrieve", "fail", "service_not_found");
|
|
132
|
-
return { error: "service_not_found" };
|
|
133
|
-
}
|
|
134
|
-
if (!svc.enabled) {
|
|
135
|
-
await auditLog(sb, machine_hash, service, "retrieve", "denied", "service_disabled");
|
|
136
|
-
return { error: "service_disabled" };
|
|
137
|
-
}
|
|
138
|
-
if (!svc.vault_key) {
|
|
139
|
-
await auditLog(sb, machine_hash, service, "retrieve", "fail", "no_key_stored");
|
|
140
|
-
return { error: "no_key_stored" };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Retrieve from vault
|
|
144
|
-
const { data: secret } = await sb.rpc("vault_decrypt_secret", {
|
|
145
|
-
secret_name: svc.vault_key
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
await sb
|
|
149
|
-
.from("clauth_services")
|
|
150
|
-
.update({ last_retrieved: new Date().toISOString() })
|
|
151
|
-
.eq("name", service);
|
|
152
|
-
|
|
153
|
-
await auditLog(sb, machine_hash, service, "retrieve", "success");
|
|
90
|
+
const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
|
|
91
|
+
if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
|
|
92
|
+
if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
|
|
93
|
+
if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
|
|
94
|
+
const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
|
|
95
|
+
await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
|
|
96
|
+
await auditLog(sb, mh, service, "retrieve", "success");
|
|
154
97
|
return { service, key_type: svc.key_type, value: secret };
|
|
155
98
|
}
|
|
156
99
|
|
|
157
|
-
async function handleWrite(sb:
|
|
100
|
+
async function handleWrite(sb: any, body: any, mh: string) {
|
|
158
101
|
const { service, value } = body;
|
|
159
102
|
if (!service || !value) return { error: "service and value required" };
|
|
160
|
-
|
|
161
103
|
const vaultKey = `clauth.${service}`;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
secret_value: typeof value === "string" ? value : JSON.stringify(value)
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
if (error) {
|
|
170
|
-
await auditLog(sb, machine_hash, service, "write", "fail", error.message);
|
|
171
|
-
return { error: error.message };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Update registry
|
|
175
|
-
await sb
|
|
176
|
-
.from("clauth_services")
|
|
177
|
-
.update({ vault_key: vaultKey, last_rotated: new Date().toISOString() })
|
|
178
|
-
.eq("name", service);
|
|
179
|
-
|
|
180
|
-
await auditLog(sb, machine_hash, service, "write", "success");
|
|
104
|
+
const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
|
|
105
|
+
if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
|
|
106
|
+
await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
|
|
107
|
+
await auditLog(sb, mh, service, "write", "success");
|
|
181
108
|
return { success: true, service, vault_key: vaultKey };
|
|
182
109
|
}
|
|
183
110
|
|
|
184
|
-
async function handleEnable(sb:
|
|
185
|
-
const { service, enabled } = body;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (target) query = query.eq("name", target);
|
|
190
|
-
else query = query.not("vault_key", "is", null); // only enable services with keys
|
|
191
|
-
|
|
192
|
-
const { error } = await query;
|
|
111
|
+
async function handleEnable(sb: any, body: any, mh: string) {
|
|
112
|
+
const { service, enabled } = body;
|
|
113
|
+
let q = sb.from("clauth_services").update({ enabled });
|
|
114
|
+
q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
|
|
115
|
+
const { error } = await q;
|
|
193
116
|
if (error) return { error: error.message };
|
|
194
|
-
|
|
195
|
-
await auditLog(sb, machine_hash, service, enabled ? "enable" : "disable", "success");
|
|
117
|
+
await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
|
|
196
118
|
return { success: true, service, enabled };
|
|
197
119
|
}
|
|
198
120
|
|
|
199
|
-
async function handleAdd(sb:
|
|
121
|
+
async function handleAdd(sb: any, body: any, mh: string) {
|
|
200
122
|
const { name, label, key_type, description } = body;
|
|
201
123
|
if (!name || !label || !key_type) return { error: "name, label, key_type required" };
|
|
202
|
-
|
|
203
|
-
const { error } = await sb.from("clauth_services").insert({
|
|
204
|
-
name, label, key_type, description: description || null
|
|
205
|
-
});
|
|
206
|
-
|
|
124
|
+
const { error } = await sb.from("clauth_services").insert({ name, label, key_type, description: description || null });
|
|
207
125
|
if (error) return { error: error.message };
|
|
208
|
-
await auditLog(sb,
|
|
126
|
+
await auditLog(sb, mh, name, "add", "success");
|
|
209
127
|
return { success: true, name, label, key_type };
|
|
210
128
|
}
|
|
211
129
|
|
|
212
|
-
async function handleRemove(sb:
|
|
130
|
+
async function handleRemove(sb: any, body: any, mh: string) {
|
|
213
131
|
const { service, confirm } = body;
|
|
214
|
-
if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) {
|
|
215
|
-
return { error: "confirm phrase mismatch", required: `CONFIRM REMOVE ${service.toUpperCase()}` };
|
|
216
|
-
}
|
|
217
|
-
|
|
132
|
+
if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
|
|
218
133
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
219
134
|
await sb.from("clauth_services").delete().eq("name", service);
|
|
220
|
-
await auditLog(sb,
|
|
135
|
+
await auditLog(sb, mh, service, "remove", "success");
|
|
221
136
|
return { success: true, service };
|
|
222
137
|
}
|
|
223
138
|
|
|
224
|
-
async function handleRevoke(sb:
|
|
139
|
+
async function handleRevoke(sb: any, body: any, mh: string) {
|
|
225
140
|
const { service, confirm } = body;
|
|
226
141
|
const phrase = service === "all" ? "CONFIRM REVOKE ALL" : `CONFIRM REVOKE ${service.toUpperCase()}`;
|
|
227
|
-
if (confirm !== phrase) {
|
|
228
|
-
return { error: "confirm phrase mismatch", required: phrase };
|
|
229
|
-
}
|
|
230
|
-
|
|
142
|
+
if (confirm !== phrase) return { error: "confirm phrase mismatch", required: phrase };
|
|
231
143
|
if (service === "all") {
|
|
232
144
|
const { data: svcs } = await sb.from("clauth_services").select("name").not("vault_key", "is", null);
|
|
233
|
-
for (const s of svcs || []) {
|
|
234
|
-
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
|
|
235
|
-
}
|
|
145
|
+
for (const s of svcs || []) await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
|
|
236
146
|
await sb.from("clauth_services").update({ vault_key: null, enabled: false }).neq("id", "00000000-0000-0000-0000-000000000000");
|
|
237
147
|
} else {
|
|
238
148
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
239
149
|
await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
|
|
240
150
|
}
|
|
241
|
-
|
|
242
|
-
await auditLog(sb, machine_hash, service, "revoke", "success");
|
|
151
|
+
await auditLog(sb, mh, service, "revoke", "success");
|
|
243
152
|
return { success: true, service };
|
|
244
153
|
}
|
|
245
154
|
|
|
246
|
-
async function handleStatus(sb:
|
|
247
|
-
const { data: services } = await sb
|
|
248
|
-
.
|
|
249
|
-
|
|
250
|
-
.order("name");
|
|
251
|
-
|
|
252
|
-
await auditLog(sb, machine_hash, "all", "status", "success");
|
|
155
|
+
async function handleStatus(sb: any, mh: string) {
|
|
156
|
+
const { data: services } = await sb.from("clauth_services")
|
|
157
|
+
.select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at").order("name");
|
|
158
|
+
await auditLog(sb, mh, "all", "status", "success");
|
|
253
159
|
return { services: services || [] };
|
|
254
160
|
}
|
|
255
161
|
|
|
256
|
-
async function handleRegisterMachine(sb:
|
|
162
|
+
async function handleRegisterMachine(sb: any, body: any) {
|
|
257
163
|
const { machine_hash, hmac_seed_hash, label, admin_token } = body;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
machine_hash, hmac_seed_hash, label, enabled: true
|
|
264
|
-
}, { onConflict: "machine_hash" });
|
|
265
|
-
|
|
164
|
+
if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
|
|
165
|
+
const { error } = await sb.from("clauth_machines").upsert(
|
|
166
|
+
{ machine_hash, hmac_seed_hash, label, enabled: true, fail_count: 0, locked: false },
|
|
167
|
+
{ onConflict: "machine_hash" }
|
|
168
|
+
);
|
|
266
169
|
if (error) return { error: error.message };
|
|
267
170
|
return { success: true, machine_hash };
|
|
268
171
|
}
|
|
269
172
|
|
|
270
|
-
// ============================================================
|
|
271
|
-
// Main handler
|
|
272
|
-
// ============================================================
|
|
273
|
-
|
|
274
173
|
Deno.serve(async (req: Request) => {
|
|
275
|
-
const url = new URL(req.url);
|
|
276
|
-
const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
|
|
277
|
-
|
|
278
174
|
if (req.method === "OPTIONS") {
|
|
279
|
-
return new Response(null, {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
});
|
|
175
|
+
return new Response(null, { headers: {
|
|
176
|
+
"Access-Control-Allow-Origin": "*",
|
|
177
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
178
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
179
|
+
}});
|
|
286
180
|
}
|
|
181
|
+
if (req.method !== "POST") return Response.json({ error: "POST only" }, { status: 405 });
|
|
287
182
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
183
|
+
const url = new URL(req.url);
|
|
184
|
+
const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
|
|
185
|
+
const body = await req.json().catch(() => ({}));
|
|
186
|
+
const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
|
187
|
+
const ip = getClientIP(req);
|
|
291
188
|
|
|
292
|
-
|
|
293
|
-
const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
|
189
|
+
if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
|
|
294
190
|
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
191
|
+
const ipCheck = checkIP(ip);
|
|
192
|
+
if (!ipCheck.allowed) {
|
|
193
|
+
await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
|
|
194
|
+
return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
|
|
298
195
|
}
|
|
299
196
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
197
|
+
if (body.machine_hash) {
|
|
198
|
+
const rateCheck = await checkRateLimit(sb, body.machine_hash);
|
|
199
|
+
if (!rateCheck.allowed) {
|
|
200
|
+
await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
|
|
201
|
+
return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
307
204
|
|
|
205
|
+
const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
|
|
308
206
|
if (!authResult.valid) {
|
|
309
207
|
await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
|
|
310
208
|
return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
|
|
311
209
|
}
|
|
312
210
|
|
|
313
|
-
const
|
|
314
|
-
|
|
211
|
+
const mh = body.machine_hash;
|
|
315
212
|
switch (route) {
|
|
316
|
-
case "retrieve":
|
|
317
|
-
case "write":
|
|
318
|
-
case "enable":
|
|
319
|
-
case "add":
|
|
320
|
-
case "remove":
|
|
321
|
-
case "revoke":
|
|
322
|
-
case "status":
|
|
323
|
-
case "test":
|
|
324
|
-
default:
|
|
213
|
+
case "retrieve": return Response.json(await handleRetrieve(sb, body, mh));
|
|
214
|
+
case "write": return Response.json(await handleWrite(sb, body, mh));
|
|
215
|
+
case "enable": return Response.json(await handleEnable(sb, body, mh));
|
|
216
|
+
case "add": return Response.json(await handleAdd(sb, body, mh));
|
|
217
|
+
case "remove": return Response.json(await handleRemove(sb, body, mh));
|
|
218
|
+
case "revoke": return Response.json(await handleRevoke(sb, body, mh));
|
|
219
|
+
case "status": return Response.json(await handleStatus(sb, mh));
|
|
220
|
+
case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
|
|
221
|
+
default: return Response.json({ error: "unknown_route", route }, { status: 404 });
|
|
325
222
|
}
|
|
326
223
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Migration: add fail_count and locked to clauth_machines
|
|
2
|
+
-- Run via: clauth install (picks this up automatically) or apply manually
|
|
3
|
+
|
|
4
|
+
ALTER TABLE clauth_machines
|
|
5
|
+
ADD COLUMN IF NOT EXISTS fail_count INTEGER NOT NULL DEFAULT 0,
|
|
6
|
+
ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false;
|
|
7
|
+
|
|
8
|
+
-- Index for fast lockout checks
|
|
9
|
+
CREATE INDEX IF NOT EXISTS idx_clauth_machines_locked
|
|
10
|
+
ON clauth_machines (machine_hash, locked);
|
|
11
|
+
|
|
12
|
+
-- Admin helper: unlock a machine
|
|
13
|
+
-- Usage: SELECT clauth_unlock_machine('your_machine_hash');
|
|
14
|
+
CREATE OR REPLACE FUNCTION clauth_unlock_machine(p_machine_hash TEXT)
|
|
15
|
+
RETURNS void
|
|
16
|
+
LANGUAGE plpgsql
|
|
17
|
+
SECURITY DEFINER
|
|
18
|
+
AS $$
|
|
19
|
+
BEGIN
|
|
20
|
+
UPDATE clauth_machines
|
|
21
|
+
SET locked = false, fail_count = 0
|
|
22
|
+
WHERE machine_hash = p_machine_hash;
|
|
23
|
+
END;
|
|
24
|
+
$$;
|
|
25
|
+
|
|
26
|
+
REVOKE EXECUTE ON FUNCTION clauth_unlock_machine(TEXT) FROM PUBLIC;
|