hyperbrane 0.1.1 → 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 +81 -11
- package/dist/agent/agentClient.d.ts +5 -0
- package/dist/agent/agentClient.js +4 -0
- package/dist/agent/agentService.d.ts +9 -30
- package/dist/agent/agentService.js +62 -293
- package/dist/agent/agentStorage.d.ts +1 -20
- package/dist/agent/agentStorage.js +0 -30
- package/dist/agent/api.d.ts +89 -133
- package/dist/agent/api.js +4 -4
- 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 -30
- 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 +27 -18
- package/dist/agent/session/worker/protocol.js +32 -16
- package/dist/agent/session/worker/workerParams.d.ts +0 -5
- package/dist/agent/session/worker/workerParams.js +15 -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 +44 -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 -21
- package/dist/dimension/service.js +60 -224
- 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 -8
- package/dist/index.js +5 -16
- 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 -35
- package/dist/portal-gateway/gatewayService.js +79 -253
- package/dist/portal-gateway/image.d.ts +1 -2
- package/dist/portal-gateway/image.js +1 -3
- package/dist/portal-gateway/imageFiles.generated.js +3 -3
- 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 -317
- 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 +12 -82
- package/dist/portal-gateway/upstream.js +0 -1
- package/dist/sandbox/api.d.ts +121 -178
- 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 -15
- package/dist/agent/session/workerSseConnection.js +0 -26
- 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 -33
- 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 -701
- 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,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/embed-image-files.ts from portal-gateway/image/ — do not edit; run `bun run embed:images`.
|
|
2
2
|
export const IMAGE_FILES = {
|
|
3
3
|
"Dockerfile": "FROM debian:13-slim\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends bash sudo git jq ca-certificates iproute2 nftables python3 python3-venv \\\n && python3 -m venv /opt/mitmproxy \\\n && /opt/mitmproxy/bin/pip install --no-cache-dir mitmproxy \\\n && ln -s /opt/mitmproxy/bin/mitmdump /usr/local/bin/mitmdump \\\n && mitmdump --version \\\n && rm -rf /var/lib/apt/lists/*\nCOPY entry.sh /usr/local/bin/hyb-gateway\nCOPY wire-portal.sh /usr/local/bin/hyb-wire-portal\nCOPY traffic_rules.py /opt/hyperbrane/traffic_rules.py\nCOPY upstreams.py /opt/hyperbrane/upstreams.py\nRUN chmod 0755 /usr/local/bin/hyb-gateway /usr/local/bin/hyb-wire-portal\nCMD [\"hyb-gateway\"]\n",
|
|
4
|
-
"entry.sh": "#!/bin/sh\
|
|
5
|
-
"wire-portal.sh": "#!/bin/sh\
|
|
4
|
+
"entry.sh": "#!/bin/sh\nset -eu\n\nSTATE=/state\nPROXY_PORT=8080\nDNS_PORT=5353\n\nnft -f - <<EOF\ntable ip hyperbrane\ndelete table ip hyperbrane\ntable ip hyperbrane {\n chain prerouting {\n type nat hook prerouting priority dstnat; policy accept;\n iifname \"hvg*\" meta l4proto tcp redirect to :$PROXY_PORT\n iifname \"hvg*\" udp dport 53 redirect to :$DNS_PORT\n }\n chain input {\n type filter hook input priority filter; policy accept;\n iifname \"hvg*\" tcp dport $PROXY_PORT accept\n iifname \"hvg*\" udp dport $DNS_PORT accept\n iifname \"hvg*\" drop\n }\n chain forward {\n type filter hook forward priority filter; policy accept;\n iifname \"hvg*\" drop\n }\n}\nEOF\n\nrules=\"$(nft list table ip hyperbrane)\"\ncase \"$rules\" in\n *\"redirect to :$PROXY_PORT\"*\"redirect to :$DNS_PORT\"*) ;;\n *) echo \"gateway: redirect rules are not installed\" >&2; exit 1 ;;\nesac\n\nowner_uid=\"$(stat -c %u \"$STATE\")\"\nowner_gid=\"$(stat -c %g \"$STATE\")\"\nexport HOME=/tmp\nexec setpriv --reuid=\"$owner_uid\" --regid=\"$owner_gid\" --clear-groups -- \\\n mitmdump \\\n --mode \"transparent@$PROXY_PORT\" \\\n --mode \"dns@$DNS_PORT\" \\\n --set \"confdir=$STATE/mitmproxy\" \\\n --set connection_strategy=eager \\\n -s /opt/hyperbrane/traffic_rules.py \\\n -s /opt/hyperbrane/upstreams.py\n",
|
|
5
|
+
"wire-portal.sh": "#!/bin/sh\nset -eu\n\nexec 9>\"/hostrun/hyperbrane-portal-$PORTAL.lock\"\nflock -w 30 9 || { echo \"timed out waiting for the wiring lock of $PORTAL\" >&2; exit 1; }\n\nmkdir -p /var/run/netns\nln -sf \"/proc/$DIMENSION_PID/ns/net\" /var/run/netns/dimension\nln -sf \"/proc/$GATEWAY_PID/ns/net\" /var/run/netns/gateway\ntrap 'rm -f /var/run/netns/dimension /var/run/netns/gateway' EXIT\n\nwired() {\n ip -n dimension addr show dev \"$DIMENSION_IF\" 2>/dev/null | grep -q \"inet $DIMENSION_IP/30\" \\\n && ip -n dimension route show default 2>/dev/null | grep -q \"via $GATEWAY_IP dev $DIMENSION_IF metric $ROUTE_METRIC\" \\\n && ip -n gateway addr show dev \"$GATEWAY_IF\" 2>/dev/null | grep -q \"inet $GATEWAY_IP/30\"\n}\nwired && exit 0\n\nip -n dimension link del \"$DIMENSION_IF\" 2>/dev/null || true\nip -n gateway link del \"$GATEWAY_IF\" 2>/dev/null || true\nip link del \"$DIMENSION_IF\" 2>/dev/null || true\nip link del \"$GATEWAY_IF\" 2>/dev/null || true\n\nip link add \"$DIMENSION_IF\" type veth peer name \"$GATEWAY_IF\"\nip link set \"$DIMENSION_IF\" netns dimension\nip link set \"$GATEWAY_IF\" netns gateway\nip -n dimension addr replace \"$DIMENSION_IP/30\" dev \"$DIMENSION_IF\"\nip -n dimension link set \"$DIMENSION_IF\" up\nip -n gateway addr replace \"$GATEWAY_IP/30\" dev \"$GATEWAY_IF\"\nip -n gateway link set \"$GATEWAY_IF\" up\nip -n dimension route replace default via \"$GATEWAY_IP\" dev \"$DIMENSION_IF\" metric \"$ROUTE_METRIC\"\n",
|
|
6
6
|
"traffic_rules.py": "# mitmproxy addon applying the gateway's traffic rules to everything arriving through a portal.\n#\n# Rules are the JSON files the gateway service writes to /state/traffic-rules, re-read every time, so\n# a change takes effect without a reload. A rule's urlMask is a glob, and its scheme says what it\n# applies to:\n# tcp://<address>:<port> every TCP connection, by its destination, before it is made upstream.\n# address is \"*\", an IPv4 address or a CIDR; port is \"*\" or a number.\n# Hostnames aren't known at that point, so rules don't use them.\n# anything else every HTTP(S) request, matched as a glob against host:port/path?query.\n# The scheme doesn't matter (http, https or none match the same), and a\n# mask without a port matches any port, one without a path any path:\n# \"github.com\" is the whole host, \"github.com:80/*\" only its plain HTTP.\n# Rules are checked in name order and the first match decides:\n# BLOCK refuse: a 403 for a request, a closed connection for TCP\n# DELAY hold for delayMs, then carry on\n# ASK hold until the service records an answer for the rule in /state/traffic-responses (only\n# answers given after the hold began count); DENY refuses, ALLOW carries on\n\nimport asyncio\nimport fnmatch\nimport ipaddress\nimport json\nimport os\nimport time\n\nfrom mitmproxy import http\nfrom mitmproxy.proxy import server_hooks\n\nSTATE = \"/state\"\nRULES = os.path.join(STATE, \"traffic-rules\")\nRESPONSES = os.path.join(STATE, \"traffic-responses\")\nASK_POLL_SECONDS = 0.2\nTCP_SCHEME = \"tcp://\"\n\n\ndef _load(path):\n try:\n with open(path) as f:\n return json.load(f)\n except (OSError, ValueError):\n return None\n\n\ndef _rules(tcp):\n try:\n files = sorted(f for f in os.listdir(RULES) if f.endswith(\".json\"))\n except OSError:\n return []\n rules = (_load(os.path.join(RULES, f)) for f in files)\n return [r for r in rules if r and r.get(\"urlMask\", \"\").startswith(TCP_SCHEME) == tcp]\n\n\ndef _request_target(request):\n \"\"\"host:port/path?query: the port always written out, the scheme left out.\"\"\"\n return f\"{request.pretty_host.lower()}:{request.port}{request.path}\"\n\n\ndef _url_mask(mask):\n \"\"\"The mask in _request_target's form: no scheme, and \"any\" for a port or path it leaves out.\"\"\"\n if \"://\" in mask:\n mask = mask.split(\"://\", 1)[1]\n host, slash, path = mask.partition(\"/\")\n if \":\" not in host:\n host += \":*\"\n return f\"{host.lower()}/{path if slash else '*'}\"\n\n\ndef _matching_url_rule(request):\n target = _request_target(request)\n for rule in _rules(tcp=False):\n if fnmatch.fnmatchcase(target, _url_mask(rule[\"urlMask\"])):\n return rule\n return None\n\n\ndef _tcp_mask_matches(mask, ip, port):\n address, _, mask_port = mask[len(TCP_SCHEME):].rpartition(\":\")\n if mask_port != \"*\" and mask_port != str(port):\n return False\n if address == \"*\":\n return True\n try:\n return ipaddress.ip_address(ip) in ipaddress.ip_network(address, strict=False)\n except ValueError:\n return False\n\n\ndef _matching_tcp_rule(ip, port):\n for rule in _rules(tcp=True):\n if _tcp_mask_matches(rule[\"urlMask\"], ip, port):\n return rule\n return None\n\n\nasync def _await_answer(name):\n \"\"\"The ALLOW/DENY given for the rule after now, or None once the rule is no longer an ASK.\"\"\"\n asked_at = time.time() * 1000\n while True:\n answer = _load(os.path.join(RESPONSES, f\"{name}.json\"))\n if answer and answer.get(\"at\", 0) >= asked_at:\n return answer.get(\"action\")\n rule = _load(os.path.join(RULES, f\"{name}.json\"))\n if not rule or rule.get(\"action\") != \"ASK\":\n return None\n # Never time.sleep: this runs on mitmproxy's event loop, shared by every connection.\n await asyncio.sleep(ASK_POLL_SECONDS)\n\n\nasync def _verdict(rule):\n \"\"\"Applies the rule's hold, if any; returns the refusal message, or None to carry on.\"\"\"\n action = rule.get(\"action\")\n if action == \"BLOCK\":\n return f\"blocked by gateway rule {rule['name']}\"\n if action == \"DELAY\":\n await asyncio.sleep(max(0, rule.get(\"delayMs\", 0)) / 1000)\n elif action == \"ASK\" and await _await_answer(rule[\"name\"]) == \"DENY\":\n return f\"denied by gateway rule {rule['name']}\"\n return None\n\n\nasync def request(flow: http.HTTPFlow):\n rule = _matching_url_rule(flow.request)\n refusal = rule and await _verdict(rule)\n if refusal:\n flow.response = http.Response.make(403, f\"{refusal}\\n\", {\"content-type\": \"text/plain\"})\n\n\nasync def server_connect(data: server_hooks.ServerConnectionHookData):\n if not data.server.address:\n return\n rule = _matching_tcp_rule(data.server.address[0], data.server.address[1])\n refusal = rule and await _verdict(rule)\n if refusal:\n data.server.error = refusal\n\n",
|
|
7
|
-
"upstreams.py": "# mitmproxy addon applying the gateway's upstreams (see portal-gateway/upstream.ts) to traffic from\n# portals. Definitions are the JSON files the gateway service writes to /state/upstreams; this addon\n# keeps each upstream's OAuth credential in /state/credentials/<upstream>.oauth.json and reads its\n# API keys from /state/credentials/<upstream>.api-keys.json. Everything is re-read per request, so a\n# change takes effect without a reload.\n#\n# Clients sign in from dimensions, through the proxy:\n# token endpoint without a usable credential, requests go through and the response is kept as\n# the credential, reaching the dimension with fakes in place of the tokens. With\n# one, the gateway answers from it: a sign-in (whatever code it carries) and a\n# refresh made with a fake refresh token. A client's refresh goes upstream: the\n# gateway replays it (encoding, fields, User-Agent) with the real refresh token,\n# one at a time, and refreshes arriving together share its result. Every client\n# holds the same fakes, so they all get the new tokens. A sign-in is answered from\n# the credential, refreshed first if expired by replaying the last client refresh.\n# An expired credential it can't refresh (no client refresh to replay yet, or\n# upstream refused) counts as none: a sign-in goes to the upstream again.\n# answers other sign-in steps (a device flow's poll) answered as the definition says, once\n# there is a credential\n# other requests a fake in the mode's `present` location is replaced by the real secret\n\nimport asyncio\nimport http.client as http_client\nimport json\nimport os\nimport secrets\nimport string\nimport time\nimport urllib.parse\n\nfrom mitmproxy import http\n\nSTATE = \"/state\"\nUPSTREAMS = os.path.join(STATE, \"upstreams\")\nCREDENTIALS = os.path.join(STATE, \"credentials\")\n# A token this close to expiry counts as expired.\nREFRESH_MARGIN_MS = 60_000\n# Refreshes arriving within this long of the last one get its tokens instead of going upstream again.\nREFRESH_SHARED_MS = 5_000\nFAKE_ALPHABET = string.ascii_letters + string.digits + \"-_\"\nTOKEN_FIELDS = ((\"access\", \"access_token\"), (\"refresh\", \"refresh_token\"), (\"id\", \"id_token\"))\n\n# One refresh per upstream at a time: concurrent ones would each spend the rotating refresh token.\n_refresh_locks: dict[str, asyncio.Lock] = {}\n\n\ndef _now_ms():\n return int(time.time() * 1000)\n\n\ndef _load(path):\n try:\n with open(path) as f:\n return json.load(f)\n except (OSError, ValueError):\n return None\n\n\ndef _save(path, data):\n tmp = f\"{path}.{secrets.token_hex(4)}.tmp\"\n with open(tmp, \"w\") as f:\n json.dump(data, f, indent=2)\n os.replace(tmp, path)\n\n\ndef _definitions():\n try:\n files = sorted(f for f in os.listdir(UPSTREAMS) if f.endswith(\".json\"))\n except OSError:\n return []\n definitions = (_load(os.path.join(UPSTREAMS, f)) for f in files)\n return [d for d in definitions if d and d.get(\"name\")]\n\n\ndef _oauth_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.oauth.json\")\n\n\ndef _keys_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.api-keys.json\")\n\n\ndef _base(url):\n return url.split(\"?\", 1)[0].split(\"#\", 1)[0]\n\n\ndef _hosts(mode):\n return {h.lower() for h in mode.get(\"hosts\", [])}\n\n\n# --- fakes -----------------------------------------------------------------------------------------\n\n\ndef _random(length):\n return \"\".join(secrets.choice(FAKE_ALPHABET) for _ in range(length))\n\n\ndef _issue_fake(shape, real):\n if shape.get(\"jwt\"):\n signature = real.rsplit(\".\", 1)[-1] if real.count(\".\") == 2 else \"\"\n return {\"jwtSignature\": _random(max(len(signature), 43))}\n prefix = shape.get(\"prefix\", \"\")\n return {\"value\": prefix + _random(max(len(real) - len(prefix), 16))}\n\n\ndef _fake_value(stored, real):\n if \"jwtSignature\" in stored:\n header_and_claims = real.rsplit(\".\", 1)[0] if real.count(\".\") == 2 else real\n return f\"{header_and_claims}.{stored['jwtSignature']}\"\n return stored[\"value\"]\n\n\ndef _is_fake(stored, candidate):\n if not stored or not candidate:\n return False\n if \"jwtSignature\" in stored:\n return candidate.count(\".\") == 2 and candidate.rsplit(\".\", 1)[1] == stored[\"jwtSignature\"]\n return candidate == stored[\"value\"]\n\n\n# --- presenting secrets ----------------------------------------------------------------------------\n\n\ndef _presented(request, present):\n if \"query\" in present:\n return request.query.get(present[\"query\"])\n value = request.headers.get(present[\"header\"])\n scheme = present.get(\"scheme\")\n if value and scheme:\n prefix = f\"{scheme} \"\n return value[len(prefix):] if value.lower().startswith(prefix.lower()) else None\n return value\n\n\ndef _present(request, present, secret):\n if \"query\" in present:\n request.query[present[\"query\"]] = secret\n elif present.get(\"scheme\"):\n request.headers[present[\"header\"]] = f\"{present['scheme']} {secret}\"\n else:\n request.headers[present[\"header\"]] = secret\n\n\ndef _force(request, force):\n if not force:\n return\n if force.get(\"target\"):\n target = urllib.parse.urlsplit(force[\"target\"])\n request.scheme = target.scheme or request.scheme\n request.host = target.hostname or request.host\n request.port = target.port or (443 if request.scheme == \"https\" else 80)\n if force.get(\"path\"):\n request.path = force[\"path\"].rstrip(\"/\") + request.path\n for key, value in force.get(\"query\", {}).items():\n request.query[key] = value\n for key, value in force.get(\"headers\", {}).items():\n request.headers[key] = value\n\n\ndef _swap_oauth(request, name, mode):\n credential = _load(_oauth_path(name))\n presented = _presented(request, mode[\"present\"])\n if credential and _is_fake(credential[\"fakes\"].get(\"access\"), presented):\n _present(request, mode[\"present\"], credential[\"access\"])\n return True\n return False\n\n\ndef _swap_api_key(request, name, mode):\n stored = _load(_keys_path(name)) or {}\n presented = _presented(request, mode[\"present\"])\n for key in stored.get(\"keys\", {}).values():\n if presented and presented == key[\"fake\"]:\n _present(request, mode[\"present\"], key[\"key\"])\n return True\n return False\n\n\n# --- the token endpoint ----------------------------------------------------------------------------\n\n\ndef _parse_form_or_json(text, content_type):\n if not text:\n return {}\n if \"json\" in (content_type or \"\"):\n try:\n value = json.loads(text)\n return value if isinstance(value, dict) else {}\n except ValueError:\n return {}\n return dict(urllib.parse.parse_qsl(text))\n\n\ndef _refresh_request(flow, fields):\n \"\"\"How a client refreshes, to replay with the real refresh token: everything but that token.\"\"\"\n return {\n \"contentType\": flow.request.headers.get(\"content-type\", \"\"),\n \"userAgent\": flow.request.headers.get(\"user-agent\"),\n \"fields\": {k: v for k, v in fields.items() if k != \"refresh_token\"},\n }\n\n\ndef _store_tokens(name, mode, body, refresh_request):\n \"\"\"Keeps a token response as the upstream's credential and returns it.\"\"\"\n path = _oauth_path(name)\n credential = dict(_load(path) or {})\n if refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n now = _now_ms()\n for key, field in TOKEN_FIELDS:\n if body.get(field):\n credential[key] = body[field]\n if \"expires_in\" in body:\n credential[\"expiresAt\"] = now + int(body[\"expires_in\"]) * 1000\n if \"refresh_token_expires_in\" in body:\n credential[\"refreshExpiresAt\"] = now + int(body[\"refresh_token_expires_in\"]) * 1000\n if body.get(\"scope\"):\n credential[\"scopes\"] = body[\"scope\"].split()\n credential[\"lastRefresh\"] = now\n fakes = dict(credential.get(\"fakes\") or {})\n for key, _ in TOKEN_FIELDS:\n shape = mode[\"fakes\"].get(key)\n if shape and credential.get(key) and key not in fakes:\n fakes[key] = _issue_fake(shape, credential[key])\n credential[\"fakes\"] = fakes\n stripped = set((mode.get(\"strip\") or {}).get(\"fields\", []))\n credential[\"response\"] = {k: v for k, v in body.items() if k not in stripped}\n _save(path, credential)\n return credential\n\n\ndef _client_body(mode, credential):\n \"\"\"The token response the dimension gets: the last real one, with fakes for every token.\"\"\"\n body = dict(credential.get(\"response\") or {})\n fakes = credential[\"fakes\"]\n for key, field in TOKEN_FIELDS:\n if field not in body:\n continue\n if key in fakes and credential.get(key):\n body[field] = _fake_value(fakes[key], credential[key])\n else:\n del body[field] # a token with no fake shape never reaches a dimension\n now = _now_ms()\n if credential.get(\"expiresAt\") and \"expires_in\" in body:\n body[\"expires_in\"] = max(0, (credential[\"expiresAt\"] - now) // 1000)\n if credential.get(\"refreshExpiresAt\") and \"refresh_token_expires_in\" in body:\n body[\"refresh_token_expires_in\"] = max(0, (credential[\"refreshExpiresAt\"] - now) // 1000)\n return body\n\n\ndef _refresh_upstream(mode, credential):\n \"\"\"Replays the client's refresh request with the real refresh token; returns (status, parsed body\n or raw bytes). http.client, unlike urllib, adds no User-Agent of its own: a client that sent none\n stays that way.\"\"\"\n template = credential[\"refreshRequest\"]\n fields = dict(template[\"fields\"], refresh_token=credential[\"refresh\"])\n content_type = template[\"contentType\"] or \"application/x-www-form-urlencoded\"\n data = json.dumps(fields) if \"json\" in content_type else urllib.parse.urlencode(fields)\n headers = {\"content-type\": content_type, \"accept\": \"application/json\"}\n if template.get(\"userAgent\"):\n headers[\"user-agent\"] = template[\"userAgent\"]\n url = urllib.parse.urlsplit(mode[\"tokenUrl\"])\n connection_type = http_client.HTTPSConnection if url.scheme == \"https\" else http_client.HTTPConnection\n connection = connection_type(url.hostname, url.port, timeout=30)\n try:\n connection.request(\"POST\", url.path + (f\"?{url.query}\" if url.query else \"\"), body=data.encode(), headers=headers)\n response = connection.getresponse()\n raw = response.read()\n try:\n return response.status, json.loads(raw)\n except ValueError:\n return response.status, raw\n finally:\n connection.close()\n\n\nasync def _answer_from_credential(flow, name, mode, refresh_request):\n \"\"\"Answers a sign-in, or a refresh made with a fake (`refresh_request`), with the credential's\n fakes. A refresh is replayed upstream first unless another one just was; a sign-in only if the\n credential has expired. Returns False, answering nothing, when a sign-in can't be given valid\n tokens: expired, and no client refresh to replay yet or upstream refused it. A client's refresh\n gets upstream's refusal instead.\"\"\"\n lock = _refresh_locks.setdefault(name, asyncio.Lock())\n async with lock:\n credential = _load(_oauth_path(name))\n if refresh_request and credential.get(\"refreshRequest\") != refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n _save(_oauth_path(name), credential)\n now = _now_ms()\n expired = not credential.get(\"expiresAt\") or credential[\"expiresAt\"] - now < REFRESH_MARGIN_MS\n just_refreshed = now - credential.get(\"lastRefresh\", 0) < REFRESH_SHARED_MS\n if expired or (refresh_request and not just_refreshed):\n if not (credential.get(\"refresh\") and credential.get(\"refreshRequest\")):\n if not refresh_request:\n return False\n # A client's refresh with nothing to refresh with: all there is to give it.\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n status, body = await asyncio.to_thread(_refresh_upstream, mode, credential)\n if status != 200 or not isinstance(body, dict) or \"access_token\" not in body:\n if not refresh_request:\n return False\n content = body if isinstance(body, bytes) else json.dumps(body).encode()\n flow.response = http.Response.make(status, content, {\"content-type\": \"application/json\"})\n return True\n credential = _store_tokens(name, mode, body, None)\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n\n\nasync def request(flow: http.HTTPFlow):\n if flow.response: # already answered, e.g. by a traffic rule\n return\n host = flow.request.pretty_host.lower()\n url = _base(flow.request.pretty_url)\n for definition in _definitions():\n name = definition[\"name\"]\n oauth = definition.get(\"modes\", {}).get(\"oauth\")\n api_key = definition.get(\"modes\", {}).get(\"apiKey\")\n credential = _load(_oauth_path(name)) if oauth else None\n if oauth and url == _base(oauth[\"tokenUrl\"]):\n fields = _parse_form_or_json(flow.request.get_text(strict=False), flow.request.headers.get(\"content-type\"))\n refreshing = fields.get(\"grant_type\") == \"refresh_token\"\n refresh_request = _refresh_request(flow, fields) if refreshing else None\n # A refresh token the gateway didn't issue is a client that signed in before the upstream\n # was registered (or elsewhere): let it through, and keep what it gets.\n foreign_refresh = refreshing and not _is_fake(\n (credential or {}).get(\"fakes\", {}).get(\"refresh\"), fields.get(\"refresh_token\")\n )\n if credential and not foreign_refresh and await _answer_from_credential(flow, name, oauth, refresh_request):\n return\n flow.metadata[\"hyperbrane_token\"] = (name, refresh_request)\n return\n if credential:\n for answer in oauth.get(\"answers\", []):\n if url == _base(answer[\"url\"]):\n flow.response = http.Response.make(\n answer.get(\"status\", 200), json.dumps(answer[\"json\"]), {\"content-type\": \"application/json\"}\n )\n return\n for mode, swap in ((oauth, _swap_oauth), (api_key, _swap_api_key)):\n if mode and host in _hosts(mode):\n swap(flow.request, name, mode)\n _force(flow.request, mode.get(\"force\"))\n\n\ndef responseheaders(flow: http.HTTPFlow):\n # Only token responses are rewritten; everything else streams through as it arrives. mitmproxy\n # otherwise holds each response until its body is complete, so a model's streamed reply (SSE)\n # reached the dimension in one piece at the end of the turn.\n if not flow.metadata.get(\"hyperbrane_token\"):\n flow.response.stream = True\n\n\ndef response(flow: http.HTTPFlow):\n token = flow.metadata.get(\"hyperbrane_token\")\n if not token:\n return\n name, refresh_request = token\n definition = next((d for d in _definitions() if d[\"name\"] == name), None)\n oauth = (definition or {}).get(\"modes\", {}).get(\"oauth\")\n if not oauth:\n return\n body = _parse_form_or_json(flow.response.get_text(strict=False), \"json\")\n if flow.response.status_code == 200 and body.get(\"access_token\"):\n credential = _store_tokens(name, oauth, body, refresh_request)\n flow.response.text = json.dumps(_client_body(oauth, credential))\n for header in (oauth.get(\"strip\") or {}).get(\"headers\", []):\n flow.response.headers.pop(header, None)\n",
|
|
7
|
+
"upstreams.py": "# mitmproxy addon applying the gateway's upstreams (see portal-gateway/upstream.ts) to traffic from\n# portals. Definitions are the JSON files the gateway service writes to /state/upstreams; this addon\n# keeps each upstream's OAuth credential in /state/credentials/<upstream>.oauth.json and reads its\n# API keys from /state/credentials/<upstream>.api-keys.json. Everything is re-read per request, so a\n# change takes effect without a reload.\n#\n# Clients sign in from dimensions, through the proxy:\n# token endpoint without a usable credential, requests go through and the response is kept as\n# the credential, reaching the dimension with fakes in place of the tokens. With\n# one, the gateway answers from it: a sign-in (whatever code it carries) and a\n# refresh made with a fake refresh token. A client's refresh goes upstream: the\n# gateway replays it (encoding, fields, User-Agent) with the real refresh token,\n# one at a time, and refreshes arriving together share its result. Every client\n# holds the same fakes, so they all get the new tokens. A sign-in is answered from\n# the credential, refreshed first if expired by replaying the last client refresh.\n# An expired credential it can't refresh (no client refresh to replay yet, or\n# upstream refused) counts as none: a sign-in goes to the upstream again.\n# answers other sign-in steps (a device flow's poll) answered as the definition says, once\n# there is a credential\n# other requests a fake in the mode's `present` location is replaced by the real secret\n\nimport asyncio\nimport http.client as http_client\nimport json\nimport os\nimport secrets\nimport string\nimport time\nimport urllib.parse\n\nfrom mitmproxy import http\n\nSTATE = \"/state\"\nUPSTREAMS = os.path.join(STATE, \"upstreams\")\nCREDENTIALS = os.path.join(STATE, \"credentials\")\n# A token this close to expiry counts as expired.\nREFRESH_MARGIN_MS = 60_000\n# Refreshes arriving within this long of the last one get its tokens instead of going upstream again.\nREFRESH_SHARED_MS = 5_000\nFAKE_ALPHABET = string.ascii_letters + string.digits + \"-_\"\nTOKEN_FIELDS = ((\"access\", \"access_token\"), (\"refresh\", \"refresh_token\"), (\"id\", \"id_token\"))\n\n# One refresh per upstream at a time: concurrent ones would each spend the rotating refresh token.\n_refresh_locks: dict[str, asyncio.Lock] = {}\n\n\ndef _now_ms():\n return int(time.time() * 1000)\n\n\ndef _load(path):\n try:\n with open(path) as f:\n return json.load(f)\n except (OSError, ValueError):\n return None\n\n\ndef _save(path, data):\n tmp = f\"{path}.{secrets.token_hex(4)}.tmp\"\n with open(tmp, \"w\") as f:\n json.dump(data, f, indent=2)\n os.replace(tmp, path)\n\n\ndef _definitions():\n try:\n files = sorted(f for f in os.listdir(UPSTREAMS) if f.endswith(\".json\"))\n except OSError:\n return []\n definitions = (_load(os.path.join(UPSTREAMS, f)) for f in files)\n return [d for d in definitions if d and d.get(\"name\")]\n\n\ndef _oauth_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.oauth.json\")\n\n\ndef _keys_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.api-keys.json\")\n\n\ndef _base(url):\n return url.split(\"?\", 1)[0].split(\"#\", 1)[0]\n\n\ndef _hosts(mode):\n return {h.lower() for h in mode.get(\"hosts\", [])}\n\n\n# --- fakes -----------------------------------------------------------------------------------------\n\n\ndef _random(length):\n return \"\".join(secrets.choice(FAKE_ALPHABET) for _ in range(length))\n\n\ndef _issue_fake(shape, real):\n if shape.get(\"jwt\"):\n signature = real.rsplit(\".\", 1)[-1] if real.count(\".\") == 2 else \"\"\n return {\"jwtSignature\": _random(max(len(signature), 43))}\n prefix = shape.get(\"prefix\", \"\")\n return {\"value\": prefix + _random(max(len(real) - len(prefix), 16))}\n\n\ndef _fake_value(stored, real):\n if \"jwtSignature\" in stored:\n header_and_claims = real.rsplit(\".\", 1)[0] if real.count(\".\") == 2 else real\n return f\"{header_and_claims}.{stored['jwtSignature']}\"\n return stored[\"value\"]\n\n\ndef _is_fake(stored, candidate):\n if not stored or not candidate:\n return False\n if \"jwtSignature\" in stored:\n return candidate.count(\".\") == 2 and candidate.rsplit(\".\", 1)[1] == stored[\"jwtSignature\"]\n return candidate == stored[\"value\"]\n\n\n# --- presenting secrets ----------------------------------------------------------------------------\n\n\ndef _presented(request, present):\n if \"query\" in present:\n return request.query.get(present[\"query\"])\n value = request.headers.get(present[\"header\"])\n scheme = present.get(\"scheme\")\n if value and scheme:\n prefix = f\"{scheme} \"\n return value[len(prefix):] if value.lower().startswith(prefix.lower()) else None\n return value\n\n\ndef _present(request, present, secret):\n if \"query\" in present:\n request.query[present[\"query\"]] = secret\n elif present.get(\"scheme\"):\n request.headers[present[\"header\"]] = f\"{present['scheme']} {secret}\"\n else:\n request.headers[present[\"header\"]] = secret\n\n\ndef _force(request, force):\n if not force:\n return\n if force.get(\"target\"):\n target = urllib.parse.urlsplit(force[\"target\"])\n request.scheme = target.scheme or request.scheme\n request.host = target.hostname or request.host\n request.port = target.port or (443 if request.scheme == \"https\" else 80)\n if force.get(\"path\"):\n request.path = force[\"path\"].rstrip(\"/\") + request.path\n for key, value in force.get(\"query\", {}).items():\n request.query[key] = value\n for key, value in force.get(\"headers\", {}).items():\n request.headers[key] = value\n\n\ndef _swap_oauth(request, name, mode):\n credential = _load(_oauth_path(name))\n presented = _presented(request, mode[\"present\"])\n if credential and _is_fake(credential[\"fakes\"].get(\"access\"), presented):\n _present(request, mode[\"present\"], credential[\"access\"])\n return True\n return False\n\n\ndef _swap_api_key(request, name, mode):\n stored = _load(_keys_path(name)) or {}\n presented = _presented(request, mode[\"present\"])\n for key in stored.get(\"keys\", {}).values():\n if presented and presented == key[\"fake\"]:\n _present(request, mode[\"present\"], key[\"key\"])\n return True\n return False\n\n\n# --- the token endpoint ----------------------------------------------------------------------------\n\n\ndef _parse_form_or_json(text, content_type):\n if not text:\n return {}\n if \"json\" in (content_type or \"\"):\n try:\n value = json.loads(text)\n return value if isinstance(value, dict) else {}\n except ValueError:\n return {}\n return dict(urllib.parse.parse_qsl(text))\n\n\ndef _refresh_request(flow, fields):\n \"\"\"How a client refreshes, to replay with the real refresh token: everything but that token.\"\"\"\n return {\n \"contentType\": flow.request.headers.get(\"content-type\", \"\"),\n \"userAgent\": flow.request.headers.get(\"user-agent\"),\n \"fields\": {k: v for k, v in fields.items() if k != \"refresh_token\"},\n }\n\n\ndef _store_tokens(name, mode, body, refresh_request):\n \"\"\"Keeps a token response as the upstream's credential and returns it.\"\"\"\n path = _oauth_path(name)\n credential = dict(_load(path) or {})\n if refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n now = _now_ms()\n for key, field in TOKEN_FIELDS:\n if body.get(field):\n credential[key] = body[field]\n if \"expires_in\" in body:\n credential[\"expiresAt\"] = now + int(body[\"expires_in\"]) * 1000\n if \"refresh_token_expires_in\" in body:\n credential[\"refreshExpiresAt\"] = now + int(body[\"refresh_token_expires_in\"]) * 1000\n if body.get(\"scope\"):\n credential[\"scopes\"] = body[\"scope\"].split()\n credential[\"lastRefresh\"] = now\n fakes = dict(credential.get(\"fakes\") or {})\n for key, _ in TOKEN_FIELDS:\n shape = mode[\"fakes\"].get(key)\n if shape and credential.get(key) and key not in fakes:\n fakes[key] = _issue_fake(shape, credential[key])\n credential[\"fakes\"] = fakes\n stripped = set((mode.get(\"strip\") or {}).get(\"fields\", []))\n credential[\"response\"] = {k: v for k, v in body.items() if k not in stripped}\n _save(path, credential)\n return credential\n\n\ndef _client_body(mode, credential):\n \"\"\"The token response the dimension gets: the last real one, with fakes for every token.\"\"\"\n body = dict(credential.get(\"response\") or {})\n fakes = credential[\"fakes\"]\n for key, field in TOKEN_FIELDS:\n if field not in body:\n continue\n if key in fakes and credential.get(key):\n body[field] = _fake_value(fakes[key], credential[key])\n else:\n del body[field] # a token with no fake shape never reaches a dimension\n now = _now_ms()\n if credential.get(\"expiresAt\") and \"expires_in\" in body:\n body[\"expires_in\"] = max(0, (credential[\"expiresAt\"] - now) // 1000)\n if credential.get(\"refreshExpiresAt\") and \"refresh_token_expires_in\" in body:\n body[\"refresh_token_expires_in\"] = max(0, (credential[\"refreshExpiresAt\"] - now) // 1000)\n return body\n\n\ndef _refresh_upstream(mode, credential):\n \"\"\"Replays the client's refresh request with the real refresh token; returns (status, parsed body\n or raw bytes). http.client, unlike urllib, adds no User-Agent of its own: a client that sent none\n stays that way.\"\"\"\n template = credential[\"refreshRequest\"]\n fields = dict(template[\"fields\"], refresh_token=credential[\"refresh\"])\n content_type = template[\"contentType\"] or \"application/x-www-form-urlencoded\"\n data = json.dumps(fields) if \"json\" in content_type else urllib.parse.urlencode(fields)\n headers = {\"content-type\": content_type, \"accept\": \"application/json\"}\n if template.get(\"userAgent\"):\n headers[\"user-agent\"] = template[\"userAgent\"]\n url = urllib.parse.urlsplit(mode[\"tokenUrl\"])\n connection_type = http_client.HTTPSConnection if url.scheme == \"https\" else http_client.HTTPConnection\n connection = connection_type(url.hostname, url.port, timeout=30)\n try:\n connection.request(\"POST\", url.path + (f\"?{url.query}\" if url.query else \"\"), body=data.encode(), headers=headers)\n response = connection.getresponse()\n raw = response.read()\n try:\n return response.status, json.loads(raw)\n except ValueError:\n return response.status, raw\n finally:\n connection.close()\n\n\nasync def _answer_from_credential(flow, name, mode, refresh_request):\n \"\"\"Answers a sign-in, or a refresh made with a fake (`refresh_request`), with the credential's\n fakes. A refresh is replayed upstream first unless another one just was; a sign-in only if the\n credential has expired. Returns False, answering nothing, when a sign-in can't be given valid\n tokens: expired, and no client refresh to replay yet or upstream refused it. A client's refresh\n gets upstream's refusal instead.\"\"\"\n lock = _refresh_locks.setdefault(name, asyncio.Lock())\n async with lock:\n credential = _load(_oauth_path(name))\n if refresh_request and credential.get(\"refreshRequest\") != refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n _save(_oauth_path(name), credential)\n now = _now_ms()\n expired = not credential.get(\"expiresAt\") or credential[\"expiresAt\"] - now < REFRESH_MARGIN_MS\n just_refreshed = now - credential.get(\"lastRefresh\", 0) < REFRESH_SHARED_MS\n if expired or (refresh_request and not just_refreshed):\n if not (credential.get(\"refresh\") and credential.get(\"refreshRequest\")):\n if not refresh_request:\n return False\n # A client's refresh with nothing to refresh with: all there is to give it.\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n status, body = await asyncio.to_thread(_refresh_upstream, mode, credential)\n if status != 200 or not isinstance(body, dict) or \"access_token\" not in body:\n if not refresh_request:\n return False\n content = body if isinstance(body, bytes) else json.dumps(body).encode()\n flow.response = http.Response.make(status, content, {\"content-type\": \"application/json\"})\n return True\n credential = _store_tokens(name, mode, body, None)\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n\n\nasync def request(flow: http.HTTPFlow):\n if flow.response: # already answered, e.g. by a traffic rule\n return\n host = flow.request.pretty_host.lower()\n url = _base(flow.request.pretty_url)\n for definition in _definitions():\n name = definition[\"name\"]\n oauth = definition.get(\"modes\", {}).get(\"oauth\")\n api_key = definition.get(\"modes\", {}).get(\"apiKey\")\n forced = definition.get(\"modes\", {}).get(\"forced\")\n credential = _load(_oauth_path(name)) if oauth else None\n if oauth and url == _base(oauth[\"tokenUrl\"]):\n fields = _parse_form_or_json(flow.request.get_text(strict=False), flow.request.headers.get(\"content-type\"))\n refreshing = fields.get(\"grant_type\") == \"refresh_token\"\n refresh_request = _refresh_request(flow, fields) if refreshing else None\n # A refresh token the gateway didn't issue is a client that signed in before the upstream\n # was registered (or elsewhere): let it through, and keep what it gets.\n foreign_refresh = refreshing and not _is_fake(\n (credential or {}).get(\"fakes\", {}).get(\"refresh\"), fields.get(\"refresh_token\")\n )\n if credential and not foreign_refresh and await _answer_from_credential(flow, name, oauth, refresh_request):\n return\n flow.metadata[\"hyperbrane_token\"] = (name, refresh_request)\n return\n if credential:\n for answer in oauth.get(\"answers\", []):\n if url == _base(answer[\"url\"]):\n flow.response = http.Response.make(\n answer.get(\"status\", 200), json.dumps(answer[\"json\"]), {\"content-type\": \"application/json\"}\n )\n return\n for mode, swap in ((oauth, _swap_oauth), (api_key, _swap_api_key), (forced, None)):\n if mode and host in _hosts(mode):\n if swap:\n swap(flow.request, name, mode)\n _force(flow.request, mode.get(\"force\"))\n\n\ndef responseheaders(flow: http.HTTPFlow):\n # Only token responses are rewritten; everything else streams through as it arrives. mitmproxy\n # otherwise holds each response until its body is complete, so a model's streamed reply (SSE)\n # reached the dimension in one piece at the end of the turn.\n if not flow.metadata.get(\"hyperbrane_token\"):\n flow.response.stream = True\n\n\ndef response(flow: http.HTTPFlow):\n token = flow.metadata.get(\"hyperbrane_token\")\n if not token:\n return\n name, refresh_request = token\n definition = next((d for d in _definitions() if d[\"name\"] == name), None)\n oauth = (definition or {}).get(\"modes\", {}).get(\"oauth\")\n if not oauth:\n return\n body = _parse_form_or_json(flow.response.get_text(strict=False), \"json\")\n if flow.response.status_code == 200 and body.get(\"access_token\"):\n credential = _store_tokens(name, oauth, body, refresh_request)\n flow.response.text = json.dumps(_client_body(oauth, credential))\n for header in (oauth.get(\"strip\") or {}).get(\"headers\", []):\n flow.response.headers.pop(header, None)\n",
|
|
8
8
|
};
|
|
@@ -1,50 +1,8 @@
|
|
|
1
|
-
import { type DockerClient } from "../lib/docker.ts";
|
|
2
|
-
import type { ManagedImage } from "../lib/image.ts";
|
|
3
1
|
import type { Sandbox } from "../sandbox/api.ts";
|
|
4
|
-
import {
|
|
5
|
-
import type { GatewayStorage } from "./gatewayStorage.ts";
|
|
6
|
-
import type { PortalStorage } from "./portalStorage.ts";
|
|
2
|
+
import type { PortalClient } from "./api.ts";
|
|
7
3
|
/** @sealed */
|
|
8
|
-
export interface PortalService {
|
|
9
|
-
createPortal(portal: Portal): Promise<Portal>;
|
|
10
|
-
destroyPortal(portalName: string): Promise<void>;
|
|
11
|
-
listPortals(): Promise<Portal[]>;
|
|
12
|
-
/**
|
|
13
|
-
* Re-wires the ready portals (optionally only those of one gateway or dimension) whose containers
|
|
14
|
-
* are running and whose link is gone: a container restart takes its network namespace, and the
|
|
15
|
-
* portal's veth, with it. Portals still wired cost one look at each gateway's interfaces, so this
|
|
16
|
-
* is cheap enough to run before every command. Failures are logged per portal rather than thrown.
|
|
17
|
-
*/
|
|
18
|
-
restorePortals(filter?: {
|
|
19
|
-
gatewayName?: string;
|
|
20
|
-
dimensionName?: string;
|
|
21
|
-
}): Promise<void>;
|
|
4
|
+
export interface PortalService extends PortalClient {
|
|
22
5
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
dimensionIp: string;
|
|
27
|
-
gatewayIp: string;
|
|
28
|
-
metric: number;
|
|
29
|
-
};
|
|
30
|
-
/**
|
|
31
|
-
* Interface names come from the portal name (so a portal can always be found again) and addresses
|
|
32
|
-
* from its claimed subnet. The gateway only intercepts interfaces named "hvg*".
|
|
33
|
-
*/
|
|
34
|
-
export declare function portalLink(portalName: string, subnet: number): PortalLink;
|
|
35
|
-
/** The OS trust bundle; the gateways' CAs end up in it. */
|
|
36
|
-
export declare const OS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
|
|
37
|
-
/**
|
|
38
|
-
* `host` is the machine the docker daemon runs on, rooted at "/" (see createGatewayService).
|
|
39
|
-
* `image` is the gateway image: it carries the wiring script run by the privileged helper.
|
|
40
|
-
*/
|
|
41
|
-
export declare function createPortalService(storage: PortalStorage, gateways: GatewayStorage, docker: DockerClient, image: ManagedImage, host: Sandbox): PortalService;
|
|
42
|
-
/** @sealed */
|
|
43
|
-
export interface PortalWatcher {
|
|
44
|
-
/** Restores every portal, then keeps restoring those of each hyperbrane container that starts. */
|
|
45
|
-
start(): void;
|
|
46
|
-
stop(): Promise<void>;
|
|
47
|
-
}
|
|
48
|
-
/** Run one per host: the portals of a container that restarts are gone until something re-wires them. */
|
|
49
|
-
export declare function createPortalWatcher(portals: PortalService, docker: DockerClient): PortalWatcher;
|
|
50
|
-
export {};
|
|
6
|
+
export declare function createPortalService(host: Sandbox, { dataDirectory }?: {
|
|
7
|
+
dataDirectory?: string;
|
|
8
|
+
}): PortalService;
|
|
@@ -1,327 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const RESTORE_RECHECK_MS = 1_000;
|
|
8
|
-
/** Each portal is a point-to-point /30 out of 10.77.0.0/16. */
|
|
9
|
-
const SUBNET_COUNT = 1 << 14;
|
|
10
|
-
/** A dimension's default routes are ordered by subnet: its portal with the lowest one is preferred. */
|
|
11
|
-
const ROUTE_METRIC_BASE = 100;
|
|
12
|
-
/**
|
|
13
|
-
* Interface names come from the portal name (so a portal can always be found again) and addresses
|
|
14
|
-
* from its claimed subnet. The gateway only intercepts interfaces named "hvg*".
|
|
15
|
-
*/
|
|
16
|
-
export function portalLink(portalName, subnet) {
|
|
17
|
-
const hash = createHash("sha256").update(portalName).digest("hex").slice(0, 8);
|
|
18
|
-
const base = `10.77.${subnet >> 6}`;
|
|
19
|
-
const offset = (subnet & 0x3f) << 2;
|
|
20
|
-
return {
|
|
21
|
-
dimensionIf: `hvd${hash}`,
|
|
22
|
-
gatewayIf: `hvg${hash}`,
|
|
23
|
-
gatewayIp: `${base}.${offset + 1}`,
|
|
24
|
-
dimensionIp: `${base}.${offset + 2}`,
|
|
25
|
-
metric: ROUTE_METRIC_BASE + subnet,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
/** The OS trust bundle; the gateways' CAs end up in it. */
|
|
29
|
-
export const OS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
|
|
30
|
-
// Run as root in the dimension: installs GATEWAY_CA as the gateway's certificate in the OS trust
|
|
31
|
-
// store, or removes it when empty. Nothing but its gateways' CAs ever signs what a dimension sees
|
|
32
|
-
// (every TCP connection is intercepted), so without the ca-certificates package the bundle can be
|
|
33
|
-
// just those CAs. Node ignores the OS store, so while any gateway is trusted NODE_EXTRA_CA_CERTS
|
|
34
|
-
// points it at the bundle, for login shells (profile.d) and PAM sessions (/etc/environment); a
|
|
35
|
-
// running container's own environment can't be changed. DNS needs no setup: the gateway answers
|
|
36
|
-
// UDP 53 whatever address it was sent to.
|
|
37
|
-
const TRUST_GATEWAY_SCRIPT = `set -eu
|
|
38
|
-
# Portals of one dimension are wired concurrently (e.g. restored together after a restart), and
|
|
39
|
-
# they share the trust store and /etc/environment.
|
|
40
|
-
exec 9>/run/hyperbrane-trust.lock
|
|
41
|
-
flock 9
|
|
42
|
-
bundle=${OS_CA_BUNDLE}
|
|
43
|
-
mkdir -p /usr/local/share/ca-certificates /etc/ssl/certs /etc/profile.d
|
|
44
|
-
cert="/usr/local/share/ca-certificates/hyperbrane-gateway-$GATEWAY.crt"
|
|
45
|
-
if [ -n "$GATEWAY_CA" ]; then printf '%s\\n' "$GATEWAY_CA" > "$cert"; else rm -f "$cert"; fi
|
|
46
|
-
if command -v update-ca-certificates >/dev/null; then
|
|
47
|
-
update-ca-certificates --fresh >/dev/null
|
|
48
|
-
else
|
|
49
|
-
cat /usr/local/share/ca-certificates/hyperbrane-gateway-*.crt > "$bundle" 2>/dev/null || : > "$bundle"
|
|
50
|
-
if command -v openssl >/dev/null; then openssl rehash /etc/ssl/certs; fi
|
|
51
|
-
fi
|
|
52
|
-
{ grep -v '^NODE_EXTRA_CA_CERTS=' /etc/environment 2>/dev/null || true; } > /etc/environment.hyperbrane
|
|
53
|
-
if ls /usr/local/share/ca-certificates/hyperbrane-gateway-*.crt >/dev/null 2>&1; then
|
|
54
|
-
echo "NODE_EXTRA_CA_CERTS=$bundle" >> /etc/environment.hyperbrane
|
|
55
|
-
echo "export NODE_EXTRA_CA_CERTS=$bundle" > /etc/profile.d/hyperbrane-gateway.sh
|
|
56
|
-
else
|
|
57
|
-
rm -f /etc/profile.d/hyperbrane-gateway.sh
|
|
58
|
-
fi
|
|
59
|
-
mv /etc/environment.hyperbrane /etc/environment
|
|
60
|
-
`;
|
|
61
|
-
/**
|
|
62
|
-
* `host` is the machine the docker daemon runs on, rooted at "/" (see createGatewayService).
|
|
63
|
-
* `image` is the gateway image: it carries the wiring script run by the privileged helper.
|
|
64
|
-
*/
|
|
65
|
-
export function createPortalService(storage, gateways, docker, image, host) {
|
|
66
|
-
// Re-running re-wires, so it also repairs a portal whose veth went away.
|
|
1
|
+
import { createDockerClient } from "../lib/docker.js";
|
|
2
|
+
import { gatewayControl } from "./control.js";
|
|
3
|
+
import { assertValidName } from "./gatewayService.js";
|
|
4
|
+
import { createGatewayImage } from "./image.js";
|
|
5
|
+
export function createPortalService(host, { dataDirectory } = {}) {
|
|
6
|
+
const control = gatewayControl(host, () => createGatewayImage(createDockerClient(host)).ensure(), { dataDirectory });
|
|
67
7
|
async function createPortal(portal) {
|
|
68
8
|
assertValidName("portal", portal.name);
|
|
69
9
|
assertValidName("dimension", portal.dimensionName);
|
|
70
10
|
assertValidName("gateway", portal.gatewayName);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
throw new GatewayNotFoundError(`gateway ${portal.gatewayName} is not ready`);
|
|
74
|
-
// Checked again when wiring; failing here first leaves no record behind.
|
|
75
|
-
await runningPid(gatewayContainerName(portal.gatewayName), "hyperbrane.gateway", portal.gatewayName);
|
|
76
|
-
await runningPid(dimensionContainerName(portal.dimensionName), "hyperbrane.dimension", portal.dimensionName);
|
|
77
|
-
let record = await storage.insertPortal({ ...portal, state: "creating" });
|
|
78
|
-
if (record.state === "destroying")
|
|
79
|
-
throw new GatewayConflictError(`portal ${portal.name} is being destroyed`);
|
|
80
|
-
if (record.dimensionName !== portal.dimensionName || record.gatewayName !== portal.gatewayName) {
|
|
81
|
-
throw new GatewayConflictError(`portal ${portal.name} already exists with a different spec`);
|
|
82
|
-
}
|
|
83
|
-
if (record.subnet === undefined) {
|
|
84
|
-
const subnet = await storage.claimSubnet(portal.name);
|
|
85
|
-
if (subnet >= SUBNET_COUNT) {
|
|
86
|
-
await storage.releaseSubnet(subnet);
|
|
87
|
-
throw new GatewayConflictError("no portal subnets left");
|
|
88
|
-
}
|
|
89
|
-
record = { ...record, subnet };
|
|
90
|
-
await storage.savePortal(record);
|
|
91
|
-
}
|
|
92
|
-
await connect(record);
|
|
93
|
-
if (record.state !== "ready")
|
|
94
|
-
await storage.savePortal({ ...record, state: "ready" });
|
|
95
|
-
return publicPortal(record);
|
|
96
|
-
}
|
|
97
|
-
async function connect(record, pids) {
|
|
98
|
-
const { gateway, dimension } = pids ?? (await runningPids(record));
|
|
99
|
-
await wire(record.name, portalLink(record.name, record.subnet), dimension, gateway);
|
|
100
|
-
await trustGateway(dimensionContainerName(record.dimensionName), record.gatewayName, true);
|
|
101
|
-
}
|
|
102
|
-
async function runningPids(record) {
|
|
103
|
-
return {
|
|
104
|
-
gateway: await runningPid(gatewayContainerName(record.gatewayName), "hyperbrane.gateway", record.gatewayName),
|
|
105
|
-
dimension: await runningPid(dimensionContainerName(record.dimensionName), "hyperbrane.dimension", record.dimensionName),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
async function runningPid(name, label, owner) {
|
|
109
|
-
const kind = label.replace("hyperbrane.", "");
|
|
110
|
-
const container = await docker.inspectContainer(name);
|
|
111
|
-
if (!container || container.Config?.Labels?.[label] !== owner) {
|
|
112
|
-
throw new GatewayNotFoundError(`${kind} ${owner} does not exist`);
|
|
113
|
-
}
|
|
114
|
-
if (!container.State?.Running || !container.State.Pid) {
|
|
115
|
-
throw new GatewayConflictError(`${kind} ${owner} is not running`);
|
|
116
|
-
}
|
|
117
|
-
return container.State.Pid;
|
|
11
|
+
await control.createPortal(portal);
|
|
12
|
+
return { name: portal.name, dimensionName: portal.dimensionName, gatewayName: portal.gatewayName };
|
|
118
13
|
}
|
|
119
|
-
// Moving a veth between two containers' namespaces takes the host's view of both, so it runs in a
|
|
120
|
-
// throwaway privileged container; neither the dimension nor the gateway gets that power.
|
|
121
|
-
async function wire(portalName, link, dimensionPid, gatewayPid) {
|
|
122
|
-
const helper = `hybwire-${crypto.randomUUID().slice(0, 8)}`;
|
|
123
|
-
await docker.createContainer(helper, {
|
|
124
|
-
Image: await image.ensure(),
|
|
125
|
-
Cmd: ["sleep", "infinity"],
|
|
126
|
-
Labels: { hyperbrane: "true", "hyperbrane.helper": "wire-portal" },
|
|
127
|
-
HostConfig: {
|
|
128
|
-
NetworkMode: "host",
|
|
129
|
-
PidMode: "host",
|
|
130
|
-
Privileged: true,
|
|
131
|
-
Binds: ["/run:/hostrun"],
|
|
132
|
-
Init: true,
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
try {
|
|
136
|
-
await docker.startContainer(helper);
|
|
137
|
-
const result = await docker.exec(helper, {
|
|
138
|
-
cmd: ["hyb-wire-portal"],
|
|
139
|
-
user: "root",
|
|
140
|
-
env: {
|
|
141
|
-
PORTAL: portalName,
|
|
142
|
-
DIMENSION_PID: String(dimensionPid),
|
|
143
|
-
GATEWAY_PID: String(gatewayPid),
|
|
144
|
-
DIMENSION_IF: link.dimensionIf,
|
|
145
|
-
GATEWAY_IF: link.gatewayIf,
|
|
146
|
-
DIMENSION_IP: link.dimensionIp,
|
|
147
|
-
GATEWAY_IP: link.gatewayIp,
|
|
148
|
-
ROUTE_METRIC: String(link.metric),
|
|
149
|
-
},
|
|
150
|
-
});
|
|
151
|
-
if (result.exitCode !== 0)
|
|
152
|
-
throw new Error(`wiring portal ${portalName} failed: ${result.stderr.trim()}`);
|
|
153
|
-
}
|
|
154
|
-
finally {
|
|
155
|
-
await docker.removeContainer(helper);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
async function trustGateway(dimensionName, gatewayName, trusted) {
|
|
159
|
-
let ca = "";
|
|
160
|
-
if (trusted) {
|
|
161
|
-
const stateDirectory = await gateways.ensureStateDirectory(gatewayName);
|
|
162
|
-
ca = new TextDecoder().decode(await host.readFile(gatewayCaCertificatePath(stateDirectory))).trim();
|
|
163
|
-
}
|
|
164
|
-
const result = await docker.exec(dimensionName, {
|
|
165
|
-
cmd: ["sh", "-c", TRUST_GATEWAY_SCRIPT],
|
|
166
|
-
user: "root",
|
|
167
|
-
env: { GATEWAY: gatewayName, GATEWAY_CA: ca },
|
|
168
|
-
});
|
|
169
|
-
if (result.exitCode !== 0) {
|
|
170
|
-
throw new Error(`updating gateway ${gatewayName} trust in ${dimensionName} failed (exit ${result.exitCode}): ${result.stderr.trim()}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
// Deleting the gateway end removes the pair, and the dimension's route through it.
|
|
174
|
-
// Marked first: a restore already under way re-checks the record after wiring and undoes its work,
|
|
175
|
-
// and the link is removed after the mark, so whichever runs last, the link is gone.
|
|
176
14
|
async function destroyPortal(portalName) {
|
|
177
15
|
assertValidName("portal", portalName);
|
|
178
|
-
|
|
179
|
-
if (!record)
|
|
180
|
-
return;
|
|
181
|
-
if (record.state !== "destroying")
|
|
182
|
-
await storage.savePortal({ ...record, state: "destroying" });
|
|
183
|
-
if (record.subnet !== undefined) {
|
|
184
|
-
await ignoringStopped(() => unwire(record.gatewayName, portalLink(portalName, record.subnet)));
|
|
185
|
-
await storage.releaseSubnet(record.subnet);
|
|
186
|
-
}
|
|
187
|
-
const sameGateway = (await storage.listPortals()).some((other) => other.name !== portalName &&
|
|
188
|
-
other.dimensionName === record.dimensionName &&
|
|
189
|
-
other.gatewayName === record.gatewayName);
|
|
190
|
-
if (!sameGateway) {
|
|
191
|
-
await ignoringStopped(() => trustGateway(dimensionContainerName(record.dimensionName), record.gatewayName, false));
|
|
192
|
-
}
|
|
193
|
-
await storage.removePortal(portalName);
|
|
194
|
-
}
|
|
195
|
-
async function unwire(gatewayName, link) {
|
|
196
|
-
const result = await docker.exec(gatewayContainerName(gatewayName), {
|
|
197
|
-
cmd: ["sh", "-c", `ip link del ${link.gatewayIf} 2>/dev/null || true`],
|
|
198
|
-
user: "root",
|
|
199
|
-
});
|
|
200
|
-
if (result.exitCode !== 0)
|
|
201
|
-
throw new Error(`removing ${link.gatewayIf} failed: ${result.stderr.trim()}`);
|
|
202
|
-
}
|
|
203
|
-
async function listPortals() {
|
|
204
|
-
return (await storage.listPortals()).map(publicPortal);
|
|
205
|
-
}
|
|
206
|
-
async function restorePortals({ gatewayName, dimensionName } = {}) {
|
|
207
|
-
const records = (await storage.listPortals()).filter((record) => record.state === "ready" &&
|
|
208
|
-
(gatewayName === undefined || record.gatewayName === gatewayName) &&
|
|
209
|
-
(dimensionName === undefined || record.dimensionName === dimensionName));
|
|
210
|
-
// A veth pair only ever goes as a whole, so a gateway end still there means the portal is intact.
|
|
211
|
-
const interfaces = new Map();
|
|
212
|
-
const gatewayInterfaces = (name) => {
|
|
213
|
-
if (!interfaces.has(name))
|
|
214
|
-
interfaces.set(name, listInterfaces(gatewayContainerName(name)));
|
|
215
|
-
return interfaces.get(name);
|
|
216
|
-
};
|
|
217
|
-
await Promise.all(records.map(async (record) => {
|
|
218
|
-
if ((await gatewayInterfaces(record.gatewayName))?.has(portalLink(record.name, record.subnet).gatewayIf))
|
|
219
|
-
return;
|
|
220
|
-
let pids;
|
|
221
|
-
try {
|
|
222
|
-
pids = await runningPids(record);
|
|
223
|
-
}
|
|
224
|
-
catch (error) {
|
|
225
|
-
// A stopped or removed container gets wired when it starts again, or never.
|
|
226
|
-
if (error instanceof GatewayConflictError || error instanceof GatewayNotFoundError)
|
|
227
|
-
return;
|
|
228
|
-
throw error;
|
|
229
|
-
}
|
|
230
|
-
for (let attempt = 1;; attempt++) {
|
|
231
|
-
try {
|
|
232
|
-
await connect(record, pids);
|
|
233
|
-
// Destroyed meanwhile: the link just made must not outlive it (see destroyPortal).
|
|
234
|
-
if ((await storage.readPortal(record.name))?.state !== "ready") {
|
|
235
|
-
await ignoringStopped(() => unwire(record.gatewayName, portalLink(record.name, record.subnet)));
|
|
236
|
-
}
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
// A container that stopped or restarted meanwhile (killing what ran in it) is restored by
|
|
241
|
-
// its own start event. Docker kills a restarting container's processes before its state
|
|
242
|
-
// changes, so give it a moment before deciding it's still the same container.
|
|
243
|
-
await sleep(RESTORE_RECHECK_MS);
|
|
244
|
-
const now = await runningPids(record).catch(() => null);
|
|
245
|
-
if (now?.gateway !== pids.gateway || now?.dimension !== pids.dimension)
|
|
246
|
-
return;
|
|
247
|
-
if (attempt === 2)
|
|
248
|
-
return console.warn(`restoring portal ${record.name} failed:`, error);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}));
|
|
16
|
+
await control.destroyPortal(portalName);
|
|
252
17
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (result.exitCode !== 0)
|
|
258
|
-
return null;
|
|
259
|
-
return new Set(result.stdout.split("\n").map((line) => line.split(/[@\s]/)[0]).filter(Boolean));
|
|
260
|
-
}
|
|
261
|
-
catch (error) {
|
|
262
|
-
if (error instanceof DockerError && (error.status === 404 || error.status === 409))
|
|
263
|
-
return null;
|
|
264
|
-
throw error;
|
|
265
|
-
}
|
|
18
|
+
async function restorePortals(filter = {}) {
|
|
19
|
+
const failed = await control.restorePortals(filter);
|
|
20
|
+
if (failed)
|
|
21
|
+
console.warn(failed);
|
|
266
22
|
}
|
|
267
|
-
return {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
catch (error) {
|
|
275
|
-
if (!(error instanceof DockerError && (error.status === 404 || error.status === 409)))
|
|
276
|
-
throw error;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
function publicPortal({ name, dimensionName, gatewayName }) {
|
|
280
|
-
return { name, dimensionName, gatewayName };
|
|
281
|
-
}
|
|
282
|
-
const WATCH_RETRY_MS = 1_000;
|
|
283
|
-
/** Run one per host: the portals of a container that restarts are gone until something re-wires them. */
|
|
284
|
-
export function createPortalWatcher(portals, docker) {
|
|
285
|
-
let controller;
|
|
286
|
-
let watching;
|
|
287
|
-
function restore(filter) {
|
|
288
|
-
portals.restorePortals(filter).catch((error) => console.warn("restoring portals failed:", error));
|
|
289
|
-
}
|
|
290
|
-
async function watch(signal) {
|
|
291
|
-
while (!signal.aborted) {
|
|
292
|
-
try {
|
|
293
|
-
const events = docker
|
|
294
|
-
.events({ type: ["container"], event: ["start"], label: ["hyperbrane"] }, signal)[Symbol.asyncIterator]();
|
|
295
|
-
// Subscribe first, then restore: a container starting in between is covered either way.
|
|
296
|
-
let next = events.next();
|
|
297
|
-
restore();
|
|
298
|
-
for (let event = await next; !event.done; event = await next) {
|
|
299
|
-
next = events.next();
|
|
300
|
-
const labels = event.value.Actor?.Attributes ?? {};
|
|
301
|
-
if (labels["hyperbrane.gateway"])
|
|
302
|
-
restore({ gatewayName: labels["hyperbrane.gateway"] });
|
|
303
|
-
if (labels["hyperbrane.dimension"])
|
|
304
|
-
restore({ dimensionName: labels["hyperbrane.dimension"] });
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
catch (error) {
|
|
308
|
-
console.warn("watching docker events failed:", error);
|
|
309
|
-
}
|
|
310
|
-
if (!signal.aborted)
|
|
311
|
-
await sleep(WATCH_RETRY_MS);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
function start() {
|
|
315
|
-
if (controller)
|
|
316
|
-
return;
|
|
317
|
-
controller = new AbortController();
|
|
318
|
-
watching = watch(controller.signal);
|
|
319
|
-
}
|
|
320
|
-
async function stop() {
|
|
321
|
-
controller?.abort();
|
|
322
|
-
await watching;
|
|
323
|
-
controller = undefined;
|
|
324
|
-
watching = undefined;
|
|
325
|
-
}
|
|
326
|
-
return { start, stop };
|
|
23
|
+
return {
|
|
24
|
+
createPortal: (portal) => createPortal(portal),
|
|
25
|
+
destroyPortal: (portalName) => destroyPortal(portalName),
|
|
26
|
+
listPortals: () => control.listPortals(),
|
|
27
|
+
restorePortals,
|
|
28
|
+
};
|
|
327
29
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|