@wolpertingerlabs/drawlatch 1.0.0-alpha.1 → 1.0.0-alpha.3
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/README.md +5 -3
- package/bin/drawlatch.js +713 -0
- package/dist/remote/server.d.ts +1 -1
- package/dist/remote/server.js +100 -22
- package/dist/remote/tunnel.d.ts +40 -0
- package/dist/remote/tunnel.js +116 -0
- package/dist/shared/config.d.ts +1 -0
- package/dist/shared/config.js +3 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Drawlatch
|
|
2
|
+
|
|
3
|
+
> **Alpha Software:** This project is in alpha. Expect breaking changes between updates.
|
|
2
4
|
|
|
3
5
|
A config-driven MCP (Model Context Protocol) proxy that lets Claude Code make authenticated HTTP requests to external APIs. Supports 22 pre-built API connections with endpoint allowlisting, per-caller access control, and real-time event ingestion — all configured through a single JSON file.
|
|
4
6
|
|
|
@@ -29,7 +31,7 @@ The crypto layer uses **Ed25519** signatures for authentication and **X25519 ECD
|
|
|
29
31
|
|
|
30
32
|
### Local Mode (In-Process Library)
|
|
31
33
|
|
|
32
|
-
In local mode, there is no separate server, no network port, and no encryption. Your application imports
|
|
34
|
+
In local mode, there is no separate server, no network port, and no encryption. Your application imports Drawlatch's core functions directly and calls them in-process:
|
|
33
35
|
|
|
34
36
|
```
|
|
35
37
|
┌──────────────────────────────────────────┐ Authenticated ┌──────────────┐
|
|
@@ -678,7 +680,7 @@ These additional protections apply when running the two-component remote archite
|
|
|
678
680
|
|
|
679
681
|
### Local Mode Caveat
|
|
680
682
|
|
|
681
|
-
When using
|
|
683
|
+
When using Drawlatch as an in-process library (local mode), secrets are resolved from `process.env` on the same machine as the agent. The encryption and mutual authentication layers are not used. The security value in local mode comes from **structured access control** (endpoint allowlisting, per-caller route isolation) rather than cryptographic secret isolation.
|
|
682
684
|
|
|
683
685
|
## License
|
|
684
686
|
|
package/bin/drawlatch.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ── Drawlatch CLI ─────────────────────────────────────────────────
|
|
4
|
+
// Entry point for the `drawlatch` command after global npm install.
|
|
5
|
+
// Provides daemon management for the remote server, key generation,
|
|
6
|
+
// log viewing, config introspection — all with zero extra dependencies.
|
|
7
|
+
// ───────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
openSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { stat } from "node:fs/promises";
|
|
20
|
+
import { join, resolve, dirname } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
// ── Paths & constants ─────────────────────────────────────────────
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
const PKG_ROOT = resolve(__dirname, "..");
|
|
27
|
+
const SERVER_ENTRY = join(PKG_ROOT, "dist/remote/server.js");
|
|
28
|
+
const GENERATE_KEYS_ENTRY = join(PKG_ROOT, "dist/cli/generate-keys.js");
|
|
29
|
+
|
|
30
|
+
// Import config helpers from compiled drawlatch code
|
|
31
|
+
const { getConfigDir, getEnvFilePath, loadRemoteConfig } = await import(
|
|
32
|
+
join(PKG_ROOT, "dist/shared/config.js")
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const CONFIG_DIR = getConfigDir();
|
|
36
|
+
const ENV_FILE = getEnvFilePath();
|
|
37
|
+
const PID_FILE = join(CONFIG_DIR, "drawlatch.pid");
|
|
38
|
+
const LOG_DIR = join(CONFIG_DIR, "logs");
|
|
39
|
+
const LOG_FILE = join(LOG_DIR, "drawlatch.log");
|
|
40
|
+
|
|
41
|
+
// Read version from package.json
|
|
42
|
+
const pkgJson = JSON.parse(
|
|
43
|
+
readFileSync(join(PKG_ROOT, "package.json"), "utf-8"),
|
|
44
|
+
);
|
|
45
|
+
const VERSION = pkgJson.version;
|
|
46
|
+
|
|
47
|
+
// ── Argument parsing ──────────────────────────────────────────────
|
|
48
|
+
const rawArgs = process.argv.slice(2);
|
|
49
|
+
const subcommand =
|
|
50
|
+
rawArgs[0] && !rawArgs[0].startsWith("-") ? rawArgs.shift() : null;
|
|
51
|
+
|
|
52
|
+
let values, positionals;
|
|
53
|
+
try {
|
|
54
|
+
({ values, positionals } = parseArgs({
|
|
55
|
+
args: rawArgs,
|
|
56
|
+
options: {
|
|
57
|
+
help: { type: "boolean", short: "h", default: false },
|
|
58
|
+
version: { type: "boolean", short: "v", default: false },
|
|
59
|
+
foreground: { type: "boolean", short: "f", default: false },
|
|
60
|
+
tunnel: { type: "boolean", short: "t", default: false },
|
|
61
|
+
port: { type: "string" },
|
|
62
|
+
host: { type: "string" },
|
|
63
|
+
lines: { type: "string", short: "n", default: "50" },
|
|
64
|
+
follow: { type: "boolean", default: true },
|
|
65
|
+
path: { type: "boolean", default: false },
|
|
66
|
+
},
|
|
67
|
+
strict: false,
|
|
68
|
+
allowPositionals: true,
|
|
69
|
+
}));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`Error: ${err.message}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Dispatch ──────────────────────────────────────────────────────
|
|
76
|
+
if (values.version) {
|
|
77
|
+
console.log(VERSION);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
if (values.help && !subcommand) {
|
|
81
|
+
printHelp();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (subcommand) {
|
|
86
|
+
case null:
|
|
87
|
+
await cmdDefault();
|
|
88
|
+
break;
|
|
89
|
+
case "start":
|
|
90
|
+
if (values.help) {
|
|
91
|
+
printStartHelp();
|
|
92
|
+
} else {
|
|
93
|
+
await cmdStart();
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case "stop":
|
|
97
|
+
if (values.help) {
|
|
98
|
+
printStopHelp();
|
|
99
|
+
} else {
|
|
100
|
+
await cmdStop();
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case "restart":
|
|
104
|
+
if (values.help) {
|
|
105
|
+
printRestartHelp();
|
|
106
|
+
} else {
|
|
107
|
+
await cmdRestart();
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
case "status":
|
|
111
|
+
if (values.help) {
|
|
112
|
+
printStatusHelp();
|
|
113
|
+
} else {
|
|
114
|
+
await cmdStatus();
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
case "logs":
|
|
118
|
+
if (values.help) {
|
|
119
|
+
printLogsHelp();
|
|
120
|
+
} else {
|
|
121
|
+
await cmdLogs();
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
case "config":
|
|
125
|
+
if (values.help) {
|
|
126
|
+
printConfigHelp();
|
|
127
|
+
} else {
|
|
128
|
+
cmdConfig();
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "generate-keys":
|
|
132
|
+
if (values.help) {
|
|
133
|
+
printGenerateKeysHelp();
|
|
134
|
+
} else {
|
|
135
|
+
await cmdGenerateKeys();
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
case "help":
|
|
139
|
+
printHelp();
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
console.error(`Unknown command: ${subcommand}\n`);
|
|
143
|
+
printHelp();
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Commands ──────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async function cmdDefault() {
|
|
150
|
+
const pid = readPid();
|
|
151
|
+
if (pid) {
|
|
152
|
+
await cmdStatus();
|
|
153
|
+
} else {
|
|
154
|
+
console.log("Drawlatch remote server is not running.\n");
|
|
155
|
+
printHelp();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function cmdStart() {
|
|
160
|
+
if (values.foreground) return cmdStartForeground();
|
|
161
|
+
|
|
162
|
+
ensureConfigDir();
|
|
163
|
+
|
|
164
|
+
const existingPid = readPid();
|
|
165
|
+
if (existingPid) {
|
|
166
|
+
console.log(`Remote server is already running (PID ${existingPid}).`);
|
|
167
|
+
console.log(` Use: drawlatch status`);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const config = loadRemoteConfig();
|
|
172
|
+
const port = values.port ? parseInt(values.port, 10) : config.port;
|
|
173
|
+
const host = values.host || config.host;
|
|
174
|
+
|
|
175
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
176
|
+
const logFd = openSync(LOG_FILE, "a");
|
|
177
|
+
|
|
178
|
+
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
|
179
|
+
detached: true,
|
|
180
|
+
stdio: ["ignore", logFd, logFd],
|
|
181
|
+
env: {
|
|
182
|
+
...process.env,
|
|
183
|
+
NODE_ENV: "production",
|
|
184
|
+
...(values.port ? { DRAWLATCH_PORT: String(port) } : {}),
|
|
185
|
+
...(values.host ? { DRAWLATCH_HOST: host } : {}),
|
|
186
|
+
...(values.tunnel ? { DRAWLATCH_TUNNEL: "1" } : {}),
|
|
187
|
+
},
|
|
188
|
+
cwd: PKG_ROOT,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
writeFileSync(PID_FILE, String(child.pid) + "\n");
|
|
192
|
+
child.unref();
|
|
193
|
+
|
|
194
|
+
console.log(`Starting drawlatch remote server on ${host}:${port}...`);
|
|
195
|
+
const healthy = await waitForHealth(host, port, 5000);
|
|
196
|
+
|
|
197
|
+
if (healthy) {
|
|
198
|
+
console.log(`\nRemote server is running (PID ${child.pid}).`);
|
|
199
|
+
console.log(` Listening: ${host}:${port}`);
|
|
200
|
+
if (values.tunnel) {
|
|
201
|
+
// The tunnel starts asynchronously after the server is healthy —
|
|
202
|
+
// poll the health endpoint until the tunnel URL appears (up to 20s).
|
|
203
|
+
console.log(` Tunnel: waiting for cloudflared...`);
|
|
204
|
+
const tunnelUrl = await waitForTunnelUrl(host, port, 20000);
|
|
205
|
+
if (tunnelUrl) {
|
|
206
|
+
console.log(` Tunnel: ${tunnelUrl}`);
|
|
207
|
+
console.log(` Webhooks: ${tunnelUrl}/webhooks/<path>`);
|
|
208
|
+
} else {
|
|
209
|
+
console.log(` Tunnel: not available (check logs: drawlatch logs)`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
console.log(` Logs: drawlatch logs`);
|
|
213
|
+
} else {
|
|
214
|
+
console.log(
|
|
215
|
+
`\nServer started (PID ${child.pid}) but health check did not pass.`,
|
|
216
|
+
);
|
|
217
|
+
console.log(` Check logs: drawlatch logs`);
|
|
218
|
+
await diagnoseStartFailure();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function cmdStartForeground() {
|
|
223
|
+
process.env.NODE_ENV = process.env.NODE_ENV || "production";
|
|
224
|
+
if (values.port) process.env.DRAWLATCH_PORT = values.port;
|
|
225
|
+
if (values.host) process.env.DRAWLATCH_HOST = values.host;
|
|
226
|
+
if (values.tunnel) process.env.DRAWLATCH_TUNNEL = "1";
|
|
227
|
+
|
|
228
|
+
ensureConfigDir();
|
|
229
|
+
|
|
230
|
+
const { main } = await import(SERVER_ENTRY);
|
|
231
|
+
main();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function cmdStop() {
|
|
235
|
+
const pid = readPid();
|
|
236
|
+
if (!pid) {
|
|
237
|
+
console.log("Remote server is not running.");
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log(`Stopping remote server (PID ${pid})...`);
|
|
242
|
+
try {
|
|
243
|
+
process.kill(pid, "SIGTERM");
|
|
244
|
+
} catch {
|
|
245
|
+
// Process already gone
|
|
246
|
+
cleanPidFile();
|
|
247
|
+
console.log("Server stopped.");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const stopped = await waitForExit(pid, 5000);
|
|
252
|
+
if (!stopped) {
|
|
253
|
+
console.log("Server did not stop gracefully, sending SIGKILL...");
|
|
254
|
+
try {
|
|
255
|
+
process.kill(pid, "SIGKILL");
|
|
256
|
+
} catch {
|
|
257
|
+
// Already gone
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cleanPidFile();
|
|
262
|
+
console.log("Server stopped.");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function cmdRestart() {
|
|
266
|
+
const pid = readPid();
|
|
267
|
+
if (pid) {
|
|
268
|
+
// If the previous server had an active tunnel, carry the flag forward
|
|
269
|
+
// so the restarted server also starts a tunnel (unless --tunnel is
|
|
270
|
+
// already set or the user explicitly omitted it).
|
|
271
|
+
if (!values.tunnel) {
|
|
272
|
+
const config = loadRemoteConfig();
|
|
273
|
+
const prevHealth = await healthCheckFull(config.host, config.port);
|
|
274
|
+
if (prevHealth?.tunnelUrl) {
|
|
275
|
+
console.log("Previous server had an active tunnel — re-enabling --tunnel.");
|
|
276
|
+
values.tunnel = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await cmdStop();
|
|
280
|
+
}
|
|
281
|
+
await cmdStart();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function cmdStatus() {
|
|
285
|
+
const pid = readPid();
|
|
286
|
+
if (!pid) {
|
|
287
|
+
console.log("Drawlatch remote server is not running.");
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const config = loadRemoteConfig();
|
|
292
|
+
const port = config.port;
|
|
293
|
+
const host = config.host;
|
|
294
|
+
|
|
295
|
+
let uptime = "unknown";
|
|
296
|
+
try {
|
|
297
|
+
const pidStat = await stat(PID_FILE);
|
|
298
|
+
uptime = formatUptime(Date.now() - pidStat.mtimeMs);
|
|
299
|
+
} catch {
|
|
300
|
+
// Can't stat PID file
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const healthData = await healthCheckFull(host, port);
|
|
304
|
+
|
|
305
|
+
console.log("Drawlatch remote server is running.");
|
|
306
|
+
console.log(` PID: ${pid}`);
|
|
307
|
+
console.log(` Listening: ${host}:${port}`);
|
|
308
|
+
console.log(` Uptime: ${uptime}`);
|
|
309
|
+
console.log(
|
|
310
|
+
` Health: ${healthData ? "healthy" : "unhealthy (not responding)"}`,
|
|
311
|
+
);
|
|
312
|
+
if (healthData) {
|
|
313
|
+
console.log(` Active sessions: ${healthData.activeSessions}`);
|
|
314
|
+
if (healthData.tunnelUrl) {
|
|
315
|
+
console.log(` Tunnel: ${healthData.tunnelUrl}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function cmdLogs() {
|
|
321
|
+
if (!existsSync(LOG_FILE)) {
|
|
322
|
+
console.log("No log file found. Start the server first:");
|
|
323
|
+
console.log(" drawlatch start");
|
|
324
|
+
process.exit(0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const lines = parseInt(values.lines, 10) || 50;
|
|
328
|
+
const follow = values.follow;
|
|
329
|
+
|
|
330
|
+
const tailArgs = follow
|
|
331
|
+
? ["-n", String(lines), "-f", LOG_FILE]
|
|
332
|
+
: ["-n", String(lines), LOG_FILE];
|
|
333
|
+
|
|
334
|
+
const tail = spawn("tail", tailArgs, { stdio: "inherit" });
|
|
335
|
+
|
|
336
|
+
tail.on("error", () => {
|
|
337
|
+
// Fallback: read last N lines with Node.js if tail is not available
|
|
338
|
+
try {
|
|
339
|
+
const content = readFileSync(LOG_FILE, "utf-8");
|
|
340
|
+
const allLines = content.split("\n");
|
|
341
|
+
const lastLines = allLines.slice(-lines).join("\n");
|
|
342
|
+
console.log(lastLines);
|
|
343
|
+
if (follow) {
|
|
344
|
+
console.log(
|
|
345
|
+
"\n(Live following not available \u2014 'tail' command not found)",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
console.error(`Error reading log file: ${err.message}`);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Forward SIGINT to cleanly exit
|
|
355
|
+
process.on("SIGINT", () => {
|
|
356
|
+
tail.kill();
|
|
357
|
+
process.exit(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Wait for tail to exit (when using --no-follow)
|
|
361
|
+
await new Promise((res) => tail.on("close", res));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function cmdConfig() {
|
|
365
|
+
ensureConfigDir();
|
|
366
|
+
|
|
367
|
+
if (values.path) {
|
|
368
|
+
console.log(join(CONFIG_DIR, "remote.config.json"));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const config = loadRemoteConfig();
|
|
373
|
+
|
|
374
|
+
console.log(`\nDrawlatch Configuration`);
|
|
375
|
+
console.log(`=======================`);
|
|
376
|
+
|
|
377
|
+
console.log(`\nRemote Server:`);
|
|
378
|
+
console.log(` Host: ${config.host}`);
|
|
379
|
+
console.log(` Port: ${config.port}`);
|
|
380
|
+
console.log(` Rate limit: ${config.rateLimitPerMinute} req/min`);
|
|
381
|
+
console.log(` Local keys dir: ${config.localKeysDir}`);
|
|
382
|
+
|
|
383
|
+
const callerEntries = Object.entries(config.callers || {});
|
|
384
|
+
console.log(` Callers: ${callerEntries.length}`);
|
|
385
|
+
for (const [alias, caller] of callerEntries) {
|
|
386
|
+
console.log(
|
|
387
|
+
` ${alias}: ${caller.connections ? caller.connections.length : 0} connection(s)`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
console.log(
|
|
392
|
+
` Connectors: ${config.connectors ? config.connectors.length : 0}`,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
console.log(`\nPaths:`);
|
|
396
|
+
console.log(` Config dir: ${CONFIG_DIR}`);
|
|
397
|
+
console.log(` Env file: ${ENV_FILE}`);
|
|
398
|
+
console.log(` Remote cfg: ${join(CONFIG_DIR, "remote.config.json")}`);
|
|
399
|
+
console.log(` Proxy cfg: ${join(CONFIG_DIR, "proxy.config.json")}`);
|
|
400
|
+
console.log(` Logs: ${LOG_FILE}`);
|
|
401
|
+
console.log(` PID file: ${PID_FILE}`);
|
|
402
|
+
console.log();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function cmdGenerateKeys() {
|
|
406
|
+
// Forward all remaining positional args to the generate-keys script
|
|
407
|
+
const child = spawn(process.execPath, [GENERATE_KEYS_ENTRY, ...positionals], {
|
|
408
|
+
stdio: "inherit",
|
|
409
|
+
cwd: PKG_ROOT,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await new Promise((res) => child.on("close", res));
|
|
413
|
+
process.exit(child.exitCode ?? 0);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── PID utilities ─────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
function isProcessAlive(pid) {
|
|
419
|
+
try {
|
|
420
|
+
process.kill(pid, 0);
|
|
421
|
+
return true;
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return e.code === "EPERM"; // EPERM = alive but owned by another user
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function readPid() {
|
|
428
|
+
if (!existsSync(PID_FILE)) return null;
|
|
429
|
+
const raw = readFileSync(PID_FILE, "utf-8").trim();
|
|
430
|
+
const pid = parseInt(raw, 10);
|
|
431
|
+
if (isNaN(pid)) {
|
|
432
|
+
cleanPidFile();
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
if (!isProcessAlive(pid)) {
|
|
436
|
+
cleanPidFile();
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
return pid;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function cleanPidFile() {
|
|
443
|
+
try {
|
|
444
|
+
unlinkSync(PID_FILE);
|
|
445
|
+
} catch {
|
|
446
|
+
// Already gone
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Health check utilities ────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
async function healthCheck(host, port) {
|
|
453
|
+
try {
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
456
|
+
const res = await fetch(`http://${host}:${port}/health`, {
|
|
457
|
+
signal: controller.signal,
|
|
458
|
+
});
|
|
459
|
+
clearTimeout(timeout);
|
|
460
|
+
return res.ok;
|
|
461
|
+
} catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function healthCheckFull(host, port) {
|
|
467
|
+
try {
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
470
|
+
const res = await fetch(`http://${host}:${port}/health`, {
|
|
471
|
+
signal: controller.signal,
|
|
472
|
+
});
|
|
473
|
+
clearTimeout(timeout);
|
|
474
|
+
if (!res.ok) return null;
|
|
475
|
+
return await res.json();
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function waitForTunnelUrl(host, port, timeoutMs) {
|
|
482
|
+
const start = Date.now();
|
|
483
|
+
while (Date.now() - start < timeoutMs) {
|
|
484
|
+
const data = await healthCheckFull(host, port);
|
|
485
|
+
if (data?.tunnelUrl) return data.tunnelUrl;
|
|
486
|
+
await sleep(500);
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function waitForHealth(host, port, timeoutMs) {
|
|
492
|
+
const start = Date.now();
|
|
493
|
+
while (Date.now() - start < timeoutMs) {
|
|
494
|
+
if (await healthCheck(host, port)) return true;
|
|
495
|
+
await sleep(500);
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function waitForExit(pid, timeoutMs) {
|
|
501
|
+
const start = Date.now();
|
|
502
|
+
while (Date.now() - start < timeoutMs) {
|
|
503
|
+
if (!isProcessAlive(pid)) return true;
|
|
504
|
+
await sleep(250);
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Config utilities ──────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
function ensureConfigDir() {
|
|
512
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── Diagnostic utilities ──────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
async function diagnoseStartFailure() {
|
|
518
|
+
if (!existsSync(LOG_FILE)) return;
|
|
519
|
+
try {
|
|
520
|
+
const content = readFileSync(LOG_FILE, "utf-8");
|
|
521
|
+
const lines = content.split("\n").slice(-20);
|
|
522
|
+
const eaddrinuse = lines.find((l) => l.includes("EADDRINUSE"));
|
|
523
|
+
const eacces = lines.find((l) => l.includes("EACCES"));
|
|
524
|
+
if (eaddrinuse) {
|
|
525
|
+
console.log("\n Error: Port is already in use.");
|
|
526
|
+
console.log(" Another process may be using the same port.");
|
|
527
|
+
} else if (eacces) {
|
|
528
|
+
console.log("\n Error: Permission denied.");
|
|
529
|
+
console.log(" Try using a port >= 1024.");
|
|
530
|
+
}
|
|
531
|
+
} catch {
|
|
532
|
+
// Best effort
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Output / formatting ──────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
function formatUptime(ms) {
|
|
539
|
+
const s = Math.floor(ms / 1000);
|
|
540
|
+
const d = Math.floor(s / 86400);
|
|
541
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
542
|
+
const m = Math.floor((s % 3600) / 60);
|
|
543
|
+
if (d > 0) return `${d}d ${h}h ${m}m`;
|
|
544
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
545
|
+
return `${m}m`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function sleep(ms) {
|
|
549
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── Help text ─────────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
function printHelp() {
|
|
555
|
+
console.log(`
|
|
556
|
+
drawlatch v${VERSION}
|
|
557
|
+
|
|
558
|
+
Usage: drawlatch [command] [options]
|
|
559
|
+
|
|
560
|
+
Commands:
|
|
561
|
+
start Start the remote server (background by default)
|
|
562
|
+
stop Stop the background remote server
|
|
563
|
+
restart Restart the background remote server
|
|
564
|
+
status Show server status (PID, port, uptime, health, sessions)
|
|
565
|
+
logs View and follow remote server logs
|
|
566
|
+
config Show effective configuration
|
|
567
|
+
generate-keys Generate Ed25519 + X25519 keypairs
|
|
568
|
+
|
|
569
|
+
Options:
|
|
570
|
+
-h, --help Show this help message
|
|
571
|
+
-v, --version Show version number
|
|
572
|
+
|
|
573
|
+
Running 'drawlatch' with no arguments shows status (if running) or this help.
|
|
574
|
+
|
|
575
|
+
Examples:
|
|
576
|
+
drawlatch Show status or help
|
|
577
|
+
drawlatch start Start remote server in background
|
|
578
|
+
drawlatch start -f Start remote server in foreground
|
|
579
|
+
drawlatch start -f --tunnel Start with a public tunnel for webhooks
|
|
580
|
+
drawlatch start --port 8080 Start on a custom port
|
|
581
|
+
drawlatch status Check if server is running
|
|
582
|
+
drawlatch logs -n 100 View last 100 log lines
|
|
583
|
+
drawlatch generate-keys remote Generate remote server keypair
|
|
584
|
+
drawlatch generate-keys local mybot Generate local keypair for alias "mybot"
|
|
585
|
+
`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function printStartHelp() {
|
|
589
|
+
console.log(`
|
|
590
|
+
drawlatch start
|
|
591
|
+
|
|
592
|
+
Start the drawlatch remote server.
|
|
593
|
+
|
|
594
|
+
Usage: drawlatch start [options]
|
|
595
|
+
|
|
596
|
+
Options:
|
|
597
|
+
-f, --foreground Run in foreground (default when no command given)
|
|
598
|
+
-t, --tunnel Start a Cloudflare tunnel for webhook ingestion (requires cloudflared)
|
|
599
|
+
--port <number> Override the configured port
|
|
600
|
+
--host <address> Override the configured host
|
|
601
|
+
-h, --help Show this help message
|
|
602
|
+
|
|
603
|
+
By default, starts the server as a background daemon. The server
|
|
604
|
+
process ID is stored in ~/.drawlatch/drawlatch.pid.
|
|
605
|
+
`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function printStopHelp() {
|
|
609
|
+
console.log(`
|
|
610
|
+
drawlatch stop
|
|
611
|
+
|
|
612
|
+
Stop the drawlatch remote server.
|
|
613
|
+
|
|
614
|
+
Usage: drawlatch stop [options]
|
|
615
|
+
|
|
616
|
+
Options:
|
|
617
|
+
-h, --help Show this help message
|
|
618
|
+
|
|
619
|
+
Sends SIGTERM to the server process and waits for graceful shutdown.
|
|
620
|
+
Falls back to SIGKILL if the process does not exit within 5 seconds.
|
|
621
|
+
`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function printRestartHelp() {
|
|
625
|
+
console.log(`
|
|
626
|
+
drawlatch restart
|
|
627
|
+
|
|
628
|
+
Restart the drawlatch remote server.
|
|
629
|
+
|
|
630
|
+
Usage: drawlatch restart [options]
|
|
631
|
+
|
|
632
|
+
Options:
|
|
633
|
+
--port <number> Override the configured port
|
|
634
|
+
--host <address> Override the configured host
|
|
635
|
+
-h, --help Show this help message
|
|
636
|
+
|
|
637
|
+
Stops the running server (if any) and starts a new instance.
|
|
638
|
+
`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function printStatusHelp() {
|
|
642
|
+
console.log(`
|
|
643
|
+
drawlatch status
|
|
644
|
+
|
|
645
|
+
Show server status.
|
|
646
|
+
|
|
647
|
+
Usage: drawlatch status [options]
|
|
648
|
+
|
|
649
|
+
Options:
|
|
650
|
+
-h, --help Show this help message
|
|
651
|
+
|
|
652
|
+
Displays PID, host, port, uptime, health check result, and
|
|
653
|
+
active session count.
|
|
654
|
+
`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function printLogsHelp() {
|
|
658
|
+
console.log(`
|
|
659
|
+
drawlatch logs
|
|
660
|
+
|
|
661
|
+
View server logs.
|
|
662
|
+
|
|
663
|
+
Usage: drawlatch logs [options]
|
|
664
|
+
|
|
665
|
+
Options:
|
|
666
|
+
-n, --lines <number> Number of lines to show (default: 50)
|
|
667
|
+
--no-follow Print lines and exit (default: follow/tail)
|
|
668
|
+
-h, --help Show this help message
|
|
669
|
+
|
|
670
|
+
Log file: ~/.drawlatch/logs/drawlatch.log
|
|
671
|
+
`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function printConfigHelp() {
|
|
675
|
+
console.log(`
|
|
676
|
+
drawlatch config
|
|
677
|
+
|
|
678
|
+
Show effective configuration.
|
|
679
|
+
|
|
680
|
+
Usage: drawlatch config [options]
|
|
681
|
+
|
|
682
|
+
Options:
|
|
683
|
+
--path Print the config file path only
|
|
684
|
+
-h, --help Show this help message
|
|
685
|
+
|
|
686
|
+
Reads ~/.drawlatch/remote.config.json and displays the effective
|
|
687
|
+
server configuration including callers and connections.
|
|
688
|
+
`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function printGenerateKeysHelp() {
|
|
692
|
+
console.log(`
|
|
693
|
+
drawlatch generate-keys
|
|
694
|
+
|
|
695
|
+
Generate Ed25519 + X25519 keypairs for authentication and encryption.
|
|
696
|
+
|
|
697
|
+
Usage: drawlatch generate-keys <subcommand> [options]
|
|
698
|
+
|
|
699
|
+
Subcommands:
|
|
700
|
+
local [alias] Generate MCP proxy (local) keypair
|
|
701
|
+
Alias defaults to "default" if omitted.
|
|
702
|
+
Keys are stored in keys/local/<alias>/
|
|
703
|
+
remote Generate remote server keypair
|
|
704
|
+
--dir <path> Generate keypair in a custom directory
|
|
705
|
+
show <path> Show fingerprint of an existing keypair
|
|
706
|
+
|
|
707
|
+
Keys are saved as PEM files:
|
|
708
|
+
<dir>/signing.pub.pem Ed25519 public key (safe to share)
|
|
709
|
+
<dir>/signing.key.pem Ed25519 private key (keep secret!)
|
|
710
|
+
<dir>/exchange.pub.pem X25519 public key (safe to share)
|
|
711
|
+
<dir>/exchange.key.pem X25519 private key (keep secret!)
|
|
712
|
+
`);
|
|
713
|
+
}
|
package/dist/remote/server.d.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* - Maintains an audit log of all operations
|
|
13
13
|
* - Rate-limits requests per session
|
|
14
14
|
*/
|
|
15
|
-
import 'dotenv/config';
|
|
16
15
|
import { type RemoteServerConfig, type ResolvedRoute } from '../shared/config.js';
|
|
17
16
|
import { EncryptedChannel, type PublicKeyBundle } from '../shared/crypto/index.js';
|
|
18
17
|
import { HandshakeResponder, type HandshakeInit } from '../shared/protocol/index.js';
|
|
@@ -100,4 +99,5 @@ export interface CreateAppOptions {
|
|
|
100
99
|
ingestorManager?: IngestorManager;
|
|
101
100
|
}
|
|
102
101
|
export declare function createApp(options?: CreateAppOptions): import("express-serve-static-core").Express;
|
|
102
|
+
export declare function main(): void;
|
|
103
103
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/remote/server.js
CHANGED
|
@@ -12,13 +12,29 @@
|
|
|
12
12
|
* - Maintains an audit log of all operations
|
|
13
13
|
* - Rate-limits requests per session
|
|
14
14
|
*/
|
|
15
|
-
import 'dotenv
|
|
15
|
+
import dotenv from 'dotenv';
|
|
16
16
|
import express from 'express';
|
|
17
17
|
import fs from 'node:fs';
|
|
18
|
-
import { loadRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, resolvePlaceholders, } from '../shared/config.js';
|
|
18
|
+
import { loadRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, resolvePlaceholders, getEnvFilePath, } from '../shared/config.js';
|
|
19
19
|
import { loadKeyBundle, loadPublicKeys, EncryptedChannel, } from '../shared/crypto/index.js';
|
|
20
20
|
import { HandshakeResponder, } from '../shared/protocol/index.js';
|
|
21
21
|
import { IngestorManager } from './ingestors/index.js';
|
|
22
|
+
// ── Environment loading ─────────────────────────────────────────────────────
|
|
23
|
+
/** Load environment from ~/.drawlatch/.env, falling back to cwd .env (legacy). */
|
|
24
|
+
function loadEnvFile() {
|
|
25
|
+
const configDirEnvPath = getEnvFilePath();
|
|
26
|
+
if (fs.existsSync(configDirEnvPath)) {
|
|
27
|
+
dotenv.config({ path: configDirEnvPath });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Backward compat: fall back to cwd .env
|
|
31
|
+
const result = dotenv.config();
|
|
32
|
+
if (result.parsed) {
|
|
33
|
+
console.warn(`[remote] Loaded .env from working directory. ` +
|
|
34
|
+
`Move it to ${configDirEnvPath} for portable operation.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
loadEnvFile();
|
|
22
38
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
23
39
|
const sessions = new Map();
|
|
24
40
|
const pendingHandshakes = new Map();
|
|
@@ -446,9 +462,24 @@ export function createApp(options = {}) {
|
|
|
446
462
|
status: 'ok',
|
|
447
463
|
activeSessions: sessions.size,
|
|
448
464
|
uptime: process.uptime(),
|
|
465
|
+
tunnelUrl: process.env.DRAWLATCH_TUNNEL_URL ?? null,
|
|
449
466
|
});
|
|
450
467
|
});
|
|
451
468
|
// ── Webhook receiver ─────────────────────────────────────────────────
|
|
469
|
+
// Trello (and potentially other services) send a HEAD request to the
|
|
470
|
+
// callback URL to verify it is reachable before activating the webhook.
|
|
471
|
+
// Respond with 200 if at least one ingestor is registered for the path.
|
|
472
|
+
app.head('/webhooks/:path', (req, res) => {
|
|
473
|
+
const webhookPath = req.params.path;
|
|
474
|
+
const mgr = app.locals.ingestorManager;
|
|
475
|
+
const ingestors = mgr.getWebhookIngestors(webhookPath);
|
|
476
|
+
if (ingestors.length === 0) {
|
|
477
|
+
res.status(404).end();
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
res.status(200).end();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
452
483
|
app.post('/webhooks/:path', (req, res) => {
|
|
453
484
|
const webhookPath = req.params.path;
|
|
454
485
|
const mgr = app.locals.ingestorManager;
|
|
@@ -482,29 +513,80 @@ export function createApp(options = {}) {
|
|
|
482
513
|
return app;
|
|
483
514
|
}
|
|
484
515
|
// ── Start ──────────────────────────────────────────────────────────────────
|
|
485
|
-
function main() {
|
|
516
|
+
export function main() {
|
|
486
517
|
const config = loadRemoteConfig();
|
|
518
|
+
const port = process.env.DRAWLATCH_PORT ? parseInt(process.env.DRAWLATCH_PORT, 10) : config.port;
|
|
519
|
+
const host = process.env.DRAWLATCH_HOST ?? config.host;
|
|
520
|
+
const useTunnel = process.env.DRAWLATCH_TUNNEL === '1';
|
|
487
521
|
const app = createApp();
|
|
488
522
|
const ingestorManager = app.locals.ingestorManager;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
523
|
+
// Holds the tunnel stop function if a tunnel is active (set inside the
|
|
524
|
+
// listen callback, read by the shutdown handler — both share this scope).
|
|
525
|
+
let stopTunnel;
|
|
526
|
+
const server = app.listen(port, host, () => void (async () => {
|
|
527
|
+
console.log(`[remote] Secure remote server listening on ${host}:${port}`);
|
|
528
|
+
// If a tunnel was requested, start it before ingestors so that
|
|
529
|
+
// process.env.DRAWLATCH_TUNNEL_URL is available during secret resolution.
|
|
530
|
+
if (useTunnel) {
|
|
531
|
+
try {
|
|
532
|
+
const { startTunnel } = await import('./tunnel.js');
|
|
533
|
+
const tunnel = await startTunnel({ port, host });
|
|
534
|
+
stopTunnel = tunnel.stop;
|
|
535
|
+
process.env.DRAWLATCH_TUNNEL_URL = tunnel.url;
|
|
536
|
+
// Auto-populate callback URL env vars for webhook ingestors whose
|
|
537
|
+
// connection templates reference an env var that is not yet set.
|
|
538
|
+
for (const [callerAlias, _callerConfig] of Object.entries(config.callers)) {
|
|
539
|
+
const rawRoutes = resolveCallerRoutes(config, callerAlias);
|
|
540
|
+
for (const route of rawRoutes) {
|
|
541
|
+
const callbackTpl = route.ingestor?.webhook?.callbackUrl;
|
|
542
|
+
const webhookPath = route.ingestor?.webhook?.path;
|
|
543
|
+
if (!callbackTpl || !webhookPath)
|
|
544
|
+
continue;
|
|
545
|
+
// Extract env var name from "${VAR}" pattern
|
|
546
|
+
const match = /^\$\{(\w+)\}$/.exec(callbackTpl);
|
|
547
|
+
if (match) {
|
|
548
|
+
const envVar = match[1];
|
|
549
|
+
if (!process.env[envVar]) {
|
|
550
|
+
const fullUrl = `${tunnel.url}/webhooks/${webhookPath}`;
|
|
551
|
+
process.env[envVar] = fullUrl;
|
|
552
|
+
console.log(`[remote] Auto-set ${envVar}=${fullUrl}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
console.log(`[remote] Tunnel active: ${tunnel.url}`);
|
|
558
|
+
console.log(`[remote] Webhook URL: ${tunnel.url}/webhooks/<path>`);
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
console.error('[remote] Failed to start tunnel:', err);
|
|
562
|
+
console.error('[remote] Continuing without tunnel. Webhooks will only work on localhost.');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Start ingestors after tunnel (if any) is ready
|
|
492
566
|
ingestorManager.startAll().catch((err) => {
|
|
493
567
|
console.error('[remote] Failed to start ingestors:', err);
|
|
494
568
|
});
|
|
495
|
-
});
|
|
496
|
-
// Graceful shutdown: stop ingestors, then close the server.
|
|
569
|
+
})());
|
|
570
|
+
// Graceful shutdown: stop tunnel, then ingestors, then close the server.
|
|
497
571
|
const shutdown = () => {
|
|
498
572
|
console.log('[remote] Shutting down gracefully...');
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
.catch((err) => {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
573
|
+
// Stop tunnel first (fast — just kills a child process)
|
|
574
|
+
const tunnelDone = stopTunnel
|
|
575
|
+
? stopTunnel().catch((err) => {
|
|
576
|
+
console.error('[remote] Error stopping tunnel:', err);
|
|
577
|
+
})
|
|
578
|
+
: Promise.resolve();
|
|
579
|
+
void tunnelDone.then(() => {
|
|
580
|
+
ingestorManager
|
|
581
|
+
.stopAll()
|
|
582
|
+
.catch((err) => {
|
|
583
|
+
console.error('[remote] Error stopping ingestors:', err);
|
|
584
|
+
})
|
|
585
|
+
.finally(() => {
|
|
586
|
+
server.close(() => {
|
|
587
|
+
console.log('[remote] Server closed.');
|
|
588
|
+
process.exit(0);
|
|
589
|
+
});
|
|
508
590
|
});
|
|
509
591
|
});
|
|
510
592
|
// Force exit after 10 seconds if connections don't drain
|
|
@@ -518,12 +600,8 @@ function main() {
|
|
|
518
600
|
}
|
|
519
601
|
// Only run when executed directly (not when imported as a library).
|
|
520
602
|
// Check if the entry script is this file (covers both ts-node and compiled js).
|
|
521
|
-
// Also check PM2's pm_exec_path for cluster mode where argv[1] is PM2's internal module.
|
|
522
603
|
const entryScript = process.argv[1] ?? '';
|
|
523
|
-
const
|
|
524
|
-
const isDirectRun = entryScript.endsWith('remote/server.ts') ||
|
|
525
|
-
entryScript.endsWith('remote/server.js') ||
|
|
526
|
-
pm2ExecPath.endsWith('remote/server.js');
|
|
604
|
+
const isDirectRun = entryScript.endsWith('remote/server.ts') || entryScript.endsWith('remote/server.js');
|
|
527
605
|
if (isDirectRun) {
|
|
528
606
|
try {
|
|
529
607
|
main();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel management for exposing the local Drawlatch server to the internet.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a `cloudflared tunnel` process that creates a free Cloudflare Quick
|
|
5
|
+
* Tunnel, parses the assigned public URL from its stderr output, and provides
|
|
6
|
+
* graceful start/stop lifecycle management.
|
|
7
|
+
*
|
|
8
|
+
* The tunnel URL is injected into `process.env.DRAWLATCH_TUNNEL_URL` so that
|
|
9
|
+
* webhook ingestors can reference it during secret resolution (e.g., setting
|
|
10
|
+
* TRELLO_CALLBACK_URL=${DRAWLATCH_TUNNEL_URL}/webhooks/trello in .env).
|
|
11
|
+
*/
|
|
12
|
+
export interface TunnelOptions {
|
|
13
|
+
/** Local port the server is listening on. */
|
|
14
|
+
port: number;
|
|
15
|
+
/** Local host the server is bound to. */
|
|
16
|
+
host: string;
|
|
17
|
+
/** Timeout (ms) to wait for the tunnel URL to be assigned. Default: 15 000. */
|
|
18
|
+
timeout?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface TunnelResult {
|
|
21
|
+
/** The public HTTPS URL assigned by Cloudflare (e.g. https://abc.trycloudflare.com). */
|
|
22
|
+
url: string;
|
|
23
|
+
/** Gracefully stop the tunnel process. */
|
|
24
|
+
stop: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check whether the `cloudflared` binary is available on the system PATH.
|
|
28
|
+
* Resolves to `true` if available, `false` otherwise.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isCloudflaredAvailable(): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Start a Cloudflare Quick Tunnel that forwards traffic from a public
|
|
33
|
+
* `*.trycloudflare.com` URL to the local Drawlatch server.
|
|
34
|
+
*
|
|
35
|
+
* Resolves once the tunnel URL has been parsed from cloudflared's output.
|
|
36
|
+
* Rejects if `cloudflared` is not installed, fails to start, or does not
|
|
37
|
+
* emit a URL within the configured timeout.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startTunnel(options: TunnelOptions): Promise<TunnelResult>;
|
|
40
|
+
//# sourceMappingURL=tunnel.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel management for exposing the local Drawlatch server to the internet.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a `cloudflared tunnel` process that creates a free Cloudflare Quick
|
|
5
|
+
* Tunnel, parses the assigned public URL from its stderr output, and provides
|
|
6
|
+
* graceful start/stop lifecycle management.
|
|
7
|
+
*
|
|
8
|
+
* The tunnel URL is injected into `process.env.DRAWLATCH_TUNNEL_URL` so that
|
|
9
|
+
* webhook ingestors can reference it during secret resolution (e.g., setting
|
|
10
|
+
* TRELLO_CALLBACK_URL=${DRAWLATCH_TUNNEL_URL}/webhooks/trello in .env).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { createLogger } from '../shared/logger.js';
|
|
14
|
+
const log = createLogger('tunnel');
|
|
15
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
16
|
+
/** Regex to extract the Cloudflare Quick Tunnel URL from cloudflared output. */
|
|
17
|
+
const TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
18
|
+
const INSTALL_HINT = 'Install it: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
|
|
19
|
+
/**
|
|
20
|
+
* Check whether the `cloudflared` binary is available on the system PATH.
|
|
21
|
+
* Resolves to `true` if available, `false` otherwise.
|
|
22
|
+
*/
|
|
23
|
+
export async function isCloudflaredAvailable() {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const child = spawn('cloudflared', ['--version'], { stdio: 'ignore' });
|
|
26
|
+
child.on('error', () => resolve(false));
|
|
27
|
+
child.on('close', (code) => resolve(code === 0));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// ── Main entry point ─────────────────────────────────────────────────────
|
|
31
|
+
/**
|
|
32
|
+
* Start a Cloudflare Quick Tunnel that forwards traffic from a public
|
|
33
|
+
* `*.trycloudflare.com` URL to the local Drawlatch server.
|
|
34
|
+
*
|
|
35
|
+
* Resolves once the tunnel URL has been parsed from cloudflared's output.
|
|
36
|
+
* Rejects if `cloudflared` is not installed, fails to start, or does not
|
|
37
|
+
* emit a URL within the configured timeout.
|
|
38
|
+
*/
|
|
39
|
+
export async function startTunnel(options) {
|
|
40
|
+
const { port, host, timeout = 15_000 } = options;
|
|
41
|
+
// ── Pre-flight check ────────────────────────────────────────────────
|
|
42
|
+
const available = await isCloudflaredAvailable();
|
|
43
|
+
if (!available) {
|
|
44
|
+
throw new Error(`cloudflared binary not found. ${INSTALL_HINT}`);
|
|
45
|
+
}
|
|
46
|
+
// ── Spawn cloudflared ───────────────────────────────────────────────
|
|
47
|
+
const localUrl = `http://${host}:${port}`;
|
|
48
|
+
log.info(`Starting Cloudflare Quick Tunnel → ${localUrl}`);
|
|
49
|
+
const child = spawn('cloudflared', ['tunnel', '--url', localUrl, '--no-autoupdate'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let settled = false;
|
|
52
|
+
let tunnelUrl = null;
|
|
53
|
+
// ── Timeout guard ───────────────────────────────────────────────
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
if (!settled) {
|
|
56
|
+
settled = true;
|
|
57
|
+
child.kill('SIGTERM');
|
|
58
|
+
reject(new Error(`Timed out after ${timeout}ms waiting for tunnel URL`));
|
|
59
|
+
}
|
|
60
|
+
}, timeout);
|
|
61
|
+
// ── Parse URL from stderr (cloudflared logs to stderr) ──────────
|
|
62
|
+
const handleData = (chunk) => {
|
|
63
|
+
const line = chunk.toString('utf-8');
|
|
64
|
+
log.debug(line.trimEnd());
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
const match = TUNNEL_URL_RE.exec(line);
|
|
68
|
+
if (match) {
|
|
69
|
+
tunnelUrl = match[0];
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
log.info(`Tunnel URL: ${tunnelUrl}`);
|
|
73
|
+
resolve({ url: tunnelUrl, stop: stopTunnel });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
child.stderr?.on('data', handleData);
|
|
77
|
+
child.stdout?.on('data', handleData);
|
|
78
|
+
// ── Handle unexpected exit before URL is found ──────────────────
|
|
79
|
+
child.on('error', (err) => {
|
|
80
|
+
if (!settled) {
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(new Error(`cloudflared failed to start: ${err.message}`));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
child.on('close', (code) => {
|
|
87
|
+
if (!settled) {
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
reject(new Error(`cloudflared exited with code ${code} before emitting a URL`));
|
|
91
|
+
}
|
|
92
|
+
else if (tunnelUrl) {
|
|
93
|
+
// Tunnel was active but has now dropped
|
|
94
|
+
log.warn('cloudflared process exited unexpectedly — tunnel is down');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// ── Stop helper ─────────────────────────────────────────────────
|
|
98
|
+
async function stopTunnel() {
|
|
99
|
+
if (child.exitCode !== null)
|
|
100
|
+
return; // already exited
|
|
101
|
+
return new Promise((res) => {
|
|
102
|
+
const killTimer = setTimeout(() => {
|
|
103
|
+
log.warn('cloudflared did not exit in time, sending SIGKILL');
|
|
104
|
+
child.kill('SIGKILL');
|
|
105
|
+
}, 5_000);
|
|
106
|
+
child.on('close', () => {
|
|
107
|
+
clearTimeout(killTimer);
|
|
108
|
+
log.info('Tunnel stopped');
|
|
109
|
+
res();
|
|
110
|
+
});
|
|
111
|
+
child.kill('SIGTERM');
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=tunnel.js.map
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare function getKeysDir(): string;
|
|
|
26
26
|
export declare function getLocalKeysDir(): string;
|
|
27
27
|
export declare function getRemoteKeysDir(): string;
|
|
28
28
|
export declare function getPeerKeysDir(): string;
|
|
29
|
+
export declare function getEnvFilePath(): string;
|
|
29
30
|
/** MCP proxy (local) configuration */
|
|
30
31
|
export interface ProxyConfig {
|
|
31
32
|
/** Remote server URL */
|
package/dist/shared/config.js
CHANGED
|
@@ -45,6 +45,9 @@ export function getRemoteKeysDir() {
|
|
|
45
45
|
export function getPeerKeysDir() {
|
|
46
46
|
return path.join(getKeysDir(), 'peers');
|
|
47
47
|
}
|
|
48
|
+
export function getEnvFilePath() {
|
|
49
|
+
return path.join(getConfigDir(), '.env');
|
|
50
|
+
}
|
|
48
51
|
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
49
52
|
function proxyDefaults() {
|
|
50
53
|
return {
|
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wolpertingerlabs/drawlatch",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
4
|
"description": "Encrypted MCP proxy with mutual authentication. Local MCP server forwards requests through an encrypted channel to a remote secrets-holding server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/mcp/server.js",
|
|
7
7
|
"types": "./dist/mcp/server.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"drawlatch": "./bin/drawlatch.js"
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
12
|
"dist",
|
|
10
13
|
"!dist/**/*.test.*",
|
|
11
14
|
"!dist/**/*.js.map",
|
|
12
15
|
"!dist/**/*.d.ts.map",
|
|
16
|
+
"bin",
|
|
13
17
|
"README.md",
|
|
14
18
|
"CONNECTIONS.md",
|
|
15
19
|
"INGESTORS.md"
|
|
@@ -52,7 +56,6 @@
|
|
|
52
56
|
"generate-keys": "tsx src/cli/generate-keys.ts",
|
|
53
57
|
"start:remote": "NODE_ENV=production node dist/remote/server.js",
|
|
54
58
|
"start:mcp": "node dist/mcp/server.js",
|
|
55
|
-
"redeploy:prod": "node start-server.js",
|
|
56
59
|
"test": "vitest run",
|
|
57
60
|
"test:watch": "vitest",
|
|
58
61
|
"test:coverage": "vitest run --coverage",
|
|
@@ -61,7 +64,8 @@
|
|
|
61
64
|
"format": "prettier --write 'src/**/*.ts' '*.{json,ts,js}'",
|
|
62
65
|
"format:check": "prettier --check 'src/**/*.ts' '*.{json,ts,js}'",
|
|
63
66
|
"prepublishOnly": "npm run lint && npm test && npm run build",
|
|
64
|
-
"publish:dry-run": "npm publish --dry-run"
|
|
67
|
+
"publish:dry-run": "npm publish --dry-run",
|
|
68
|
+
"reload": "npm install --include=dev && npm run build && VERSION=$(node -p \"require('./package.json').version\") && npm pack --pack-destination /tmp && npm install -g \"/tmp/wolpertingerlabs-drawlatch-$VERSION.tgz\" && rm \"/tmp/wolpertingerlabs-drawlatch-$VERSION.tgz\""
|
|
65
69
|
},
|
|
66
70
|
"engines": {
|
|
67
71
|
"node": ">=22"
|