hyperbrane 0.1.2 → 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/README.md +67 -25
- package/dist/agent/agentClient.d.ts +5 -0
- package/dist/agent/agentClient.js +4 -0
- package/dist/agent/agentService.d.ts +9 -37
- package/dist/agent/agentService.js +62 -322
- package/dist/agent/agentStorage.d.ts +1 -20
- package/dist/agent/agentStorage.js +0 -30
- package/dist/agent/api.d.ts +80 -151
- package/dist/agent/api.js +1 -8
- package/dist/agent/client.d.ts +3 -1
- package/dist/agent/client.js +2 -3
- package/dist/agent/control.d.ts +23 -0
- package/dist/agent/control.js +479 -0
- package/dist/agent/hostTranscript.d.ts +0 -6
- package/dist/agent/hostTranscript.js +3 -14
- package/dist/agent/session/backend.d.ts +17 -32
- package/dist/agent/session/claude/home.d.ts +0 -1
- package/dist/agent/session/claude/home.js +0 -1
- package/dist/agent/session/claude/provision.d.ts +10 -0
- package/dist/agent/session/claude/provision.js +17 -0
- package/dist/agent/session/claude/transcript.d.ts +2 -7
- package/dist/agent/session/claude/transcript.js +68 -14
- package/dist/agent/session/client.d.ts +1 -10
- package/dist/agent/session/client.js +0 -3
- package/dist/agent/session/codex/provision.d.ts +14 -0
- package/dist/agent/session/codex/provision.js +25 -0
- package/dist/agent/session/fileTranscript.d.ts +6 -49
- package/dist/agent/session/fileTranscript.js +26 -82
- package/dist/agent/session/install.d.ts +5 -0
- package/dist/agent/session/install.js +35 -0
- package/dist/agent/session/sessionStorage.d.ts +4 -35
- package/dist/agent/session/sessionStorage.js +10 -16
- package/dist/agent/session/sharedConnection.d.ts +0 -8
- package/dist/agent/session/sharedConnection.js +0 -7
- package/dist/agent/session/storedTranscripts.d.ts +1 -6
- package/dist/agent/session/storedTranscripts.js +0 -3
- package/dist/agent/session/worker/jsonRpc.d.ts +2 -13
- package/dist/agent/session/worker/jsonRpc.js +2 -11
- package/dist/agent/session/worker/protocol.d.ts +26 -20
- package/dist/agent/session/worker/protocol.js +29 -18
- package/dist/agent/session/worker/workerParams.d.ts +0 -5
- package/dist/agent/session/worker/workerParams.js +14 -14
- package/dist/agent/session/workerConnection.d.ts +2 -13
- package/dist/agent/session/workerConnection.js +7 -12
- package/dist/agent/session/workerGatewayConnection.d.ts +18 -0
- package/dist/agent/session/workerGatewayConnection.js +86 -0
- package/dist/agent/vendor-types/manifest.json +87 -0
- package/dist/agent/workerBundle.d.ts +4 -19
- package/dist/agent/workerBundle.generated.js +1 -1
- package/dist/agent/workerBundle.js +3 -30
- package/dist/agent/workerReplacement.d.ts +3 -0
- package/dist/agent/workerReplacement.js +49 -0
- package/dist/cli/agent.js +31 -16
- package/dist/cli/command.d.ts +10 -11
- package/dist/cli/command.js +0 -4
- package/dist/cli/dimension.js +6 -6
- package/dist/cli/gateway.js +3 -6
- package/dist/cli/main.js +14 -31
- package/dist/cli/streamPrinter.d.ts +2 -10
- package/dist/cli/streamPrinter.js +16 -46
- package/dist/cli/table.d.ts +0 -3
- package/dist/cli/table.js +0 -4
- package/dist/dimension/api.d.ts +39 -27
- package/dist/dimension/api.js +28 -47
- package/dist/dimension/client.d.ts +5 -0
- package/dist/dimension/client.js +4 -0
- package/dist/dimension/control.d.ts +21 -0
- package/dist/dimension/control.js +328 -0
- package/dist/dimension/image.d.ts +0 -5
- package/dist/dimension/image.js +0 -8
- package/dist/dimension/imageFiles.generated.js +1 -1
- package/dist/dimension/paths.d.ts +5 -4
- package/dist/dimension/paths.js +15 -6
- package/dist/dimension/service.d.ts +8 -24
- package/dist/dimension/service.js +60 -228
- package/dist/dimension/transport-worker.d.ts +1 -0
- package/dist/dimension/transport-worker.js +151 -0
- package/dist/dimension/transportBundle.d.ts +2 -0
- package/dist/dimension/transportBundle.generated.d.ts +1 -0
- package/dist/dimension/transportBundle.generated.js +2 -0
- package/dist/dimension/transportBundle.js +4 -0
- package/dist/dimension/transportClient.d.ts +10 -0
- package/dist/dimension/transportClient.js +115 -0
- package/dist/dimension/transportProtocol.d.ts +53 -0
- package/dist/dimension/transportProtocol.js +8 -0
- package/dist/index.d.ts +5 -9
- package/dist/index.js +5 -19
- package/dist/lib/control.d.ts +8 -0
- package/dist/lib/control.js +62 -0
- package/dist/lib/docker.d.ts +0 -22
- package/dist/lib/docker.js +0 -10
- package/dist/lib/image.d.ts +0 -3
- package/dist/lib/image.js +0 -2
- package/dist/lib/jsonDirectory.d.ts +0 -13
- package/dist/lib/jsonDirectory.js +10 -15
- package/dist/lib/scriptBundle.d.ts +6 -0
- package/dist/lib/scriptBundle.js +28 -0
- package/dist/lib/sh.d.ts +2 -0
- package/dist/lib/sh.js +8 -0
- package/dist/lib/sleep.d.ts +0 -1
- package/dist/lib/sleep.js +0 -1
- package/dist/lib/streamFetch.d.ts +15 -0
- package/dist/lib/streamFetch.js +150 -0
- package/dist/lib/tar.d.ts +0 -1
- package/dist/lib/tar.js +5 -11
- package/dist/portal-gateway/api.d.ts +13 -44
- package/dist/portal-gateway/api.js +1 -3
- package/dist/portal-gateway/control.d.ts +42 -0
- package/dist/portal-gateway/control.js +657 -0
- package/dist/portal-gateway/gatewayClient.d.ts +5 -0
- package/dist/portal-gateway/gatewayClient.js +4 -0
- package/dist/portal-gateway/gatewayService.d.ts +8 -38
- package/dist/portal-gateway/gatewayService.js +68 -254
- package/dist/portal-gateway/image.d.ts +0 -1
- package/dist/portal-gateway/image.js +0 -2
- package/dist/portal-gateway/imageFiles.generated.js +2 -2
- package/dist/portal-gateway/portalClient.d.ts +5 -0
- package/dist/portal-gateway/portalClient.js +4 -0
- package/dist/portal-gateway/portalService.d.ts +5 -47
- package/dist/portal-gateway/portalService.js +19 -349
- package/dist/portal-gateway/transport-worker.d.ts +1 -0
- package/dist/portal-gateway/transport-worker.js +205 -0
- package/dist/portal-gateway/transportBundle.d.ts +2 -0
- package/dist/portal-gateway/transportBundle.generated.d.ts +1 -0
- package/dist/portal-gateway/transportBundle.generated.js +2 -0
- package/dist/portal-gateway/transportBundle.js +4 -0
- package/dist/portal-gateway/upstream.d.ts +0 -81
- package/dist/portal-gateway/upstream.js +0 -1
- package/dist/sandbox/api.d.ts +121 -187
- package/dist/sandbox/api.js +5 -7
- package/dist/sandbox/client.d.ts +2 -0
- package/dist/sandbox/client.js +4 -0
- package/dist/sandbox/data-endpoints/dataEndpoints.d.ts +13 -0
- package/dist/sandbox/data-endpoints/dataEndpoints.js +100 -0
- package/dist/sandbox/data-endpoints/firewalledPorts.d.ts +4 -0
- package/dist/sandbox/data-endpoints/firewalledPorts.js +28 -0
- package/dist/sandbox/lib/httpOverStream.d.ts +3 -0
- package/dist/sandbox/lib/httpOverStream.js +21 -0
- package/dist/sandbox/lib/remoteFileOperations.d.ts +10 -0
- package/dist/sandbox/lib/remoteFileOperations.js +70 -0
- package/dist/sandbox/lib/shellCommand.d.ts +3 -0
- package/dist/sandbox/lib/shellCommand.js +7 -0
- package/dist/sandbox/providers/boat/boatService.d.ts +2 -0
- package/dist/sandbox/providers/boat/boatService.js +321 -0
- package/dist/sandbox/providers/exedev/exeDevService.d.ts +2 -0
- package/dist/sandbox/providers/exedev/exeDevService.js +127 -0
- package/dist/sandbox/providers/exedev/exeDevToken.d.ts +1 -0
- package/dist/sandbox/providers/exedev/exeDevToken.js +54 -0
- package/dist/sandbox/providers/local/localService.d.ts +2 -0
- package/dist/sandbox/providers/local/localService.js +219 -0
- package/dist/sandbox/providers/ssh/sshService.d.ts +2 -0
- package/dist/sandbox/providers/ssh/sshService.js +82 -0
- package/dist/sandbox/providers/ssh/sshTransport.d.ts +12 -0
- package/dist/sandbox/providers/ssh/sshTransport.js +216 -0
- package/dist/sandbox/service.d.ts +2 -0
- package/dist/sandbox/service.js +18 -0
- package/dist/sandbox/storage/descriptors.d.ts +6 -0
- package/dist/sandbox/storage/descriptors.js +25 -0
- package/dist/sandbox/storage/sandboxStorage.d.ts +11 -0
- package/dist/sandbox/storage/sandboxStorage.js +42 -0
- package/dist/upstreams/anthropic.d.ts +0 -5
- package/dist/upstreams/anthropic.js +1 -8
- package/dist/upstreams/index.d.ts +2 -6
- package/dist/upstreams/index.js +0 -4
- package/dist/upstreams/openai.d.ts +0 -5
- package/dist/upstreams/openai.js +1 -13
- package/package.json +12 -4
- package/dist/agent/gatewaySignIns.d.ts +0 -5
- package/dist/agent/gatewaySignIns.js +0 -11
- package/dist/agent/session/codex/home.d.ts +0 -2
- package/dist/agent/session/codex/home.js +0 -4
- package/dist/agent/session/codex/messages.d.ts +0 -30
- package/dist/agent/session/codex/messages.js +0 -135
- package/dist/agent/session/codex/transcript.d.ts +0 -12
- package/dist/agent/session/codex/transcript.js +0 -22
- package/dist/agent/session/sdkMessages.d.ts +0 -15
- package/dist/agent/session/sdkMessages.js +0 -28
- package/dist/agent/session/workerSseConnection.d.ts +0 -31
- package/dist/agent/session/workerSseConnection.js +0 -76
- package/dist/agent/vendor-types/codex/app-server/AbsolutePathBuf.d.ts +0 -10
- package/dist/agent/vendor-types/codex/app-server/AbsolutePathBuf.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputBody.d.ts +0 -2
- package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputBody.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputContentItem.d.ts +0 -22
- package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputContentItem.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/ImageDetail.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/ImageDetail.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/ImageGenerationFailure.d.ts +0 -5
- package/dist/agent/vendor-types/codex/app-server/ImageGenerationFailure.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/ImageGenerationItem.d.ts +0 -11
- package/dist/agent/vendor-types/codex/app-server/ImageGenerationItem.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/LegacyAppPathString.d.ts +0 -23
- package/dist/agent/vendor-types/codex/app-server/LegacyAppPathString.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/MessagePhase.d.ts +0 -7
- package/dist/agent/vendor-types/codex/app-server/MessagePhase.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/ReasoningEffort.d.ts +0 -4
- package/dist/agent/vendor-types/codex/app-server/ReasoningEffort.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/SleepItem.d.ts +0 -7
- package/dist/agent/vendor-types/codex/app-server/SleepItem.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/WebSearchItem.d.ts +0 -14
- package/dist/agent/vendor-types/codex/app-server/WebSearchItem.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/serde_json/JsonValue.d.ts +0 -3
- package/dist/agent/vendor-types/codex/app-server/serde_json/JsonValue.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/AgentMessageDelivery.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/AgentMessageDelivery.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/AsyncUserInputQuestion.d.ts +0 -4
- package/dist/agent/vendor-types/codex/app-server/v2/AsyncUserInputQuestion.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/ByteRange.d.ts +0 -4
- package/dist/agent/vendor-types/codex/app-server/v2/ByteRange.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentState.d.ts +0 -5
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentState.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentStatus.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentStatus.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentTool.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentTool.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentToolCallStatus.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentToolCallStatus.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CommandAction.d.ts +0 -19
- package/dist/agent/vendor-types/codex/app-server/v2/CommandAction.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionSource.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionSource.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionStatus.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionStatus.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallOutputContentItem.d.ts +0 -10
- package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallOutputContentItem.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallStatus.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallStatus.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/FileUpdateChange.d.ts +0 -6
- package/dist/agent/vendor-types/codex/app-server/v2/FileUpdateChange.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/HookPromptFragment.d.ts +0 -4
- package/dist/agent/vendor-types/codex/app-server/v2/HookPromptFragment.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/McpAppDisplayMode.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/McpAppDisplayMode.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/McpAppUi.d.ts +0 -8
- package/dist/agent/vendor-types/codex/app-server/v2/McpAppUi.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallAppContext.d.ts +0 -7
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallAppContext.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallError.d.ts +0 -3
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallError.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallResult.d.ts +0 -6
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallResult.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallStatus.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallStatus.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitation.d.ts +0 -5
- package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitation.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitationEntry.d.ts +0 -6
- package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitationEntry.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/PatchApplyStatus.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/PatchApplyStatus.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/PatchChangeKind.d.ts +0 -8
- package/dist/agent/vendor-types/codex/app-server/v2/PatchChangeKind.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/SubAgentActivityKind.d.ts +0 -1
- package/dist/agent/vendor-types/codex/app-server/v2/SubAgentActivityKind.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/TextElement.d.ts +0 -11
- package/dist/agent/vendor-types/codex/app-server/v2/TextElement.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/ThreadItem.d.ts +0 -215
- package/dist/agent/vendor-types/codex/app-server/v2/ThreadItem.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/UserInput.d.ts +0 -35
- package/dist/agent/vendor-types/codex/app-server/v2/UserInput.js +0 -2
- package/dist/agent/vendor-types/codex/app-server/v2/WebSearchAction.d.ts +0 -14
- package/dist/agent/vendor-types/codex/app-server/v2/WebSearchAction.js +0 -2
- package/dist/cli/services.d.ts +0 -19
- package/dist/cli/services.js +0 -34
- package/dist/dimension/http.d.ts +0 -7
- package/dist/dimension/http.js +0 -77
- package/dist/dimension/storage.d.ts +0 -39
- package/dist/dimension/storage.js +0 -79
- package/dist/portal-gateway/fakes.d.ts +0 -3
- package/dist/portal-gateway/fakes.js +0 -11
- package/dist/portal-gateway/gatewayStorage.d.ts +0 -66
- package/dist/portal-gateway/gatewayStorage.js +0 -125
- package/dist/portal-gateway/portalStorage.d.ts +0 -34
- package/dist/portal-gateway/portalStorage.js +0 -44
- package/dist/sandbox/boatService.d.ts +0 -52
- package/dist/sandbox/boatService.js +0 -343
- package/dist/sandbox/containerService.d.ts +0 -15
- package/dist/sandbox/containerService.js +0 -184
- package/dist/sandbox/endpointProxy.d.ts +0 -90
- package/dist/sandbox/endpointProxy.js +0 -716
- package/dist/sandbox/exeDevAuth.d.ts +0 -16
- package/dist/sandbox/exeDevAuth.js +0 -49
- package/dist/sandbox/localService.d.ts +0 -19
- package/dist/sandbox/localService.js +0 -200
- package/dist/sandbox/sseConnection.d.ts +0 -41
- package/dist/sandbox/sseConnection.js +0 -166
- package/dist/sandbox/sshService.d.ts +0 -30
- package/dist/sandbox/sshService.js +0 -680
|
@@ -1,716 +0,0 @@
|
|
|
1
|
-
import { SandboxEndpointError, } from "./api.js";
|
|
2
|
-
/** "host", "host:port", "[v6]:port" or a bare IPv6 address; throws SandboxEndpointError for a bad port. */
|
|
3
|
-
export function parseAccessibleHost(value) {
|
|
4
|
-
const match = /^\[(.+)\]:(\d+)$/.exec(value) ?? /^([^:]+):(\d+)$/.exec(value);
|
|
5
|
-
if (!match)
|
|
6
|
-
return { host: value.replace(/^\[(.+)\]$/, "$1"), port: null };
|
|
7
|
-
const port = Number(match[2]);
|
|
8
|
-
checkPort(port);
|
|
9
|
-
return { host: match[1], port };
|
|
10
|
-
}
|
|
11
|
-
const BASIC_USER = "hyperbrane";
|
|
12
|
-
/**
|
|
13
|
-
* The default: the proxy itself checks credentials it mints per endpoint (bearer or basic). The
|
|
14
|
-
* proxy speaks plain HTTP; the accessible host is assumed to terminate TLS in front of it, on the
|
|
15
|
-
* same port, so clients are given https URLs.
|
|
16
|
-
*/
|
|
17
|
-
export function proxyCredentialsAuth() {
|
|
18
|
-
return {
|
|
19
|
-
bind({ host, port: pinned }) {
|
|
20
|
-
const name = host.includes(":") ? `[${host}]` : host;
|
|
21
|
-
return {
|
|
22
|
-
firewall: true,
|
|
23
|
-
plan({ scheme, port }) {
|
|
24
|
-
if (scheme !== "bearer" && scheme !== "basic") {
|
|
25
|
-
throw new SandboxEndpointError(`the endpoint proxy checks bearer or basic credentials, not "${scheme}"`);
|
|
26
|
-
}
|
|
27
|
-
const listen = pinnedPort(pinned, port);
|
|
28
|
-
const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url");
|
|
29
|
-
const credentials = scheme === "bearer" ? { scheme, token: secret } : { scheme, username: BASIC_USER, password: secret };
|
|
30
|
-
return {
|
|
31
|
-
// What follows the scheme in the Authorization header.
|
|
32
|
-
check: { scheme, credentials: scheme === "bearer" ? secret : btoa(`${BASIC_USER}:${secret}`) },
|
|
33
|
-
ports: [listen ?? 0, listen ?? 0],
|
|
34
|
-
publish: async (listening) => ({ url: `https://${name}:${listening}`, credentials }),
|
|
35
|
-
};
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
/** The port a request must listen on: the accessible host's, or its own; null for any. */
|
|
42
|
-
export function pinnedPort(pinned, requested) {
|
|
43
|
-
if (requested !== undefined)
|
|
44
|
-
checkPort(requested);
|
|
45
|
-
if (pinned !== null && requested !== undefined && requested !== pinned) {
|
|
46
|
-
throw new SandboxEndpointError(`endpoints here listen on port ${pinned} (the accessible host's), not ${requested}`);
|
|
47
|
-
}
|
|
48
|
-
return pinned ?? requested ?? null;
|
|
49
|
-
}
|
|
50
|
-
export function checkPort(port) {
|
|
51
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
52
|
-
throw new SandboxEndpointError(`not a port: ${port}`);
|
|
53
|
-
}
|
|
54
|
-
const ID_PATTERN = /^hyb-endpoint-(\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
55
|
-
const ENDPOINT_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
56
|
-
/** The port an endpoint listens on and its name there, from its id; throws for anything that isn't an endpoint id. */
|
|
57
|
-
export function parseEndpointId(endpointId) {
|
|
58
|
-
const match = ID_PATTERN.exec(endpointId);
|
|
59
|
-
if (!match)
|
|
60
|
-
throw new SandboxEndpointError(`not an endpoint id: "${endpointId}"`);
|
|
61
|
-
return { port: Number(match[1]), name: match[2] };
|
|
62
|
-
}
|
|
63
|
-
// The scripts below run on the machine. Each proxy keeps its endpoints in $B/<port>/ (made by the
|
|
64
|
-
// proxy, only the user can read it): <name>.json per endpoint, and .pid, the proxy's own.
|
|
65
|
-
const STATE = 'B="${B:-$HOME/.local/state/hyperbrane/endpoints}"';
|
|
66
|
-
const RUNNING = `pid=$(cat "$B/$P/.pid" 2>/dev/null) && kill -0 "$pid" 2>/dev/null && ps -p "$pid" -o args= | grep -q hyb-endpoint-proxy`;
|
|
67
|
-
/** Starts a proxy (argv: the PROXY source); it prints its port once it serves $B/<port>/. */
|
|
68
|
-
const START_SCRIPT = `${STATE}\nexec python3 -c "$1" hyb-endpoint-proxy "$B"`;
|
|
69
|
-
/** Prints endpoint $N's record (its secret included: the user's own); exit 1 if there's none. */
|
|
70
|
-
const READ_SCRIPT = `${STATE}\ncat "$B/$P/$N.json" 2>/dev/null || exit 1`;
|
|
71
|
-
/** Exit 0 if the proxy on $P runs. */
|
|
72
|
-
const RUNNING_SCRIPT = `${STATE}\n${RUNNING}`;
|
|
73
|
-
/** Records endpoint $N (stdin) with the proxy on $P, atomically. */
|
|
74
|
-
const WRITE_SCRIPT = `${STATE}\numask 077\n[ -d "$B/$P" ] || exit 44\ncat > "$B/$P/.$N.tmp" && mv -f "$B/$P/.$N.tmp" "$B/$P/$N.json"`;
|
|
75
|
-
/** Exit 0 if endpoint $N is recorded and its proxy on $P runs, 3 if it is recorded but the proxy doesn't, 1 if it isn't recorded. */
|
|
76
|
-
const STATUS_SCRIPT = `${STATE}\n[ -e "$B/$P/$N.json" ] || exit 1\n${RUNNING} || exit 3`;
|
|
77
|
-
/** Removes endpoint $N; exits 10 once that was the proxy's last, and it is stopped. */
|
|
78
|
-
const REMOVE_SCRIPT = `${STATE}
|
|
79
|
-
rm -f "$B/$P/$N.json"
|
|
80
|
-
for other in "$B/$P"/*.json; do [ -e "$other" ] && exit 0; done
|
|
81
|
-
if ${RUNNING}; then kill "$pid"; fi
|
|
82
|
-
rm -rf "$B/$P"
|
|
83
|
-
exit 10`;
|
|
84
|
-
export function createProxyEndpoints(commands, options, where) {
|
|
85
|
-
const accessible = parseAccessibleHost(options.accessibleHost);
|
|
86
|
-
const auth = (options.auth ?? proxyCredentialsAuth()).bind(accessible);
|
|
87
|
-
const firewall = options.needsFirewall === true && auth.firewall;
|
|
88
|
-
const env = (vars) => ({
|
|
89
|
-
...(options.stateDirectory === undefined ? {} : { B: options.stateDirectory }),
|
|
90
|
-
...Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, String(value)])),
|
|
91
|
-
});
|
|
92
|
-
const sh = (script, vars, stdin) => commands.runCommand(["sh", "-c", script], { env: env(vars), stdin });
|
|
93
|
-
async function ufw(...args) {
|
|
94
|
-
const result = await commands.runCommand(["sudo", "-n", "ufw", ...args]);
|
|
95
|
-
if (result.exitCode !== 0)
|
|
96
|
-
throw new Error(`ufw ${args.join(" ")} on ${where} failed (exit ${result.exitCode}): ${result.stderr.trim()}`);
|
|
97
|
-
}
|
|
98
|
-
async function running(port) {
|
|
99
|
-
return (await sh(RUNNING_SCRIPT, { P: port })).exitCode === 0;
|
|
100
|
-
}
|
|
101
|
-
async function stop(port) {
|
|
102
|
-
const result = await sh(REMOVE_SCRIPT, { P: port, N: "-" });
|
|
103
|
-
if (result.exitCode === 10 && firewall)
|
|
104
|
-
await ufw("delete", "allow", `${port}/tcp`);
|
|
105
|
-
}
|
|
106
|
-
/** A proxy listening as `plan` asks: the running one on a fixed port, or a new one. */
|
|
107
|
-
async function proxyFor(plan) {
|
|
108
|
-
const [low, high] = plan.ports;
|
|
109
|
-
if (low === high && low !== 0 && (await running(low)))
|
|
110
|
-
return low;
|
|
111
|
-
const port = await start(plan.ports, plan.except ?? []);
|
|
112
|
-
if (port === null)
|
|
113
|
-
throw new Error(`starting the endpoint proxy on ${where} failed: no free port in ${low}-${high}`);
|
|
114
|
-
return port;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Starts a proxy on a port of `ports` (it serves what is recorded there already) and opens the
|
|
118
|
-
* port in the firewall; null if none is free. One just started on a fixed port by someone else
|
|
119
|
-
* counts as started.
|
|
120
|
-
*/
|
|
121
|
-
async function start(ports, except) {
|
|
122
|
-
const [low, high] = ports;
|
|
123
|
-
const result = await commands.runCommand(["sh", "-c", START_SCRIPT, "sh", PROXY], {
|
|
124
|
-
env: env({}),
|
|
125
|
-
stdin: `${JSON.stringify({ ports, except })}\n`,
|
|
126
|
-
});
|
|
127
|
-
const port = Number(result.stdout.trim());
|
|
128
|
-
if (result.exitCode !== 0 || !Number.isInteger(port) || port <= 0) {
|
|
129
|
-
if (low === high && low !== 0 && (await running(low)))
|
|
130
|
-
return low;
|
|
131
|
-
if (/no free port/.test(result.stderr))
|
|
132
|
-
return null;
|
|
133
|
-
throw new Error(`starting the endpoint proxy on ${where} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`);
|
|
134
|
-
}
|
|
135
|
-
if (firewall) {
|
|
136
|
-
try {
|
|
137
|
-
await ufw("allow", `${port}/tcp`);
|
|
138
|
-
}
|
|
139
|
-
catch (error) {
|
|
140
|
-
await stop(port).catch(() => { });
|
|
141
|
-
throw error;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return port;
|
|
145
|
-
}
|
|
146
|
-
function exposeEndpoint(request) {
|
|
147
|
-
return exposeEndpointAs(crypto.randomUUID(), request);
|
|
148
|
-
}
|
|
149
|
-
async function exposeEndpointAs(name, request) {
|
|
150
|
-
if (!ENDPOINT_NAME.test(name))
|
|
151
|
-
throw new SandboxEndpointError(`not an endpoint name: "${name}"`);
|
|
152
|
-
const { target, transport = "http", allowedOrigins = [] } = request;
|
|
153
|
-
if (transport !== "http" && transport !== "sse")
|
|
154
|
-
throw new SandboxEndpointError(`unknown transport: ${transport}`);
|
|
155
|
-
const plan = auth.plan(request);
|
|
156
|
-
const port = await proxyFor(plan);
|
|
157
|
-
const id = `hyb-endpoint-${port}-${name}`;
|
|
158
|
-
const published = await plan.publish(port);
|
|
159
|
-
// The same name exposed again for the same thing keeps its credentials: whoever holds them
|
|
160
|
-
// (another request using the endpoint right now) keeps getting through.
|
|
161
|
-
const kept = await sh(READ_SCRIPT, { P: port, N: name });
|
|
162
|
-
if (kept.exitCode === 0) {
|
|
163
|
-
const record = JSON.parse(kept.stdout);
|
|
164
|
-
const same = JSON.stringify(record.target) === JSON.stringify(target) &&
|
|
165
|
-
record.transport === transport &&
|
|
166
|
-
[...record.origins].sort().join("\n") === [...allowedOrigins].sort().join("\n") &&
|
|
167
|
-
(record.check?.scheme ?? null) === (plan.check?.scheme ?? null);
|
|
168
|
-
if (same)
|
|
169
|
-
return { id, url: `${published.url}/${name}`, credentials: credentialsOf(record.check, published.credentials), allowedOrigins };
|
|
170
|
-
}
|
|
171
|
-
// The secret goes in on stdin, never on a command line other users could read.
|
|
172
|
-
const record = JSON.stringify({ target, transport, check: plan.check, origins: allowedOrigins });
|
|
173
|
-
const written = await sh(WRITE_SCRIPT, { P: port, N: name }, record);
|
|
174
|
-
if (written.exitCode !== 0) {
|
|
175
|
-
throw new Error(`recording the endpoint for ${describe(target)} on ${where} failed (exit ${written.exitCode}): ${written.stderr.trim()}`);
|
|
176
|
-
}
|
|
177
|
-
return { id, url: `${published.url}/${name}`, credentials: published.credentials, allowedOrigins };
|
|
178
|
-
}
|
|
179
|
-
async function closeEndpoint(endpointId) {
|
|
180
|
-
const { port, name } = parseEndpointId(endpointId);
|
|
181
|
-
const result = await sh(REMOVE_SCRIPT, { P: port, N: name });
|
|
182
|
-
if (result.exitCode !== 0 && result.exitCode !== 10) {
|
|
183
|
-
throw new Error(`closing endpoint ${endpointId} on ${where} failed (exit ${result.exitCode}): ${result.stderr.trim()}`);
|
|
184
|
-
}
|
|
185
|
-
if (result.exitCode === 10 && firewall)
|
|
186
|
-
await ufw("delete", "allow", `${port}/tcp`);
|
|
187
|
-
}
|
|
188
|
-
// The proxy reads the record on every request, so the change applies to the next one.
|
|
189
|
-
async function updateEndpoint(endpointId, { allowedOrigins }) {
|
|
190
|
-
const { port, name } = parseEndpointId(endpointId);
|
|
191
|
-
const read = await sh(READ_SCRIPT, { P: port, N: name });
|
|
192
|
-
if (read.exitCode !== 0)
|
|
193
|
-
return false;
|
|
194
|
-
const record = { ...JSON.parse(read.stdout), origins: allowedOrigins };
|
|
195
|
-
const written = await sh(WRITE_SCRIPT, { P: port, N: name }, JSON.stringify(record));
|
|
196
|
-
if (written.exitCode === 44)
|
|
197
|
-
return false; // its proxy's directory went with its last endpoint
|
|
198
|
-
if (written.exitCode !== 0) {
|
|
199
|
-
throw new Error(`updating endpoint ${endpointId} on ${where} failed (exit ${written.exitCode}): ${written.stderr.trim()}`);
|
|
200
|
-
}
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
// Its record says what to serve; the proxy is started again on the port the endpoint's URL names.
|
|
204
|
-
async function ensureEndpoint(endpointId) {
|
|
205
|
-
const { port, name } = parseEndpointId(endpointId);
|
|
206
|
-
const status = await sh(STATUS_SCRIPT, { P: port, N: name });
|
|
207
|
-
if (status.exitCode === 0)
|
|
208
|
-
return true;
|
|
209
|
-
if (status.exitCode === 1)
|
|
210
|
-
return false;
|
|
211
|
-
if (status.exitCode !== 3) {
|
|
212
|
-
throw new Error(`checking endpoint ${endpointId} on ${where} failed (exit ${status.exitCode}): ${status.stderr.trim()}`);
|
|
213
|
-
}
|
|
214
|
-
if ((await start([port, port], [])) !== null)
|
|
215
|
-
return true;
|
|
216
|
-
// Its port went to something else meanwhile: the endpoint can't be served as it was given out.
|
|
217
|
-
await closeEndpoint(endpointId);
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
return { accessibleHost: options.accessibleHost, exposeEndpoint, exposeEndpointAs, closeEndpoint, ensureEndpoint, updateEndpoint };
|
|
221
|
-
}
|
|
222
|
-
/** The credentials a client presents for a recorded check; `none` when the endpoint checks nothing itself. */
|
|
223
|
-
function credentialsOf(check, none) {
|
|
224
|
-
if (!check)
|
|
225
|
-
return none;
|
|
226
|
-
if (check.scheme === "bearer")
|
|
227
|
-
return { scheme: "bearer", token: check.credentials };
|
|
228
|
-
const decoded = atob(check.credentials);
|
|
229
|
-
const colon = decoded.indexOf(":");
|
|
230
|
-
return { scheme: "basic", username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
|
231
|
-
}
|
|
232
|
-
function describe(target) {
|
|
233
|
-
return "port" in target ? `port ${target.port}` : target.unix;
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* The endpoint proxy. Reads which ports it may use as a JSON line on stdin, binds one, then serves
|
|
237
|
-
* the endpoints recorded in <state>/<port>/ (argv[2] is <state>), those left by a proxy that's gone
|
|
238
|
-
* included; prints the port once it has detached. Each request names its endpoint by the first
|
|
239
|
-
* segment of its path, whose record is read afresh, so a closed endpoint is refused at once (and its
|
|
240
|
-
* streams end within a heartbeat). Each connection carries one request, checked (when the endpoint
|
|
241
|
-
* has a check) before anything reaches the target; browsers' CORS preflights from the endpoint's
|
|
242
|
-
* allowed origins are answered without reaching it.
|
|
243
|
-
*
|
|
244
|
-
* "http": the request goes on with the endpoint's segment taken off its path, its body framed here
|
|
245
|
-
* and `Connection: close`, so nothing sent after it (a pipelined request) gets through unchecked;
|
|
246
|
-
* only a 101 answer to an Upgrade request (a WebSocket) leaves the connection open both ways.
|
|
247
|
-
*
|
|
248
|
-
* "sse": `GET /<endpoint>` connects to the target and streams it back: `event: open` with the
|
|
249
|
-
* connection's id, then each line it sends as a `data:` event (a `: ping` comment every 15s), and
|
|
250
|
-
* `event: close` when it closes. `POST /<endpoint>/<connection>` writes the body to that connection
|
|
251
|
-
* and answers 204. Lines are text, up to 16 MiB. A client that goes away takes its connection with it.
|
|
252
|
-
*
|
|
253
|
-
* `GET /__hyb/return?to=<url>` redirects to `url` if its origin is allowed by an endpoint here that a
|
|
254
|
-
* proxy in front checks (exe.dev's login returns there, on this port).
|
|
255
|
-
*/
|
|
256
|
-
const PROXY = String.raw `
|
|
257
|
-
import errno, hmac, json, os, random, re, secrets, socket, sys, threading, urllib.parse
|
|
258
|
-
|
|
259
|
-
config = json.loads(sys.stdin.readline())
|
|
260
|
-
MAX_LINE = 16 * 1024 * 1024
|
|
261
|
-
HEARTBEAT = 15
|
|
262
|
-
PREFLIGHT = b"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: authorization, content-type\r\nAccess-Control-Max-Age: 600\r\n"
|
|
263
|
-
MAX_HEAD = 65536
|
|
264
|
-
HEAD_TIMEOUT = 30
|
|
265
|
-
HOP_BY_HOP = {b"authorization", b"connection", b"keep-alive", b"proxy-connection", b"proxy-authorization"}
|
|
266
|
-
ENDPOINT_NAME = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def listen(port):
|
|
270
|
-
if socket.has_dualstack_ipv6():
|
|
271
|
-
return socket.create_server(("", port), family=socket.AF_INET6, dualstack_ipv6=True, backlog=128)
|
|
272
|
-
return socket.create_server(("", port), backlog=128)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
low, high = config["ports"]
|
|
276
|
-
candidates = [port for port in range(low, high + 1) if port not in config["except"]]
|
|
277
|
-
random.shuffle(candidates)
|
|
278
|
-
server = None
|
|
279
|
-
for port in candidates:
|
|
280
|
-
try:
|
|
281
|
-
server = listen(port)
|
|
282
|
-
break
|
|
283
|
-
except OSError as e:
|
|
284
|
-
if e.errno != errno.EADDRINUSE:
|
|
285
|
-
raise
|
|
286
|
-
if server is None:
|
|
287
|
-
sys.stderr.write("no free port in %d-%d\n" % (low, high))
|
|
288
|
-
sys.exit(1)
|
|
289
|
-
PORT = server.getsockname()[1]
|
|
290
|
-
|
|
291
|
-
# Endpoints recorded by a proxy that's gone (a reboot) are served again, as they were given out;
|
|
292
|
-
# only that proxy's own leftovers (its pid, half-written records) go.
|
|
293
|
-
os.umask(0o077)
|
|
294
|
-
DIR = os.path.join(sys.argv[2], str(PORT))
|
|
295
|
-
os.makedirs(DIR, mode=0o700, exist_ok=True)
|
|
296
|
-
for stale in os.listdir(DIR):
|
|
297
|
-
if stale.startswith("."):
|
|
298
|
-
os.remove(os.path.join(DIR, stale))
|
|
299
|
-
|
|
300
|
-
# Detached; the port is printed once the proxy's pid is recorded, so whoever started it can find it.
|
|
301
|
-
ready_read, ready_write = os.pipe()
|
|
302
|
-
if os.fork():
|
|
303
|
-
os.close(ready_write)
|
|
304
|
-
if os.read(ready_read, 2) != b"ok":
|
|
305
|
-
sys.exit(1)
|
|
306
|
-
sys.stdout.write("%d\n" % PORT)
|
|
307
|
-
sys.stdout.flush()
|
|
308
|
-
os._exit(0)
|
|
309
|
-
os.setsid()
|
|
310
|
-
if os.fork():
|
|
311
|
-
os._exit(0)
|
|
312
|
-
with open(os.path.join(DIR, ".pid"), "w") as pid_file:
|
|
313
|
-
pid_file.write(str(os.getpid()))
|
|
314
|
-
os.write(ready_write, b"ok")
|
|
315
|
-
os.close(ready_write)
|
|
316
|
-
null = os.open(os.devnull, os.O_RDWR)
|
|
317
|
-
for fd in (0, 1, 2):
|
|
318
|
-
os.dup2(null, fd)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def record_path(name):
|
|
322
|
-
return os.path.join(DIR, name + ".json")
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def load(name):
|
|
326
|
-
"""The endpoint recorded under that name, or None."""
|
|
327
|
-
if not ENDPOINT_NAME.match(name):
|
|
328
|
-
return None
|
|
329
|
-
try:
|
|
330
|
-
with open(record_path(name)) as record:
|
|
331
|
-
return json.load(record)
|
|
332
|
-
except (OSError, ValueError):
|
|
333
|
-
return None
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
class Reply(Exception):
|
|
337
|
-
"""Answers the request with an empty body and ends the connection: a refusal, a 204, a redirect."""
|
|
338
|
-
|
|
339
|
-
def __init__(self, status, extra=b""):
|
|
340
|
-
self.status = status
|
|
341
|
-
self.extra = extra
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
class Reader:
|
|
345
|
-
def __init__(self, conn):
|
|
346
|
-
self.conn = conn
|
|
347
|
-
self.buffer = b""
|
|
348
|
-
|
|
349
|
-
def fill(self):
|
|
350
|
-
data = self.conn.recv(65536)
|
|
351
|
-
if not data:
|
|
352
|
-
raise Reply(b"400 Bad Request")
|
|
353
|
-
self.buffer += data
|
|
354
|
-
|
|
355
|
-
def until(self, marker, limit):
|
|
356
|
-
while marker not in self.buffer:
|
|
357
|
-
if len(self.buffer) > limit:
|
|
358
|
-
raise Reply(b"431 Request Header Fields Too Large")
|
|
359
|
-
self.fill()
|
|
360
|
-
found, self.buffer = self.buffer.split(marker, 1)
|
|
361
|
-
return found
|
|
362
|
-
|
|
363
|
-
def exactly(self, size):
|
|
364
|
-
while len(self.buffer) < size:
|
|
365
|
-
self.fill()
|
|
366
|
-
found, self.buffer = self.buffer[:size], self.buffer[size:]
|
|
367
|
-
return found
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def parse(head):
|
|
371
|
-
lines = head.split(b"\r\n")
|
|
372
|
-
if any(c in line for line in lines for c in (b"\r", b"\n", b"\0")):
|
|
373
|
-
raise Reply(b"400 Bad Request")
|
|
374
|
-
method, sep, rest = lines[0].partition(b" ")
|
|
375
|
-
path, sep2, version = rest.partition(b" ")
|
|
376
|
-
if not (sep and sep2 and method and path.startswith(b"/")) or version not in (b"HTTP/1.1", b"HTTP/1.0"):
|
|
377
|
-
raise Reply(b"400 Bad Request")
|
|
378
|
-
headers = []
|
|
379
|
-
for line in lines[1:]:
|
|
380
|
-
name, sep, value = line.partition(b":")
|
|
381
|
-
if not sep or not name or any(c in name for c in b" \t"):
|
|
382
|
-
raise Reply(b"400 Bad Request")
|
|
383
|
-
headers.append((name, value.strip()))
|
|
384
|
-
return method, path, version, headers
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def header(headers, name):
|
|
388
|
-
for n, v in headers:
|
|
389
|
-
if n.lower() == name:
|
|
390
|
-
return v
|
|
391
|
-
return None
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def authorized(headers, check):
|
|
395
|
-
values = [v for n, v in headers if n.lower() == b"authorization"]
|
|
396
|
-
if len(values) != 1:
|
|
397
|
-
return False
|
|
398
|
-
scheme, _, credentials = values[0].partition(b" ")
|
|
399
|
-
return scheme.lower() == check["scheme"].encode() and hmac.compare_digest(credentials.strip(), check["credentials"].encode())
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def challenge(check):
|
|
403
|
-
if check["scheme"] == "basic":
|
|
404
|
-
return b'WWW-Authenticate: Basic realm="hyperbrane", charset="UTF-8"\r\n'
|
|
405
|
-
return b"WWW-Authenticate: Bearer\r\n"
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def cors_headers(origin, endpoint):
|
|
409
|
-
if origin is None or origin.decode("latin-1") not in endpoint["origins"]:
|
|
410
|
-
return b""
|
|
411
|
-
return b"Access-Control-Allow-Origin: " + origin + b"\r\nAccess-Control-Allow-Credentials: true\r\nVary: Origin\r\n"
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def framing(headers):
|
|
415
|
-
lengths = [v for n, v in headers if n.lower() == b"content-length"]
|
|
416
|
-
encodings = [v for n, v in headers if n.lower() == b"transfer-encoding"]
|
|
417
|
-
if encodings:
|
|
418
|
-
if lengths or len(encodings) != 1 or encodings[0].lower() != b"chunked":
|
|
419
|
-
raise Reply(b"400 Bad Request")
|
|
420
|
-
return "chunked"
|
|
421
|
-
if len(lengths) > 1 or (lengths and not lengths[0].isdigit()):
|
|
422
|
-
raise Reply(b"400 Bad Request")
|
|
423
|
-
return int(lengths[0]) if lengths else 0
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
def forward_body(reader, up, body):
|
|
427
|
-
if body == "chunked":
|
|
428
|
-
while True:
|
|
429
|
-
line = reader.until(b"\r\n", 4096)
|
|
430
|
-
size_text = line.split(b";", 1)[0].strip()
|
|
431
|
-
if not size_text or any(c not in b"0123456789abcdefABCDEF" for c in size_text):
|
|
432
|
-
raise Reply(b"400 Bad Request")
|
|
433
|
-
size = int(size_text, 16)
|
|
434
|
-
if size == 0:
|
|
435
|
-
# Trailers are dropped: read up to the blank line that ends them.
|
|
436
|
-
while reader.until(b"\r\n", MAX_HEAD):
|
|
437
|
-
pass
|
|
438
|
-
up.sendall(b"0\r\n\r\n")
|
|
439
|
-
return
|
|
440
|
-
data = reader.exactly(size)
|
|
441
|
-
if reader.exactly(2) != b"\r\n":
|
|
442
|
-
raise Reply(b"400 Bad Request")
|
|
443
|
-
up.sendall(b"%x\r\n" % size + data + b"\r\n")
|
|
444
|
-
remaining = body
|
|
445
|
-
while remaining:
|
|
446
|
-
data = reader.exactly(min(remaining, 65536))
|
|
447
|
-
up.sendall(data)
|
|
448
|
-
remaining -= len(data)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def tune(sock):
|
|
452
|
-
# Streams (an agent's, as SSE) go out event by event, and a peer that vanished without closing
|
|
453
|
-
# is noticed within a couple of minutes instead of holding its connection forever.
|
|
454
|
-
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
455
|
-
if sock.family == socket.AF_UNIX:
|
|
456
|
-
return
|
|
457
|
-
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
458
|
-
for name, value in (("TCP_KEEPIDLE", 60), ("TCP_KEEPINTVL", 15), ("TCP_KEEPCNT", 4)):
|
|
459
|
-
if hasattr(socket, name):
|
|
460
|
-
sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, name), value)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def connect_target(target):
|
|
464
|
-
if "unix" in target:
|
|
465
|
-
up = socket.socket(socket.AF_UNIX)
|
|
466
|
-
up.connect(target["unix"])
|
|
467
|
-
else:
|
|
468
|
-
up = socket.create_connection(("localhost", target["port"]))
|
|
469
|
-
tune(up)
|
|
470
|
-
return up
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def hang_up(sock):
|
|
474
|
-
# shutdown, not just close: it also wakes a thread blocked reading the socket.
|
|
475
|
-
if sock is None:
|
|
476
|
-
return
|
|
477
|
-
try:
|
|
478
|
-
sock.shutdown(socket.SHUT_RDWR)
|
|
479
|
-
except OSError:
|
|
480
|
-
pass
|
|
481
|
-
try:
|
|
482
|
-
sock.close()
|
|
483
|
-
except OSError:
|
|
484
|
-
pass
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
def pump_response(up, conn, decided, state):
|
|
488
|
-
"""Copies the target's side to the client until either end goes away, then closes the client's."""
|
|
489
|
-
first = b""
|
|
490
|
-
try:
|
|
491
|
-
while True:
|
|
492
|
-
data = up.recv(65536)
|
|
493
|
-
if not data:
|
|
494
|
-
break
|
|
495
|
-
if not decided.is_set():
|
|
496
|
-
first += data
|
|
497
|
-
if b"\r\n" in first or len(first) > 64:
|
|
498
|
-
state["upgraded"] = first.startswith((b"HTTP/1.1 101", b"HTTP/1.0 101"))
|
|
499
|
-
decided.set()
|
|
500
|
-
conn.sendall(data)
|
|
501
|
-
except OSError:
|
|
502
|
-
pass
|
|
503
|
-
finally:
|
|
504
|
-
decided.set()
|
|
505
|
-
hang_up(conn)
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
class Collect:
|
|
509
|
-
"""forward_body's destination for a body kept here (a POST to an "sse" connection)."""
|
|
510
|
-
|
|
511
|
-
def __init__(self):
|
|
512
|
-
self.data = b""
|
|
513
|
-
|
|
514
|
-
def sendall(self, data):
|
|
515
|
-
self.data += data
|
|
516
|
-
if len(self.data) > MAX_LINE:
|
|
517
|
-
raise Reply(b"413 Content Too Large")
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
class Connection:
|
|
521
|
-
"""An "sse" connection to an endpoint's target; POSTs write to it, one at a time."""
|
|
522
|
-
|
|
523
|
-
def __init__(self, endpoint_name, sock):
|
|
524
|
-
self.endpoint_name = endpoint_name
|
|
525
|
-
self.sock = sock
|
|
526
|
-
self.lock = threading.Lock()
|
|
527
|
-
|
|
528
|
-
def write(self, data):
|
|
529
|
-
with self.lock:
|
|
530
|
-
self.sock.sendall(data)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
CONNECTIONS = {}
|
|
534
|
-
CONNECTIONS_LOCK = threading.Lock()
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def serve_sse(conn, reader, method, rest, body, cors, name, endpoint):
|
|
538
|
-
if rest in (b"", b"/"):
|
|
539
|
-
if method != b"GET":
|
|
540
|
-
raise Reply(b"405 Method Not Allowed", b"Allow: GET\r\n" + cors)
|
|
541
|
-
return stream_target(conn, cors, name, endpoint)
|
|
542
|
-
if method != b"POST" or rest.count(b"/") != 1:
|
|
543
|
-
raise Reply(b"404 Not Found", cors)
|
|
544
|
-
with CONNECTIONS_LOCK:
|
|
545
|
-
target = CONNECTIONS.get(rest[1:].decode("ascii", "replace"))
|
|
546
|
-
if target is None or target.endpoint_name != name:
|
|
547
|
-
raise Reply(b"404 Not Found", cors)
|
|
548
|
-
collected = Collect()
|
|
549
|
-
forward_body(reader, collected, body)
|
|
550
|
-
try:
|
|
551
|
-
target.write(collected.data)
|
|
552
|
-
except OSError:
|
|
553
|
-
raise Reply(b"404 Not Found", cors)
|
|
554
|
-
raise Reply(b"204 No Content", cors)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def watch_client(conn, up):
|
|
558
|
-
# Anything the client sends after its request is dropped; its leaving ends the connection.
|
|
559
|
-
try:
|
|
560
|
-
while conn.recv(65536):
|
|
561
|
-
pass
|
|
562
|
-
except OSError:
|
|
563
|
-
pass
|
|
564
|
-
hang_up(up)
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def heartbeat(send, stop, name, up):
|
|
568
|
-
while not stop.wait(HEARTBEAT):
|
|
569
|
-
# A closed endpoint takes its streams with it.
|
|
570
|
-
if not os.path.exists(record_path(name)):
|
|
571
|
-
hang_up(up)
|
|
572
|
-
return
|
|
573
|
-
try:
|
|
574
|
-
send(b": ping\n\n")
|
|
575
|
-
except OSError:
|
|
576
|
-
return
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def stream_target(conn, cors, name, endpoint):
|
|
580
|
-
try:
|
|
581
|
-
up = connect_target(endpoint["target"])
|
|
582
|
-
except OSError:
|
|
583
|
-
raise Reply(b"502 Bad Gateway", cors)
|
|
584
|
-
connection_id = secrets.token_hex(16)
|
|
585
|
-
lock = threading.Lock()
|
|
586
|
-
|
|
587
|
-
def send(data):
|
|
588
|
-
with lock:
|
|
589
|
-
conn.sendall(data)
|
|
590
|
-
|
|
591
|
-
stop = threading.Event()
|
|
592
|
-
with CONNECTIONS_LOCK:
|
|
593
|
-
CONNECTIONS[connection_id] = Connection(name, up)
|
|
594
|
-
try:
|
|
595
|
-
conn.settimeout(None)
|
|
596
|
-
send(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache, no-transform\r\nX-Accel-Buffering: no\r\nConnection: close\r\n" + cors + b"\r\n")
|
|
597
|
-
send(b"event: open\ndata: " + json.dumps({"connection": connection_id}).encode() + b"\n\n")
|
|
598
|
-
threading.Thread(target=heartbeat, args=(send, stop, name, up), daemon=True).start()
|
|
599
|
-
threading.Thread(target=watch_client, args=(conn, up), daemon=True).start()
|
|
600
|
-
pending = b""
|
|
601
|
-
while True:
|
|
602
|
-
data = up.recv(65536)
|
|
603
|
-
if not data:
|
|
604
|
-
break
|
|
605
|
-
*lines, pending = (pending + data).split(b"\n")
|
|
606
|
-
for line in lines:
|
|
607
|
-
if line.endswith(b"\r"):
|
|
608
|
-
line = line[:-1]
|
|
609
|
-
# A bare CR would end the event early on the client: the line can't go as SSE.
|
|
610
|
-
if b"\r" in line:
|
|
611
|
-
return
|
|
612
|
-
send(b"data: " + line + b"\n\n")
|
|
613
|
-
if len(pending) > MAX_LINE:
|
|
614
|
-
return
|
|
615
|
-
send(b"event: close\ndata: {}\n\n")
|
|
616
|
-
except OSError:
|
|
617
|
-
pass
|
|
618
|
-
finally:
|
|
619
|
-
stop.set()
|
|
620
|
-
with CONNECTIONS_LOCK:
|
|
621
|
-
CONNECTIONS.pop(connection_id, None)
|
|
622
|
-
hang_up(up)
|
|
623
|
-
hang_up(conn)
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
def return_to(method, query):
|
|
627
|
-
if method != b"GET":
|
|
628
|
-
raise Reply(b"405 Method Not Allowed", b"Allow: GET\r\n")
|
|
629
|
-
to = urllib.parse.parse_qs(query.decode("latin-1")).get("to", [""])[0]
|
|
630
|
-
parts = urllib.parse.urlsplit(to)
|
|
631
|
-
if parts.scheme not in ("http", "https") or not parts.netloc or any(ord(c) < 0x21 or ord(c) > 0x7E for c in to):
|
|
632
|
-
raise Reply(b"400 Bad Request")
|
|
633
|
-
origin = parts.scheme + "://" + parts.netloc
|
|
634
|
-
for entry in os.listdir(DIR):
|
|
635
|
-
endpoint = load(entry[:-5]) if entry.endswith(".json") else None
|
|
636
|
-
if endpoint is not None and endpoint["check"] is None and origin in endpoint["origins"]:
|
|
637
|
-
raise Reply(b"302 Found", b"Location: " + to.encode() + b"\r\nCache-Control: no-store\r\n")
|
|
638
|
-
raise Reply(b"400 Bad Request")
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def handle(conn):
|
|
642
|
-
up = None
|
|
643
|
-
try:
|
|
644
|
-
conn.settimeout(HEAD_TIMEOUT)
|
|
645
|
-
reader = Reader(conn)
|
|
646
|
-
method, path, version, headers = parse(reader.until(b"\r\n\r\n", MAX_HEAD))
|
|
647
|
-
route, _, query = path.partition(b"?")
|
|
648
|
-
if route == b"/__hyb/return":
|
|
649
|
-
return_to(method, query)
|
|
650
|
-
name_bytes, slash, rest = route[1:].partition(b"/")
|
|
651
|
-
name = name_bytes.decode("ascii", "replace")
|
|
652
|
-
endpoint = load(name)
|
|
653
|
-
if endpoint is None:
|
|
654
|
-
raise Reply(b"404 Not Found")
|
|
655
|
-
cors = cors_headers(header(headers, b"origin"), endpoint)
|
|
656
|
-
# A browser's preflight carries no credentials; it is answered here and never reaches the target.
|
|
657
|
-
if method == b"OPTIONS" and header(headers, b"access-control-request-method") is not None:
|
|
658
|
-
raise Reply(b"204 No Content", cors + PREFLIGHT) if cors else Reply(b"403 Forbidden")
|
|
659
|
-
check = endpoint["check"]
|
|
660
|
-
if check and not authorized(headers, check):
|
|
661
|
-
raise Reply(b"401 Unauthorized", challenge(check) + cors)
|
|
662
|
-
body = framing(headers)
|
|
663
|
-
if endpoint["transport"] == "sse":
|
|
664
|
-
return serve_sse(conn, reader, method, slash + rest, body, cors, name, endpoint)
|
|
665
|
-
upgrade = any(n.lower() == b"upgrade" for n, _ in headers)
|
|
666
|
-
try:
|
|
667
|
-
up = connect_target(endpoint["target"])
|
|
668
|
-
except OSError:
|
|
669
|
-
raise Reply(b"502 Bad Gateway", cors)
|
|
670
|
-
target_path = slash + rest if slash else b"/"
|
|
671
|
-
if query:
|
|
672
|
-
target_path += b"?" + query
|
|
673
|
-
kept = [n + b": " + v for n, v in headers if n.lower() not in HOP_BY_HOP]
|
|
674
|
-
kept.append(b"Connection: Upgrade" if upgrade else b"Connection: close")
|
|
675
|
-
up.sendall(method + b" " + target_path + b" " + version + b"\r\n" + b"\r\n".join(kept) + b"\r\n\r\n")
|
|
676
|
-
decided = threading.Event()
|
|
677
|
-
state = {"upgraded": False}
|
|
678
|
-
# The request is done once the response is: the client side closes with it.
|
|
679
|
-
pump = threading.Thread(target=pump_response, args=(up, conn, decided, state), daemon=True)
|
|
680
|
-
pump.start()
|
|
681
|
-
forward_body(reader, up, body)
|
|
682
|
-
conn.settimeout(None)
|
|
683
|
-
if upgrade:
|
|
684
|
-
decided.wait()
|
|
685
|
-
if upgrade and state["upgraded"]:
|
|
686
|
-
if reader.buffer:
|
|
687
|
-
up.sendall(reader.buffer)
|
|
688
|
-
while True:
|
|
689
|
-
data = conn.recv(65536)
|
|
690
|
-
if not data:
|
|
691
|
-
break
|
|
692
|
-
up.sendall(data)
|
|
693
|
-
else:
|
|
694
|
-
# Anything more from the client is dropped: it would reach the target unchecked.
|
|
695
|
-
while conn.recv(65536):
|
|
696
|
-
pass
|
|
697
|
-
# The client is gone (or the response ended and closed it): a stream it was reading stops too.
|
|
698
|
-
hang_up(up)
|
|
699
|
-
pump.join()
|
|
700
|
-
except Reply as reply:
|
|
701
|
-
try:
|
|
702
|
-
conn.sendall(b"HTTP/1.1 " + reply.status + b"\r\nContent-Length: 0\r\nConnection: close\r\n" + reply.extra + b"\r\n")
|
|
703
|
-
except OSError:
|
|
704
|
-
pass
|
|
705
|
-
except (OSError, ValueError):
|
|
706
|
-
pass
|
|
707
|
-
finally:
|
|
708
|
-
hang_up(up)
|
|
709
|
-
hang_up(conn)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
while True:
|
|
713
|
-
conn, _ = server.accept()
|
|
714
|
-
tune(conn)
|
|
715
|
-
threading.Thread(target=handle, args=(conn,), daemon=True).start()
|
|
716
|
-
`;
|