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.
- package/README.md +44 -15
- package/agent/db.ts +682 -68
- package/agent/dbQueryWorker.ts +41 -10
- package/agent/index.ts +4 -1
- package/agent/manager.ts +267 -41
- package/agent/server.ts +131 -36
- package/dist/.vite/manifest.json +215 -159
- package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
- package/dist/assets/Alerts-C39RrGbQ.js +1 -0
- package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
- package/dist/assets/Benchmark-CM60tHbf.js +1 -0
- package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
- package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
- package/dist/assets/Cron-s1e-jgOo.js +1 -0
- package/dist/assets/CronManager-BostU7BX.js +1 -0
- package/dist/assets/Database-ZluStWMz.js +3 -0
- package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
- package/dist/assets/Dlq-CtcLzs-b.js +1 -0
- package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
- package/dist/assets/DlqPro-B68en6AG.js +1 -0
- package/dist/assets/Flows-CD_bkCBM.js +1 -0
- package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
- package/dist/assets/Jobs-BUtcBKKk.js +1 -0
- package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
- package/dist/assets/Logs-CmVODYCI.js +1 -0
- package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
- package/dist/assets/McpServer-dMjyJKgT.js +22 -0
- package/dist/assets/Metrics-Dsbeplz-.js +1 -0
- package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
- package/dist/assets/Overview-DH9C2dsf.js +1 -0
- package/dist/assets/{Pagination-D4ki-pGz.js → Pagination-DpxiBnt8.js} +1 -1
- package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
- package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
- package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
- package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
- package/dist/assets/Queues-2_v7DcNi.js +1 -0
- package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
- package/dist/assets/S3Backup-CPADzKQE.js +1 -0
- package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
- package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
- package/dist/assets/Settings-CSzoM5up.js +1 -0
- package/dist/assets/Usage-B4B7tmrc.js +1 -0
- package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
- package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
- package/dist/assets/Workers-Bg8xKjkg.js +1 -0
- package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
- package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
- package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
- package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
- package/dist/assets/form-DsUq0w8s.js +1 -0
- package/dist/assets/index-BWoUj86M.css +1 -0
- package/dist/assets/index-Bn2R06qm.js +8 -0
- package/dist/assets/{install-DhTYJCUC.js → install-CUS8Kijb.js} +2 -2
- package/dist/assets/jobActions-LQD4KfCc.js +1 -0
- package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
- package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
- package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
- package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
- package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
- package/dist/index.html +5 -4
- package/package.json +11 -3
- package/scripts/embedded.gen.ts +132 -126
- package/scripts/serve.ts +501 -108
- package/dist/assets/AddJob-CAhdg38n.js +0 -3
- package/dist/assets/Alerts-70bxf-PT.js +0 -1
- package/dist/assets/Benchmark-CFZ3WCvj.js +0 -1
- package/dist/assets/BulkAddJobs-BzgzpGg_.js +0 -5
- package/dist/assets/CopilotPanel-DZUGIf1q.js +0 -44
- package/dist/assets/Cron-ltjSoTjj.js +0 -1
- package/dist/assets/CronManager-BcFALDeT.js +0 -1
- package/dist/assets/Database-CV2yt5O2.js +0 -3
- package/dist/assets/Diagnostics-D-EK4GEi.js +0 -1
- package/dist/assets/Dlq-fZAKwQoC.js +0 -1
- package/dist/assets/DlqControl-eTrrQCrn.js +0 -1
- package/dist/assets/DlqPro-CF79339e.js +0 -1
- package/dist/assets/Flows-Ba8G7mcC.js +0 -1
- package/dist/assets/JobInspector--d_8ca9p.js +0 -2
- package/dist/assets/Jobs-DEqAc1A5.js +0 -1
- package/dist/assets/JobsPro-BcaokZIf.js +0 -1
- package/dist/assets/Logs-DBTukPIX.js +0 -1
- package/dist/assets/LogsPro-Wl_e6hZM.js +0 -1
- package/dist/assets/McpServer-CnWJsKw7.js +0 -22
- package/dist/assets/Metrics-DzeIhNL-.js +0 -1
- package/dist/assets/MetricsPro-O9eVJSH0.js +0 -1
- package/dist/assets/Overview-Bnu7rWqt.js +0 -1
- package/dist/assets/QueueActions-DHoSkWRz.js +0 -1
- package/dist/assets/QueueControl-DKqEXnkM.js +0 -1
- package/dist/assets/QueueDetail-DLRow04B.js +0 -1
- package/dist/assets/QueueDetailPro-BYmOVd4T.js +0 -1
- package/dist/assets/Queues-CoyjRFN7.js +0 -1
- package/dist/assets/QueuesOverview-BeWdJpKi.js +0 -1
- package/dist/assets/S3Backup-BaLFgl9d.js +0 -1
- package/dist/assets/S3BackupPro-DJB4oWgz.js +0 -1
- package/dist/assets/ServerControl-_0lVB33E.js +0 -3
- package/dist/assets/Settings-zKFJTtnq.js +0 -1
- package/dist/assets/Usage-CKGkvl0j.js +0 -1
- package/dist/assets/UsagePro-B-TJ6fp4.js +0 -1
- package/dist/assets/Webhooks-C3zmvY-_.js +0 -1
- package/dist/assets/Workers-jKhhXl3W.js +0 -1
- package/dist/assets/WorkersPro-mvY5VX8I.js +0 -1
- package/dist/assets/connectionStore-Ci04lJmR.js +0 -1
- package/dist/assets/form-DGinTEEb.js +0 -1
- package/dist/assets/index-BOcegTHa.js +0 -8
- package/dist/assets/index-DWV3tgrk.css +0 -1
- package/dist/assets/jobActions-CuOdMglt.js +0 -1
- package/dist/assets/promisePool-Daxkc1OB.js +0 -1
- 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 ·
|
|
18
|
-
*
|
|
19
|
-
* LOG_LEVEL (pino level,
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
106
|
-
//
|
|
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
|
|
114
|
-
// this changes reachability,
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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 =
|
|
149
|
-
if (asset) return new Response(Bun.file(asset));
|
|
150
|
-
if (url.pathname.startsWith('/assets/'))
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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();
|