@knpkv/herdr-approvals 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/activity-history.d.ts +36 -0
- package/dist/activity-history.d.ts.map +1 -0
- package/dist/activity-history.js +233 -0
- package/dist/activity-history.js.map +1 -0
- package/dist/approval-app-view.d.ts +26 -0
- package/dist/approval-app-view.d.ts.map +1 -0
- package/dist/approval-app-view.js +79 -0
- package/dist/approval-app-view.js.map +1 -0
- package/dist/approval-client.d.ts +2 -0
- package/dist/approval-client.d.ts.map +1 -0
- package/dist/approval-client.js +520 -0
- package/dist/approval-client.js.map +1 -0
- package/dist/approval-sw.js +28 -0
- package/dist/approval-sw.js.map +7 -0
- package/dist/approval-url.d.ts +37 -0
- package/dist/approval-url.d.ts.map +1 -0
- package/dist/approval-url.js +37 -0
- package/dist/approval-url.js.map +1 -0
- package/dist/approval.js +188 -0
- package/dist/approval.js.map +7 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +14 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +47 -0
- package/dist/bin.js.map +1 -0
- package/dist/connect-entry.d.ts +2 -0
- package/dist/connect-entry.d.ts.map +1 -0
- package/dist/connect-entry.js +2 -0
- package/dist/connect-entry.js.map +1 -0
- package/dist/connect.js +188 -0
- package/dist/connect.js.map +7 -0
- package/dist/dashboard-model.d.ts +719 -0
- package/dist/dashboard-model.d.ts.map +1 -0
- package/dist/dashboard-model.js +83 -0
- package/dist/dashboard-model.js.map +1 -0
- package/dist/dashboard-view.d.ts +42 -0
- package/dist/dashboard-view.d.ts.map +1 -0
- package/dist/dashboard-view.js +194 -0
- package/dist/dashboard-view.js.map +1 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -0
- package/dist/fleetctl-submission.d.ts +68 -0
- package/dist/fleetctl-submission.d.ts.map +1 -0
- package/dist/fleetctl-submission.js +31 -0
- package/dist/fleetctl-submission.js.map +1 -0
- package/dist/fleetctl.d.ts +22 -0
- package/dist/fleetctl.d.ts.map +1 -0
- package/dist/fleetctl.js +264 -0
- package/dist/fleetctl.js.map +1 -0
- package/dist/geist-latin-wght-normal.woff2 +0 -0
- package/dist/geist-mono-latin-wght-normal.woff2 +0 -0
- package/dist/http.d.ts +1512 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +1653 -0
- package/dist/http.js.map +1 -0
- package/dist/index.css +2 -0
- package/dist/index.css.map +7 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/config-path.d.ts +3 -0
- package/dist/internal/config-path.d.ts.map +1 -0
- package/dist/internal/config-path.js +3 -0
- package/dist/internal/config-path.js.map +1 -0
- package/dist/internal/dashboard-pending-state.d.ts +49 -0
- package/dist/internal/dashboard-pending-state.d.ts.map +1 -0
- package/dist/internal/dashboard-pending-state.js +98 -0
- package/dist/internal/dashboard-pending-state.js.map +1 -0
- package/dist/internal/fleet-follow.d.ts +54 -0
- package/dist/internal/fleet-follow.d.ts.map +1 -0
- package/dist/internal/fleet-follow.js +10 -0
- package/dist/internal/fleet-follow.js.map +1 -0
- package/dist/internal/fleet-request.d.ts +4 -0
- package/dist/internal/fleet-request.d.ts.map +1 -0
- package/dist/internal/fleet-request.js +11 -0
- package/dist/internal/fleet-request.js.map +1 -0
- package/dist/internal/html.d.ts +3 -0
- package/dist/internal/html.d.ts.map +1 -0
- package/dist/internal/html.js +8 -0
- package/dist/internal/html.js.map +1 -0
- package/dist/internal/websocket.d.ts +4 -0
- package/dist/internal/websocket.d.ts.map +1 -0
- package/dist/internal/websocket.js +12 -0
- package/dist/internal/websocket.js.map +1 -0
- package/dist/model.d.ts +40 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +53 -0
- package/dist/model.js.map +1 -0
- package/dist/operations.d.ts +72 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +284 -0
- package/dist/operations.js.map +1 -0
- package/dist/push-policy.d.ts +3 -0
- package/dist/push-policy.d.ts.map +1 -0
- package/dist/push-policy.js +3 -0
- package/dist/push-policy.js.map +1 -0
- package/dist/push-sender.d.ts +18 -0
- package/dist/push-sender.d.ts.map +1 -0
- package/dist/push-sender.js +33 -0
- package/dist/push-sender.js.map +1 -0
- package/dist/push-subscription.d.ts +17 -0
- package/dist/push-subscription.d.ts.map +1 -0
- package/dist/push-subscription.js +62 -0
- package/dist/push-subscription.js.map +1 -0
- package/dist/push-worker.d.ts +20 -0
- package/dist/push-worker.d.ts.map +1 -0
- package/dist/push-worker.js +32 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/pwa.d.ts +39 -0
- package/dist/pwa.d.ts.map +1 -0
- package/dist/pwa.js +51 -0
- package/dist/pwa.js.map +1 -0
- package/dist/shell-view.d.ts +20 -0
- package/dist/shell-view.d.ts.map +1 -0
- package/dist/shell-view.js +86 -0
- package/dist/shell-view.js.map +1 -0
- package/dist/store.d.ts +46 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +291 -0
- package/dist/store.js.map +1 -0
- package/dist/work-checkpoint.d.ts +107 -0
- package/dist/work-checkpoint.d.ts.map +1 -0
- package/dist/work-checkpoint.js +37 -0
- package/dist/work-checkpoint.js.map +1 -0
- package/package.json +90 -0
package/dist/http.js
ADDED
|
@@ -0,0 +1,1653 @@
|
|
|
1
|
+
import { NodeHttpClient, NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { AgentActivityStore, AgentRelationshipStore, ConnectAgentCursor, fleetConnectAgents, localConnectAgents, makeHerdrTerminalConnector, pageFleetConnectAgents, TerminalClientCommand, terminalCommandMaxPayloadBytes, terminalFrameMaxEncodedBytes, TerminalSelection, TerminalServerSignal } from "@knpkv/herdr-connect";
|
|
3
|
+
import { ChatRequest, ChatStore, makeCoordinatorChat } from "@knpkv/herdr-coordinator";
|
|
4
|
+
import { decodeBoundedResponseJson, FleetAuthorizationError, FleetJobNotFoundError, FleetOperationError, fleetResponseBodyMaxBytes, FleetValidationError, JobHash, JobIdentifier, JobRequest, PeerPendingDecodeError, PeerPendingHostMismatchError, PeerPendingStatusError, PeerPendingTimeoutError, PeerPendingTransportError, PeerPendingUnavailableError, PendingApprovalCursor } from "@knpkv/herdr-fleet";
|
|
5
|
+
import { authorizeWhois, discoverFleetPeers, layer as tailscaleLayer, Tailscale } from "@knpkv/herdr-tailscale";
|
|
6
|
+
import { makeWorkService, WorkStore } from "@knpkv/herdr-work";
|
|
7
|
+
import { WorkGoalCheckpoint } from "@knpkv/herdr-work/model";
|
|
8
|
+
import { Cause, Clock, Crypto, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime, Queue, Result, Schema, Scope, Semaphore, Stream } from "effect";
|
|
9
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { createServer as createSecureServer } from "node:https";
|
|
12
|
+
import { createElement } from "react";
|
|
13
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
14
|
+
import WebSocketClient, { WebSocketServer } from "ws";
|
|
15
|
+
import { authorize, authorizeLoopback } from "./auth.js";
|
|
16
|
+
import { PendingApprovalContinuation, PendingApprovalSummary } from "./dashboard-model.js";
|
|
17
|
+
import { DashboardView } from "./dashboard-view.js";
|
|
18
|
+
import { DashboardResponseBudgetError } from "./errors.js";
|
|
19
|
+
import { dashboardDocumentTitle } from "./internal/html.js";
|
|
20
|
+
import { relayTerminalCloseCode, terminalBufferCanAccept } from "./internal/websocket.js";
|
|
21
|
+
import { ApprovalNotificationCandidate, PushSubscriptionRecord, PushSubscriptionRemoval } from "./model.js";
|
|
22
|
+
import { generateVapidKeys, makePushSender } from "./push-sender.js";
|
|
23
|
+
import { validatePushEndpoint } from "./push-subscription.js";
|
|
24
|
+
import { makePushWorker } from "./push-worker.js";
|
|
25
|
+
import { ApprovalAppStore } from "./store.js";
|
|
26
|
+
import { workCheckpointPath, workSnapshotPath } from "./work-checkpoint.js";
|
|
27
|
+
const Approval = Schema.Struct({ hash: JobHash, nonce: Schema.String });
|
|
28
|
+
const TcpAddress = Schema.Struct({ address: Schema.String, port: Schema.Number });
|
|
29
|
+
const decodeJobPathSegment = Effect.fn("ApprovalHttp.decodeJobPathSegment")((segment) => Effect.try({
|
|
30
|
+
try: () => decodeURIComponent(segment),
|
|
31
|
+
catch: () => new FleetValidationError({ detail: "invalid job identifier path segment" })
|
|
32
|
+
}).pipe(Effect.flatMap((value) => Schema.decodeUnknownEffect(JobIdentifier)(value).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid job identifier path segment" }))))));
|
|
33
|
+
const peerPendingTimeoutMs = 1_500;
|
|
34
|
+
const terminalFrameMaxPayload = terminalFrameMaxEncodedBytes;
|
|
35
|
+
const json = (response, status, value) => {
|
|
36
|
+
response.writeHead(status, {
|
|
37
|
+
"content-type": "application/json; charset=utf-8"
|
|
38
|
+
});
|
|
39
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
40
|
+
};
|
|
41
|
+
const apiError = (error) => {
|
|
42
|
+
switch (error._tag) {
|
|
43
|
+
case "FleetAuthorizationError":
|
|
44
|
+
return { status: 403, body: { error: error._tag, actor: error.actor } };
|
|
45
|
+
case "FleetJobNotFoundError":
|
|
46
|
+
return { status: 404, body: { error: error._tag, jobId: error.jobId } };
|
|
47
|
+
case "FleetJobConflictError":
|
|
48
|
+
return { status: 409, body: { error: error._tag, jobId: error.jobId } };
|
|
49
|
+
case "FleetApprovalError":
|
|
50
|
+
return { status: 409, body: { error: error._tag, detail: error.detail } };
|
|
51
|
+
case "FleetTransitionConflictError":
|
|
52
|
+
return { status: 409, body: { error: error._tag, jobId: error.jobId } };
|
|
53
|
+
case "FleetValidationError":
|
|
54
|
+
return { status: 400, body: { error: error._tag, detail: error.detail } };
|
|
55
|
+
case "PushEndpointNotAllowedError":
|
|
56
|
+
return { status: 400, body: { error: error._tag, origin: error.origin } };
|
|
57
|
+
case "ConnectPeerError":
|
|
58
|
+
return {
|
|
59
|
+
status: 503,
|
|
60
|
+
body: { error: error._tag, host: error.host, reason: error.reason }
|
|
61
|
+
};
|
|
62
|
+
case "FleetOperationError":
|
|
63
|
+
case "TerminalTransportError":
|
|
64
|
+
return { status: 503, body: { error: error._tag, detail: error.detail } };
|
|
65
|
+
case "FleetStoreError":
|
|
66
|
+
return { status: 500, body: { error: error._tag, detail: error.detail } };
|
|
67
|
+
case "ApprovalAppStoreError":
|
|
68
|
+
case "ChatHistoryError":
|
|
69
|
+
case "WorkProjectionError":
|
|
70
|
+
return { status: 500, body: { error: error._tag, detail: error.detail } };
|
|
71
|
+
case "WorkStoreError":
|
|
72
|
+
return { status: 500, body: { error: error._tag, detail: error.operation } };
|
|
73
|
+
case "WorkCheckpointConflictError":
|
|
74
|
+
return { status: 409, body: { error: error._tag, eventId: error.eventId } };
|
|
75
|
+
case "DashboardResponseBudgetError":
|
|
76
|
+
return {
|
|
77
|
+
status: 413,
|
|
78
|
+
body: {
|
|
79
|
+
error: error._tag,
|
|
80
|
+
encodedBytes: error.encodedBytes,
|
|
81
|
+
maximumBytes: error.maximumBytes
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const header = (request, name) => {
|
|
87
|
+
const value = request.headers[name];
|
|
88
|
+
return Array.isArray(value) ? value[0] : value;
|
|
89
|
+
};
|
|
90
|
+
const actor = (request, config, allowLocal) => authorize({
|
|
91
|
+
remoteAddress: request.socket.remoteAddress,
|
|
92
|
+
login: header(request, "tailscale-user-login")
|
|
93
|
+
}, config.allowedUsers, allowLocal);
|
|
94
|
+
const sameOrigin = (request, expected) => {
|
|
95
|
+
const received = header(request, "origin");
|
|
96
|
+
return received === expected
|
|
97
|
+
? Effect.void
|
|
98
|
+
: Effect.fail(new FleetAuthorizationError({ actor: received ?? "missing-origin" }));
|
|
99
|
+
};
|
|
100
|
+
export const listenerAuthority = (address, port) => new URL(`http://${address}:${port}/`).host.toLowerCase();
|
|
101
|
+
const approvalIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#111418"/><path d="M146 264l72 72 148-160" fill="none" stroke="#a6e3a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="52"/></svg>`;
|
|
102
|
+
const approvalManifest = JSON.stringify({
|
|
103
|
+
id: "/",
|
|
104
|
+
name: "Fleet approvals",
|
|
105
|
+
short_name: "Approvals",
|
|
106
|
+
start_url: "/",
|
|
107
|
+
scope: "/",
|
|
108
|
+
display: "standalone",
|
|
109
|
+
background_color: "#111418",
|
|
110
|
+
theme_color: "#111418",
|
|
111
|
+
icons: [
|
|
112
|
+
{
|
|
113
|
+
src: "/assets/approval-icon.svg",
|
|
114
|
+
sizes: "any",
|
|
115
|
+
type: "image/svg+xml",
|
|
116
|
+
purpose: "any maskable"
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
});
|
|
120
|
+
const tailnetActor = Effect.fn("ApprovalHttp.tailnetActor")(function* (request, config, requiredNodeIds) {
|
|
121
|
+
const address = request.socket.remoteAddress;
|
|
122
|
+
if (address === undefined) {
|
|
123
|
+
return yield* new FleetAuthorizationError({ actor: "unknown" });
|
|
124
|
+
}
|
|
125
|
+
const tailscale = yield* Tailscale;
|
|
126
|
+
const identity = yield* tailscale.whois(address).pipe(Effect.mapError((cause) => new FleetOperationError({
|
|
127
|
+
cause,
|
|
128
|
+
detail: String(cause),
|
|
129
|
+
operation: "tailscale.whois"
|
|
130
|
+
})));
|
|
131
|
+
return yield* authorizeWhois(identity, config.allowedUsers, requiredNodeIds).pipe(Effect.mapError((error) => new FleetAuthorizationError({ actor: error.actor })));
|
|
132
|
+
});
|
|
133
|
+
const readBody = (request) => Effect.tryPromise({
|
|
134
|
+
try: async () => {
|
|
135
|
+
const chunks = [];
|
|
136
|
+
let size = 0;
|
|
137
|
+
for await (const chunk of request) {
|
|
138
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
139
|
+
size += buffer.length;
|
|
140
|
+
if (size > 1024 * 1024)
|
|
141
|
+
throw new Error("request body exceeds 1 MiB");
|
|
142
|
+
chunks.push(buffer);
|
|
143
|
+
}
|
|
144
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
145
|
+
},
|
|
146
|
+
catch: (cause) => new FleetValidationError({ detail: String(cause) })
|
|
147
|
+
});
|
|
148
|
+
const readJson = Effect.fn("ApprovalHttp.readJson")(function* (request, schema) {
|
|
149
|
+
const mediaType = (header(request, "content-type") ?? "")
|
|
150
|
+
.split(";", 1)[0]
|
|
151
|
+
?.trim()
|
|
152
|
+
.toLowerCase();
|
|
153
|
+
if (mediaType !== "application/json") {
|
|
154
|
+
return yield* new FleetValidationError({
|
|
155
|
+
detail: "content-type must be application/json"
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const text = yield* readBody(request);
|
|
159
|
+
const unknown = yield* Effect.try({
|
|
160
|
+
try: () => JSON.parse(text),
|
|
161
|
+
catch: (cause) => new FleetValidationError({ detail: `invalid JSON: ${String(cause)}` })
|
|
162
|
+
});
|
|
163
|
+
return yield* Schema.decodeUnknownEffect(schema, {
|
|
164
|
+
onExcessProperty: "error"
|
|
165
|
+
})(unknown).pipe(Effect.mapError((error) => new FleetValidationError({
|
|
166
|
+
detail: `invalid request: ${String(error)}`
|
|
167
|
+
})));
|
|
168
|
+
});
|
|
169
|
+
const authorizeOriginlessMutation = (request) => {
|
|
170
|
+
const origin = header(request, "origin");
|
|
171
|
+
const fetchSite = header(request, "sec-fetch-site");
|
|
172
|
+
return origin === undefined && fetchSite !== "cross-site"
|
|
173
|
+
? Effect.void
|
|
174
|
+
: Effect.fail(new FleetAuthorizationError({ actor: origin ?? fetchSite ?? "browser" }));
|
|
175
|
+
};
|
|
176
|
+
export const recordWorkCheckpointRequest = Effect.fn("ApprovalHttp.recordWorkCheckpointRequest")(function* (authorization, decode, work) {
|
|
177
|
+
yield* authorization;
|
|
178
|
+
const checkpoint = yield* decode;
|
|
179
|
+
return yield* work.record(checkpoint);
|
|
180
|
+
});
|
|
181
|
+
const pathOf = (request) => {
|
|
182
|
+
const url = new URL(request.url ?? "/", "http://hostd.local");
|
|
183
|
+
if (url.pathname === "/fleet")
|
|
184
|
+
url.pathname = "/";
|
|
185
|
+
if (url.pathname.startsWith("/fleet/")) {
|
|
186
|
+
url.pathname = url.pathname.slice("/fleet".length);
|
|
187
|
+
}
|
|
188
|
+
return url;
|
|
189
|
+
};
|
|
190
|
+
const fleetPeers = Effect.fn("HostHttp.fleetPeers")(function* (config) {
|
|
191
|
+
const peers = yield* discoverFleetPeers(config.host, config.machines).pipe(Effect.mapError((cause) => new FleetOperationError({
|
|
192
|
+
operation: "tailscale.status",
|
|
193
|
+
detail: String(cause),
|
|
194
|
+
cause
|
|
195
|
+
})));
|
|
196
|
+
return peers.map((peer) => {
|
|
197
|
+
const address = peer.ipv4 ?? undefined;
|
|
198
|
+
const approvalHub = peer.host.toLowerCase() ===
|
|
199
|
+
config.approvalHub.host.toLowerCase() &&
|
|
200
|
+
config.machines.some((machine) => machine.host.toLowerCase() === peer.host.toLowerCase() &&
|
|
201
|
+
machine.nodeId === config.approvalHub.nodeId);
|
|
202
|
+
return {
|
|
203
|
+
host: peer.host,
|
|
204
|
+
online: peer.online,
|
|
205
|
+
approvalUrl: address === undefined
|
|
206
|
+
? null
|
|
207
|
+
: approvalHub
|
|
208
|
+
? config.approvalHub.url
|
|
209
|
+
: `http://${address}:${config.approvalPort}/`,
|
|
210
|
+
pendingUrl: address === undefined
|
|
211
|
+
? null
|
|
212
|
+
: `http://${address}:${config.port}/v1/pending-approvals`,
|
|
213
|
+
connectAgentsUrl: address === undefined
|
|
214
|
+
? null
|
|
215
|
+
: `http://${address}:${config.port}/v1/connect/agents/local`,
|
|
216
|
+
terminalUrl: address === undefined
|
|
217
|
+
? null
|
|
218
|
+
: `ws://${address}:${config.port}/v1/connect/terminal`
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
const pendingApproval = (record) => ({
|
|
223
|
+
id: record.id,
|
|
224
|
+
createdAt: record.createdAt,
|
|
225
|
+
actor: record.actor,
|
|
226
|
+
approvalExpiresAt: record.approvalExpiresAt ?? null,
|
|
227
|
+
status: "pending_approval",
|
|
228
|
+
payload: record.payload
|
|
229
|
+
});
|
|
230
|
+
const dashboardHistoryMaxBytes = 512 * 1024;
|
|
231
|
+
export const dashboardSnapshotBytes = (snapshot) => new TextEncoder().encode(JSON.stringify(snapshot)).byteLength + 1;
|
|
232
|
+
export const budgetDashboardSnapshot = Effect.fn("HostHttp.budgetDashboardSnapshot")(function* (snapshot) {
|
|
233
|
+
const originalRecords = snapshot.records;
|
|
234
|
+
const records = [...originalRecords];
|
|
235
|
+
while (records.length > 0 &&
|
|
236
|
+
dashboardSnapshotBytes({ ...snapshot, records }) > fleetResponseBodyMaxBytes) {
|
|
237
|
+
records.pop();
|
|
238
|
+
}
|
|
239
|
+
const first = originalRecords.at(0);
|
|
240
|
+
if (first !== undefined && records.length === 0) {
|
|
241
|
+
return yield* new DashboardResponseBudgetError({
|
|
242
|
+
encodedBytes: dashboardSnapshotBytes({
|
|
243
|
+
...snapshot,
|
|
244
|
+
records: [first]
|
|
245
|
+
}),
|
|
246
|
+
maximumBytes: fleetResponseBodyMaxBytes
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const last = records.at(-1);
|
|
250
|
+
const candidate = records.length === originalRecords.length
|
|
251
|
+
? snapshot
|
|
252
|
+
: {
|
|
253
|
+
...snapshot,
|
|
254
|
+
records,
|
|
255
|
+
historyNextCursor: last === undefined
|
|
256
|
+
? snapshot.historyNextCursor
|
|
257
|
+
: { createdAt: last.createdAt, id: last.id }
|
|
258
|
+
};
|
|
259
|
+
const encodedBytes = dashboardSnapshotBytes(candidate);
|
|
260
|
+
if (encodedBytes > fleetResponseBodyMaxBytes) {
|
|
261
|
+
return yield* new DashboardResponseBudgetError({
|
|
262
|
+
encodedBytes,
|
|
263
|
+
maximumBytes: fleetResponseBodyMaxBytes
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return candidate;
|
|
267
|
+
});
|
|
268
|
+
const dashboardHistory = Effect.fn("HostHttp.dashboardHistory")(function* (service, cursor) {
|
|
269
|
+
const candidates = yield* service.historyAfter(cursor, 51);
|
|
270
|
+
const records = [];
|
|
271
|
+
for (const candidate of candidates.slice(0, 50)) {
|
|
272
|
+
const projected = { ...candidate, error: null, result: null };
|
|
273
|
+
const bytes = new TextEncoder().encode(JSON.stringify([...records, projected])).byteLength;
|
|
274
|
+
if (bytes > dashboardHistoryMaxBytes)
|
|
275
|
+
break;
|
|
276
|
+
records.push(projected);
|
|
277
|
+
}
|
|
278
|
+
const last = records.at(-1);
|
|
279
|
+
return {
|
|
280
|
+
records,
|
|
281
|
+
nextCursor: records.length < candidates.length && last !== undefined
|
|
282
|
+
? { createdAt: last.createdAt, id: last.id }
|
|
283
|
+
: null
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
const localPendingSummary = Effect.fn("HostHttp.localPendingSummary")(function* (host, service, cursor) {
|
|
287
|
+
const page = yield* service.pendingApprovalPage(cursor);
|
|
288
|
+
return {
|
|
289
|
+
host,
|
|
290
|
+
approvals: page.records.map(pendingApproval),
|
|
291
|
+
nextCursor: page.nextCursor
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
const decodePendingApprovalCursor = Effect.fn("HostHttp.decodePendingApprovalCursor")(function* (url) {
|
|
295
|
+
const createdAt = url.searchParams.get("cursorCreatedAt");
|
|
296
|
+
const id = url.searchParams.get("cursorId");
|
|
297
|
+
if (createdAt === null && id === null)
|
|
298
|
+
return null;
|
|
299
|
+
if (createdAt === null || id === null) {
|
|
300
|
+
return yield* new FleetValidationError({
|
|
301
|
+
detail: "pending approval cursor requires cursorCreatedAt and cursorId"
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return yield* Schema.decodeUnknownEffect(PendingApprovalCursor)({
|
|
305
|
+
createdAt: Number(createdAt),
|
|
306
|
+
id
|
|
307
|
+
}).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid pending approval cursor" })));
|
|
308
|
+
});
|
|
309
|
+
const decodePendingApprovalContinuation = Effect.fn("HostHttp.decodePendingApprovalContinuation")(function* (url) {
|
|
310
|
+
const host = url.searchParams.get("cursorHost");
|
|
311
|
+
const cursor = yield* decodePendingApprovalCursor(url);
|
|
312
|
+
if (host === null || cursor === null) {
|
|
313
|
+
return yield* new FleetValidationError({
|
|
314
|
+
detail: "dashboard pending continuation requires cursorHost, cursorCreatedAt and cursorId"
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return yield* Schema.decodeUnknownEffect(PendingApprovalContinuation)({
|
|
318
|
+
host,
|
|
319
|
+
cursor
|
|
320
|
+
}).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid dashboard pending continuation" })));
|
|
321
|
+
});
|
|
322
|
+
const decodeConnectAgentCursor = Effect.fn("HostHttp.decodeConnectAgentCursor")(function* (url) {
|
|
323
|
+
const host = url.searchParams.get("cursorHost");
|
|
324
|
+
const id = url.searchParams.get("cursorId");
|
|
325
|
+
if (host === null && id === null)
|
|
326
|
+
return null;
|
|
327
|
+
if (host === null || id === null) {
|
|
328
|
+
return yield* new FleetValidationError({
|
|
329
|
+
detail: "Connect agent cursor requires cursorHost and cursorId"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return yield* Schema.decodeUnknownEffect(ConnectAgentCursor)({ host, id }).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid Connect agent cursor" })));
|
|
333
|
+
});
|
|
334
|
+
const fetchPeerPending = Effect.fn("HostHttp.fetchPeerPending")(function* (peer, cursor) {
|
|
335
|
+
const pendingUrl = peer.pendingUrl;
|
|
336
|
+
if (pendingUrl === null || peer.approvalUrl === null) {
|
|
337
|
+
return yield* new PeerPendingUnavailableError({
|
|
338
|
+
host: peer.host,
|
|
339
|
+
reason: "unavailable"
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (!peer.online) {
|
|
343
|
+
return yield* new PeerPendingUnavailableError({
|
|
344
|
+
host: peer.host,
|
|
345
|
+
reason: "offline"
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const client = yield* HttpClient.HttpClient;
|
|
349
|
+
const pageUrl = new URL(pendingUrl);
|
|
350
|
+
if (cursor !== null) {
|
|
351
|
+
pageUrl.searchParams.set("cursorCreatedAt", String(cursor.createdAt));
|
|
352
|
+
pageUrl.searchParams.set("cursorId", cursor.id);
|
|
353
|
+
}
|
|
354
|
+
const response = yield* client.get(pageUrl.toString()).pipe(Effect.mapError((cause) => new PeerPendingTransportError({
|
|
355
|
+
host: peer.host,
|
|
356
|
+
detail: String(cause),
|
|
357
|
+
cause
|
|
358
|
+
})));
|
|
359
|
+
if (response.status < 200 || response.status >= 300) {
|
|
360
|
+
return yield* new PeerPendingStatusError({
|
|
361
|
+
host: peer.host,
|
|
362
|
+
status: response.status
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const summary = yield* decodeBoundedResponseJson(response, PendingApprovalSummary).pipe(Effect.mapError((cause) => new PeerPendingDecodeError({
|
|
366
|
+
host: peer.host,
|
|
367
|
+
detail: String(cause),
|
|
368
|
+
cause
|
|
369
|
+
})));
|
|
370
|
+
if (summary.host.toLowerCase() !== peer.host.toLowerCase()) {
|
|
371
|
+
return yield* new PeerPendingHostMismatchError({
|
|
372
|
+
expectedHost: peer.host,
|
|
373
|
+
receivedHost: summary.host
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return summary;
|
|
377
|
+
}, (effect, peer) => effect.pipe(Effect.timeoutOrElse({
|
|
378
|
+
duration: peerPendingTimeoutMs,
|
|
379
|
+
orElse: () => Effect.fail(new PeerPendingTimeoutError({
|
|
380
|
+
host: peer.host,
|
|
381
|
+
timeoutMs: peerPendingTimeoutMs
|
|
382
|
+
}))
|
|
383
|
+
})));
|
|
384
|
+
const fetchAllPeerPending = Effect.fn("HostHttp.fetchAllPeerPending")(function* (peer) {
|
|
385
|
+
const approvals = [];
|
|
386
|
+
const seen = new Set();
|
|
387
|
+
let cursor = null;
|
|
388
|
+
do {
|
|
389
|
+
const page = yield* fetchPeerPending(peer, cursor);
|
|
390
|
+
for (const approval of page.approvals)
|
|
391
|
+
approvals.push(approval);
|
|
392
|
+
cursor = page.nextCursor;
|
|
393
|
+
if (cursor !== null) {
|
|
394
|
+
const key = `${cursor.createdAt}\u0000${cursor.id}`;
|
|
395
|
+
if (seen.has(key)) {
|
|
396
|
+
return yield* new PeerPendingDecodeError({
|
|
397
|
+
cause: key,
|
|
398
|
+
detail: "peer repeated a pending approval cursor",
|
|
399
|
+
host: peer.host
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
seen.add(key);
|
|
403
|
+
}
|
|
404
|
+
} while (cursor !== null);
|
|
405
|
+
return { approvals, host: peer.host, nextCursor: null };
|
|
406
|
+
});
|
|
407
|
+
const pendingFailureReason = (error) => {
|
|
408
|
+
switch (error._tag) {
|
|
409
|
+
case "PeerPendingUnavailableError":
|
|
410
|
+
return error.reason;
|
|
411
|
+
case "PeerPendingTimeoutError":
|
|
412
|
+
return "timeout";
|
|
413
|
+
case "PeerPendingDecodeError":
|
|
414
|
+
case "PeerPendingHostMismatchError":
|
|
415
|
+
return "invalid_response";
|
|
416
|
+
case "PeerPendingStatusError":
|
|
417
|
+
case "PeerPendingTransportError":
|
|
418
|
+
return "request_failed";
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const aggregatePeerPending = Effect.fn("HostHttp.aggregatePeerPending")(function* (peers, pagination = "first") {
|
|
422
|
+
const results = yield* Effect.all(peers.map((peer) => Effect.result(pagination === "all"
|
|
423
|
+
? fetchAllPeerPending(peer)
|
|
424
|
+
: fetchPeerPending(peer, null)).pipe(Effect.map((result) => ({ peer, result })))), { concurrency: 4 });
|
|
425
|
+
const remote = [];
|
|
426
|
+
const failures = [];
|
|
427
|
+
const nextCursors = [];
|
|
428
|
+
for (const { peer, result } of results) {
|
|
429
|
+
if (Result.isFailure(result)) {
|
|
430
|
+
failures.push({
|
|
431
|
+
host: peer.host,
|
|
432
|
+
reason: pendingFailureReason(result.failure)
|
|
433
|
+
});
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (peer.approvalUrl === null) {
|
|
437
|
+
failures.push({ host: peer.host, reason: "unavailable" });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
for (const approval of result.success.approvals) {
|
|
441
|
+
remote.push({
|
|
442
|
+
host: peer.host,
|
|
443
|
+
approvalUrl: peer.approvalUrl,
|
|
444
|
+
approval
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (result.success.nextCursor !== null) {
|
|
448
|
+
nextCursors.push({ host: peer.host, cursor: result.success.nextCursor });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { remote, failures, nextCursors };
|
|
452
|
+
});
|
|
453
|
+
const dashboardPendingPage = Effect.fn("HostHttp.dashboardPendingPage")(function* (config, service, continuation) {
|
|
454
|
+
if (continuation.host.toLowerCase() === config.host.toLowerCase()) {
|
|
455
|
+
const page = yield* service.pendingApprovalPage(continuation.cursor);
|
|
456
|
+
return {
|
|
457
|
+
local: page.records,
|
|
458
|
+
remote: [],
|
|
459
|
+
failures: [],
|
|
460
|
+
nextCursors: page.nextCursor === null
|
|
461
|
+
? []
|
|
462
|
+
: [{ host: config.host, cursor: page.nextCursor }]
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const peers = yield* fleetPeers(config);
|
|
466
|
+
const peer = peers.find(({ host }) => host.toLowerCase() === continuation.host.toLowerCase());
|
|
467
|
+
if (peer === undefined || peer.approvalUrl === null) {
|
|
468
|
+
return yield* new FleetValidationError({
|
|
469
|
+
detail: `unknown pending approval host ${continuation.host}`
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const approvalUrl = peer.approvalUrl;
|
|
473
|
+
const page = yield* fetchPeerPending(peer, continuation.cursor).pipe(Effect.mapError((cause) => new FleetOperationError({
|
|
474
|
+
cause,
|
|
475
|
+
detail: `could not continue pending approvals on ${peer.host}`,
|
|
476
|
+
operation: "fleet.pending_approvals"
|
|
477
|
+
})));
|
|
478
|
+
return {
|
|
479
|
+
local: [],
|
|
480
|
+
remote: page.approvals.map((approval) => ({
|
|
481
|
+
approval,
|
|
482
|
+
approvalUrl,
|
|
483
|
+
host: peer.host
|
|
484
|
+
})),
|
|
485
|
+
failures: [],
|
|
486
|
+
nextCursors: page.nextCursor === null
|
|
487
|
+
? []
|
|
488
|
+
: [{ host: peer.host, cursor: page.nextCursor }]
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
const resolvePendingApprovalTarget = Effect.fn("HostHttp.resolvePendingApprovalTarget")(function* (config, service, target) {
|
|
492
|
+
if (target.host.toLowerCase() === config.host.toLowerCase()) {
|
|
493
|
+
const record = yield* service.get(target.jobId);
|
|
494
|
+
if (record.status !== "pending_approval") {
|
|
495
|
+
return yield* new FleetJobNotFoundError({ jobId: target.jobId });
|
|
496
|
+
}
|
|
497
|
+
return { _tag: "local", record };
|
|
498
|
+
}
|
|
499
|
+
const peers = yield* fleetPeers(config);
|
|
500
|
+
const peer = peers.find(({ host }) => host.toLowerCase() === target.host.toLowerCase());
|
|
501
|
+
if (peer === undefined || peer.approvalUrl === null) {
|
|
502
|
+
return yield* new FleetJobNotFoundError({ jobId: target.jobId });
|
|
503
|
+
}
|
|
504
|
+
const seen = new Set();
|
|
505
|
+
let cursor = null;
|
|
506
|
+
do {
|
|
507
|
+
const page = yield* fetchPeerPending(peer, cursor).pipe(Effect.mapError((cause) => new FleetOperationError({
|
|
508
|
+
cause,
|
|
509
|
+
detail: `could not resolve pending approval on ${peer.host}`,
|
|
510
|
+
operation: "fleet.pending_approval_target"
|
|
511
|
+
})));
|
|
512
|
+
const approval = page.approvals.find(({ id }) => id === target.jobId);
|
|
513
|
+
if (approval !== undefined) {
|
|
514
|
+
return {
|
|
515
|
+
_tag: "remote",
|
|
516
|
+
remote: { approval, approvalUrl: peer.approvalUrl, host: peer.host }
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
cursor = page.nextCursor;
|
|
520
|
+
if (cursor !== null) {
|
|
521
|
+
const key = `${cursor.createdAt}\u0000${cursor.id}`;
|
|
522
|
+
if (seen.has(key)) {
|
|
523
|
+
return yield* new FleetOperationError({
|
|
524
|
+
cause: key,
|
|
525
|
+
detail: `peer ${peer.host} repeated a pending approval cursor`,
|
|
526
|
+
operation: "fleet.pending_approval_target"
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
seen.add(key);
|
|
530
|
+
}
|
|
531
|
+
} while (cursor !== null);
|
|
532
|
+
return yield* new FleetJobNotFoundError({ jobId: target.jobId });
|
|
533
|
+
});
|
|
534
|
+
export const notificationCandidates = Effect.fn("HostHttp.notificationCandidates")(function* (config, service) {
|
|
535
|
+
const local = yield* service.pendingApprovals();
|
|
536
|
+
const localCandidates = local.map((record) => ({
|
|
537
|
+
host: config.host,
|
|
538
|
+
jobId: record.id
|
|
539
|
+
}));
|
|
540
|
+
const peers = yield* Effect.result(fleetPeers(config));
|
|
541
|
+
if (Result.isFailure(peers)) {
|
|
542
|
+
yield* Effect.logWarning("PushWorker.peer_directory_unavailable", peers.failure);
|
|
543
|
+
return {
|
|
544
|
+
candidates: localCandidates,
|
|
545
|
+
pendingCount: null
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const aggregated = yield* aggregatePeerPending(peers.success, "all");
|
|
549
|
+
if (aggregated.failures.length > 0) {
|
|
550
|
+
yield* Effect.logWarning("PushWorker.peer_pending_partial_failure", aggregated.failures);
|
|
551
|
+
}
|
|
552
|
+
const candidates = [
|
|
553
|
+
...localCandidates,
|
|
554
|
+
...aggregated.remote.map((remote) => ({
|
|
555
|
+
host: remote.host,
|
|
556
|
+
jobId: remote.approval.id
|
|
557
|
+
}))
|
|
558
|
+
];
|
|
559
|
+
return {
|
|
560
|
+
candidates,
|
|
561
|
+
pendingCount: aggregated.failures.length === 0 ? candidates.length : null
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
const dashboardPage = (snapshot) => {
|
|
565
|
+
const markup = snapshot.approvalApp.canonical ? "" : renderToStaticMarkup(createElement("div", { className: "dashboard-gesture" }, createElement(DashboardView, {
|
|
566
|
+
busyJobId: null,
|
|
567
|
+
chatBusy: false,
|
|
568
|
+
notificationState: "loading",
|
|
569
|
+
onChatSubmit: undefined,
|
|
570
|
+
onDecision: () => undefined,
|
|
571
|
+
onDisableNotifications: undefined,
|
|
572
|
+
onEnableNotifications: undefined,
|
|
573
|
+
onRefresh: () => undefined,
|
|
574
|
+
pull: { distance: 0, ready: false, refreshing: false },
|
|
575
|
+
snapshot
|
|
576
|
+
})));
|
|
577
|
+
const data = JSON.stringify(snapshot).replaceAll("<", "\\u003c");
|
|
578
|
+
return `<!doctype html>
|
|
579
|
+
<html lang="en">
|
|
580
|
+
<head>
|
|
581
|
+
<meta charset="utf-8">
|
|
582
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
583
|
+
<meta name="color-scheme" content="dark">
|
|
584
|
+
<meta name="theme-color" content="#111418">
|
|
585
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
586
|
+
<meta name="apple-mobile-web-app-title" content="Approvals">
|
|
587
|
+
<title>${dashboardDocumentTitle(snapshot.host)}</title>
|
|
588
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
589
|
+
<link rel="icon" href="/assets/approval-icon.svg" type="image/svg+xml">
|
|
590
|
+
<link rel="stylesheet" href="/assets/index.css">
|
|
591
|
+
</head>
|
|
592
|
+
<body data-rly-root data-rly-theme="dark">
|
|
593
|
+
<div id="fleet-dashboard-root">${markup}</div>
|
|
594
|
+
<script id="fleet-dashboard-data" type="application/json">${data}</script>
|
|
595
|
+
<script src="/assets/approval.js" defer></script>
|
|
596
|
+
</body>
|
|
597
|
+
</html>`;
|
|
598
|
+
};
|
|
599
|
+
const connectPage = () => `<!doctype html>
|
|
600
|
+
<html lang="en">
|
|
601
|
+
<head>
|
|
602
|
+
<meta charset="utf-8">
|
|
603
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover">
|
|
604
|
+
<meta name="color-scheme" content="dark">
|
|
605
|
+
<meta name="theme-color" content="#0b0d10">
|
|
606
|
+
<title>Fleet connect</title>
|
|
607
|
+
<link rel="stylesheet" href="/assets/index.css">
|
|
608
|
+
</head>
|
|
609
|
+
<body data-rly-root data-rly-theme="dark" class="connect-body">
|
|
610
|
+
<div id="fleet-connect-root"></div>
|
|
611
|
+
<script src="/assets/connect.js" defer></script>
|
|
612
|
+
</body>
|
|
613
|
+
</html>`;
|
|
614
|
+
const rejectUpgrade = (socket, status) => {
|
|
615
|
+
socket.end(`HTTP/1.1 ${status} ${status === 403 ? "Forbidden" : "Bad Request"}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
616
|
+
};
|
|
617
|
+
const terminalSelectionFromUrl = (url) => Schema.decodeUnknownEffect(TerminalSelection)({
|
|
618
|
+
host: url.searchParams.get("host"),
|
|
619
|
+
agentId: url.searchParams.get("agent"),
|
|
620
|
+
cols: Number(url.searchParams.get("cols")),
|
|
621
|
+
rows: Number(url.searchParams.get("rows"))
|
|
622
|
+
}).pipe(Effect.mapError((cause) => new FleetValidationError({
|
|
623
|
+
detail: `invalid terminal selection: ${String(cause)}`
|
|
624
|
+
})));
|
|
625
|
+
const terminalCloseCode = (error) => {
|
|
626
|
+
switch (error._tag) {
|
|
627
|
+
case "TerminalAgentNotFoundError":
|
|
628
|
+
return 4404;
|
|
629
|
+
case "TerminalBusyError":
|
|
630
|
+
return 4423;
|
|
631
|
+
case "TerminalProtocolError":
|
|
632
|
+
return 4400;
|
|
633
|
+
case "TerminalTransportError":
|
|
634
|
+
return 4503;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
const readApproval = Effect.fn("ApprovalHttp.readApproval")(function* (request) {
|
|
638
|
+
const contentType = header(request, "content-type") ?? "";
|
|
639
|
+
if (contentType.startsWith("application/json")) {
|
|
640
|
+
return yield* readJson(request, Approval);
|
|
641
|
+
}
|
|
642
|
+
const body = yield* readBody(request);
|
|
643
|
+
const form = new URLSearchParams(body);
|
|
644
|
+
return yield* Schema.decodeUnknownEffect(Approval)({
|
|
645
|
+
hash: form.get("hash"),
|
|
646
|
+
nonce: form.get("nonce")
|
|
647
|
+
}).pipe(Effect.mapError((error) => new FleetValidationError({
|
|
648
|
+
detail: `invalid approval: ${String(error)}`
|
|
649
|
+
})));
|
|
650
|
+
});
|
|
651
|
+
export const makeRunner = Effect.fn("HostRunner.make")(function* (runJob) {
|
|
652
|
+
const scope = yield* Scope.make();
|
|
653
|
+
const serial = yield* Semaphore.make(1);
|
|
654
|
+
const runPromise = Effect.runPromiseWith(yield* Effect.context());
|
|
655
|
+
let accepting = true;
|
|
656
|
+
const enqueue = (jobId) => {
|
|
657
|
+
if (!accepting)
|
|
658
|
+
return Promise.resolve(false);
|
|
659
|
+
const run = serial.withPermits(1)(runJob(jobId).pipe(Effect.tapError((error) => Effect.logError("HostRunner.job_failed", error).pipe(Effect.annotateLogs({ jobId }))), Effect.ignore));
|
|
660
|
+
return runPromise(Effect.forkIn(run, scope)).then(() => true);
|
|
661
|
+
};
|
|
662
|
+
const close = async () => {
|
|
663
|
+
if (!accepting)
|
|
664
|
+
return;
|
|
665
|
+
accepting = false;
|
|
666
|
+
await runPromise(Scope.close(scope, Exit.void));
|
|
667
|
+
};
|
|
668
|
+
return { close, enqueue };
|
|
669
|
+
});
|
|
670
|
+
export const startHttpServer = async (config, service, uiAssets, options = {}) => {
|
|
671
|
+
const isHub = config.crossHost &&
|
|
672
|
+
config.host.toLowerCase() === config.approvalHub.host.toLowerCase();
|
|
673
|
+
const approvalTls = config.approvalTls;
|
|
674
|
+
if (isHub && approvalTls === null) {
|
|
675
|
+
throw new FleetValidationError({
|
|
676
|
+
detail: "approval hub requires direct TLS certificate and private key paths"
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const configuredTailscale = tailscaleLayer(config.tailscaleCommand).pipe(Layer.provide(NodeServices.layer));
|
|
680
|
+
const httpRuntime = ManagedRuntime.make(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerNodeHttp, configuredTailscale));
|
|
681
|
+
const activeServers = new Set();
|
|
682
|
+
const activeHttpSockets = new Set();
|
|
683
|
+
const activeRequestControllers = new Set();
|
|
684
|
+
const terminalControllers = new Set();
|
|
685
|
+
const terminalTasks = new Set();
|
|
686
|
+
const webSockets = new Set();
|
|
687
|
+
const finalizers = [
|
|
688
|
+
() => httpRuntime.dispose()
|
|
689
|
+
];
|
|
690
|
+
let acceptingRequests = false;
|
|
691
|
+
let closed = false;
|
|
692
|
+
const runRequest = (effect) => {
|
|
693
|
+
const controller = new AbortController();
|
|
694
|
+
activeRequestControllers.add(controller);
|
|
695
|
+
return httpRuntime.runPromise(effect, { signal: controller.signal }).finally(() => activeRequestControllers.delete(controller));
|
|
696
|
+
};
|
|
697
|
+
const shutdown = async () => {
|
|
698
|
+
if (closed)
|
|
699
|
+
return;
|
|
700
|
+
closed = true;
|
|
701
|
+
acceptingRequests = false;
|
|
702
|
+
for (const controller of activeRequestControllers)
|
|
703
|
+
controller.abort();
|
|
704
|
+
for (const socket of webSockets)
|
|
705
|
+
socket.terminate();
|
|
706
|
+
for (const socket of activeHttpSockets)
|
|
707
|
+
socket.destroy();
|
|
708
|
+
const failures = [];
|
|
709
|
+
const serverResults = await Promise.allSettled([...activeServers].map((server) => new Promise((resolve, reject) => {
|
|
710
|
+
server.close((error) => error === undefined ? resolve() : reject(error));
|
|
711
|
+
})));
|
|
712
|
+
for (const result of serverResults) {
|
|
713
|
+
if (result.status === "rejected")
|
|
714
|
+
failures.push(result.reason);
|
|
715
|
+
}
|
|
716
|
+
for (const socket of webSockets)
|
|
717
|
+
socket.terminate();
|
|
718
|
+
const terminalResults = await Promise.allSettled(terminalTasks);
|
|
719
|
+
for (const result of terminalResults) {
|
|
720
|
+
if (result.status === "rejected")
|
|
721
|
+
failures.push(result.reason);
|
|
722
|
+
}
|
|
723
|
+
for (const finalize of finalizers) {
|
|
724
|
+
try {
|
|
725
|
+
await finalize();
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
failures.push(error);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (failures.length > 0) {
|
|
732
|
+
throw new AggregateError(failures, "hostd cleanup failed");
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
try {
|
|
736
|
+
const tlsCredentials = isHub && approvalTls !== null
|
|
737
|
+
? await httpRuntime.runPromise(Effect.gen(function* () {
|
|
738
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
739
|
+
const read = (path, operation) => fileSystem.readFileString(path).pipe(Effect.mapError((cause) => new FleetOperationError({
|
|
740
|
+
cause,
|
|
741
|
+
detail: `cannot read approval TLS material at ${path}`,
|
|
742
|
+
operation
|
|
743
|
+
})));
|
|
744
|
+
return {
|
|
745
|
+
certificate: yield* read(approvalTls.certificatePath, "hostd.approval_tls.read_certificate"),
|
|
746
|
+
privateKey: yield* read(approvalTls.privateKeyPath, "hostd.approval_tls.read_private_key")
|
|
747
|
+
};
|
|
748
|
+
}))
|
|
749
|
+
: null;
|
|
750
|
+
const respond = async (response, effect, status = 200) => {
|
|
751
|
+
const result = await runRequest(Effect.result(effect));
|
|
752
|
+
if (Result.isSuccess(result)) {
|
|
753
|
+
json(response, status, result.success);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const mapped = apiError(result.failure);
|
|
757
|
+
json(response, mapped.status, mapped.body);
|
|
758
|
+
};
|
|
759
|
+
const statePath = `${config.stateDirectory}/approval-app.sqlite`;
|
|
760
|
+
const approvalStore = await httpRuntime.runPromise(ApprovalAppStore.open(statePath));
|
|
761
|
+
finalizers.unshift(() => Promise.resolve().then(() => approvalStore.close()));
|
|
762
|
+
const cryptoService = await httpRuntime.runPromise(Crypto.Crypto);
|
|
763
|
+
const chatStore = await httpRuntime.runPromise(ChatStore.open(statePath));
|
|
764
|
+
finalizers.unshift(() => Promise.resolve().then(() => chatStore.close()));
|
|
765
|
+
const activityStore = await httpRuntime.runPromise(AgentActivityStore.open(statePath));
|
|
766
|
+
finalizers.unshift(() => Promise.resolve().then(() => activityStore.close()));
|
|
767
|
+
const relationshipStore = await httpRuntime.runPromise(AgentRelationshipStore.open(statePath));
|
|
768
|
+
finalizers.unshift(() => Promise.resolve().then(() => relationshipStore.close()));
|
|
769
|
+
const workStore = await httpRuntime.runPromise(WorkStore.open(statePath));
|
|
770
|
+
finalizers.unshift(() => Promise.resolve().then(() => workStore.close()));
|
|
771
|
+
const work = await httpRuntime.runPromise(makeWorkService(workStore));
|
|
772
|
+
const now = options.now ?? (() => httpRuntime.runSync(Clock.currentTimeMillis));
|
|
773
|
+
const chat = await httpRuntime.runPromise(makeCoordinatorChat({
|
|
774
|
+
config,
|
|
775
|
+
fleet: service,
|
|
776
|
+
store: chatStore
|
|
777
|
+
}));
|
|
778
|
+
const runJob = Effect.fn("HostRunner.runJob")(function* (jobId) {
|
|
779
|
+
const record = yield* service.get(jobId);
|
|
780
|
+
return yield* record.payload.kind === "agent.delegate" &&
|
|
781
|
+
record.payload.channel === "coordinator_chat"
|
|
782
|
+
? chat.run(jobId)
|
|
783
|
+
: service.run(jobId);
|
|
784
|
+
});
|
|
785
|
+
const runner = await Effect.runPromise(makeRunner(runJob));
|
|
786
|
+
finalizers.unshift(() => runner.close());
|
|
787
|
+
const enqueueJob = (jobId) => Effect.promise(() => runner.enqueue(jobId)).pipe(Effect.flatMap((accepted) => accepted
|
|
788
|
+
? Effect.void
|
|
789
|
+
: Effect.fail(new FleetOperationError({
|
|
790
|
+
cause: jobId,
|
|
791
|
+
detail: "job runner is closed",
|
|
792
|
+
operation: "hostd.runner.enqueue"
|
|
793
|
+
}))));
|
|
794
|
+
const vapidKeys = isHub
|
|
795
|
+
? await Effect.runPromise(approvalStore.getOrCreateVapidKeys(generateVapidKeys))
|
|
796
|
+
: null;
|
|
797
|
+
const pushSender = vapidKeys === null
|
|
798
|
+
? null
|
|
799
|
+
: (options.pushSender ??
|
|
800
|
+
makePushSender(vapidKeys, config.pushSubject, approvalStore, config.pushAllowedOrigins));
|
|
801
|
+
const workerScope = await Effect.runPromise(Scope.make());
|
|
802
|
+
finalizers.unshift(() => Effect.runPromise(Scope.close(workerScope, Exit.void)));
|
|
803
|
+
const terminalConnector = options.terminalConnector ??
|
|
804
|
+
(await httpRuntime.runPromise(makeHerdrTerminalConnector(config, service)));
|
|
805
|
+
const webSocketServer = new WebSocketServer({
|
|
806
|
+
noServer: true,
|
|
807
|
+
maxPayload: terminalCommandMaxPayloadBytes,
|
|
808
|
+
perMessageDeflate: false
|
|
809
|
+
});
|
|
810
|
+
const watchSocket = (socket) => {
|
|
811
|
+
let alive = true;
|
|
812
|
+
socket.on("error", () => socket.terminate());
|
|
813
|
+
const heartbeat = setInterval(() => {
|
|
814
|
+
if (!alive) {
|
|
815
|
+
socket.terminate();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
alive = false;
|
|
819
|
+
socket.ping();
|
|
820
|
+
}, 25_000);
|
|
821
|
+
heartbeat.unref();
|
|
822
|
+
socket.on("pong", () => {
|
|
823
|
+
alive = true;
|
|
824
|
+
});
|
|
825
|
+
socket.once("close", () => clearInterval(heartbeat));
|
|
826
|
+
};
|
|
827
|
+
const closeSocket = (socket, code, reason) => {
|
|
828
|
+
if (socket.readyState === WebSocketClient.OPEN)
|
|
829
|
+
socket.close(code, reason);
|
|
830
|
+
};
|
|
831
|
+
const attachTerminal = async (socket, selection) => {
|
|
832
|
+
if (terminalControllers.size >= 4) {
|
|
833
|
+
closeSocket(socket, 4429, "terminal connection limit reached");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const key = `${selection.host.toLowerCase()}:${selection.agentId}`;
|
|
837
|
+
if (terminalControllers.has(key)) {
|
|
838
|
+
closeSocket(socket, 4423, "agent already controlled");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
terminalControllers.add(key);
|
|
842
|
+
const scope = await Effect.runPromise(Scope.make());
|
|
843
|
+
let finalized = false;
|
|
844
|
+
let finalizePromise;
|
|
845
|
+
let idleTimer;
|
|
846
|
+
const finalize = async () => {
|
|
847
|
+
if (finalizePromise !== undefined)
|
|
848
|
+
return finalizePromise;
|
|
849
|
+
finalized = true;
|
|
850
|
+
if (idleTimer !== undefined)
|
|
851
|
+
clearTimeout(idleTimer);
|
|
852
|
+
finalizePromise = Effect.runPromise(Scope.close(scope, Exit.void)).finally(() => terminalControllers.delete(key));
|
|
853
|
+
return finalizePromise;
|
|
854
|
+
};
|
|
855
|
+
const finalizeFromSocket = () => {
|
|
856
|
+
void finalize().catch((error) => Effect.runPromise(Effect.logError("HostHttp.terminal_finalize_failed", error)));
|
|
857
|
+
};
|
|
858
|
+
socket.once("close", finalizeFromSocket);
|
|
859
|
+
socket.once("error", finalizeFromSocket);
|
|
860
|
+
const openFiber = await Effect.runPromise(Effect.forkIn(Effect.result(Scope.provide(scope)(terminalConnector.open(selection))), scope));
|
|
861
|
+
const openExit = await Effect.runPromise(Fiber.await(openFiber));
|
|
862
|
+
if (Exit.isFailure(openExit)) {
|
|
863
|
+
await finalize();
|
|
864
|
+
if (Cause.hasInterruptsOnly(openExit.cause))
|
|
865
|
+
return;
|
|
866
|
+
await Effect.runPromise(Effect.failCause(openExit.cause));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const opened = openExit.value;
|
|
870
|
+
if (Result.isFailure(opened)) {
|
|
871
|
+
await finalize();
|
|
872
|
+
closeSocket(socket, terminalCloseCode(opened.failure), opened.failure._tag);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (finalized || socket.readyState !== WebSocketClient.OPEN) {
|
|
876
|
+
await finalize();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const session = opened.success;
|
|
880
|
+
const inputQueue = await Effect.runPromise(Queue.dropping(64));
|
|
881
|
+
const inputLoop = Stream.fromQueue(inputQueue).pipe(Stream.runForEach(session.send), Effect.catch((error) => Effect.sync(() => closeSocket(socket, terminalCloseCode(error), error._tag))));
|
|
882
|
+
await Effect.runPromise(Effect.forkIn(inputLoop, scope));
|
|
883
|
+
const resetIdle = () => {
|
|
884
|
+
if (idleTimer !== undefined)
|
|
885
|
+
clearTimeout(idleTimer);
|
|
886
|
+
idleTimer = setTimeout(() => closeSocket(socket, 4408, "terminal idle timeout"), 30 * 60 * 1_000);
|
|
887
|
+
idleTimer.unref();
|
|
888
|
+
};
|
|
889
|
+
resetIdle();
|
|
890
|
+
const readySignal = JSON.stringify(Schema.decodeUnknownSync(TerminalServerSignal)({
|
|
891
|
+
type: "terminal.ready"
|
|
892
|
+
}));
|
|
893
|
+
setImmediate(() => {
|
|
894
|
+
if (socket.readyState === WebSocketClient.OPEN)
|
|
895
|
+
socket.send(readySignal);
|
|
896
|
+
});
|
|
897
|
+
socket.on("message", (data, isBinary) => {
|
|
898
|
+
if (isBinary) {
|
|
899
|
+
closeSocket(socket, 4400, "binary client frames are forbidden");
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
resetIdle();
|
|
903
|
+
try {
|
|
904
|
+
const command = Schema.decodeUnknownSync(TerminalClientCommand)(JSON.parse(data.toString()));
|
|
905
|
+
if (!Queue.offerUnsafe(inputQueue, command)) {
|
|
906
|
+
closeSocket(socket, 4429, "terminal input queue limit reached");
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
closeSocket(socket, 4400, "invalid terminal command");
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
const eventLoop = Stream.runForEach(session.events, (event) => Effect.sync(() => {
|
|
914
|
+
if (socket.readyState !== WebSocketClient.OPEN)
|
|
915
|
+
return;
|
|
916
|
+
if (event.type === "terminal.frame") {
|
|
917
|
+
const payload = Buffer.from(event.bytes, "base64");
|
|
918
|
+
if (!terminalBufferCanAccept(socket.bufferedAmount, payload.byteLength)) {
|
|
919
|
+
closeSocket(socket, 4429, "terminal output backpressure limit hit");
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
socket.send(payload, { binary: true });
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
const payload = JSON.stringify(Schema.decodeUnknownSync(TerminalServerSignal)({
|
|
926
|
+
type: "terminal.closed",
|
|
927
|
+
reason: "terminal ended"
|
|
928
|
+
}));
|
|
929
|
+
if (!terminalBufferCanAccept(socket.bufferedAmount, Buffer.byteLength(payload))) {
|
|
930
|
+
closeSocket(socket, 4429, "terminal output backpressure limit hit");
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
socket.send(payload);
|
|
934
|
+
closeSocket(socket, 1000, "terminal closed");
|
|
935
|
+
}
|
|
936
|
+
})).pipe(Effect.tap(() => Effect.sync(() => closeSocket(socket, 1000, "terminal ended"))), Effect.catch((error) => Effect.sync(() => closeSocket(socket, terminalCloseCode(error), error._tag))));
|
|
937
|
+
const eventFiber = await Effect.runPromise(Effect.forkIn(eventLoop, scope));
|
|
938
|
+
await Effect.runPromise(Fiber.await(eventFiber));
|
|
939
|
+
await finalize();
|
|
940
|
+
};
|
|
941
|
+
const proxyTerminal = async (socket, selection) => {
|
|
942
|
+
const peers = await httpRuntime.runPromise(fleetPeers(config));
|
|
943
|
+
const peer = peers.find((candidate) => candidate.host.toLowerCase() === selection.host.toLowerCase());
|
|
944
|
+
if (peer?.terminalUrl === null || peer?.terminalUrl === undefined) {
|
|
945
|
+
closeSocket(socket, 4404, "host unavailable");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const remoteUrl = new URL(peer.terminalUrl);
|
|
949
|
+
remoteUrl.searchParams.set("host", selection.host);
|
|
950
|
+
remoteUrl.searchParams.set("agent", selection.agentId);
|
|
951
|
+
remoteUrl.searchParams.set("cols", String(selection.cols));
|
|
952
|
+
remoteUrl.searchParams.set("rows", String(selection.rows));
|
|
953
|
+
const remote = new WebSocketClient(remoteUrl, {
|
|
954
|
+
headers: { host: remoteUrl.host },
|
|
955
|
+
maxPayload: terminalFrameMaxPayload,
|
|
956
|
+
perMessageDeflate: false
|
|
957
|
+
});
|
|
958
|
+
const closed = new Promise((resolve) => remote.once("close", () => resolve()));
|
|
959
|
+
webSockets.add(remote);
|
|
960
|
+
const connectTimeout = setTimeout(() => {
|
|
961
|
+
remote.terminate();
|
|
962
|
+
closeSocket(socket, 4408, "remote terminal connection timeout");
|
|
963
|
+
}, 5_000);
|
|
964
|
+
connectTimeout.unref();
|
|
965
|
+
remote.once("open", () => {
|
|
966
|
+
clearTimeout(connectTimeout);
|
|
967
|
+
watchSocket(remote);
|
|
968
|
+
socket.on("message", (data, isBinary) => {
|
|
969
|
+
if (isBinary || remote.readyState !== WebSocketClient.OPEN) {
|
|
970
|
+
closeSocket(socket, 4400, "invalid terminal command");
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
try {
|
|
974
|
+
const decoded = Schema.decodeUnknownSync(TerminalClientCommand)(JSON.parse(data.toString()));
|
|
975
|
+
const encoded = JSON.stringify(decoded);
|
|
976
|
+
if (!terminalBufferCanAccept(remote.bufferedAmount, Buffer.byteLength(encoded))) {
|
|
977
|
+
closeSocket(socket, 4429, "remote input backpressure limit hit");
|
|
978
|
+
remote.close(4429, "remote input backpressure limit hit");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
remote.send(encoded);
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
closeSocket(socket, 4400, "invalid terminal command");
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
remote.on("message", (data, isBinary) => {
|
|
989
|
+
if (socket.readyState === WebSocketClient.OPEN) {
|
|
990
|
+
const payloadBytes = Array.isArray(data)
|
|
991
|
+
? data.reduce((bytes, part) => bytes + part.byteLength, 0)
|
|
992
|
+
: data.byteLength;
|
|
993
|
+
if (!terminalBufferCanAccept(socket.bufferedAmount, payloadBytes)) {
|
|
994
|
+
closeSocket(socket, 4429, "terminal output backpressure limit hit");
|
|
995
|
+
if (remote.readyState === WebSocketClient.OPEN) {
|
|
996
|
+
remote.close(4429, "browser backpressure limit hit");
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
socket.send(data, { binary: isBinary });
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
remote.once("unexpected-response", (_request, response) => {
|
|
1004
|
+
clearTimeout(connectTimeout);
|
|
1005
|
+
response.resume();
|
|
1006
|
+
closeSocket(socket, 4403, "remote host rejected connection");
|
|
1007
|
+
remote.terminate();
|
|
1008
|
+
});
|
|
1009
|
+
remote.once("error", () => {
|
|
1010
|
+
clearTimeout(connectTimeout);
|
|
1011
|
+
closeSocket(socket, 4503, "remote host unavailable");
|
|
1012
|
+
});
|
|
1013
|
+
remote.once("close", (code, reason) => {
|
|
1014
|
+
clearTimeout(connectTimeout);
|
|
1015
|
+
webSockets.delete(remote);
|
|
1016
|
+
const relayedCode = relayTerminalCloseCode(code);
|
|
1017
|
+
closeSocket(socket, relayedCode, relayedCode === code
|
|
1018
|
+
? reason.toString()
|
|
1019
|
+
: "remote terminal closed abnormally");
|
|
1020
|
+
});
|
|
1021
|
+
socket.once("close", () => {
|
|
1022
|
+
webSockets.delete(remote);
|
|
1023
|
+
if (remote.readyState === WebSocketClient.CONNECTING) {
|
|
1024
|
+
remote.terminate();
|
|
1025
|
+
}
|
|
1026
|
+
else if (remote.readyState === WebSocketClient.OPEN) {
|
|
1027
|
+
remote.close(1000, "browser disconnected");
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
await closed;
|
|
1031
|
+
};
|
|
1032
|
+
const listen = async (address, port, mode, tls = null) => {
|
|
1033
|
+
let expectedHost = mode === "serve"
|
|
1034
|
+
? new URL(config.approvalHub.url).host.toLowerCase()
|
|
1035
|
+
: "";
|
|
1036
|
+
const expectedOrigin = () => mode === "serve"
|
|
1037
|
+
? new URL(config.approvalHub.url).origin
|
|
1038
|
+
: `http://${expectedHost}`;
|
|
1039
|
+
const requestHandler = async (request, response) => {
|
|
1040
|
+
try {
|
|
1041
|
+
const url = pathOf(request);
|
|
1042
|
+
if (header(request, "host")?.toLowerCase() !== expectedHost) {
|
|
1043
|
+
json(response, 403, {
|
|
1044
|
+
error: "FleetAuthorizationError",
|
|
1045
|
+
actor: "host"
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (!acceptingRequests) {
|
|
1050
|
+
json(response, 503, { error: "starting" });
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (request.method === "GET" && url.pathname === "/assets/index.css") {
|
|
1054
|
+
response.writeHead(200, {
|
|
1055
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1056
|
+
"content-type": "text/css; charset=utf-8"
|
|
1057
|
+
});
|
|
1058
|
+
response.end(uiAssets.stylesheet);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (request.method === "GET" &&
|
|
1062
|
+
url.pathname === "/assets/approval.js") {
|
|
1063
|
+
response.writeHead(200, {
|
|
1064
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1065
|
+
"content-type": "text/javascript; charset=utf-8"
|
|
1066
|
+
});
|
|
1067
|
+
response.end(uiAssets.script);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (request.method === "GET" && url.pathname === "/assets/connect.js") {
|
|
1071
|
+
response.writeHead(200, {
|
|
1072
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1073
|
+
"content-type": "text/javascript; charset=utf-8"
|
|
1074
|
+
});
|
|
1075
|
+
response.end(uiAssets.connectScript);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (request.method === "GET" &&
|
|
1079
|
+
url.pathname === "/assets/approval-sw.js") {
|
|
1080
|
+
response.writeHead(200, {
|
|
1081
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1082
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
1083
|
+
"service-worker-allowed": "/"
|
|
1084
|
+
});
|
|
1085
|
+
response.end(uiAssets.worker);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (request.method === "GET" &&
|
|
1089
|
+
url.pathname === "/manifest.webmanifest") {
|
|
1090
|
+
response.writeHead(200, {
|
|
1091
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1092
|
+
"content-type": "application/manifest+json; charset=utf-8"
|
|
1093
|
+
});
|
|
1094
|
+
response.end(approvalManifest);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (request.method === "GET" &&
|
|
1098
|
+
url.pathname === "/assets/approval-icon.svg") {
|
|
1099
|
+
response.writeHead(200, {
|
|
1100
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1101
|
+
"content-type": "image/svg+xml; charset=utf-8"
|
|
1102
|
+
});
|
|
1103
|
+
response.end(approvalIcon);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const fontMatch = /^\/assets\/([^/]+\.woff2)$/.exec(url.pathname);
|
|
1107
|
+
const font = fontMatch?.[1] === undefined
|
|
1108
|
+
? undefined
|
|
1109
|
+
: uiAssets.fonts.get(fontMatch[1]);
|
|
1110
|
+
if (request.method === "GET" && font !== undefined) {
|
|
1111
|
+
response.writeHead(200, {
|
|
1112
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
1113
|
+
"content-type": "font/woff2"
|
|
1114
|
+
});
|
|
1115
|
+
response.end(font);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const authorized = mode === "approval"
|
|
1119
|
+
? tailnetActor(request, config, config.approvalNodes)
|
|
1120
|
+
: mode === "tailnet" || mode === "serve"
|
|
1121
|
+
? tailnetActor(request, config, null)
|
|
1122
|
+
: actor(request, config, true);
|
|
1123
|
+
const loopbackAuthorized = authorizeLoopback({
|
|
1124
|
+
login: header(request, "tailscale-user-login"),
|
|
1125
|
+
remoteAddress: request.socket.remoteAddress
|
|
1126
|
+
});
|
|
1127
|
+
const approvalSurface = mode === "approval" || mode === "serve";
|
|
1128
|
+
const dashboard = Effect.gen(function* () {
|
|
1129
|
+
const observedAt = now();
|
|
1130
|
+
const resolvedLocalPage = approvalSurface
|
|
1131
|
+
? yield* service.pendingApprovalPage(null)
|
|
1132
|
+
: { records: [], nextCursor: null };
|
|
1133
|
+
const state = yield* Effect.all({
|
|
1134
|
+
history: dashboardHistory(service, null),
|
|
1135
|
+
status: service.status()
|
|
1136
|
+
});
|
|
1137
|
+
let directory = null;
|
|
1138
|
+
let pendingApprovals = {
|
|
1139
|
+
local: approvalSurface
|
|
1140
|
+
? resolvedLocalPage.records
|
|
1141
|
+
: state.history.records.filter((record) => record.status === "pending_approval"),
|
|
1142
|
+
remote: [],
|
|
1143
|
+
failures: [],
|
|
1144
|
+
nextCursors: approvalSurface && resolvedLocalPage.nextCursor !== null
|
|
1145
|
+
? [{ host: config.host, cursor: resolvedLocalPage.nextCursor }]
|
|
1146
|
+
: []
|
|
1147
|
+
};
|
|
1148
|
+
if (approvalSurface) {
|
|
1149
|
+
const currentUrl = mode === "serve"
|
|
1150
|
+
? config.approvalHub.url
|
|
1151
|
+
: `http://${expectedHost}/`;
|
|
1152
|
+
const peersResult = yield* Effect.result(fleetPeers(config));
|
|
1153
|
+
directory = Result.isSuccess(peersResult)
|
|
1154
|
+
? {
|
|
1155
|
+
currentUrl,
|
|
1156
|
+
links: peersResult.success.map((peer) => ({
|
|
1157
|
+
host: peer.host,
|
|
1158
|
+
online: peer.online,
|
|
1159
|
+
url: peer.approvalUrl
|
|
1160
|
+
})),
|
|
1161
|
+
error: null
|
|
1162
|
+
}
|
|
1163
|
+
: {
|
|
1164
|
+
currentUrl,
|
|
1165
|
+
links: [],
|
|
1166
|
+
error: peersResult.failure.detail
|
|
1167
|
+
};
|
|
1168
|
+
if (Result.isSuccess(peersResult)) {
|
|
1169
|
+
const aggregated = yield* aggregatePeerPending(peersResult.success);
|
|
1170
|
+
pendingApprovals = {
|
|
1171
|
+
local: resolvedLocalPage.records,
|
|
1172
|
+
...aggregated,
|
|
1173
|
+
nextCursors: resolvedLocalPage.nextCursor === null
|
|
1174
|
+
? aggregated.nextCursors
|
|
1175
|
+
: [
|
|
1176
|
+
{ host: config.host, cursor: resolvedLocalPage.nextCursor },
|
|
1177
|
+
...aggregated.nextCursors
|
|
1178
|
+
]
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
pendingApprovals = {
|
|
1183
|
+
local: resolvedLocalPage.records,
|
|
1184
|
+
remote: [],
|
|
1185
|
+
nextCursors: resolvedLocalPage.nextCursor === null
|
|
1186
|
+
? []
|
|
1187
|
+
: [{ host: config.host, cursor: resolvedLocalPage.nextCursor }],
|
|
1188
|
+
failures: config.machines
|
|
1189
|
+
.filter(({ host }) => host.toLowerCase() !== config.host.toLowerCase())
|
|
1190
|
+
.map(({ host }) => ({
|
|
1191
|
+
host,
|
|
1192
|
+
reason: "unavailable"
|
|
1193
|
+
}))
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return yield* budgetDashboardSnapshot({
|
|
1198
|
+
host: config.host,
|
|
1199
|
+
observedAt,
|
|
1200
|
+
approvalsEnabled: approvalSurface,
|
|
1201
|
+
approvalApp: {
|
|
1202
|
+
canonical: mode === "serve",
|
|
1203
|
+
canonicalUrl: config.approvalHub.url,
|
|
1204
|
+
chatEnabled: mode === "serve",
|
|
1205
|
+
pushEnabled: mode === "serve"
|
|
1206
|
+
},
|
|
1207
|
+
chat: null,
|
|
1208
|
+
work: null,
|
|
1209
|
+
status: state.status,
|
|
1210
|
+
records: state.history.records,
|
|
1211
|
+
historyNextCursor: state.history.nextCursor,
|
|
1212
|
+
directory,
|
|
1213
|
+
pendingApprovals
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
1216
|
+
if (request.method === "GET" && url.pathname === "/v1/dashboard") {
|
|
1217
|
+
await respond(response, Effect.andThen(authorized, dashboard));
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (approvalSurface &&
|
|
1221
|
+
request.method === "GET" &&
|
|
1222
|
+
url.pathname === "/v1/dashboard-history") {
|
|
1223
|
+
const effect = Effect.gen(function* () {
|
|
1224
|
+
yield* authorized;
|
|
1225
|
+
const cursor = yield* decodePendingApprovalCursor(url);
|
|
1226
|
+
return yield* dashboardHistory(service, cursor);
|
|
1227
|
+
});
|
|
1228
|
+
await respond(response, effect);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (approvalSurface &&
|
|
1232
|
+
request.method === "GET" &&
|
|
1233
|
+
url.pathname === "/v1/pending-approval") {
|
|
1234
|
+
const effect = Effect.gen(function* () {
|
|
1235
|
+
yield* authorized;
|
|
1236
|
+
const target = yield* Schema.decodeUnknownEffect(ApprovalNotificationCandidate)({
|
|
1237
|
+
host: url.searchParams.get("host"),
|
|
1238
|
+
jobId: url.searchParams.get("jobId")
|
|
1239
|
+
}).pipe(Effect.mapError(() => new FleetValidationError({
|
|
1240
|
+
detail: "pending approval target requires valid host and jobId"
|
|
1241
|
+
})));
|
|
1242
|
+
return yield* resolvePendingApprovalTarget(config, service, target);
|
|
1243
|
+
});
|
|
1244
|
+
await respond(response, effect);
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if ((mode === "local" || mode === "serve") &&
|
|
1248
|
+
request.method === "GET" &&
|
|
1249
|
+
url.pathname === workSnapshotPath) {
|
|
1250
|
+
const workAuthorization = mode === "local" ? loopbackAuthorized : authorized;
|
|
1251
|
+
await respond(response, Effect.andThen(workAuthorization, work.snapshots(now())));
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (mode === "local" &&
|
|
1255
|
+
request.method === "POST" &&
|
|
1256
|
+
url.pathname === workCheckpointPath) {
|
|
1257
|
+
const effect = recordWorkCheckpointRequest(loopbackAuthorized, authorizeOriginlessMutation(request).pipe(Effect.andThen(readJson(request, WorkGoalCheckpoint))), work);
|
|
1258
|
+
await respond(response, effect, 201);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (mode === "serve" &&
|
|
1262
|
+
request.method === "GET" &&
|
|
1263
|
+
url.pathname === "/v1/push/config") {
|
|
1264
|
+
await respond(response, Effect.andThen(authorized, Effect.succeed({
|
|
1265
|
+
canonicalUrl: config.approvalHub.url,
|
|
1266
|
+
enabled: vapidKeys !== null,
|
|
1267
|
+
publicKey: vapidKeys?.publicKey ?? null
|
|
1268
|
+
})));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (mode === "serve" &&
|
|
1272
|
+
request.method === "GET" &&
|
|
1273
|
+
url.pathname === "/v1/push/subscriptions") {
|
|
1274
|
+
const effect = Effect.gen(function* () {
|
|
1275
|
+
const who = yield* authorized;
|
|
1276
|
+
const removal = yield* Schema.decodeUnknownEffect(PushSubscriptionRemoval)({ endpoint: url.searchParams.get("endpoint") }).pipe(Effect.mapError((cause) => new FleetValidationError({
|
|
1277
|
+
detail: `invalid subscription endpoint: ${String(cause)}`
|
|
1278
|
+
})));
|
|
1279
|
+
return {
|
|
1280
|
+
subscribed: yield* approvalStore.hasSubscription(removal.endpoint, who)
|
|
1281
|
+
};
|
|
1282
|
+
});
|
|
1283
|
+
await respond(response, effect);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (mode === "serve" &&
|
|
1287
|
+
request.method === "POST" &&
|
|
1288
|
+
url.pathname === "/v1/push/subscriptions") {
|
|
1289
|
+
const effect = Effect.gen(function* () {
|
|
1290
|
+
const who = yield* authorized;
|
|
1291
|
+
yield* sameOrigin(request, expectedOrigin());
|
|
1292
|
+
const subscription = yield* readJson(request, PushSubscriptionRecord);
|
|
1293
|
+
yield* validatePushEndpoint(subscription.endpoint, config.pushAllowedOrigins);
|
|
1294
|
+
yield* approvalStore.putSubscription(subscription, who);
|
|
1295
|
+
return { subscribed: true };
|
|
1296
|
+
});
|
|
1297
|
+
await respond(response, effect, 201);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (mode === "serve" &&
|
|
1301
|
+
request.method === "DELETE" &&
|
|
1302
|
+
url.pathname === "/v1/push/subscriptions") {
|
|
1303
|
+
const effect = Effect.gen(function* () {
|
|
1304
|
+
const who = yield* authorized;
|
|
1305
|
+
yield* sameOrigin(request, expectedOrigin());
|
|
1306
|
+
const removal = yield* readJson(request, PushSubscriptionRemoval);
|
|
1307
|
+
yield* approvalStore.deleteOwnedSubscription(removal.endpoint, who);
|
|
1308
|
+
return { subscribed: false };
|
|
1309
|
+
});
|
|
1310
|
+
await respond(response, effect);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (mode === "serve" &&
|
|
1314
|
+
request.method === "GET" &&
|
|
1315
|
+
url.pathname === "/v1/chat") {
|
|
1316
|
+
await respond(response, Effect.andThen(authorized, chat.history()));
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (mode === "serve" &&
|
|
1320
|
+
request.method === "POST" &&
|
|
1321
|
+
url.pathname === "/v1/chat") {
|
|
1322
|
+
const effect = Effect.gen(function* () {
|
|
1323
|
+
const who = yield* authorized;
|
|
1324
|
+
yield* sameOrigin(request, expectedOrigin());
|
|
1325
|
+
const input = yield* readJson(request, ChatRequest);
|
|
1326
|
+
const submitted = yield* chat.submit(input, who);
|
|
1327
|
+
if (submitted.queued)
|
|
1328
|
+
yield* enqueueJob(submitted.jobId);
|
|
1329
|
+
return submitted.entry;
|
|
1330
|
+
});
|
|
1331
|
+
await respond(response, effect, 202);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (approvalSurface &&
|
|
1335
|
+
request.method === "GET" &&
|
|
1336
|
+
url.pathname === "/v1/dashboard-pending") {
|
|
1337
|
+
const effect = Effect.gen(function* () {
|
|
1338
|
+
yield* authorized;
|
|
1339
|
+
const continuation = yield* decodePendingApprovalContinuation(url);
|
|
1340
|
+
return yield* dashboardPendingPage(config, service, continuation);
|
|
1341
|
+
});
|
|
1342
|
+
await respond(response, effect);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (mode === "tailnet" &&
|
|
1346
|
+
request.method === "GET" &&
|
|
1347
|
+
url.pathname === "/v1/pending-approvals") {
|
|
1348
|
+
const effect = Effect.gen(function* () {
|
|
1349
|
+
yield* authorized;
|
|
1350
|
+
const cursor = yield* decodePendingApprovalCursor(url);
|
|
1351
|
+
return yield* localPendingSummary(config.host, service, cursor);
|
|
1352
|
+
});
|
|
1353
|
+
await respond(response, effect);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (mode === "tailnet" &&
|
|
1357
|
+
request.method === "GET" &&
|
|
1358
|
+
url.pathname === "/v1/connect/agents/local") {
|
|
1359
|
+
await respond(response, Effect.andThen(tailnetActor(request, config, [config.approvalHub.nodeId]), localConnectAgents(config, service, activityStore, relationshipStore, now()).pipe(Effect.provideService(Crypto.Crypto, cryptoService))));
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (mode === "serve" &&
|
|
1363
|
+
request.method === "GET" &&
|
|
1364
|
+
url.pathname === "/v1/connect/agents") {
|
|
1365
|
+
await respond(response, Effect.andThen(authorized, Effect.gen(function* () {
|
|
1366
|
+
const cursor = yield* decodeConnectAgentCursor(url);
|
|
1367
|
+
const peers = yield* fleetPeers(config);
|
|
1368
|
+
const directory = yield* fleetConnectAgents(localConnectAgents(config, service, activityStore, relationshipStore, now()).pipe(Effect.provideService(Crypto.Crypto, cryptoService)), peers.map((peer) => ({
|
|
1369
|
+
agentsUrl: peer.connectAgentsUrl,
|
|
1370
|
+
host: peer.host,
|
|
1371
|
+
online: peer.online,
|
|
1372
|
+
terminalUrl: peer.terminalUrl
|
|
1373
|
+
})));
|
|
1374
|
+
return yield* pageFleetConnectAgents(directory, cursor);
|
|
1375
|
+
})));
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (!approvalSurface &&
|
|
1379
|
+
request.method === "GET" &&
|
|
1380
|
+
url.pathname === "/v1/status") {
|
|
1381
|
+
await respond(response, Effect.andThen(authorized, service.status()));
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (!approvalSurface &&
|
|
1385
|
+
request.method === "GET" &&
|
|
1386
|
+
url.pathname === "/v1/history") {
|
|
1387
|
+
const rawLimit = Number(url.searchParams.get("limit") ?? "50");
|
|
1388
|
+
const limit = Number.isInteger(rawLimit)
|
|
1389
|
+
? Math.min(Math.max(rawLimit, 1), 500)
|
|
1390
|
+
: 50;
|
|
1391
|
+
const effect = Effect.gen(function* () {
|
|
1392
|
+
yield* authorized;
|
|
1393
|
+
const cursor = yield* decodePendingApprovalCursor(url);
|
|
1394
|
+
return yield* service.historyPage(cursor, limit);
|
|
1395
|
+
});
|
|
1396
|
+
await respond(response, effect);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const jobMatch = /^\/v1\/jobs\/([^/]+)$/.exec(url.pathname);
|
|
1400
|
+
const jobIdSegment = jobMatch?.[1];
|
|
1401
|
+
if (!approvalSurface &&
|
|
1402
|
+
request.method === "GET" &&
|
|
1403
|
+
jobIdSegment !== undefined) {
|
|
1404
|
+
await respond(response, Effect.gen(function* () {
|
|
1405
|
+
yield* authorized;
|
|
1406
|
+
const jobId = yield* decodeJobPathSegment(jobIdSegment);
|
|
1407
|
+
return yield* service.get(jobId);
|
|
1408
|
+
}));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (!approvalSurface &&
|
|
1412
|
+
request.method === "POST" &&
|
|
1413
|
+
url.pathname === "/v1/jobs") {
|
|
1414
|
+
const effect = Effect.gen(function* () {
|
|
1415
|
+
const who = yield* authorized;
|
|
1416
|
+
yield* authorizeOriginlessMutation(request);
|
|
1417
|
+
const input = yield* readJson(request, JobRequest);
|
|
1418
|
+
const record = yield* service.submit(input, who);
|
|
1419
|
+
if (record.status === "queued")
|
|
1420
|
+
yield* enqueueJob(record.id);
|
|
1421
|
+
return record;
|
|
1422
|
+
});
|
|
1423
|
+
await respond(response, effect, 202);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const approvalMatch = /^\/v1\/jobs\/([^/]+)\/(approve|reject)$/.exec(url.pathname);
|
|
1427
|
+
const approvalJobId = approvalMatch?.[1];
|
|
1428
|
+
const decision = approvalMatch?.[2];
|
|
1429
|
+
if (approvalSurface &&
|
|
1430
|
+
request.method === "POST" &&
|
|
1431
|
+
approvalJobId !== undefined &&
|
|
1432
|
+
(decision === "approve" || decision === "reject")) {
|
|
1433
|
+
const effect = Effect.gen(function* () {
|
|
1434
|
+
const who = yield* authorized;
|
|
1435
|
+
yield* sameOrigin(request, expectedOrigin());
|
|
1436
|
+
const jobId = yield* decodeJobPathSegment(approvalJobId);
|
|
1437
|
+
const approval = yield* readApproval(request);
|
|
1438
|
+
const record = decision === "approve"
|
|
1439
|
+
? yield* service.approve(jobId, approval, who)
|
|
1440
|
+
: yield* service.reject(jobId, approval, who);
|
|
1441
|
+
if (record.status === "queued")
|
|
1442
|
+
yield* enqueueJob(record.id);
|
|
1443
|
+
return record;
|
|
1444
|
+
});
|
|
1445
|
+
if (header(request, "content-type")?.startsWith("application/x-www-form-urlencoded") === true) {
|
|
1446
|
+
const result = await runRequest(Effect.result(effect));
|
|
1447
|
+
if (Result.isFailure(result)) {
|
|
1448
|
+
const mapped = apiError(result.failure);
|
|
1449
|
+
json(response, mapped.status, mapped.body);
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
response.writeHead(303, { location: "/" });
|
|
1453
|
+
response.end();
|
|
1454
|
+
}
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
await respond(response, effect);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
1461
|
+
const result = await runRequest(Effect.result(Effect.andThen(authorized, dashboard)));
|
|
1462
|
+
if (Result.isFailure(result)) {
|
|
1463
|
+
const mapped = apiError(result.failure);
|
|
1464
|
+
json(response, mapped.status, mapped.body);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
response.writeHead(200, {
|
|
1468
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1469
|
+
"content-security-policy": "frame-ancestors 'none'",
|
|
1470
|
+
"content-type": "text/html; charset=utf-8",
|
|
1471
|
+
"x-frame-options": "DENY"
|
|
1472
|
+
});
|
|
1473
|
+
response.end(dashboardPage(result.success));
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
if (mode === "serve" &&
|
|
1477
|
+
request.method === "GET" &&
|
|
1478
|
+
url.pathname === "/connect/") {
|
|
1479
|
+
const result = await runRequest(Effect.result(authorized));
|
|
1480
|
+
if (Result.isFailure(result)) {
|
|
1481
|
+
const mapped = apiError(result.failure);
|
|
1482
|
+
json(response, mapped.status, mapped.body);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
response.writeHead(200, {
|
|
1486
|
+
"cache-control": "no-cache, must-revalidate",
|
|
1487
|
+
"content-security-policy": "frame-ancestors 'none'",
|
|
1488
|
+
"content-type": "text/html; charset=utf-8",
|
|
1489
|
+
"x-frame-options": "DENY"
|
|
1490
|
+
});
|
|
1491
|
+
response.end(connectPage());
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
json(response, 404, { error: "not_found" });
|
|
1495
|
+
}
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
if (closed || response.destroyed)
|
|
1498
|
+
return;
|
|
1499
|
+
json(response, 500, { error: "internal", detail: String(error) });
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
const server = tls === null
|
|
1503
|
+
? createServer(requestHandler)
|
|
1504
|
+
: await httpRuntime.runPromise(Effect.try({
|
|
1505
|
+
try: () => createSecureServer({ cert: tls.certificate, key: tls.privateKey }, requestHandler),
|
|
1506
|
+
catch: (cause) => new FleetOperationError({
|
|
1507
|
+
cause,
|
|
1508
|
+
detail: "approval TLS certificate or private key is invalid",
|
|
1509
|
+
operation: "hostd.approval_tls.create_server"
|
|
1510
|
+
})
|
|
1511
|
+
}));
|
|
1512
|
+
server.on("connection", (connection) => {
|
|
1513
|
+
activeHttpSockets.add(connection);
|
|
1514
|
+
connection.once("close", () => activeHttpSockets.delete(connection));
|
|
1515
|
+
});
|
|
1516
|
+
server.on("upgrade", async (request, socket, head) => {
|
|
1517
|
+
const url = pathOf(request);
|
|
1518
|
+
const isServeTerminal = mode === "serve" && url.pathname === "/v1/connect/session";
|
|
1519
|
+
const isTailnetTerminal = mode === "tailnet" && url.pathname === "/v1/connect/terminal";
|
|
1520
|
+
if (!isServeTerminal && !isTailnetTerminal) {
|
|
1521
|
+
rejectUpgrade(socket, 404);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (header(request, "host")?.toLowerCase() !== expectedHost) {
|
|
1525
|
+
rejectUpgrade(socket, 403);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (!acceptingRequests) {
|
|
1529
|
+
rejectUpgrade(socket, 503);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const access = Effect.gen(function* () {
|
|
1533
|
+
if (isServeTerminal) {
|
|
1534
|
+
yield* tailnetActor(request, config, null);
|
|
1535
|
+
yield* sameOrigin(request, expectedOrigin());
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
yield* tailnetActor(request, config, [config.approvalHub.nodeId]);
|
|
1539
|
+
}
|
|
1540
|
+
return yield* terminalSelectionFromUrl(url);
|
|
1541
|
+
});
|
|
1542
|
+
const result = await httpRuntime.runPromise(Effect.result(access)).catch(() => null);
|
|
1543
|
+
if (result === null) {
|
|
1544
|
+
socket.destroy();
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (Result.isFailure(result)) {
|
|
1548
|
+
rejectUpgrade(socket, 403);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
if (closed || !acceptingRequests) {
|
|
1552
|
+
rejectUpgrade(socket, 503);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
1556
|
+
watchSocket(webSocket);
|
|
1557
|
+
webSockets.add(webSocket);
|
|
1558
|
+
webSocket.once("close", () => webSockets.delete(webSocket));
|
|
1559
|
+
const task = isTailnetTerminal ||
|
|
1560
|
+
result.success.host.toLowerCase() === config.host.toLowerCase()
|
|
1561
|
+
? attachTerminal(webSocket, result.success)
|
|
1562
|
+
: proxyTerminal(webSocket, result.success);
|
|
1563
|
+
terminalTasks.add(task);
|
|
1564
|
+
void task.then(() => terminalTasks.delete(task), () => {
|
|
1565
|
+
terminalTasks.delete(task);
|
|
1566
|
+
closeSocket(webSocket, 4503, "terminal unavailable");
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
});
|
|
1570
|
+
await new Promise((resolve, reject) => {
|
|
1571
|
+
server.once("error", reject);
|
|
1572
|
+
server.listen(port, address, resolve);
|
|
1573
|
+
});
|
|
1574
|
+
const decodedAddress = Schema.decodeUnknownResult(TcpAddress)(server.address());
|
|
1575
|
+
if (Result.isFailure(decodedAddress)) {
|
|
1576
|
+
throw new FleetOperationError({
|
|
1577
|
+
cause: decodedAddress.failure,
|
|
1578
|
+
detail: "hostd did not receive a TCP address",
|
|
1579
|
+
operation: "hostd.listen.address"
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
const bound = decodedAddress.success;
|
|
1583
|
+
if (mode !== "serve") {
|
|
1584
|
+
expectedHost = listenerAuthority(bound.address, bound.port);
|
|
1585
|
+
}
|
|
1586
|
+
activeServers.add(server);
|
|
1587
|
+
return {
|
|
1588
|
+
server,
|
|
1589
|
+
url: `${tls === null ? "http" : "https"}://${bound.address}:${bound.port}`
|
|
1590
|
+
};
|
|
1591
|
+
};
|
|
1592
|
+
const tailscaleIp = config.crossHost
|
|
1593
|
+
? await httpRuntime.runPromise(Effect.flatMap(Tailscale, (tailscale) => tailscale.ipv4))
|
|
1594
|
+
: null;
|
|
1595
|
+
if (tailscaleIp === "") {
|
|
1596
|
+
throw new FleetOperationError({
|
|
1597
|
+
cause: tailscaleIp,
|
|
1598
|
+
detail: "tailscale ip -4 returned no address",
|
|
1599
|
+
operation: "hostd.tailscale.ipv4"
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
const recoveredJobIds = await Effect.runPromise(service.recover());
|
|
1603
|
+
const local = await listen("127.0.0.1", config.localPort, "local");
|
|
1604
|
+
if (tailscaleIp === null) {
|
|
1605
|
+
for (const jobId of recoveredJobIds)
|
|
1606
|
+
await Effect.runPromise(enqueueJob(jobId));
|
|
1607
|
+
acceptingRequests = true;
|
|
1608
|
+
return {
|
|
1609
|
+
url: local.url,
|
|
1610
|
+
tailnetUrl: null,
|
|
1611
|
+
approvalUrl: null,
|
|
1612
|
+
serveUrl: null,
|
|
1613
|
+
close: shutdown
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
const remote = await listen(tailscaleIp, config.port, "tailnet");
|
|
1617
|
+
const approval = isHub
|
|
1618
|
+
? null
|
|
1619
|
+
: await listen(tailscaleIp, config.approvalPort, "approval");
|
|
1620
|
+
const serve = isHub
|
|
1621
|
+
? await listen(tailscaleIp, config.approvalPort, "serve", tlsCredentials)
|
|
1622
|
+
: null;
|
|
1623
|
+
for (const jobId of recoveredJobIds)
|
|
1624
|
+
await Effect.runPromise(enqueueJob(jobId));
|
|
1625
|
+
if (pushSender !== null) {
|
|
1626
|
+
await httpRuntime.runPromise(Effect.forkIn(makePushWorker({
|
|
1627
|
+
allowedPushOrigins: config.pushAllowedOrigins,
|
|
1628
|
+
allowedUsers: config.allowedUsers,
|
|
1629
|
+
loadCandidates: () => notificationCandidates(config, service),
|
|
1630
|
+
send: pushSender,
|
|
1631
|
+
store: approvalStore
|
|
1632
|
+
}), workerScope));
|
|
1633
|
+
}
|
|
1634
|
+
acceptingRequests = true;
|
|
1635
|
+
return {
|
|
1636
|
+
url: local.url,
|
|
1637
|
+
tailnetUrl: remote.url,
|
|
1638
|
+
approvalUrl: approval?.url ?? serve?.url ?? null,
|
|
1639
|
+
serveUrl: serve?.url ?? null,
|
|
1640
|
+
close: shutdown
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
catch (error) {
|
|
1644
|
+
try {
|
|
1645
|
+
await shutdown();
|
|
1646
|
+
}
|
|
1647
|
+
catch (cleanupError) {
|
|
1648
|
+
throw new AggregateError([error, cleanupError], "hostd startup and cleanup both failed", { cause: cleanupError });
|
|
1649
|
+
}
|
|
1650
|
+
throw error;
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
//# sourceMappingURL=http.js.map
|