bunqueue-dashboard 0.0.31 → 0.0.33

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.
Files changed (107) hide show
  1. package/README.md +44 -15
  2. package/agent/db.ts +682 -68
  3. package/agent/dbQueryWorker.ts +41 -10
  4. package/agent/index.ts +4 -1
  5. package/agent/manager.ts +267 -41
  6. package/agent/server.ts +131 -36
  7. package/dist/.vite/manifest.json +215 -159
  8. package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
  9. package/dist/assets/Alerts-C39RrGbQ.js +1 -0
  10. package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
  11. package/dist/assets/Benchmark-CM60tHbf.js +1 -0
  12. package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
  13. package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
  14. package/dist/assets/Cron-s1e-jgOo.js +1 -0
  15. package/dist/assets/CronManager-BostU7BX.js +1 -0
  16. package/dist/assets/Database-ZluStWMz.js +3 -0
  17. package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
  18. package/dist/assets/Dlq-CtcLzs-b.js +1 -0
  19. package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
  20. package/dist/assets/DlqPro-B68en6AG.js +1 -0
  21. package/dist/assets/Flows-CD_bkCBM.js +1 -0
  22. package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
  23. package/dist/assets/Jobs-BUtcBKKk.js +1 -0
  24. package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
  25. package/dist/assets/Logs-CmVODYCI.js +1 -0
  26. package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
  27. package/dist/assets/McpServer-dMjyJKgT.js +22 -0
  28. package/dist/assets/Metrics-Dsbeplz-.js +1 -0
  29. package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
  30. package/dist/assets/Overview-DH9C2dsf.js +1 -0
  31. package/dist/assets/{Pagination-D4ki-pGz.js → Pagination-DpxiBnt8.js} +1 -1
  32. package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
  33. package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
  34. package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
  35. package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
  36. package/dist/assets/Queues-2_v7DcNi.js +1 -0
  37. package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
  38. package/dist/assets/S3Backup-CPADzKQE.js +1 -0
  39. package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
  40. package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
  41. package/dist/assets/Settings-CSzoM5up.js +1 -0
  42. package/dist/assets/Usage-B4B7tmrc.js +1 -0
  43. package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
  44. package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
  45. package/dist/assets/Workers-Bg8xKjkg.js +1 -0
  46. package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
  47. package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
  48. package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
  49. package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
  50. package/dist/assets/form-DsUq0w8s.js +1 -0
  51. package/dist/assets/index-BWoUj86M.css +1 -0
  52. package/dist/assets/index-Bn2R06qm.js +8 -0
  53. package/dist/assets/{install-DhTYJCUC.js → install-CUS8Kijb.js} +2 -2
  54. package/dist/assets/jobActions-LQD4KfCc.js +1 -0
  55. package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
  56. package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
  57. package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
  58. package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
  59. package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
  60. package/dist/index.html +5 -4
  61. package/package.json +11 -3
  62. package/scripts/embedded.gen.ts +132 -126
  63. package/scripts/serve.ts +501 -108
  64. package/dist/assets/AddJob-CAhdg38n.js +0 -3
  65. package/dist/assets/Alerts-70bxf-PT.js +0 -1
  66. package/dist/assets/Benchmark-CFZ3WCvj.js +0 -1
  67. package/dist/assets/BulkAddJobs-BzgzpGg_.js +0 -5
  68. package/dist/assets/CopilotPanel-DZUGIf1q.js +0 -44
  69. package/dist/assets/Cron-ltjSoTjj.js +0 -1
  70. package/dist/assets/CronManager-BcFALDeT.js +0 -1
  71. package/dist/assets/Database-CV2yt5O2.js +0 -3
  72. package/dist/assets/Diagnostics-D-EK4GEi.js +0 -1
  73. package/dist/assets/Dlq-fZAKwQoC.js +0 -1
  74. package/dist/assets/DlqControl-eTrrQCrn.js +0 -1
  75. package/dist/assets/DlqPro-CF79339e.js +0 -1
  76. package/dist/assets/Flows-Ba8G7mcC.js +0 -1
  77. package/dist/assets/JobInspector--d_8ca9p.js +0 -2
  78. package/dist/assets/Jobs-DEqAc1A5.js +0 -1
  79. package/dist/assets/JobsPro-BcaokZIf.js +0 -1
  80. package/dist/assets/Logs-DBTukPIX.js +0 -1
  81. package/dist/assets/LogsPro-Wl_e6hZM.js +0 -1
  82. package/dist/assets/McpServer-CnWJsKw7.js +0 -22
  83. package/dist/assets/Metrics-DzeIhNL-.js +0 -1
  84. package/dist/assets/MetricsPro-O9eVJSH0.js +0 -1
  85. package/dist/assets/Overview-Bnu7rWqt.js +0 -1
  86. package/dist/assets/QueueActions-DHoSkWRz.js +0 -1
  87. package/dist/assets/QueueControl-DKqEXnkM.js +0 -1
  88. package/dist/assets/QueueDetail-DLRow04B.js +0 -1
  89. package/dist/assets/QueueDetailPro-BYmOVd4T.js +0 -1
  90. package/dist/assets/Queues-CoyjRFN7.js +0 -1
  91. package/dist/assets/QueuesOverview-BeWdJpKi.js +0 -1
  92. package/dist/assets/S3Backup-BaLFgl9d.js +0 -1
  93. package/dist/assets/S3BackupPro-DJB4oWgz.js +0 -1
  94. package/dist/assets/ServerControl-_0lVB33E.js +0 -3
  95. package/dist/assets/Settings-zKFJTtnq.js +0 -1
  96. package/dist/assets/Usage-CKGkvl0j.js +0 -1
  97. package/dist/assets/UsagePro-B-TJ6fp4.js +0 -1
  98. package/dist/assets/Webhooks-C3zmvY-_.js +0 -1
  99. package/dist/assets/Workers-jKhhXl3W.js +0 -1
  100. package/dist/assets/WorkersPro-mvY5VX8I.js +0 -1
  101. package/dist/assets/connectionStore-Ci04lJmR.js +0 -1
  102. package/dist/assets/form-DGinTEEb.js +0 -1
  103. package/dist/assets/index-BOcegTHa.js +0 -8
  104. package/dist/assets/index-DWV3tgrk.css +0 -1
  105. package/dist/assets/jobActions-CuOdMglt.js +0 -1
  106. package/dist/assets/promisePool-Daxkc1OB.js +0 -1
  107. package/dist/assets/useThroughputSeries-Chp5OLx3.js +0 -1
package/scripts/serve.ts CHANGED
@@ -13,120 +13,366 @@
13
13
  * SPA finds the agent regardless of AGENT_PORT and even from a remote
14
14
  * browser, where loopback :6800 would be unreachable.
15
15
  *
16
+ * Control-plane exposure rule (the agent can spawn processes):
17
+ * - Truly local loopback access (the default): /agent/* keeps zero-config
18
+ * reads, and the direct loopback :6800 listener remains local-only.
19
+ * - Non-loopback bind (BIND_ADDR=0.0.0.0): AGENT_TOKEN is mandatory and gates
20
+ * EVERY agent route, including status/log/database reads. The public
21
+ * listener also keeps a Host allowlist; put every public/LAN hostname or IP
22
+ * in AGENT_ALLOWED_HOSTS (or AGENT_ALLOWED_ORIGINS) to admit it. There is no
23
+ * unauthenticated remote-control opt-in.
24
+ * - A loopback bind reached through a reverse proxy follows the same remote
25
+ * rule. TRUST_PROXY, non-loopback allowlists/origins, forwarding headers or
26
+ * a non-loopback request Host all switch the bridge to all-route token auth.
27
+ * - The same signals gate the administrative /api proxy independently with
28
+ * BUNQUEUE_TOKEN; without it, remote/proxied /api access is disabled.
29
+ *
16
30
  * Env: PORT (dashboard, default 8080) · BIND_ADDR (default 127.0.0.1) ·
17
- * BUNQUEUE_URL · AGENT_PORT · AGENT_ALLOWED_ORIGINS · AGENT_TOKEN ·
18
- * BUNQUEUE_START_CMD · HTTP_PORT · TCP_PORT · BUNQUEUE_DATA_PATH ·
19
- * LOG_LEVEL (pino level, default info)
31
+ * BUNQUEUE_URL · AGENT_PORT · AGENT_ALLOWED_ORIGINS · AGENT_ALLOWED_HOSTS ·
32
+ * AGENT_TOKEN · BUNQUEUE_TOKEN · TRUST_PROXY · BUNQUEUE_START_CMD ·
33
+ * HTTP_PORT · TCP_PORT · BUNQUEUE_DATA_PATH · LOG_LEVEL (pino level, info)
20
34
  */
35
+ import { timingSafeEqual } from 'node:crypto';
21
36
  import { logger } from '../agent/logger';
22
- import { ProcessManager } from '../agent/manager';
23
37
  import { setQueryWorkerUrl } from '../agent/db';
24
38
  import {
25
39
  createFetchHandler,
40
+ hostnameOf,
26
41
  isHostAllowed,
42
+ isOriginAllowed,
27
43
  resolveAllowedHosts,
28
44
  resolveAllowedOrigins,
29
45
  } from '../agent/server';
30
- import { ASSETS } from './embedded.gen';
31
-
32
- // Bun emits secondary TypeScript entrypoints under /$bunfs/root as JavaScript.
33
- // Point db.ts at that embedded path in a compiled executable; when this npm bin
34
- // runs from source, use the real TypeScript module beside agent/db.ts.
35
- const compiled = import.meta.url.includes('/$bunfs/');
36
- setQueryWorkerUrl(
37
- compiled
38
- ? new URL('/$bunfs/root/agent/dbQueryWorker.js', 'file:///').href
39
- : new URL('../agent/dbQueryWorker.ts', import.meta.url).href
40
- );
41
-
42
- const PORT = Number(process.env.PORT) || 8080;
43
- // Bind loopback by default: the /api proxy forwards to bunqueue's admin API, so
44
- // listening on all interfaces would expose it to the whole network. Set
45
- // BIND_ADDR=0.0.0.0 for direct LAN access (e.g. no reverse proxy in front).
46
- const HOST = process.env.BIND_ADDR || '127.0.0.1';
47
- const API = (process.env.BUNQUEUE_URL || 'http://localhost:6790').replace(/\/$/, '');
48
- const AGENT_PORT = Number(process.env.AGENT_PORT) || 6800;
49
-
50
- // Control agent — loopback only, allowlisted CORS; the origins this binary
51
- // serves the dashboard from are allowed automatically.
52
- const allowedOrigins = Array.from(
53
- new Set([
54
- ...resolveAllowedOrigins(),
55
- `http://localhost:${PORT}`,
56
- `http://127.0.0.1:${PORT}`,
57
- ])
58
- );
59
-
60
- // DNS-rebinding defense (see agent/server.ts). Only enforced when bound to
61
- // loopback — the common "looks safe" case where a rebound page could reach the
62
- // /api proxy and /agent inspector over same-origin GETs. When the user binds a
63
- // non-loopback interface (BIND_ADDR=0.0.0.0) they opted into network exposure,
64
- // where a fixed host allowlist would break legitimate LAN-IP access; leave it
65
- // off there (configure a reverse proxy / AGENT_ALLOWED_HOSTS instead).
66
- const loopbackBind = HOST === '127.0.0.1' || HOST === '::1' || HOST === 'localhost';
67
- const originHosts = allowedOrigins
68
- .map((o) => {
69
- try {
70
- return new URL(o).hostname;
71
- } catch {
72
- return '';
73
- }
74
- })
75
- .filter(Boolean);
76
- const allowedHosts = loopbackBind ? resolveAllowedHosts(process.env, originHosts) : undefined;
77
-
78
- const mgr = new ProcessManager();
79
- const agentHandle = createFetchHandler(mgr, {
80
- allowedOrigins,
81
- allowedHosts,
82
- token: process.env.AGENT_TOKEN || undefined,
83
- });
84
- Bun.serve({ port: AGENT_PORT, hostname: '127.0.0.1', fetch: agentHandle });
85
-
86
- // index.html with the runtime agent base injected, prepared once at startup.
87
- // The SPA's lib/bq.ts reads window.__BUNQUEUE_AGENT_URL__ before its baked-in
88
- // default, so Server Control works whatever AGENT_PORT is — the /agent/*
89
- // route below forwards to the in-process agent handler.
90
- const INDEX_HTML = (await Bun.file(ASSETS['/index.html']).text()).replace(
91
- '</head>',
92
- "<script>window.__BUNQUEUE_AGENT_URL__='/agent'</script></head>"
93
- );
94
- const indexResponse = () =>
95
- new Response(INDEX_HTML, { headers: { 'content-type': 'text/html; charset=utf-8' } });
96
-
97
- // Dashboard + /api proxy + same-origin /agent proxy.
98
- Bun.serve({
99
- port: PORT,
100
- hostname: HOST,
101
- async fetch(req) {
46
+
47
+ export const RESPONSE_SECURITY_HEADERS = {
48
+ 'Content-Security-Policy': "frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
49
+ 'X-Frame-Options': 'DENY',
50
+ 'X-Content-Type-Options': 'nosniff',
51
+ 'Referrer-Policy': 'no-referrer',
52
+ } as const;
53
+
54
+ export function withSecurityHeaders(response: Response): Response {
55
+ const headers = new Headers(response.headers);
56
+ for (const [name, value] of Object.entries(RESPONSE_SECURITY_HEADERS)) headers.set(name, value);
57
+ return new Response(response.body, {
58
+ status: response.status,
59
+ statusText: response.statusText,
60
+ headers,
61
+ });
62
+ }
63
+
64
+ /** True loopback names/addresses. 0.0.0.0 is a wildcard, never local-only. */
65
+ export function isLoopbackHost(host: string): boolean {
66
+ const value = hostnameOf(host);
67
+ const octets = value.split('.');
68
+ const ipv4Loopback =
69
+ octets.length === 4 &&
70
+ octets[0] === '127' &&
71
+ octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255);
72
+ return (
73
+ value === 'localhost' ||
74
+ value === '::1' ||
75
+ value === '0:0:0:0:0:0:0:1' ||
76
+ ipv4Loopback
77
+ );
78
+ }
79
+
80
+ /** Bind addresses that mean "only this machine can reach the listener". */
81
+ export function isLoopbackBind(host: string): boolean {
82
+ return isLoopbackHost(host);
83
+ }
84
+
85
+ function configuredValues(value: string | undefined): string[] {
86
+ return (value ?? '')
87
+ .split(',')
88
+ .map((entry) => entry.trim())
89
+ .filter(Boolean);
90
+ }
91
+
92
+ /**
93
+ * A loopback socket can still be public through a reverse proxy. These are
94
+ * operator-controlled signals that the same-origin /agent bridge is intended
95
+ * to be reachable beyond the machine, so its reads and writes must both use
96
+ * the bearer-token policy.
97
+ */
98
+ export function remoteBridgeRequiresToken(
99
+ loopbackBind: boolean,
100
+ env: Record<string, string | undefined> = process.env
101
+ ): boolean {
102
+ if (!loopbackBind || env.TRUST_PROXY === '1') return true;
103
+ const configured = [
104
+ ...configuredValues(env.AGENT_ALLOWED_HOSTS),
105
+ ...configuredValues(env.AGENT_ALLOWED_ORIGINS),
106
+ ];
107
+ return configured.some((value) => !isLoopbackHost(value));
108
+ }
109
+
110
+ const PROXY_HINT_HEADERS = [
111
+ 'forwarded',
112
+ 'x-forwarded-for',
113
+ 'x-forwarded-host',
114
+ 'x-forwarded-proto',
115
+ ] as const;
116
+
117
+ /**
118
+ * Per-request backstop for a public Host/Origin or a proxy that adds forwarding
119
+ * metadata. Forwarded-header presence is only used to demand stronger auth;
120
+ * values remain untrusted unless TRUST_PROXY enables the Origin comparison.
121
+ */
122
+ export function isRemoteBridgeRequest(req: Request, remotePolicy = false): boolean {
123
+ if (remotePolicy || PROXY_HINT_HEADERS.some((name) => req.headers.has(name))) return true;
124
+ const origin = req.headers.get('origin');
125
+ if (origin && !isLoopbackHost(origin)) return true;
126
+ const host = req.headers.get('host') ?? new URL(req.url).hostname;
127
+ return !isLoopbackHost(host);
128
+ }
129
+
130
+ /** Exact, timing-safe bearer comparison for the standalone admin-API proxy. */
131
+ export function apiTokenOk(req: Request, token: string | undefined): boolean {
132
+ if (!token) return false;
133
+ const authorization = req.headers.get('authorization');
134
+ if (!authorization?.startsWith('Bearer ')) return false;
135
+ const supplied = Buffer.from(authorization.slice('Bearer '.length));
136
+ const expected = Buffer.from(token);
137
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
138
+ }
139
+
140
+ /** Host policy shared by main() and deployment regression tests. */
141
+ export function resolveServeAllowedHosts(
142
+ bindHost: string,
143
+ allowedOrigins: string[],
144
+ env: Record<string, string | undefined> = process.env
145
+ ): string[] {
146
+ const originHosts = allowedOrigins
147
+ .map((origin) => {
148
+ try {
149
+ return new URL(origin).hostname;
150
+ } catch {
151
+ return '';
152
+ }
153
+ })
154
+ .filter(Boolean);
155
+ const concreteBindHosts = bindHost === '0.0.0.0' || bindHost === '::' ? [] : [bindHost];
156
+ return resolveAllowedHosts(env, [...originHosts, ...concreteBindHosts]);
157
+ }
158
+
159
+ /**
160
+ * May the process-spawning bridge be enabled? Truly local access stays
161
+ * zero-config; any remote deployment policy requires a real token. This
162
+ * intentionally ignores the former unauthenticated escape hatch.
163
+ */
164
+ export function remoteControlEnabled(
165
+ remotePolicy: boolean,
166
+ env: Record<string, string | undefined> = process.env
167
+ ): boolean {
168
+ if (!remotePolicy) return true;
169
+ return Boolean(env.AGENT_TOKEN?.trim());
170
+ }
171
+
172
+ /**
173
+ * Build the inner agent URL from the bridged path. `new URL(rest, base)` throws
174
+ * on a path like `//x:y/z` (it reads `//` as an authority); assigning .pathname
175
+ * never reinterprets it, so a hostile path can't crash the handler.
176
+ */
177
+ export function agentSubUrl(pathname: string, search: string): URL {
178
+ const rest = pathname.slice('/agent'.length) || '/';
179
+ const sub = new URL('http://agent.internal');
180
+ sub.pathname = rest.startsWith('/') ? rest : `/${rest}`;
181
+ sub.search = search;
182
+ return sub;
183
+ }
184
+
185
+ export interface ServeHandlerOptions {
186
+ /** bunqueue base URL the /api prefix proxies to (no trailing slash). */
187
+ api: string;
188
+ /** index.html with the agent base already injected. */
189
+ indexHtml: string;
190
+ /** embedded dist assets: request path → on-disk path. */
191
+ assets: Record<string, string>;
192
+ /** Local policy: zero-config reads, configured token on mutations. */
193
+ agentHandle: (req: Request) => Response | Promise<Response>;
194
+ /** Remote/proxied policy: configured token on every agent route. */
195
+ remoteAgentHandle: (req: Request) => Response | Promise<Response>;
196
+ allowedOrigins: string[];
197
+ allowedHosts?: string[];
198
+ /** false → /agent/* is refused under a remote policy without AGENT_TOKEN. */
199
+ agentBridge: boolean;
200
+ /** Whether the remote bridge has a non-empty AGENT_TOKEN to enforce. */
201
+ agentTokenConfigured: boolean;
202
+ /**
203
+ * Bearer required by /api on every remote/proxied request. It is deliberately
204
+ * independent of AGENT_TOKEN because the two credentials protect different
205
+ * principals and can be rotated independently.
206
+ */
207
+ apiToken?: string;
208
+ /** Deployment-level signal that even a loopback bind is externally proxied. */
209
+ remoteBridgePolicy?: boolean;
210
+ /**
211
+ * TRUST_PROXY=1 — a reverse proxy in front of us owns X-Forwarded-Host, so it
212
+ * may be believed. Off by default: the header is otherwise client-settable
213
+ * and would let any direct caller declare itself same-origin.
214
+ */
215
+ trustProxy?: boolean;
216
+ }
217
+
218
+ /**
219
+ * The dashboard listener: static assets + /api proxy + same-origin /agent
220
+ * bridge. Factored out of the Bun.serve call so the policy is unit-testable.
221
+ */
222
+ export function createServeHandler(opts: ServeHandlerOptions) {
223
+ const {
224
+ api,
225
+ indexHtml,
226
+ assets,
227
+ agentHandle,
228
+ remoteAgentHandle,
229
+ allowedOrigins,
230
+ allowedHosts,
231
+ agentBridge,
232
+ agentTokenConfigured,
233
+ apiToken,
234
+ remoteBridgePolicy = false,
235
+ trustProxy = false,
236
+ } = opts;
237
+ const secure = withSecurityHeaders;
238
+ const indexResponse = () =>
239
+ new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } });
240
+
241
+ return async function handle(req: Request): Promise<Response> {
102
242
  const url = new URL(req.url);
103
243
 
104
244
  // DNS-rebinding defense across every route (/api proxy, /agent, assets):
105
- // a page rebound to this loopback port sends its own domain as Host. No-op
106
- // on non-loopback binds (allowedHosts is undefined there).
245
+ // a rebound page sends its own domain as Host. Main configures this on both
246
+ // loopback and network binds; tests/custom embedders may omit the policy.
107
247
  if (!isHostAllowed(req.headers.get('host'), allowedHosts)) {
108
- return new Response('Host not allowed', { status: 403 });
248
+ return secure(new Response('Host not allowed', { status: 403 }));
109
249
  }
110
250
 
251
+ // A request whose Origin is the origin this listener serves from is by
252
+ // definition not the cross-site case the allowlist exists for. It is what a
253
+ // LAN browser sends (http://<lan-ip>:PORT), which no static allowlist can
254
+ // predict — treat it as allowed everywhere below.
255
+ //
256
+ // Compared by HOST, not by full origin: this listener only ever speaks
257
+ // plain http, so behind a TLS-terminating reverse proxy the browser sends
258
+ // `https://dash.example.com` while req.url reads `http://dash.example.com`.
259
+ // A full-origin compare would 403 every mutation on a proxied deployment
260
+ // while read-only GETs (which carry no Origin) kept working — a dashboard
261
+ // that looks healthy and fails on click. The host is the part that
262
+ // identifies a cross-site page (its Origin carries its OWN host), so it is
263
+ // the part worth comparing. x-forwarded-host is honoured for proxies that
264
+ // rewrite Host.
265
+ const origin = req.headers.get('origin');
266
+ let originHost = '';
267
+ try {
268
+ originHost = origin ? new URL(origin).host.toLowerCase() : '';
269
+ } catch {
270
+ originHost = ''; // unparseable Origin — treat as cross-site
271
+ }
272
+ // X-Forwarded-Host is attacker-controlled unless something in front of us
273
+ // is guaranteed to overwrite it: a direct caller can send
274
+ // `Origin: https://evil.example` + `x-forwarded-host: evil.example` and
275
+ // declare ITSELF same-origin. So it is consulted only when the operator
276
+ // sets TRUST_PROXY=1, which asserts a reverse proxy owns that header.
277
+ // Without it, a proxy that preserves Host still works (the Host compare
278
+ // below covers it); only a Host-REWRITING proxy needs the opt-in.
279
+ const fwd = trustProxy ? req.headers.get('x-forwarded-host')?.split(',') : undefined;
280
+ const forwardedHost = fwd?.[fwd.length - 1]?.trim().toLowerCase();
281
+ const sameOrigin =
282
+ originHost !== '' &&
283
+ (originHost === url.host.toLowerCase() || originHost === forwardedHost);
284
+
111
285
  // Same-origin bridge to the control agent: strip the /agent prefix and
112
286
  // hand the request to the in-process agent handler (no loopback hop).
113
- // The agent's own Origin allowlist + optional AGENT_TOKEN still apply —
114
- // this changes reachability, not authorization.
287
+ // The selected agent handler still applies Host, Origin and token policy;
288
+ // this bridge changes reachability, never authorization.
115
289
  if (url.pathname === '/agent' || url.pathname.startsWith('/agent/')) {
116
- const sub = new URL(url.pathname.slice('/agent'.length) || '/', 'http://agent.internal');
117
- sub.search = url.search;
118
- return agentHandle(new Request(sub.href, req));
290
+ const remoteRequest = isRemoteBridgeRequest(req, remoteBridgePolicy);
291
+ if (!agentBridge || (remoteRequest && !agentTokenConfigured)) {
292
+ return secure(
293
+ Response.json(
294
+ {
295
+ ok: false,
296
+ error:
297
+ 'Control agent disabled for remote or proxied access. Set AGENT_TOKEN to expose it.',
298
+ },
299
+ { status: 403 }
300
+ )
301
+ );
302
+ }
303
+ const sub = agentSubUrl(url.pathname, url.search);
304
+ const headers = new Headers(req.headers);
305
+ // Drop a same-origin Origin the agent's static allowlist cannot know
306
+ // (a LAN IP / hostname alias). Loopback origins are already allowlisted,
307
+ // so they are forwarded untouched and CORS behaviour is unchanged.
308
+ if (sameOrigin && origin && !isOriginAllowed(origin, allowedOrigins)) headers.delete('origin');
309
+ const handleAgent = remoteRequest ? remoteAgentHandle : agentHandle;
310
+ return secure(
311
+ await handleAgent(
312
+ new Request(sub.href, {
313
+ method: req.method,
314
+ headers,
315
+ body: req.body,
316
+ signal: req.signal,
317
+ })
318
+ )
319
+ );
119
320
  }
120
321
 
121
322
  // Same-origin proxy to the bunqueue server (mirrors the Vite dev proxy).
122
323
  if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
123
- const target = API + (url.pathname.slice(4) || '/') + url.search;
124
- const res = await fetch(target, {
125
- method: req.method,
126
- headers: req.headers,
127
- body: req.body,
128
- redirect: 'manual',
129
- });
324
+ // The proxy talks to bunqueue's admin API, so it needs the same drive-by
325
+ // CSRF gate as the agent: a cross-site page must not reach it.
326
+ if (!sameOrigin && !isOriginAllowed(origin, allowedOrigins)) {
327
+ return secure(
328
+ Response.json({ ok: false, error: 'Origin not allowed' }, { status: 403 })
329
+ );
330
+ }
331
+ // Host/Origin checks stop browser CSRF and DNS rebinding; they do not
332
+ // authenticate curl, LAN peers or callers whose upstream has AUTH_TOKENS
333
+ // disabled. Fail closed for every public/proxied request at this boundary.
334
+ if (isRemoteBridgeRequest(req, remoteBridgePolicy)) {
335
+ if (!apiToken) {
336
+ return secure(
337
+ Response.json(
338
+ {
339
+ ok: false,
340
+ error:
341
+ 'Admin API proxy disabled for remote or proxied access. Set BUNQUEUE_TOKEN to expose it.',
342
+ },
343
+ { status: 403 }
344
+ )
345
+ );
346
+ }
347
+ if (!apiTokenOk(req, apiToken)) {
348
+ return secure(
349
+ Response.json(
350
+ { ok: false, error: 'A valid BUNQUEUE_TOKEN bearer token is required.' },
351
+ { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }
352
+ )
353
+ );
354
+ }
355
+ }
356
+ const target = api + (url.pathname.slice(4) || '/') + url.search;
357
+ let res: Response;
358
+ try {
359
+ res = await fetch(target, {
360
+ method: req.method,
361
+ headers: req.headers,
362
+ body: req.body,
363
+ redirect: 'manual',
364
+ signal: req.signal,
365
+ });
366
+ } catch (err) {
367
+ // bunqueue not running / wrong BUNQUEUE_URL: answer in the shape the
368
+ // dashboard parses instead of letting Bun render an HTML error page.
369
+ return secure(
370
+ Response.json(
371
+ { ok: false, error: `bunqueue unreachable at ${api}: ${(err as Error).message}` },
372
+ { status: 502 }
373
+ )
374
+ );
375
+ }
130
376
  // Bun's fetch advertises accept-encoding upstream and transparently
131
377
  // DECOMPRESSES the body, but leaves the upstream headers intact. If the
132
378
  // bunqueue server sits behind any gzip-compressing proxy, forwarding
@@ -136,7 +382,9 @@ Bun.serve({
136
382
  headers.delete('content-encoding');
137
383
  headers.delete('content-length');
138
384
  headers.delete('transfer-encoding');
139
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
385
+ return secure(
386
+ new Response(res.body, { status: res.status, statusText: res.statusText, headers })
387
+ );
140
388
  }
141
389
 
142
390
  // Embedded static assets with SPA history fallback. A missing fingerprinted
@@ -144,19 +392,164 @@ Bun.serve({
144
392
  // index.html would feed HTML to a stale chunk import() and mask the miss.
145
393
  // index.html (direct or via fallback) is served from the injected copy.
146
394
  const key = url.pathname === '/' ? '/index.html' : url.pathname;
147
- if (key === '/index.html') return indexResponse();
148
- const asset = ASSETS[key];
149
- if (asset) return new Response(Bun.file(asset));
150
- if (url.pathname.startsWith('/assets/')) return new Response('Not found', { status: 404 });
151
- return indexResponse();
152
- },
153
- });
154
-
155
- logger.info(
156
- {
157
- dashboard: `http://${HOST}:${PORT}`,
158
- apiProxy: API,
159
- agent: `http://127.0.0.1:${AGENT_PORT}/control`,
160
- },
161
- 'bunqueue dashboard (standalone) ready'
162
- );
395
+ if (key === '/index.html') return secure(indexResponse());
396
+ const asset = assets[key];
397
+ if (asset) return secure(new Response(Bun.file(asset)));
398
+ if (url.pathname.startsWith('/assets/')) {
399
+ return secure(new Response('Not found', { status: 404 }));
400
+ }
401
+ return secure(indexResponse());
402
+ };
403
+ }
404
+
405
+ async function main(): Promise<void> {
406
+ // Bun emits secondary TypeScript entrypoints under /$bunfs/root as JavaScript.
407
+ // Point db.ts at that embedded path in a compiled executable; when this npm bin
408
+ // runs from source, use the real TypeScript module beside agent/db.ts.
409
+ const compiled = import.meta.url.includes('/$bunfs/');
410
+ setQueryWorkerUrl(
411
+ compiled
412
+ ? new URL('/$bunfs/root/agent/dbQueryWorker.js', 'file:///').href
413
+ : new URL('../agent/dbQueryWorker.ts', import.meta.url).href
414
+ );
415
+
416
+ const PORT = Number(process.env.PORT) || 8080;
417
+ // Bind loopback by default: the /api proxy forwards to bunqueue's admin API, so
418
+ // listening on all interfaces would expose it to the whole network. Set
419
+ // BIND_ADDR=0.0.0.0 for direct LAN access (e.g. no reverse proxy in front).
420
+ const HOST = process.env.BIND_ADDR || '127.0.0.1';
421
+ const API = (process.env.BUNQUEUE_URL || 'http://localhost:6790').replace(/\/$/, '');
422
+ const AGENT_PORT = Number(process.env.AGENT_PORT) || 6800;
423
+
424
+ // Control agent — loopback only, allowlisted CORS; the origins this binary
425
+ // serves the dashboard from are allowed automatically.
426
+ const allowedOrigins = Array.from(
427
+ new Set([
428
+ ...resolveAllowedOrigins(),
429
+ `http://localhost:${PORT}`,
430
+ `http://127.0.0.1:${PORT}`,
431
+ ])
432
+ );
433
+
434
+ // DNS-rebinding defense (see agent/server.ts). Enforced on the loopback :6800
435
+ // listener AND on the dashboard listener for every bind. For a concrete bind
436
+ // address, admit that address automatically. A wildcard bind cannot reveal
437
+ // which LAN/public address clients use, so the operator must list those names
438
+ // or IPs in AGENT_ALLOWED_HOSTS (origins in AGENT_ALLOWED_ORIGINS also count).
439
+ const loopbackBind = isLoopbackBind(HOST);
440
+ const remoteBridgePolicy = remoteBridgeRequiresToken(loopbackBind, process.env);
441
+ const allowedHosts = resolveServeAllowedHosts(HOST, allowedOrigins, process.env);
442
+
443
+ // Imported here rather than at module scope: this file's exported policy
444
+ // helpers are unit-tested, and a top-level import would pull the whole
445
+ // ProcessManager into every test that touches them — which, besides being
446
+ // wasteful, makes Bun attribute a barely-executed copy of agent/manager.ts to
447
+ // the coverage report and hides that module's real coverage.
448
+ const { ProcessManager } = await import('../agent/manager');
449
+ const mgr = new ProcessManager();
450
+ const token = process.env.AGENT_TOKEN?.trim() || undefined;
451
+ const apiToken = process.env.BUNQUEUE_TOKEN?.trim() || undefined;
452
+ // Two handlers, same manager: the loopback :6800 listener keeps the Host gate
453
+ // unconditionally (it is always reached as localhost/127.0.0.1), while the
454
+ // bridged one sees the dashboard client's Host and follows `allowedHosts`.
455
+ const localAgentHandle = createFetchHandler(mgr, {
456
+ allowedOrigins,
457
+ allowedHosts,
458
+ token,
459
+ });
460
+ const remoteAgentHandle = createFetchHandler(mgr, {
461
+ allowedOrigins,
462
+ allowedHosts,
463
+ token,
464
+ requireTokenForAll: true,
465
+ });
466
+ // `error` is a backstop: without it an unexpected throw renders Bun's HTML
467
+ // error page (with a stack trace) to the client instead of a plain 500.
468
+ const onError = () => withSecurityHeaders(new Response('Internal error', { status: 500 }));
469
+ Bun.serve({
470
+ port: AGENT_PORT,
471
+ hostname: '127.0.0.1',
472
+ fetch: localAgentHandle,
473
+ error: onError,
474
+ });
475
+
476
+ const agentBridge = remoteControlEnabled(remoteBridgePolicy, process.env);
477
+ if (!agentBridge) {
478
+ logger.warn(
479
+ { bind: HOST, remoteBridgePolicy },
480
+ 'remote or proxied bridge policy without AGENT_TOKEN — /agent is disabled (403)'
481
+ );
482
+ }
483
+ if (remoteBridgePolicy && !apiToken) {
484
+ logger.warn(
485
+ { apiProxy: API },
486
+ 'remote or proxied bridge policy without BUNQUEUE_TOKEN — /api is disabled (403)'
487
+ );
488
+ } else if (remoteBridgePolicy) {
489
+ logger.info(
490
+ { apiProxy: API },
491
+ 'remote or proxied /api requires the configured BUNQUEUE_TOKEN bearer'
492
+ );
493
+ }
494
+
495
+ // index.html with the runtime agent base injected, prepared once at startup.
496
+ // The SPA's lib/bq.ts reads window.__BUNQUEUE_AGENT_URL__ before its baked-in
497
+ // default, so Server Control works whatever AGENT_PORT is — the /agent/*
498
+ // route below forwards to the in-process agent handler.
499
+ const { ASSETS } = await import('./embedded.gen');
500
+ const indexHtml = (await Bun.file(ASSETS['/index.html']).text()).replace(
501
+ '</head>',
502
+ "<script>window.__BUNQUEUE_AGENT_URL__='/agent'</script></head>"
503
+ );
504
+
505
+ // Dashboard + /api proxy + same-origin /agent proxy.
506
+ Bun.serve({
507
+ port: PORT,
508
+ hostname: HOST,
509
+ fetch: createServeHandler({
510
+ api: API,
511
+ indexHtml,
512
+ assets: ASSETS,
513
+ agentHandle: localAgentHandle,
514
+ remoteAgentHandle,
515
+ allowedOrigins,
516
+ allowedHosts,
517
+ agentBridge,
518
+ agentTokenConfigured: Boolean(token),
519
+ apiToken,
520
+ remoteBridgePolicy,
521
+ trustProxy: process.env.TRUST_PROXY === '1',
522
+ }),
523
+ error: onError,
524
+ });
525
+
526
+ // Stop the managed server before exiting — without this, Ctrl-C / SIGTERM on
527
+ // the binary orphans the spawned bunqueue child (it reparents to PID 1 and
528
+ // keeps holding the ports and the SQLite db, so the next start fails).
529
+ let shuttingDown = false;
530
+ const shutdown = (signal: string) => {
531
+ if (shuttingDown) return;
532
+ shuttingDown = true;
533
+ logger.info({ signal }, 'signal received, stopping managed server');
534
+ // shutdown(), not stop(): it latches, so a restart() already in flight
535
+ // can't spawn a fresh child after we've stopped the old one (mirrors
536
+ // agent/index.ts).
537
+ void mgr.shutdown().finally(() => process.exit(0));
538
+ };
539
+ process.on('SIGINT', () => shutdown('SIGINT'));
540
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
541
+
542
+ logger.info(
543
+ {
544
+ dashboard: `http://${HOST}:${PORT}`,
545
+ apiProxy: API,
546
+ agent: `http://127.0.0.1:${AGENT_PORT}/control`,
547
+ agentBridge,
548
+ remoteBridgePolicy,
549
+ allowedHosts,
550
+ },
551
+ 'bunqueue dashboard (standalone) ready'
552
+ );
553
+ }
554
+
555
+ if (import.meta.main) await main();