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