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.
Files changed (287) hide show
  1. package/README.md +81 -11
  2. package/dist/agent/agentClient.d.ts +5 -0
  3. package/dist/agent/agentClient.js +4 -0
  4. package/dist/agent/agentService.d.ts +9 -30
  5. package/dist/agent/agentService.js +62 -293
  6. package/dist/agent/agentStorage.d.ts +1 -20
  7. package/dist/agent/agentStorage.js +0 -30
  8. package/dist/agent/api.d.ts +89 -133
  9. package/dist/agent/api.js +4 -4
  10. package/dist/agent/client.d.ts +3 -1
  11. package/dist/agent/client.js +2 -3
  12. package/dist/agent/control.d.ts +23 -0
  13. package/dist/agent/control.js +479 -0
  14. package/dist/agent/hostTranscript.d.ts +0 -6
  15. package/dist/agent/hostTranscript.js +3 -14
  16. package/dist/agent/session/backend.d.ts +17 -30
  17. package/dist/agent/session/claude/home.d.ts +0 -1
  18. package/dist/agent/session/claude/home.js +0 -1
  19. package/dist/agent/session/claude/provision.d.ts +10 -0
  20. package/dist/agent/session/claude/provision.js +17 -0
  21. package/dist/agent/session/claude/transcript.d.ts +2 -7
  22. package/dist/agent/session/claude/transcript.js +68 -14
  23. package/dist/agent/session/client.d.ts +1 -10
  24. package/dist/agent/session/client.js +0 -3
  25. package/dist/agent/session/codex/provision.d.ts +14 -0
  26. package/dist/agent/session/codex/provision.js +25 -0
  27. package/dist/agent/session/fileTranscript.d.ts +6 -49
  28. package/dist/agent/session/fileTranscript.js +26 -82
  29. package/dist/agent/session/install.d.ts +5 -0
  30. package/dist/agent/session/install.js +35 -0
  31. package/dist/agent/session/sessionStorage.d.ts +4 -35
  32. package/dist/agent/session/sessionStorage.js +10 -16
  33. package/dist/agent/session/sharedConnection.d.ts +0 -8
  34. package/dist/agent/session/sharedConnection.js +0 -7
  35. package/dist/agent/session/storedTranscripts.d.ts +1 -6
  36. package/dist/agent/session/storedTranscripts.js +0 -3
  37. package/dist/agent/session/worker/jsonRpc.d.ts +2 -13
  38. package/dist/agent/session/worker/jsonRpc.js +2 -11
  39. package/dist/agent/session/worker/protocol.d.ts +27 -18
  40. package/dist/agent/session/worker/protocol.js +32 -16
  41. package/dist/agent/session/worker/workerParams.d.ts +0 -5
  42. package/dist/agent/session/worker/workerParams.js +15 -14
  43. package/dist/agent/session/workerConnection.d.ts +2 -13
  44. package/dist/agent/session/workerConnection.js +7 -12
  45. package/dist/agent/session/workerGatewayConnection.d.ts +18 -0
  46. package/dist/agent/session/workerGatewayConnection.js +86 -0
  47. package/dist/agent/vendor-types/manifest.json +87 -0
  48. package/dist/agent/workerBundle.d.ts +4 -19
  49. package/dist/agent/workerBundle.generated.js +1 -1
  50. package/dist/agent/workerBundle.js +3 -30
  51. package/dist/agent/workerReplacement.d.ts +3 -0
  52. package/dist/agent/workerReplacement.js +49 -0
  53. package/dist/cli/agent.js +44 -16
  54. package/dist/cli/command.d.ts +10 -11
  55. package/dist/cli/command.js +0 -4
  56. package/dist/cli/dimension.js +6 -6
  57. package/dist/cli/gateway.js +3 -6
  58. package/dist/cli/main.js +14 -31
  59. package/dist/cli/streamPrinter.d.ts +2 -10
  60. package/dist/cli/streamPrinter.js +16 -46
  61. package/dist/cli/table.d.ts +0 -3
  62. package/dist/cli/table.js +0 -4
  63. package/dist/dimension/api.d.ts +39 -27
  64. package/dist/dimension/api.js +28 -47
  65. package/dist/dimension/client.d.ts +5 -0
  66. package/dist/dimension/client.js +4 -0
  67. package/dist/dimension/control.d.ts +21 -0
  68. package/dist/dimension/control.js +328 -0
  69. package/dist/dimension/image.d.ts +0 -5
  70. package/dist/dimension/image.js +0 -8
  71. package/dist/dimension/imageFiles.generated.js +1 -1
  72. package/dist/dimension/paths.d.ts +5 -4
  73. package/dist/dimension/paths.js +15 -6
  74. package/dist/dimension/service.d.ts +8 -21
  75. package/dist/dimension/service.js +60 -224
  76. package/dist/dimension/transport-worker.d.ts +1 -0
  77. package/dist/dimension/transport-worker.js +151 -0
  78. package/dist/dimension/transportBundle.d.ts +2 -0
  79. package/dist/dimension/transportBundle.generated.d.ts +1 -0
  80. package/dist/dimension/transportBundle.generated.js +2 -0
  81. package/dist/dimension/transportBundle.js +4 -0
  82. package/dist/dimension/transportClient.d.ts +10 -0
  83. package/dist/dimension/transportClient.js +115 -0
  84. package/dist/dimension/transportProtocol.d.ts +53 -0
  85. package/dist/dimension/transportProtocol.js +8 -0
  86. package/dist/index.d.ts +5 -8
  87. package/dist/index.js +5 -16
  88. package/dist/lib/control.d.ts +8 -0
  89. package/dist/lib/control.js +62 -0
  90. package/dist/lib/docker.d.ts +0 -22
  91. package/dist/lib/docker.js +0 -10
  92. package/dist/lib/image.d.ts +0 -3
  93. package/dist/lib/image.js +0 -2
  94. package/dist/lib/jsonDirectory.d.ts +0 -13
  95. package/dist/lib/jsonDirectory.js +10 -15
  96. package/dist/lib/scriptBundle.d.ts +6 -0
  97. package/dist/lib/scriptBundle.js +28 -0
  98. package/dist/lib/sh.d.ts +2 -0
  99. package/dist/lib/sh.js +8 -0
  100. package/dist/lib/sleep.d.ts +0 -1
  101. package/dist/lib/sleep.js +0 -1
  102. package/dist/lib/streamFetch.d.ts +15 -0
  103. package/dist/lib/streamFetch.js +150 -0
  104. package/dist/lib/tar.d.ts +0 -1
  105. package/dist/lib/tar.js +5 -11
  106. package/dist/portal-gateway/api.d.ts +13 -44
  107. package/dist/portal-gateway/api.js +1 -3
  108. package/dist/portal-gateway/control.d.ts +42 -0
  109. package/dist/portal-gateway/control.js +657 -0
  110. package/dist/portal-gateway/gatewayClient.d.ts +5 -0
  111. package/dist/portal-gateway/gatewayClient.js +4 -0
  112. package/dist/portal-gateway/gatewayService.d.ts +8 -35
  113. package/dist/portal-gateway/gatewayService.js +79 -253
  114. package/dist/portal-gateway/image.d.ts +1 -2
  115. package/dist/portal-gateway/image.js +1 -3
  116. package/dist/portal-gateway/imageFiles.generated.js +3 -3
  117. package/dist/portal-gateway/portalClient.d.ts +5 -0
  118. package/dist/portal-gateway/portalClient.js +4 -0
  119. package/dist/portal-gateway/portalService.d.ts +5 -47
  120. package/dist/portal-gateway/portalService.js +19 -317
  121. package/dist/portal-gateway/transport-worker.d.ts +1 -0
  122. package/dist/portal-gateway/transport-worker.js +205 -0
  123. package/dist/portal-gateway/transportBundle.d.ts +2 -0
  124. package/dist/portal-gateway/transportBundle.generated.d.ts +1 -0
  125. package/dist/portal-gateway/transportBundle.generated.js +2 -0
  126. package/dist/portal-gateway/transportBundle.js +4 -0
  127. package/dist/portal-gateway/upstream.d.ts +12 -82
  128. package/dist/portal-gateway/upstream.js +0 -1
  129. package/dist/sandbox/api.d.ts +121 -178
  130. package/dist/sandbox/api.js +5 -7
  131. package/dist/sandbox/client.d.ts +2 -0
  132. package/dist/sandbox/client.js +4 -0
  133. package/dist/sandbox/data-endpoints/dataEndpoints.d.ts +13 -0
  134. package/dist/sandbox/data-endpoints/dataEndpoints.js +100 -0
  135. package/dist/sandbox/data-endpoints/firewalledPorts.d.ts +4 -0
  136. package/dist/sandbox/data-endpoints/firewalledPorts.js +28 -0
  137. package/dist/sandbox/lib/httpOverStream.d.ts +3 -0
  138. package/dist/sandbox/lib/httpOverStream.js +21 -0
  139. package/dist/sandbox/lib/remoteFileOperations.d.ts +10 -0
  140. package/dist/sandbox/lib/remoteFileOperations.js +70 -0
  141. package/dist/sandbox/lib/shellCommand.d.ts +3 -0
  142. package/dist/sandbox/lib/shellCommand.js +7 -0
  143. package/dist/sandbox/providers/boat/boatService.d.ts +2 -0
  144. package/dist/sandbox/providers/boat/boatService.js +321 -0
  145. package/dist/sandbox/providers/exedev/exeDevService.d.ts +2 -0
  146. package/dist/sandbox/providers/exedev/exeDevService.js +127 -0
  147. package/dist/sandbox/providers/exedev/exeDevToken.d.ts +1 -0
  148. package/dist/sandbox/providers/exedev/exeDevToken.js +54 -0
  149. package/dist/sandbox/providers/local/localService.d.ts +2 -0
  150. package/dist/sandbox/providers/local/localService.js +219 -0
  151. package/dist/sandbox/providers/ssh/sshService.d.ts +2 -0
  152. package/dist/sandbox/providers/ssh/sshService.js +82 -0
  153. package/dist/sandbox/providers/ssh/sshTransport.d.ts +12 -0
  154. package/dist/sandbox/providers/ssh/sshTransport.js +216 -0
  155. package/dist/sandbox/service.d.ts +2 -0
  156. package/dist/sandbox/service.js +18 -0
  157. package/dist/sandbox/storage/descriptors.d.ts +6 -0
  158. package/dist/sandbox/storage/descriptors.js +25 -0
  159. package/dist/sandbox/storage/sandboxStorage.d.ts +11 -0
  160. package/dist/sandbox/storage/sandboxStorage.js +42 -0
  161. package/dist/upstreams/anthropic.d.ts +0 -5
  162. package/dist/upstreams/anthropic.js +1 -8
  163. package/dist/upstreams/index.d.ts +2 -6
  164. package/dist/upstreams/index.js +0 -4
  165. package/dist/upstreams/openai.d.ts +0 -5
  166. package/dist/upstreams/openai.js +1 -13
  167. package/package.json +12 -4
  168. package/dist/agent/gatewaySignIns.d.ts +0 -5
  169. package/dist/agent/gatewaySignIns.js +0 -11
  170. package/dist/agent/session/codex/home.d.ts +0 -2
  171. package/dist/agent/session/codex/home.js +0 -4
  172. package/dist/agent/session/codex/messages.d.ts +0 -30
  173. package/dist/agent/session/codex/messages.js +0 -135
  174. package/dist/agent/session/codex/transcript.d.ts +0 -12
  175. package/dist/agent/session/codex/transcript.js +0 -22
  176. package/dist/agent/session/sdkMessages.d.ts +0 -15
  177. package/dist/agent/session/sdkMessages.js +0 -28
  178. package/dist/agent/session/workerSseConnection.d.ts +0 -15
  179. package/dist/agent/session/workerSseConnection.js +0 -26
  180. package/dist/agent/vendor-types/codex/app-server/AbsolutePathBuf.d.ts +0 -10
  181. package/dist/agent/vendor-types/codex/app-server/AbsolutePathBuf.js +0 -2
  182. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputBody.d.ts +0 -2
  183. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputBody.js +0 -2
  184. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputContentItem.d.ts +0 -22
  185. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputContentItem.js +0 -2
  186. package/dist/agent/vendor-types/codex/app-server/ImageDetail.d.ts +0 -1
  187. package/dist/agent/vendor-types/codex/app-server/ImageDetail.js +0 -2
  188. package/dist/agent/vendor-types/codex/app-server/ImageGenerationFailure.d.ts +0 -5
  189. package/dist/agent/vendor-types/codex/app-server/ImageGenerationFailure.js +0 -2
  190. package/dist/agent/vendor-types/codex/app-server/ImageGenerationItem.d.ts +0 -11
  191. package/dist/agent/vendor-types/codex/app-server/ImageGenerationItem.js +0 -2
  192. package/dist/agent/vendor-types/codex/app-server/LegacyAppPathString.d.ts +0 -23
  193. package/dist/agent/vendor-types/codex/app-server/LegacyAppPathString.js +0 -2
  194. package/dist/agent/vendor-types/codex/app-server/MessagePhase.d.ts +0 -7
  195. package/dist/agent/vendor-types/codex/app-server/MessagePhase.js +0 -2
  196. package/dist/agent/vendor-types/codex/app-server/ReasoningEffort.d.ts +0 -4
  197. package/dist/agent/vendor-types/codex/app-server/ReasoningEffort.js +0 -2
  198. package/dist/agent/vendor-types/codex/app-server/SleepItem.d.ts +0 -7
  199. package/dist/agent/vendor-types/codex/app-server/SleepItem.js +0 -2
  200. package/dist/agent/vendor-types/codex/app-server/WebSearchItem.d.ts +0 -14
  201. package/dist/agent/vendor-types/codex/app-server/WebSearchItem.js +0 -2
  202. package/dist/agent/vendor-types/codex/app-server/serde_json/JsonValue.d.ts +0 -3
  203. package/dist/agent/vendor-types/codex/app-server/serde_json/JsonValue.js +0 -2
  204. package/dist/agent/vendor-types/codex/app-server/v2/AgentMessageDelivery.d.ts +0 -1
  205. package/dist/agent/vendor-types/codex/app-server/v2/AgentMessageDelivery.js +0 -2
  206. package/dist/agent/vendor-types/codex/app-server/v2/AsyncUserInputQuestion.d.ts +0 -4
  207. package/dist/agent/vendor-types/codex/app-server/v2/AsyncUserInputQuestion.js +0 -2
  208. package/dist/agent/vendor-types/codex/app-server/v2/ByteRange.d.ts +0 -4
  209. package/dist/agent/vendor-types/codex/app-server/v2/ByteRange.js +0 -2
  210. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentState.d.ts +0 -5
  211. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentState.js +0 -2
  212. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentStatus.d.ts +0 -1
  213. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentStatus.js +0 -2
  214. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentTool.d.ts +0 -1
  215. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentTool.js +0 -2
  216. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentToolCallStatus.d.ts +0 -1
  217. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentToolCallStatus.js +0 -2
  218. package/dist/agent/vendor-types/codex/app-server/v2/CommandAction.d.ts +0 -19
  219. package/dist/agent/vendor-types/codex/app-server/v2/CommandAction.js +0 -2
  220. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionSource.d.ts +0 -1
  221. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionSource.js +0 -2
  222. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionStatus.d.ts +0 -1
  223. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionStatus.js +0 -2
  224. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallOutputContentItem.d.ts +0 -10
  225. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallOutputContentItem.js +0 -2
  226. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallStatus.d.ts +0 -1
  227. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallStatus.js +0 -2
  228. package/dist/agent/vendor-types/codex/app-server/v2/FileUpdateChange.d.ts +0 -6
  229. package/dist/agent/vendor-types/codex/app-server/v2/FileUpdateChange.js +0 -2
  230. package/dist/agent/vendor-types/codex/app-server/v2/HookPromptFragment.d.ts +0 -4
  231. package/dist/agent/vendor-types/codex/app-server/v2/HookPromptFragment.js +0 -2
  232. package/dist/agent/vendor-types/codex/app-server/v2/McpAppDisplayMode.d.ts +0 -1
  233. package/dist/agent/vendor-types/codex/app-server/v2/McpAppDisplayMode.js +0 -2
  234. package/dist/agent/vendor-types/codex/app-server/v2/McpAppUi.d.ts +0 -8
  235. package/dist/agent/vendor-types/codex/app-server/v2/McpAppUi.js +0 -2
  236. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallAppContext.d.ts +0 -7
  237. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallAppContext.js +0 -2
  238. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallError.d.ts +0 -3
  239. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallError.js +0 -2
  240. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallResult.d.ts +0 -6
  241. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallResult.js +0 -2
  242. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallStatus.d.ts +0 -1
  243. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallStatus.js +0 -2
  244. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitation.d.ts +0 -5
  245. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitation.js +0 -2
  246. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitationEntry.d.ts +0 -6
  247. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitationEntry.js +0 -2
  248. package/dist/agent/vendor-types/codex/app-server/v2/PatchApplyStatus.d.ts +0 -1
  249. package/dist/agent/vendor-types/codex/app-server/v2/PatchApplyStatus.js +0 -2
  250. package/dist/agent/vendor-types/codex/app-server/v2/PatchChangeKind.d.ts +0 -8
  251. package/dist/agent/vendor-types/codex/app-server/v2/PatchChangeKind.js +0 -2
  252. package/dist/agent/vendor-types/codex/app-server/v2/SubAgentActivityKind.d.ts +0 -1
  253. package/dist/agent/vendor-types/codex/app-server/v2/SubAgentActivityKind.js +0 -2
  254. package/dist/agent/vendor-types/codex/app-server/v2/TextElement.d.ts +0 -11
  255. package/dist/agent/vendor-types/codex/app-server/v2/TextElement.js +0 -2
  256. package/dist/agent/vendor-types/codex/app-server/v2/ThreadItem.d.ts +0 -215
  257. package/dist/agent/vendor-types/codex/app-server/v2/ThreadItem.js +0 -2
  258. package/dist/agent/vendor-types/codex/app-server/v2/UserInput.d.ts +0 -35
  259. package/dist/agent/vendor-types/codex/app-server/v2/UserInput.js +0 -2
  260. package/dist/agent/vendor-types/codex/app-server/v2/WebSearchAction.d.ts +0 -14
  261. package/dist/agent/vendor-types/codex/app-server/v2/WebSearchAction.js +0 -2
  262. package/dist/cli/services.d.ts +0 -19
  263. package/dist/cli/services.js +0 -33
  264. package/dist/dimension/http.d.ts +0 -7
  265. package/dist/dimension/http.js +0 -77
  266. package/dist/dimension/storage.d.ts +0 -39
  267. package/dist/dimension/storage.js +0 -79
  268. package/dist/portal-gateway/fakes.d.ts +0 -3
  269. package/dist/portal-gateway/fakes.js +0 -11
  270. package/dist/portal-gateway/gatewayStorage.d.ts +0 -66
  271. package/dist/portal-gateway/gatewayStorage.js +0 -125
  272. package/dist/portal-gateway/portalStorage.d.ts +0 -34
  273. package/dist/portal-gateway/portalStorage.js +0 -44
  274. package/dist/sandbox/boatService.d.ts +0 -52
  275. package/dist/sandbox/boatService.js +0 -343
  276. package/dist/sandbox/containerService.d.ts +0 -15
  277. package/dist/sandbox/containerService.js +0 -184
  278. package/dist/sandbox/endpointProxy.d.ts +0 -90
  279. package/dist/sandbox/endpointProxy.js +0 -701
  280. package/dist/sandbox/exeDevAuth.d.ts +0 -16
  281. package/dist/sandbox/exeDevAuth.js +0 -49
  282. package/dist/sandbox/localService.d.ts +0 -19
  283. package/dist/sandbox/localService.js +0 -200
  284. package/dist/sandbox/sseConnection.d.ts +0 -41
  285. package/dist/sandbox/sseConnection.js +0 -166
  286. package/dist/sandbox/sshService.d.ts +0 -30
  287. 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\n# Gateway container entry: redirects everything arriving over a portal into mitmproxy, then becomes\n# mitmproxy. If the redirect can't be installed, or mitmproxy exits, the container exits: a gateway\n# that isn't intercepting must not look healthy.\nset -eu\n\nSTATE=/state\nPROXY_PORT=8080\nDNS_PORT=5353\n\n# Portals end in \"hvg*\" interfaces. All TCP goes to the transparent proxy and DNS to its resolver;\n# nothing is forwarded, so there is no path out that bypasses mitmproxy. mitmproxy's own upstream\n# connections are locally generated and never pass PREROUTING, so it doesn't intercept itself.\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\n# An exit code of 0 is not proof: read the rules back.\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\n# mitmproxy runs as the owner of the state directory, so everything it writes there (the CA) stays\n# readable and removable by the host service.\nowner_uid=\"$(stat -c %u \"$STATE\")\"\nowner_gid=\"$(stat -c %g \"$STATE\")\"\nexport HOME=/tmp\n# Eager: connect upstream as soon as the client connects, or protocols where the server speaks first\n# (SSH, SMTP) wait forever for a client that is waiting for them.\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\n# Wires a portal: a veth pair with one end in the dimension's network namespace and the other in the\n# gateway's, and a default route down it. A dimension with several portals has one default route per\n# portal; the lowest ROUTE_METRIC wins, and when its veth goes away (its gateway stopped) so does the\n# route, so traffic falls over to the next portal.\n# Runs in a privileged helper container sharing the host's network and pid namespaces, with the\n# host's /run at /hostrun.\n#\n# Inputs: PORTAL, DIMENSION_PID, GATEWAY_PID, DIMENSION_IF, GATEWAY_IF, DIMENSION_IP, GATEWAY_IP,\n# ROUTE_METRIC.\n# Safe to re-run: an already correct wiring is left alone, anything else is torn down and rebuilt.\nset -eu\n\n# Concurrent wirings of one portal would delete each other's veths; the lock lives on the host so it\n# is shared by every helper container.\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\n# A stale end can be in either namespace, or left on the host when a container died mid-wiring.\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",
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
  };
@@ -0,0 +1,5 @@
1
+ import type { Sandbox } from "../sandbox/api.ts";
2
+ import type { PortalClient } from "./api.ts";
3
+ export declare function createPortalClient(sandbox: Sandbox, options?: {
4
+ dataDirectory?: string;
5
+ }): PortalClient;
@@ -0,0 +1,4 @@
1
+ import { createPortalService } from "./portalService.js";
2
+ export function createPortalClient(sandbox, options = {}) {
3
+ return createPortalService(sandbox, options);
4
+ }
@@ -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 { type Portal } from "./api.ts";
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
- type PortalLink = {
24
- dimensionIf: string;
25
- gatewayIf: string;
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 { createHash } from "node:crypto";
2
- import { containerName as dimensionContainerName } from "../dimension/service.js";
3
- import { DockerError } from "../lib/docker.js";
4
- import { sleep } from "../lib/sleep.js";
5
- import { GatewayConflictError, GatewayNotFoundError } from "./api.js";
6
- import { assertValidName, gatewayCaCertificatePath, gatewayContainerName } from "./gatewayService.js";
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
- const gateway = await gateways.readGateway(portal.gatewayName);
72
- if (gateway?.state !== "ready")
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
- const record = await storage.readPortal(portalName);
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
- /** Null when the container isn't running. */
254
- async function listInterfaces(container) {
255
- try {
256
- const result = await docker.exec(container, { cmd: ["ip", "-br", "link"] });
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 { createPortal, destroyPortal, listPortals, restorePortals };
268
- }
269
- /** Runs `action` unless its container isn't running, in which case there's nothing to undo. */
270
- async function ignoringStopped(action) {
271
- try {
272
- await action();
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 {};