@wolpertingerlabs/drawlatch 1.0.0-alpha.2 → 1.0.0-alpha.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONNECTIONS.md +3 -0
- package/README.md +395 -465
- package/bin/drawlatch.js +1018 -49
- package/dist/auth/auth.d.ts +10 -0
- package/dist/auth/auth.js +146 -0
- package/dist/auth/env-writer.d.ts +6 -0
- package/dist/auth/env-writer.js +52 -0
- package/dist/auth/password.d.ts +8 -0
- package/dist/auth/password.js +28 -0
- package/dist/auth/sessions.d.ts +13 -0
- package/dist/auth/sessions.js +85 -0
- package/dist/cli/generate-keys.d.ts +4 -4
- package/dist/cli/generate-keys.js +30 -25
- package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
- package/dist/connections/{devin.json → ai/devin.json} +7 -1
- package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
- package/dist/connections/{openai.json → ai/openai.json} +7 -1
- package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
- package/dist/connections/developer-tools/github.json +138 -0
- package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
- package/dist/connections/developer-tools/linear.json +75 -0
- package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
- package/dist/connections/messaging/agentmail.json +21 -0
- package/dist/connections/messaging/discord-bot.json +114 -0
- package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
- package/dist/connections/messaging/slack.json +75 -0
- package/dist/connections/messaging/telegram.json +66 -0
- package/dist/connections/{google.json → productivity/google.json} +7 -1
- package/dist/connections/productivity/notion.json +75 -0
- package/dist/connections/productivity/stripe.json +74 -0
- package/dist/connections/productivity/trello.json +113 -0
- package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
- package/dist/connections/social-media/mastodon.json +65 -0
- package/dist/connections/social-media/reddit.json +80 -0
- package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
- package/dist/connections/social-media/x.json +67 -0
- package/dist/mcp/server.js +544 -31
- package/dist/remote/admin-mutations.d.ts +37 -0
- package/dist/remote/admin-mutations.js +251 -0
- package/dist/remote/admin-types.d.ts +149 -0
- package/dist/remote/admin-types.js +11 -0
- package/dist/remote/admin.d.ts +37 -0
- package/dist/remote/admin.js +316 -0
- package/dist/remote/caller-bootstrap.d.ts +71 -0
- package/dist/remote/caller-bootstrap.js +141 -0
- package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
- package/dist/remote/ingestors/base-ingestor.js +27 -6
- package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
- package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
- package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
- package/dist/remote/ingestors/e2e/setup.js +147 -0
- package/dist/remote/ingestors/manager.d.ts +110 -10
- package/dist/remote/ingestors/manager.js +449 -42
- package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
- package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
- package/dist/remote/ingestors/registry.d.ts +2 -2
- package/dist/remote/ingestors/registry.js +2 -2
- package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
- package/dist/remote/ingestors/slack/socket-mode.js +28 -6
- package/dist/remote/ingestors/types.d.ts +25 -0
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
- package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
- package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
- package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
- package/dist/remote/server.d.ts +25 -33
- package/dist/remote/server.js +681 -195
- package/dist/remote/tool-dispatch.d.ts +84 -0
- package/dist/remote/tool-dispatch.js +910 -0
- package/dist/remote/triggers/rule-engine.d.ts +40 -0
- package/dist/remote/triggers/rule-engine.js +228 -0
- package/dist/remote/triggers/types.d.ts +69 -0
- package/dist/remote/triggers/types.js +10 -0
- package/dist/remote/tunnel-state.d.ts +14 -0
- package/dist/remote/tunnel-state.js +20 -0
- package/dist/remote/tunnel.d.ts +53 -0
- package/dist/remote/tunnel.js +149 -0
- package/dist/shared/config.d.ts +97 -25
- package/dist/shared/config.js +48 -35
- package/dist/shared/connections.d.ts +21 -5
- package/dist/shared/connections.js +56 -14
- package/dist/shared/crypto/index.d.ts +1 -0
- package/dist/shared/crypto/index.js +1 -0
- package/dist/shared/crypto/key-manager.d.ts +67 -0
- package/dist/shared/crypto/key-manager.js +152 -0
- package/dist/shared/env-utils.d.ts +42 -0
- package/dist/shared/env-utils.js +150 -0
- package/dist/shared/listener-config.d.ts +157 -0
- package/dist/shared/listener-config.js +10 -0
- package/dist/shared/migrations.d.ts +40 -0
- package/dist/shared/migrations.js +122 -0
- package/dist/shared/protocol/handshake.js +14 -1
- package/dist/shared/protocol/index.d.ts +2 -0
- package/dist/shared/protocol/index.js +2 -0
- package/dist/shared/protocol/sync-client.d.ts +52 -0
- package/dist/shared/protocol/sync-client.js +99 -0
- package/dist/shared/protocol/sync.d.ts +71 -0
- package/dist/shared/protocol/sync.js +176 -0
- package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
- package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
- package/frontend/dist/index.html +15 -0
- package/package.json +66 -13
- package/dist/connections/discord-bot.json +0 -24
- package/dist/connections/github.json +0 -25
- package/dist/connections/linear.json +0 -29
- package/dist/connections/mastodon.json +0 -25
- package/dist/connections/notion.json +0 -33
- package/dist/connections/reddit.json +0 -28
- package/dist/connections/slack.json +0 -23
- package/dist/connections/stripe.json +0 -25
- package/dist/connections/telegram.json +0 -26
- package/dist/connections/trello.json +0 -25
- package/dist/connections/x.json +0 -27
package/dist/remote/server.js
CHANGED
|
@@ -12,23 +12,38 @@
|
|
|
12
12
|
* - Maintains an audit log of all operations
|
|
13
13
|
* - Rate-limits requests per session
|
|
14
14
|
*/
|
|
15
|
+
import cookieParser from 'cookie-parser';
|
|
15
16
|
import dotenv from 'dotenv';
|
|
16
17
|
import express from 'express';
|
|
18
|
+
import rateLimit from 'express-rate-limit';
|
|
17
19
|
import fs from 'node:fs';
|
|
18
|
-
import
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { loadRemoteConfig, saveRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, getEnvFilePath, getRemoteConfigPath, } from '../shared/config.js';
|
|
19
23
|
import { loadKeyBundle, loadPublicKeys, EncryptedChannel, } from '../shared/crypto/index.js';
|
|
20
24
|
import { HandshakeResponder, } from '../shared/protocol/index.js';
|
|
25
|
+
import { decryptSyncPayload, encryptSyncPayload, validateSyncRequest, isSyncSessionActive, MAX_SYNC_ATTEMPTS, } from '../shared/protocol/sync.js';
|
|
26
|
+
import { importCallerPublicKeys, exportServerPublicKeys, callerFingerprint, } from '../shared/crypto/key-manager.js';
|
|
27
|
+
import { getCallerKeysDir, getServerKeysDir } from '../shared/config.js';
|
|
21
28
|
import { IngestorManager } from './ingestors/index.js';
|
|
29
|
+
import { listConnectionTemplates } from '../shared/connections.js';
|
|
30
|
+
import { isSecretSetForCaller } from '../shared/env-utils.js';
|
|
31
|
+
import { toolHandlers } from './tool-dispatch.js';
|
|
32
|
+
import { setTunnelUrl, getTunnelUrl } from './tunnel-state.js';
|
|
33
|
+
import { migrateConfigDir } from '../shared/migrations.js';
|
|
34
|
+
import { writeEnrollToken, autoEnroll } from './caller-bootstrap.js';
|
|
35
|
+
import { createAdminRouter } from './admin.js';
|
|
36
|
+
import { loginHandler, logoutHandler, checkAuthHandler, changePasswordHandler, requireAuth, } from '../auth/auth.js';
|
|
22
37
|
// ── Environment loading ─────────────────────────────────────────────────────
|
|
23
38
|
/** Load environment from ~/.drawlatch/.env, falling back to cwd .env (legacy). */
|
|
24
39
|
function loadEnvFile() {
|
|
25
40
|
const configDirEnvPath = getEnvFilePath();
|
|
26
41
|
if (fs.existsSync(configDirEnvPath)) {
|
|
27
|
-
dotenv.config({ path: configDirEnvPath });
|
|
42
|
+
dotenv.config({ path: configDirEnvPath, quiet: true });
|
|
28
43
|
return;
|
|
29
44
|
}
|
|
30
45
|
// Backward compat: fall back to cwd .env
|
|
31
|
-
const result = dotenv.config();
|
|
46
|
+
const result = dotenv.config({ quiet: true });
|
|
32
47
|
if (result.parsed) {
|
|
33
48
|
console.warn(`[remote] Loaded .env from working directory. ` +
|
|
34
49
|
`Move it to ${configDirEnvPath} for portable operation.`);
|
|
@@ -39,26 +54,68 @@ loadEnvFile();
|
|
|
39
54
|
const sessions = new Map();
|
|
40
55
|
const pendingHandshakes = new Map();
|
|
41
56
|
let rateLimitPerMinute = 60;
|
|
57
|
+
/** Read package.json version once at module load. */
|
|
58
|
+
const PKG_VERSION = (() => {
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8');
|
|
61
|
+
return JSON.parse(raw).version ?? 'unknown';
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return 'unknown';
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
/** Resolve the listen port from DRAWLATCH_PORT (env override) or fall back to
|
|
68
|
+
* the configured port. Guards against non-numeric env values that would
|
|
69
|
+
* otherwise be silently coerced to NaN by parseInt. */
|
|
70
|
+
function resolvePort(envValue, fallback) {
|
|
71
|
+
if (envValue === undefined)
|
|
72
|
+
return fallback;
|
|
73
|
+
const parsed = parseInt(envValue, 10);
|
|
74
|
+
if (Number.isNaN(parsed)) {
|
|
75
|
+
console.warn(`[remote] Ignoring DRAWLATCH_PORT="${envValue}" (not a number); falling back to configured port ${fallback}`);
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
/** Loopback guard — used by /sync/listen, /sync/status, /events/stream, /admin.
|
|
81
|
+
* Hoisted to module scope so the admin router and its tests can reuse it. */
|
|
82
|
+
export function requireLoopback(req, res, next) {
|
|
83
|
+
const addr = req.socket.remoteAddress;
|
|
84
|
+
if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') {
|
|
85
|
+
next();
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
res.status(403).json({ error: 'Forbidden: local access only' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Active sync session (at most one at a time). */
|
|
92
|
+
let activeSyncSession = null;
|
|
42
93
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
43
94
|
/**
|
|
44
95
|
* Load authorized peers from per-caller config.
|
|
45
|
-
*
|
|
96
|
+
* Caller public keys are loaded from keys/callers/<alias>/.
|
|
46
97
|
*/
|
|
47
98
|
function loadCallerPeers(callers) {
|
|
48
99
|
const peers = [];
|
|
100
|
+
const callersDir = getCallerKeysDir();
|
|
49
101
|
for (const [alias, caller] of Object.entries(callers)) {
|
|
50
|
-
|
|
51
|
-
|
|
102
|
+
const keysDir = path.join(callersDir, alias);
|
|
103
|
+
if (!fs.existsSync(keysDir)) {
|
|
104
|
+
console.error(`[remote] Caller keys not found for "${alias}": ${keysDir}`);
|
|
52
105
|
continue;
|
|
53
106
|
}
|
|
54
107
|
try {
|
|
55
|
-
peers.push({ alias, name: caller.name, keys: loadPublicKeys(
|
|
108
|
+
peers.push({ alias, name: caller.name, keys: loadPublicKeys(keysDir) });
|
|
56
109
|
console.log(`[remote] Loaded authorized peer: ${alias}`);
|
|
57
110
|
}
|
|
58
111
|
catch (err) {
|
|
59
112
|
console.error(`[remote] Failed to load peer ${alias}:`, err);
|
|
60
113
|
}
|
|
61
114
|
}
|
|
115
|
+
if (peers.length === 0 && Object.keys(callers).length > 0) {
|
|
116
|
+
console.error('[remote] WARNING: No authorized peers loaded. No clients will be able to connect.');
|
|
117
|
+
console.error('[remote] Check peer key directories in remote.config.json.');
|
|
118
|
+
}
|
|
62
119
|
return peers;
|
|
63
120
|
}
|
|
64
121
|
function auditLog(sessionId, action, details = {}) {
|
|
@@ -70,35 +127,11 @@ function auditLog(sessionId, action, details = {}) {
|
|
|
70
127
|
};
|
|
71
128
|
console.log(`[audit] ${JSON.stringify(entry)}`);
|
|
72
129
|
}
|
|
73
|
-
export function isEndpointAllowed(url, patterns) {
|
|
74
|
-
if (patterns.length === 0)
|
|
75
|
-
return true; // no restrictions if empty
|
|
76
|
-
return patterns.some((pattern) => {
|
|
77
|
-
// Support simple glob patterns: * matches anything within a segment, ** matches across segments
|
|
78
|
-
const regex = new RegExp('^' +
|
|
79
|
-
pattern
|
|
80
|
-
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
81
|
-
.replace(/\*\*/g, '.__DOUBLE_STAR__.')
|
|
82
|
-
.replace(/\*/g, '[^/]*')
|
|
83
|
-
.replace(/\.__DOUBLE_STAR__\./g, '.*') +
|
|
84
|
-
'$');
|
|
85
|
-
return regex.test(url);
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
130
|
// Re-export resolvePlaceholders from config for backward compatibility with tests
|
|
89
131
|
export { resolvePlaceholders } from '../shared/config.js';
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*/
|
|
94
|
-
export function matchRoute(url, routes) {
|
|
95
|
-
for (const route of routes) {
|
|
96
|
-
if (route.allowedEndpoints.length > 0 && isEndpointAllowed(url, route.allowedEndpoints)) {
|
|
97
|
-
return route;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
132
|
+
// Re-export the canonical tool-dispatch surface (item D) so existing importers
|
|
133
|
+
// of './server.js' keep working after the extraction to './tool-dispatch.js'.
|
|
134
|
+
export { toolHandlers, executeProxyRequest, isEndpointAllowed, matchRoute, } from './tool-dispatch.js';
|
|
102
135
|
export function checkRateLimit(session, limit) {
|
|
103
136
|
const now = Date.now();
|
|
104
137
|
const windowMs = 60_000;
|
|
@@ -135,162 +168,120 @@ setInterval(() => {
|
|
|
135
168
|
cleanupSessions(sessions, pendingHandshakes);
|
|
136
169
|
}, 60_000);
|
|
137
170
|
/**
|
|
138
|
-
*
|
|
171
|
+
* Project the active sessions into a sanitized snapshot for read-only use.
|
|
139
172
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* - callboard's `LocalProxy` class (in-process, no encryption)
|
|
143
|
-
*
|
|
144
|
-
* Pure in the sense that it takes routes as input rather than reading global state.
|
|
145
|
-
* The only side effect is the outbound fetch().
|
|
173
|
+
* Drops `channel` (holds AES keys) and `resolvedRoutes` (carry decrypted
|
|
174
|
+
* secrets). Used by the loopback /admin API; never call res.json(session).
|
|
146
175
|
*/
|
|
147
|
-
export
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (route.allowedEndpoints.length === 0)
|
|
160
|
-
continue;
|
|
161
|
-
const candidateUrl = resolvePlaceholders(url, route.secrets);
|
|
162
|
-
if (isEndpointAllowed(candidateUrl, route.allowedEndpoints)) {
|
|
163
|
-
matched = route;
|
|
164
|
-
resolvedUrl = candidateUrl;
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
if (!matched) {
|
|
170
|
-
throw new Error(`Endpoint not allowed: ${url}`);
|
|
171
|
-
}
|
|
172
|
-
// Step 2: Resolve client headers using matched route's secrets
|
|
173
|
-
const resolvedHeaders = {};
|
|
174
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
175
|
-
resolvedHeaders[k] = resolvePlaceholders(v, matched.secrets);
|
|
176
|
-
}
|
|
177
|
-
// Step 3: Check for header conflicts — reject if client provides a header
|
|
178
|
-
// that conflicts with a route-level header (case-insensitive)
|
|
179
|
-
const routeHeaderKeys = new Set(Object.keys(matched.headers).map((k) => k.toLowerCase()));
|
|
180
|
-
for (const clientKey of Object.keys(resolvedHeaders)) {
|
|
181
|
-
if (routeHeaderKeys.has(clientKey.toLowerCase())) {
|
|
182
|
-
throw new Error(`Header conflict: client-provided header "${clientKey}" conflicts with a route-level header. Remove it from the request.`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// Step 4: Merge route-level headers (they take effect after conflict check)
|
|
186
|
-
for (const [k, v] of Object.entries(matched.headers)) {
|
|
187
|
-
resolvedHeaders[k] = v;
|
|
188
|
-
}
|
|
189
|
-
// Step 5: Resolve body placeholders using matched route's secrets.
|
|
190
|
-
// Only when the route explicitly opts in via resolveSecretsInBody — prevents
|
|
191
|
-
// exfiltration of secrets by writing placeholder strings into API resources
|
|
192
|
-
// and reading them back.
|
|
193
|
-
let resolvedBody;
|
|
194
|
-
if (typeof body === 'string') {
|
|
195
|
-
resolvedBody = matched.resolveSecretsInBody ? resolvePlaceholders(body, matched.secrets) : body;
|
|
176
|
+
export function getSessionsSnapshot() {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const [id, s] of sessions) {
|
|
179
|
+
out.push({
|
|
180
|
+
sessionIdShort: id.substring(0, 12),
|
|
181
|
+
callerAlias: s.callerAlias,
|
|
182
|
+
createdAt: s.createdAt,
|
|
183
|
+
lastActivity: s.lastActivity,
|
|
184
|
+
requestCount: s.requestCount,
|
|
185
|
+
windowRequests: s.windowRequests,
|
|
186
|
+
windowStart: s.windowStart,
|
|
187
|
+
});
|
|
196
188
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
// ── Session route invalidation ────────────────────────────────────────────
|
|
192
|
+
/**
|
|
193
|
+
* Re-resolve routes for all active sessions belonging to a caller.
|
|
194
|
+
* Called after secrets or connection list changes to ensure the session
|
|
195
|
+
* uses up-to-date resolved routes without requiring a reconnection.
|
|
196
|
+
*/
|
|
197
|
+
function refreshCallerSessions(callerAlias) {
|
|
198
|
+
const config = loadRemoteConfig();
|
|
199
|
+
const caller = config.callers[callerAlias];
|
|
200
|
+
if (!caller)
|
|
201
|
+
return;
|
|
202
|
+
const callerRoutes = resolveCallerRoutes(config, callerAlias);
|
|
203
|
+
const callerEnvResolved = resolveSecrets(caller.env ?? {});
|
|
204
|
+
const callerResolvedRoutes = resolveRoutes(callerRoutes, callerEnvResolved, callerAlias);
|
|
205
|
+
for (const session of sessions.values()) {
|
|
206
|
+
if (session.callerAlias === callerAlias) {
|
|
207
|
+
session.resolvedRoutes = callerResolvedRoutes;
|
|
204
208
|
}
|
|
205
209
|
}
|
|
206
|
-
// Step 6: Final endpoint check on fully resolved URL
|
|
207
|
-
if (!isEndpointAllowed(resolvedUrl, matched.allowedEndpoints)) {
|
|
208
|
-
throw new Error(`Endpoint not allowed after resolution: ${url}`);
|
|
209
|
-
}
|
|
210
|
-
// Step 7: Make the actual HTTP request
|
|
211
|
-
const resp = await fetch(resolvedUrl, {
|
|
212
|
-
method,
|
|
213
|
-
headers: resolvedHeaders,
|
|
214
|
-
body: resolvedBody,
|
|
215
|
-
});
|
|
216
|
-
const contentType = resp.headers.get('content-type') ?? '';
|
|
217
|
-
let responseBody;
|
|
218
|
-
if (contentType.includes('application/json')) {
|
|
219
|
-
responseBody = await resp.json();
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
responseBody = await resp.text();
|
|
223
|
-
}
|
|
224
|
-
return {
|
|
225
|
-
status: resp.status,
|
|
226
|
-
statusText: resp.statusText,
|
|
227
|
-
headers: Object.fromEntries(resp.headers.entries()),
|
|
228
|
-
body: responseBody,
|
|
229
|
-
};
|
|
230
210
|
}
|
|
231
|
-
const toolHandlers = {
|
|
232
|
-
/**
|
|
233
|
-
* Proxied HTTP request with route-scoped secret injection.
|
|
234
|
-
* Delegates to the extracted executeProxyRequest() function.
|
|
235
|
-
*/
|
|
236
|
-
async http_request(input, routes, _context) {
|
|
237
|
-
return executeProxyRequest(input, routes);
|
|
238
|
-
},
|
|
239
|
-
/**
|
|
240
|
-
* List available routes with metadata, endpoint patterns, and secret names (not values).
|
|
241
|
-
* Provides full disclosure of available routes for the local agent.
|
|
242
|
-
*/
|
|
243
|
-
list_routes(_input, routes, _context) {
|
|
244
|
-
const routeList = routes.map((route, index) => {
|
|
245
|
-
const info = { index };
|
|
246
|
-
if (route.name)
|
|
247
|
-
info.name = route.name;
|
|
248
|
-
if (route.description)
|
|
249
|
-
info.description = route.description;
|
|
250
|
-
if (route.docsUrl)
|
|
251
|
-
info.docsUrl = route.docsUrl;
|
|
252
|
-
if (route.openApiUrl)
|
|
253
|
-
info.openApiUrl = route.openApiUrl;
|
|
254
|
-
info.allowedEndpoints = route.allowedEndpoints;
|
|
255
|
-
info.secretNames = Object.keys(route.secrets);
|
|
256
|
-
info.autoHeaders = Object.keys(route.headers);
|
|
257
|
-
return info;
|
|
258
|
-
});
|
|
259
|
-
return Promise.resolve(routeList);
|
|
260
|
-
},
|
|
261
|
-
/**
|
|
262
|
-
* Poll for new events from ingestors (Discord Gateway, webhooks, pollers).
|
|
263
|
-
* Returns events since a cursor, optionally filtered by connection.
|
|
264
|
-
*/
|
|
265
|
-
poll_events(input, _routes, context) {
|
|
266
|
-
const { connection, after_id } = input;
|
|
267
|
-
const afterId = after_id ?? -1;
|
|
268
|
-
if (connection) {
|
|
269
|
-
return Promise.resolve(context.ingestorManager.getEvents(context.callerAlias, connection, afterId));
|
|
270
|
-
}
|
|
271
|
-
return Promise.resolve(context.ingestorManager.getAllEvents(context.callerAlias, afterId));
|
|
272
|
-
},
|
|
273
|
-
/**
|
|
274
|
-
* Get the status of all active ingestors for this caller.
|
|
275
|
-
*/
|
|
276
|
-
ingestor_status(_input, _routes, context) {
|
|
277
|
-
return Promise.resolve(context.ingestorManager.getStatuses(context.callerAlias));
|
|
278
|
-
},
|
|
279
|
-
};
|
|
280
211
|
export function createApp(options = {}) {
|
|
281
212
|
const app = express();
|
|
282
|
-
// Parse JSON for handshake endpoints
|
|
283
|
-
app.use('/handshake', express.json());
|
|
284
|
-
// Raw buffer for encrypted request endpoint
|
|
285
|
-
app.use('/request', express.raw({ type: 'application/octet-stream', limit: '
|
|
213
|
+
// Parse JSON for handshake endpoints (64kb limit — handshake messages are <2KB)
|
|
214
|
+
app.use('/handshake', express.json({ limit: '64kb' }));
|
|
215
|
+
// Raw buffer for encrypted request endpoint (50 MB to accommodate base64-encoded file uploads)
|
|
216
|
+
app.use('/request', express.raw({ type: 'application/octet-stream', limit: '50mb' }));
|
|
217
|
+
// Plain text for sync endpoint (AES-encrypted base64 body)
|
|
218
|
+
app.use('/sync', express.text({ type: 'text/plain', limit: '64kb' }));
|
|
219
|
+
// JSON for sync management endpoints (64kb limit — sync listen messages are tiny)
|
|
220
|
+
app.use('/sync/listen', express.json({ limit: '64kb' }));
|
|
221
|
+
// JSON for the loopback auto-enroll endpoint (item E).
|
|
222
|
+
app.use('/sync/auto-enroll', express.json({ limit: '64kb' }));
|
|
286
223
|
// Raw buffer for webhook endpoints (needed for signature verification)
|
|
287
224
|
app.use('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }));
|
|
225
|
+
// ── Dashboard auth body/cookie parsers ───────────────────────────────────
|
|
226
|
+
// Scoped to the dashboard surface only — the daemon keeps its per-route
|
|
227
|
+
// body-parser pattern, so no global JSON parser is introduced. cookie-parser
|
|
228
|
+
// is needed by requireAuth on both /api/auth and /api/admin to read the
|
|
229
|
+
// session cookie; JSON parsing is needed only by the auth handlers.
|
|
230
|
+
app.use('/api', cookieParser());
|
|
231
|
+
app.use('/api/auth', express.json({ limit: '1mb' }));
|
|
232
|
+
// ── Per-IP rate limiting for pre-auth endpoints ──────────────────────────
|
|
233
|
+
// The per-session rate limit (checkRateLimit) only applies to authenticated
|
|
234
|
+
// /request calls. These limiters protect unauthenticated endpoints against
|
|
235
|
+
// volumetric abuse and brute-force attacks.
|
|
236
|
+
if (!options.disableRateLimiting) {
|
|
237
|
+
const ipRequestCounts = new Map();
|
|
238
|
+
function getIpRateLimit(ip, windowMs, max) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const key = `${ip}:${windowMs}:${max}`;
|
|
241
|
+
let entry = ipRequestCounts.get(key);
|
|
242
|
+
if (!entry || now - entry.windowStart > windowMs) {
|
|
243
|
+
entry = { windowStart: now, count: 0 };
|
|
244
|
+
ipRequestCounts.set(key, entry);
|
|
245
|
+
}
|
|
246
|
+
entry.count++;
|
|
247
|
+
const allowed = entry.count <= max;
|
|
248
|
+
return { allowed, remaining: Math.max(0, max - entry.count) };
|
|
249
|
+
}
|
|
250
|
+
function ipRateLimiter(windowMs, max) {
|
|
251
|
+
return (req, res, next) => {
|
|
252
|
+
const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown';
|
|
253
|
+
const { allowed, remaining } = getIpRateLimit(ip, windowMs, max);
|
|
254
|
+
res.setHeader('X-RateLimit-Limit', max);
|
|
255
|
+
res.setHeader('X-RateLimit-Remaining', remaining);
|
|
256
|
+
if (!allowed) {
|
|
257
|
+
res.status(429).json({ error: 'Too many requests' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
next();
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
app.use('/handshake', ipRateLimiter(60_000, 30));
|
|
264
|
+
app.use('/sync', ipRateLimiter(60_000, 10));
|
|
265
|
+
app.use('/webhooks', ipRateLimiter(60_000, 120));
|
|
266
|
+
app.use('/health', ipRateLimiter(60_000, 60));
|
|
267
|
+
// /api/admin is password-gated but the dashboard polls — give it more headroom than /health
|
|
268
|
+
app.use('/api/admin', ipRateLimiter(60_000, 300));
|
|
269
|
+
}
|
|
288
270
|
const config = options.config ?? loadRemoteConfig();
|
|
289
|
-
const ownKeys = options.ownKeys ?? loadKeyBundle(
|
|
271
|
+
const ownKeys = options.ownKeys ?? loadKeyBundle(getServerKeysDir());
|
|
290
272
|
const authorizedPeers = options.authorizedPeers ?? loadCallerPeers(config.callers);
|
|
273
|
+
// Capture for /admin/meta. Resolves the same way main() picks the listen port.
|
|
274
|
+
const startedAt = Date.now();
|
|
275
|
+
const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
|
|
291
276
|
rateLimitPerMinute = config.rateLimitPerMinute;
|
|
292
|
-
// Create or use the provided ingestor manager
|
|
293
|
-
|
|
277
|
+
// Create or use the provided ingestor manager.
|
|
278
|
+
// When config is loaded from disk (production), pass loadRemoteConfig as the
|
|
279
|
+
// config loader so startOne()/restartOne() read fresh config, picking up
|
|
280
|
+
// changes made by tool handlers without requiring a server restart.
|
|
281
|
+
// When config is injected via options (tests), omit the loader so the
|
|
282
|
+
// IngestorManager uses the injected config snapshot.
|
|
283
|
+
const configLoader = options.config ? undefined : loadRemoteConfig;
|
|
284
|
+
const ingestorManager = options.ingestorManager ?? new IngestorManager(config, configLoader);
|
|
294
285
|
app.locals.ingestorManager = ingestorManager;
|
|
295
286
|
// Log connector and caller summary
|
|
296
287
|
const connectorCount = config.connectors?.length ?? 0;
|
|
@@ -301,6 +292,30 @@ export function createApp(options = {}) {
|
|
|
301
292
|
}
|
|
302
293
|
console.log(`[remote] ${authorizedPeers.length} authorized peer(s)`);
|
|
303
294
|
console.log(`[remote] Rate limit: ${rateLimitPerMinute} req/min per session`);
|
|
295
|
+
// Boot-time connection health table: check required secrets for each caller's connections
|
|
296
|
+
const templates = listConnectionTemplates();
|
|
297
|
+
const templateMap = new Map(templates.map((t) => [t.alias, t]));
|
|
298
|
+
for (const [callerAlias, caller] of Object.entries(config.callers)) {
|
|
299
|
+
const secretIssues = [];
|
|
300
|
+
for (const connName of caller.connections) {
|
|
301
|
+
const tpl = templateMap.get(connName);
|
|
302
|
+
if (!tpl)
|
|
303
|
+
continue; // custom connector, skip
|
|
304
|
+
for (const secret of tpl.requiredSecrets) {
|
|
305
|
+
const isSet = isSecretSetForCaller(secret, callerAlias, caller.env);
|
|
306
|
+
if (!isSet) {
|
|
307
|
+
secretIssues.push(` ${connName.padEnd(16)} ${secret.padEnd(28)} [NOT SET]`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (secretIssues.length > 0) {
|
|
312
|
+
console.log(`[remote] Connection secrets for "${callerAlias}":`);
|
|
313
|
+
for (const issue of secretIssues) {
|
|
314
|
+
console.log(issue);
|
|
315
|
+
}
|
|
316
|
+
console.log(`[remote] Set missing secrets in ${getEnvFilePath()}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
304
319
|
// ── Handshake init ─────────────────────────────────────────────────────
|
|
305
320
|
app.post('/handshake/init', (req, res) => {
|
|
306
321
|
try {
|
|
@@ -311,11 +326,14 @@ export function createApp(options = {}) {
|
|
|
311
326
|
// Look up the caller alias by matching the returned PublicKeyBundle
|
|
312
327
|
const matchedPeer = authorizedPeers.find((p) => p.keys === initiatorPubKey);
|
|
313
328
|
const callerAlias = matchedPeer?.alias ?? 'unknown';
|
|
329
|
+
// Reload config from disk so new sessions pick up changes made by tool
|
|
330
|
+
// handlers (e.g. set_connection_enabled, set_secrets) without a restart.
|
|
331
|
+
const freshConfig = options.config ?? loadRemoteConfig();
|
|
314
332
|
// Resolve per-caller routes (with optional env overrides)
|
|
315
|
-
const callerRoutes = resolveCallerRoutes(
|
|
316
|
-
const caller =
|
|
333
|
+
const callerRoutes = resolveCallerRoutes(freshConfig, callerAlias);
|
|
334
|
+
const caller = freshConfig.callers[callerAlias];
|
|
317
335
|
const callerEnvResolved = resolveSecrets(caller.env ?? {});
|
|
318
|
-
const callerResolvedRoutes = resolveRoutes(callerRoutes, callerEnvResolved);
|
|
336
|
+
const callerResolvedRoutes = resolveRoutes(callerRoutes, callerEnvResolved, callerAlias);
|
|
319
337
|
// Store pending handshake for the finish step
|
|
320
338
|
pendingHandshakes.set(sessionKeys.sessionId, {
|
|
321
339
|
responder,
|
|
@@ -414,6 +432,7 @@ export function createApp(options = {}) {
|
|
|
414
432
|
const context = {
|
|
415
433
|
callerAlias: session.callerAlias,
|
|
416
434
|
ingestorManager: app.locals.ingestorManager,
|
|
435
|
+
refreshRoutes: () => refreshCallerSessions(session.callerAlias),
|
|
417
436
|
};
|
|
418
437
|
const result = await handler(request.toolInput, session.resolvedRoutes, context);
|
|
419
438
|
// Build and encrypt response
|
|
@@ -427,6 +446,7 @@ export function createApp(options = {}) {
|
|
|
427
446
|
const encrypted = session.channel.encryptJSON(response);
|
|
428
447
|
auditLog(sessionId, 'response', {
|
|
429
448
|
caller: session.callerAlias,
|
|
449
|
+
toolName: request.toolName,
|
|
430
450
|
requestId: request.id,
|
|
431
451
|
success: true,
|
|
432
452
|
});
|
|
@@ -456,15 +476,303 @@ export function createApp(options = {}) {
|
|
|
456
476
|
}
|
|
457
477
|
}
|
|
458
478
|
});
|
|
479
|
+
// ── Sync: key exchange endpoints ───────────────────────────────────────
|
|
480
|
+
// Internal management: open a sync session (called by drawlatch CLI)
|
|
481
|
+
app.post('/sync/listen', requireLoopback, (req, res) => {
|
|
482
|
+
const { inviteCode, confirmCode, encryptionKey, ttlMs } = req.body;
|
|
483
|
+
if (!inviteCode || !encryptionKey) {
|
|
484
|
+
res.status(400).json({ error: 'Missing inviteCode or encryptionKey' });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (activeSyncSession && isSyncSessionActive(activeSyncSession)) {
|
|
488
|
+
res.status(409).json({ error: 'A sync session is already active' });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
activeSyncSession = {
|
|
492
|
+
inviteCode,
|
|
493
|
+
confirmCode: confirmCode ?? null,
|
|
494
|
+
encryptionKey,
|
|
495
|
+
createdAt: Date.now(),
|
|
496
|
+
ttlMs: ttlMs ?? 5 * 60 * 1000,
|
|
497
|
+
completed: false,
|
|
498
|
+
failedAttempts: 0,
|
|
499
|
+
};
|
|
500
|
+
console.log('[sync] Sync session opened, waiting for callboard...');
|
|
501
|
+
res.json({ ok: true });
|
|
502
|
+
});
|
|
503
|
+
// Internal management: check sync session status (polled by CLI)
|
|
504
|
+
app.get('/sync/status', requireLoopback, (_req, res) => {
|
|
505
|
+
if (!activeSyncSession) {
|
|
506
|
+
res.json({ active: false, completed: false });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const active = isSyncSessionActive(activeSyncSession);
|
|
510
|
+
res.json({
|
|
511
|
+
active,
|
|
512
|
+
completed: activeSyncSession.completed,
|
|
513
|
+
...(activeSyncSession.result && {
|
|
514
|
+
callerAlias: activeSyncSession.result.callerAlias,
|
|
515
|
+
fingerprint: activeSyncSession.result.fingerprint,
|
|
516
|
+
}),
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
// External: called by callboard to exchange keys (encrypted body)
|
|
520
|
+
app.post('/sync', (req, res) => {
|
|
521
|
+
// Refuse all sync calls unless actively listening
|
|
522
|
+
if (!activeSyncSession || !isSyncSessionActive(activeSyncSession)) {
|
|
523
|
+
res.status(404).json({ error: 'NO_ACTIVE_SESSION' });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const session = activeSyncSession;
|
|
527
|
+
// Decrypt the request body
|
|
528
|
+
let decrypted;
|
|
529
|
+
try {
|
|
530
|
+
decrypted = decryptSyncPayload(req.body, session.encryptionKey);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
res.status(400).json({ error: 'DECRYPTION_FAILED' });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// Validate payload shape
|
|
537
|
+
const validationError = validateSyncRequest(decrypted);
|
|
538
|
+
if (validationError) {
|
|
539
|
+
res.status(400).json({ error: 'INVALID_PAYLOAD', detail: validationError });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const syncReq = decrypted;
|
|
543
|
+
// Validate invite code
|
|
544
|
+
if (syncReq.inviteCode !== session.inviteCode) {
|
|
545
|
+
session.failedAttempts++;
|
|
546
|
+
if (session.failedAttempts >= MAX_SYNC_ATTEMPTS) {
|
|
547
|
+
console.error('[sync] Too many failed attempts — invalidating session');
|
|
548
|
+
activeSyncSession = null;
|
|
549
|
+
}
|
|
550
|
+
res.status(403).json({ error: 'CODE_MISMATCH' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Validate confirm code (must be set by CLI before callboard calls)
|
|
554
|
+
if (!session.confirmCode) {
|
|
555
|
+
res.status(403).json({ error: 'CODE_MISMATCH', detail: 'Confirm code not yet entered' });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (syncReq.confirmCode !== session.confirmCode) {
|
|
559
|
+
session.failedAttempts++;
|
|
560
|
+
if (session.failedAttempts >= MAX_SYNC_ATTEMPTS) {
|
|
561
|
+
console.error('[sync] Too many failed attempts — invalidating session');
|
|
562
|
+
activeSyncSession = null;
|
|
563
|
+
}
|
|
564
|
+
res.status(403).json({ error: 'CODE_MISMATCH' });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// Check expiry
|
|
568
|
+
if (Date.now() - session.createdAt > session.ttlMs) {
|
|
569
|
+
activeSyncSession = null;
|
|
570
|
+
res.status(410).json({ error: 'SESSION_EXPIRED' });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// Save callboard's public keys
|
|
574
|
+
const callerAlias = syncReq.callerAlias;
|
|
575
|
+
try {
|
|
576
|
+
importCallerPublicKeys(callerAlias, syncReq.publicKeys);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
580
|
+
res.status(400).json({ error: 'INVALID_PAYLOAD', detail: `Invalid public keys: ${msg}` });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Reload config from disk so we don't clobber changes made since startup
|
|
584
|
+
const freshConfig = options.config ?? loadRemoteConfig();
|
|
585
|
+
// Register caller in config if not already present
|
|
586
|
+
if (!(callerAlias in freshConfig.callers)) {
|
|
587
|
+
freshConfig.callers[callerAlias] = {
|
|
588
|
+
connections: [],
|
|
589
|
+
};
|
|
590
|
+
saveRemoteConfig(freshConfig);
|
|
591
|
+
console.log(`[sync] Registered new caller "${callerAlias}" (0 connections — configure manually)`);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
console.log(`[sync] Caller "${callerAlias}" already exists, updated peer keys`);
|
|
595
|
+
}
|
|
596
|
+
// Reload authorized peers so the new caller can connect immediately
|
|
597
|
+
const newPeer = loadCallerPeers({ [callerAlias]: freshConfig.callers[callerAlias] });
|
|
598
|
+
for (const p of newPeer) {
|
|
599
|
+
if (!authorizedPeers.find((existing) => existing.alias === p.alias)) {
|
|
600
|
+
authorizedPeers.push(p);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Build response with remote server's public keys
|
|
604
|
+
const remotePublicKeys = exportServerPublicKeys();
|
|
605
|
+
const fp = callerFingerprint(callerAlias);
|
|
606
|
+
const syncResponse = {
|
|
607
|
+
remotePublicKeys,
|
|
608
|
+
callerAlias,
|
|
609
|
+
fingerprint: fp,
|
|
610
|
+
};
|
|
611
|
+
// Mark session as completed
|
|
612
|
+
session.completed = true;
|
|
613
|
+
session.result = { callerAlias, fingerprint: fp };
|
|
614
|
+
console.log(`[sync] Key exchange complete with "${callerAlias}" (fingerprint: ${fp})`);
|
|
615
|
+
const encryptedResponse = encryptSyncPayload(syncResponse, session.encryptionKey);
|
|
616
|
+
res.type('text/plain').send(encryptedResponse);
|
|
617
|
+
});
|
|
459
618
|
// ── Health check (unencrypted, no secrets exposed) ─────────────────────
|
|
460
619
|
app.get('/health', (_req, res) => {
|
|
461
620
|
res.json({
|
|
462
621
|
status: 'ok',
|
|
463
622
|
activeSessions: sessions.size,
|
|
464
623
|
uptime: process.uptime(),
|
|
624
|
+
// Public tunnel URL (not a secret) so `drawlatch status` and the start
|
|
625
|
+
// command's tunnel-URL probe can surface it without authenticating.
|
|
626
|
+
tunnelUrl: getTunnelUrl(),
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
// ── Event stream (loopback-only, for `drawlatch watch`) ─────────────
|
|
630
|
+
app.get('/events/stream', requireLoopback, (req, res) => {
|
|
631
|
+
res.writeHead(200, {
|
|
632
|
+
'Content-Type': 'text/event-stream',
|
|
633
|
+
'Cache-Control': 'no-cache',
|
|
634
|
+
Connection: 'keep-alive',
|
|
465
635
|
});
|
|
636
|
+
res.flushHeaders();
|
|
637
|
+
const mgr = app.locals.ingestorManager;
|
|
638
|
+
const listener = (event) => {
|
|
639
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
640
|
+
};
|
|
641
|
+
mgr.onEvent(listener);
|
|
642
|
+
req.on('close', () => {
|
|
643
|
+
mgr.offEvent(listener);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
// ── Dashboard auth routes ────────────────────────────────────────────
|
|
647
|
+
// Public login/logout/check + auth-gated change-password. The scrypt
|
|
648
|
+
// password is the trust boundary for the dashboard surface, replacing the
|
|
649
|
+
// old loopback-only posture so it can be host-bound (DRAWLATCH_HOST=0.0.0.0)
|
|
650
|
+
// while staying password-protected.
|
|
651
|
+
//
|
|
652
|
+
// Two rate limiters (same config as the former drawlatch-ui backend):
|
|
653
|
+
// - 3/min for the auth-mutating endpoints (login + change-password)
|
|
654
|
+
// - 20/min for the cheap, polling-friendly endpoints (check + logout)
|
|
655
|
+
const loginLimiter = rateLimit({
|
|
656
|
+
windowMs: 60 * 1000,
|
|
657
|
+
max: 3,
|
|
658
|
+
standardHeaders: true,
|
|
659
|
+
legacyHeaders: false,
|
|
660
|
+
message: { error: 'Too many attempts. Try again in a minute.' },
|
|
661
|
+
});
|
|
662
|
+
const checkLimiter = rateLimit({
|
|
663
|
+
windowMs: 60 * 1000,
|
|
664
|
+
max: 20,
|
|
665
|
+
standardHeaders: true,
|
|
666
|
+
legacyHeaders: false,
|
|
667
|
+
message: { error: 'Too many requests. Try again in a minute.' },
|
|
668
|
+
});
|
|
669
|
+
app.post('/api/auth/login', loginLimiter, loginHandler);
|
|
670
|
+
app.post('/api/auth/logout', checkLimiter, logoutHandler);
|
|
671
|
+
app.get('/api/auth/check', checkLimiter, checkAuthHandler);
|
|
672
|
+
app.post('/api/auth/change-password', loginLimiter, requireAuth, changePasswordHandler);
|
|
673
|
+
// ── Admin API (password-gated) ────────────────────────────────────────
|
|
674
|
+
// Powers the merged dashboard at /api/admin/* (the path the frontend uses).
|
|
675
|
+
// Mounted behind requireAuth — the password gate replaces the old loopback
|
|
676
|
+
// guard. Read endpoints never expose secrets; mutating endpoints (item A) are
|
|
677
|
+
// write-only for secrets and live-reload routes/ingestors after every change.
|
|
678
|
+
// When no password is configured, requireAuth returns 503 and the SPA shows a
|
|
679
|
+
// locked state (the daemon must never exit just because it is unconfigured).
|
|
680
|
+
/** Resolve the live routes (with secrets) for a caller, for tool dispatch. */
|
|
681
|
+
const resolveRoutesForCaller = (alias) => {
|
|
682
|
+
const freshConfig = options.config ?? loadRemoteConfig();
|
|
683
|
+
const caller = freshConfig.callers[alias];
|
|
684
|
+
if (!caller)
|
|
685
|
+
return [];
|
|
686
|
+
const callerRoutes = resolveCallerRoutes(freshConfig, alias);
|
|
687
|
+
const callerEnvResolved = resolveSecrets(caller.env ?? {});
|
|
688
|
+
return resolveRoutes(callerRoutes, callerEnvResolved, alias);
|
|
689
|
+
};
|
|
690
|
+
/** Register or refresh the authorized peer for a (possibly new) caller. */
|
|
691
|
+
const reloadPeer = (alias) => {
|
|
692
|
+
const freshConfig = options.config ?? loadRemoteConfig();
|
|
693
|
+
const callerConfig = freshConfig.callers[alias];
|
|
694
|
+
if (!callerConfig)
|
|
695
|
+
return;
|
|
696
|
+
for (const p of loadCallerPeers({ [alias]: callerConfig })) {
|
|
697
|
+
const idx = authorizedPeers.findIndex((e) => e.alias === p.alias);
|
|
698
|
+
if (idx >= 0)
|
|
699
|
+
authorizedPeers[idx] = p;
|
|
700
|
+
else
|
|
701
|
+
authorizedPeers.push(p);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
/** Drop the authorized peer + any active sessions for a deleted caller. */
|
|
705
|
+
const removePeer = (alias) => {
|
|
706
|
+
const idx = authorizedPeers.findIndex((e) => e.alias === alias);
|
|
707
|
+
if (idx >= 0)
|
|
708
|
+
authorizedPeers.splice(idx, 1);
|
|
709
|
+
for (const [id, s] of sessions) {
|
|
710
|
+
if (s.callerAlias === alias)
|
|
711
|
+
sessions.delete(id);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
// ── Loopback auto-enroll (item E) ─────────────────────────────────────
|
|
715
|
+
// A co-located client that shares our filesystem proves co-location by
|
|
716
|
+
// presenting the one-time enroll token drawlatch wrote into the config dir,
|
|
717
|
+
// and gets a caller provisioned (with keys) without the invite-code dance.
|
|
718
|
+
// Loopback-only: never reachable from off-box.
|
|
719
|
+
app.post('/sync/auto-enroll', requireLoopback, (req, res) => {
|
|
720
|
+
const { token, alias, name } = (req.body ?? {});
|
|
721
|
+
if (!token || !alias) {
|
|
722
|
+
res.status(400).json({ error: 'Missing token or alias' });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
const result = autoEnroll(token, alias, name !== undefined ? { name } : {});
|
|
727
|
+
reloadPeer(alias);
|
|
728
|
+
res.json({
|
|
729
|
+
alias: result.alias,
|
|
730
|
+
name: result.name,
|
|
731
|
+
fingerprint: result.fingerprint,
|
|
732
|
+
keysDir: result.keysDir,
|
|
733
|
+
connections: result.connections,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
738
|
+
const status = /invalid or expired/i.test(message)
|
|
739
|
+
? 403
|
|
740
|
+
: /already exists/i.test(message)
|
|
741
|
+
? 409
|
|
742
|
+
: 400;
|
|
743
|
+
res.status(status).json({ error: message });
|
|
744
|
+
}
|
|
466
745
|
});
|
|
746
|
+
// JSON body parsing for the mutating admin endpoints (cookie-parser for /api
|
|
747
|
+
// is already installed above; the daemon keeps its per-route parser pattern).
|
|
748
|
+
app.use('/api/admin', express.json({ limit: '1mb' }));
|
|
749
|
+
app.use('/api/admin', requireAuth, createAdminRouter({
|
|
750
|
+
getSessionsSnapshot,
|
|
751
|
+
ingestorManager: () => app.locals.ingestorManager,
|
|
752
|
+
loadConfig: () => options.config ?? loadRemoteConfig(),
|
|
753
|
+
resolveRoutesForCaller,
|
|
754
|
+
refreshCaller: refreshCallerSessions,
|
|
755
|
+
reloadPeer,
|
|
756
|
+
removePeer,
|
|
757
|
+
version: PKG_VERSION,
|
|
758
|
+
port,
|
|
759
|
+
startedAt,
|
|
760
|
+
}));
|
|
467
761
|
// ── Webhook receiver ─────────────────────────────────────────────────
|
|
762
|
+
// Trello (and potentially other services) send a HEAD request to the
|
|
763
|
+
// callback URL to verify it is reachable before activating the webhook.
|
|
764
|
+
// Respond with 200 if at least one ingestor is registered for the path.
|
|
765
|
+
app.head('/webhooks/:path', (req, res) => {
|
|
766
|
+
const webhookPath = req.params.path;
|
|
767
|
+
const mgr = app.locals.ingestorManager;
|
|
768
|
+
const ingestors = mgr.getWebhookIngestors(webhookPath);
|
|
769
|
+
if (ingestors.length === 0) {
|
|
770
|
+
res.status(404).end();
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
res.status(200).end();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
468
776
|
app.post('/webhooks/:path', (req, res) => {
|
|
469
777
|
const webhookPath = req.params.path;
|
|
470
778
|
const mgr = app.locals.ingestorManager;
|
|
@@ -492,37 +800,203 @@ export function createApp(options = {}) {
|
|
|
492
800
|
res.status(200).json({ received: true });
|
|
493
801
|
}
|
|
494
802
|
else {
|
|
495
|
-
|
|
803
|
+
// Log details server-side for debugging; never expose ingestor internals to callers
|
|
804
|
+
console.error('[remote] Webhook rejected by all ingestors:', JSON.stringify(results));
|
|
805
|
+
res.status(403).json({ error: 'Webhook rejected' });
|
|
496
806
|
}
|
|
497
807
|
});
|
|
808
|
+
// ── SPA serving ───────────────────────────────────────────────────────────
|
|
809
|
+
// Mounted LAST, after every API and protocol route. Static assets are served
|
|
810
|
+
// from frontend/dist; any unmatched GET/HEAD falls back to index.html so
|
|
811
|
+
// client-side deep links resolve on reload.
|
|
812
|
+
//
|
|
813
|
+
// Express 5 removed string-pattern wildcards (`app.get("*", …)` throws), so
|
|
814
|
+
// the SPA fallback is a terminal middleware instead. It skips the API and
|
|
815
|
+
// protocol prefixes so unmatched routes there return their own status (e.g.
|
|
816
|
+
// a JSON 404 from the admin router) rather than index.html.
|
|
817
|
+
//
|
|
818
|
+
// The mount is gated on the bundle existing on disk (rather than NODE_ENV),
|
|
819
|
+
// so it works in any context where the SPA has been built — production
|
|
820
|
+
// installs, local `npm start`, or a dev clone after `npm run build:frontend`.
|
|
821
|
+
// In a pure dev workflow you should still use `vite` on its own port for
|
|
822
|
+
// hot reload; this mount just makes hitting the daemon port directly do
|
|
823
|
+
// something useful.
|
|
824
|
+
const distDir = fileURLToPath(new URL('../../frontend/dist', import.meta.url));
|
|
825
|
+
const indexHtml = path.join(distDir, 'index.html');
|
|
826
|
+
if (fs.existsSync(indexHtml)) {
|
|
827
|
+
const apiPrefixes = [
|
|
828
|
+
'/api',
|
|
829
|
+
'/handshake',
|
|
830
|
+
'/request',
|
|
831
|
+
'/sync',
|
|
832
|
+
'/events',
|
|
833
|
+
'/webhooks',
|
|
834
|
+
'/health',
|
|
835
|
+
];
|
|
836
|
+
app.use(express.static(distDir));
|
|
837
|
+
app.use((req, res, next) => {
|
|
838
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
839
|
+
next();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (apiPrefixes.some((p) => req.path === p || req.path.startsWith(`${p}/`))) {
|
|
843
|
+
next();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
// IMPORTANT: pass `root` instead of an absolute path. Express 5 / send 1.x
|
|
847
|
+
// walk the absolute path components looking for "dotfiles" (segments
|
|
848
|
+
// starting with "."), and the default dotfile policy is "ignore" — which
|
|
849
|
+
// turns into a 404. When drawlatch is bundled inside callboard installed
|
|
850
|
+
// under `~/.nvm/...`, the `.nvm` segment matches the dotfile rule and
|
|
851
|
+
// every SPA deep-link reload becomes "Not Found". Routing the file
|
|
852
|
+
// through `root` makes send only inspect the relative path's parts
|
|
853
|
+
// (`./index.html`), so the dotfile guard never trips.
|
|
854
|
+
res.sendFile('index.html', { root: distDir });
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
console.warn(`[remote] Dashboard bundle not found at ${distDir}. ` +
|
|
859
|
+
`Run \`npm run build:frontend\` (or reinstall drawlatch) to enable the dashboard UI; ` +
|
|
860
|
+
`the daemon API will still work.`);
|
|
861
|
+
}
|
|
498
862
|
return app;
|
|
499
863
|
}
|
|
864
|
+
// waitForTunnelReady is now exported from tunnel.ts — imported dynamically
|
|
865
|
+
// alongside startTunnel in the tunnel startup block below.
|
|
500
866
|
// ── Start ──────────────────────────────────────────────────────────────────
|
|
501
867
|
export function main() {
|
|
868
|
+
console.log('[remote] Starting drawlatch server...');
|
|
869
|
+
// Own our on-disk layout: migrate any legacy key directories before loading
|
|
870
|
+
// keys/config (item G). Idempotent and safe on every startup.
|
|
871
|
+
migrateConfigDir();
|
|
872
|
+
// Pre-flight validation: check for common setup issues before starting
|
|
873
|
+
const remoteConfigPath = getRemoteConfigPath();
|
|
874
|
+
if (!fs.existsSync(remoteConfigPath)) {
|
|
875
|
+
console.error(`[remote] Error: No remote config found at ${remoteConfigPath}`);
|
|
876
|
+
console.error('[remote] Run: drawlatch init');
|
|
877
|
+
process.exit(1);
|
|
878
|
+
}
|
|
502
879
|
const config = loadRemoteConfig();
|
|
503
|
-
const
|
|
880
|
+
const serverKeysDirPath = getServerKeysDir();
|
|
881
|
+
const requiredKeyFiles = [
|
|
882
|
+
'signing.key.pem',
|
|
883
|
+
'signing.pub.pem',
|
|
884
|
+
'exchange.key.pem',
|
|
885
|
+
'exchange.pub.pem',
|
|
886
|
+
];
|
|
887
|
+
const missingKeyFiles = requiredKeyFiles.filter((f) => !fs.existsSync(path.join(serverKeysDirPath, f)));
|
|
888
|
+
if (missingKeyFiles.length > 0) {
|
|
889
|
+
if (!fs.existsSync(serverKeysDirPath)) {
|
|
890
|
+
console.error(`[remote] Error: Server keys not found at ${serverKeysDirPath}`);
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
console.error(`[remote] Error: Incomplete server keys in ${serverKeysDirPath}`);
|
|
894
|
+
console.error(`[remote] Missing: ${missingKeyFiles.join(', ')}`);
|
|
895
|
+
}
|
|
896
|
+
console.error('[remote] Run: drawlatch generate-keys server');
|
|
897
|
+
process.exit(1);
|
|
898
|
+
}
|
|
899
|
+
if (Object.keys(config.callers).length === 0) {
|
|
900
|
+
console.log('[remote] No callers configured — server will accept sync requests.');
|
|
901
|
+
console.log('[remote] To add callers, run: drawlatch sync');
|
|
902
|
+
}
|
|
903
|
+
// Drop a one-time enroll token into the config dir so a co-located client
|
|
904
|
+
// that shares our filesystem can auto-enroll a caller (item E).
|
|
905
|
+
try {
|
|
906
|
+
writeEnrollToken();
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
console.warn('[remote] Could not write enroll token:', err);
|
|
910
|
+
}
|
|
911
|
+
const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
|
|
504
912
|
const host = process.env.DRAWLATCH_HOST ?? config.host;
|
|
913
|
+
// Self-managed tunnel: config flag OR env override (item C).
|
|
914
|
+
const useTunnel = config.tunnel === true || process.env.DRAWLATCH_TUNNEL === '1';
|
|
505
915
|
const app = createApp();
|
|
506
916
|
const ingestorManager = app.locals.ingestorManager;
|
|
507
|
-
|
|
917
|
+
// Holds the tunnel stop function if a tunnel is active (set inside the
|
|
918
|
+
// listen callback, read by the shutdown handler — both share this scope).
|
|
919
|
+
let stopTunnel;
|
|
920
|
+
const server = app.listen(port, host, () => void (async () => {
|
|
508
921
|
console.log(`[remote] Secure remote server listening on ${host}:${port}`);
|
|
509
|
-
|
|
922
|
+
console.log(`[remote] PID: ${process.pid}, Node: ${process.version}`);
|
|
923
|
+
// If a tunnel was requested, start it before ingestors so that
|
|
924
|
+
// process.env.DRAWLATCH_TUNNEL_URL is available during secret resolution.
|
|
925
|
+
if (useTunnel) {
|
|
926
|
+
try {
|
|
927
|
+
const { startTunnel, waitForTunnelReady } = await import('./tunnel.js');
|
|
928
|
+
const tunnel = await startTunnel({ port, host });
|
|
929
|
+
stopTunnel = tunnel.stop;
|
|
930
|
+
process.env.DRAWLATCH_TUNNEL_URL = tunnel.url;
|
|
931
|
+
setTunnelUrl(tunnel.url);
|
|
932
|
+
// Auto-populate callback URL env vars for webhook ingestors whose
|
|
933
|
+
// connection templates reference an env var that is not yet set.
|
|
934
|
+
for (const [callerAlias, _callerConfig] of Object.entries(config.callers)) {
|
|
935
|
+
const rawRoutes = resolveCallerRoutes(config, callerAlias);
|
|
936
|
+
for (const route of rawRoutes) {
|
|
937
|
+
const callbackTpl = route.ingestor?.webhook?.callbackUrl;
|
|
938
|
+
const webhookPath = route.ingestor?.webhook?.path;
|
|
939
|
+
if (!callbackTpl || !webhookPath)
|
|
940
|
+
continue;
|
|
941
|
+
// Extract env var name from "${VAR}" pattern
|
|
942
|
+
const match = /^\$\{(\w+)\}$/.exec(callbackTpl);
|
|
943
|
+
if (match) {
|
|
944
|
+
const envVar = match[1];
|
|
945
|
+
const fullUrl = `${tunnel.url}/webhooks/${webhookPath}`;
|
|
946
|
+
// Set bare env var
|
|
947
|
+
if (!process.env[envVar]) {
|
|
948
|
+
process.env[envVar] = fullUrl;
|
|
949
|
+
console.log(`[remote] Auto-set ${envVar}=${fullUrl}`);
|
|
950
|
+
}
|
|
951
|
+
// Also set prefixed env var so caller-scoped secret resolution
|
|
952
|
+
// (which checks PREFIX_VAR, not bare VAR) can find it.
|
|
953
|
+
const prefix = callerAlias.toUpperCase().replace(/-/g, '_');
|
|
954
|
+
const prefixedEnvVar = `${prefix}_${envVar}`;
|
|
955
|
+
if (!process.env[prefixedEnvVar]) {
|
|
956
|
+
process.env[prefixedEnvVar] = fullUrl;
|
|
957
|
+
console.log(`[remote] Auto-set ${prefixedEnvVar}=${fullUrl}`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
console.log(`[remote] Tunnel active: ${tunnel.url}`);
|
|
963
|
+
console.log(`[remote] Webhook URL: ${tunnel.url}/webhooks/<path>`);
|
|
964
|
+
// Wait for the tunnel to be fully connected before starting ingestors.
|
|
965
|
+
// cloudflared reports the URL before the QUIC connection is established;
|
|
966
|
+
// services like Trello validate the callback URL during registration.
|
|
967
|
+
await waitForTunnelReady(tunnel.url, 10_000);
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
console.error('[remote] Failed to start tunnel:', err);
|
|
971
|
+
console.error('[remote] Continuing without tunnel. Webhooks will only work on localhost.');
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Start ingestors after tunnel (if any) is ready
|
|
510
975
|
ingestorManager.startAll().catch((err) => {
|
|
511
976
|
console.error('[remote] Failed to start ingestors:', err);
|
|
512
977
|
});
|
|
513
|
-
});
|
|
514
|
-
// Graceful shutdown: stop ingestors, then close the server.
|
|
978
|
+
})());
|
|
979
|
+
// Graceful shutdown: stop tunnel, then ingestors, then close the server.
|
|
515
980
|
const shutdown = () => {
|
|
516
981
|
console.log('[remote] Shutting down gracefully...');
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
982
|
+
// Stop tunnel first (fast — just kills a child process)
|
|
983
|
+
setTunnelUrl(null);
|
|
984
|
+
const tunnelDone = stopTunnel
|
|
985
|
+
? stopTunnel().catch((err) => {
|
|
986
|
+
console.error('[remote] Error stopping tunnel:', err);
|
|
987
|
+
})
|
|
988
|
+
: Promise.resolve();
|
|
989
|
+
void tunnelDone.then(() => {
|
|
990
|
+
ingestorManager
|
|
991
|
+
.stopAll()
|
|
992
|
+
.catch((err) => {
|
|
993
|
+
console.error('[remote] Error stopping ingestors:', err);
|
|
994
|
+
})
|
|
995
|
+
.finally(() => {
|
|
996
|
+
server.close(() => {
|
|
997
|
+
console.log('[remote] Server closed.');
|
|
998
|
+
process.exit(0);
|
|
999
|
+
});
|
|
526
1000
|
});
|
|
527
1001
|
});
|
|
528
1002
|
// Force exit after 10 seconds if connections don't drain
|
|
@@ -531,6 +1005,18 @@ export function main() {
|
|
|
531
1005
|
process.exit(1);
|
|
532
1006
|
}, 10_000).unref();
|
|
533
1007
|
};
|
|
1008
|
+
server.on('error', (err) => {
|
|
1009
|
+
if (err.code === 'EADDRINUSE') {
|
|
1010
|
+
console.error(`[remote] Error: Port ${port} is already in use.`);
|
|
1011
|
+
}
|
|
1012
|
+
else if (err.code === 'EACCES') {
|
|
1013
|
+
console.error(`[remote] Error: Permission denied for ${host}:${port}. Try a port >= 1024.`);
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
console.error(`[remote] Server error:`, err);
|
|
1017
|
+
}
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
});
|
|
534
1020
|
process.on('SIGTERM', shutdown);
|
|
535
1021
|
process.on('SIGINT', shutdown);
|
|
536
1022
|
}
|