hyperbrane 0.1.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 (123) hide show
  1. package/README.md +41 -0
  2. package/dist/agent/agentService.d.ts +31 -0
  3. package/dist/agent/agentService.js +388 -0
  4. package/dist/agent/agentStorage.d.ts +27 -0
  5. package/dist/agent/agentStorage.js +52 -0
  6. package/dist/agent/api.d.ts +264 -0
  7. package/dist/agent/api.js +14 -0
  8. package/dist/agent/gatewaySignIns.d.ts +5 -0
  9. package/dist/agent/gatewaySignIns.js +11 -0
  10. package/dist/agent/session/client.d.ts +14 -0
  11. package/dist/agent/session/client.js +47 -0
  12. package/dist/agent/session/sessionStorage.d.ts +66 -0
  13. package/dist/agent/session/sessionStorage.js +89 -0
  14. package/dist/agent/session/sharedConnection.d.ts +13 -0
  15. package/dist/agent/session/sharedConnection.js +55 -0
  16. package/dist/agent/session/worker/jsonRpc.d.ts +39 -0
  17. package/dist/agent/session/worker/jsonRpc.js +102 -0
  18. package/dist/agent/session/worker/protocol.d.ts +120 -0
  19. package/dist/agent/session/worker/protocol.js +32 -0
  20. package/dist/agent/session/workerConnection.d.ts +29 -0
  21. package/dist/agent/session/workerConnection.js +44 -0
  22. package/dist/agent/session/workerSseConnection.d.ts +15 -0
  23. package/dist/agent/session/workerSseConnection.js +26 -0
  24. package/dist/agent/workerBundle.d.ts +19 -0
  25. package/dist/agent/workerBundle.generated.d.ts +1 -0
  26. package/dist/agent/workerBundle.generated.js +2 -0
  27. package/dist/agent/workerBundle.js +31 -0
  28. package/dist/cli/agent.d.ts +3 -0
  29. package/dist/cli/agent.js +423 -0
  30. package/dist/cli/command.d.ts +46 -0
  31. package/dist/cli/command.js +98 -0
  32. package/dist/cli/dimension.d.ts +2 -0
  33. package/dist/cli/dimension.js +85 -0
  34. package/dist/cli/gateway.d.ts +6 -0
  35. package/dist/cli/gateway.js +245 -0
  36. package/dist/cli/main.d.ts +2 -0
  37. package/dist/cli/main.js +125 -0
  38. package/dist/cli/portal.d.ts +2 -0
  39. package/dist/cli/portal.js +39 -0
  40. package/dist/cli/services.d.ts +18 -0
  41. package/dist/cli/services.js +33 -0
  42. package/dist/cli/streamPrinter.d.ts +23 -0
  43. package/dist/cli/streamPrinter.js +251 -0
  44. package/dist/cli/table.d.ts +7 -0
  45. package/dist/cli/table.js +54 -0
  46. package/dist/dimension/api.d.ts +67 -0
  47. package/dist/dimension/api.js +81 -0
  48. package/dist/dimension/http.d.ts +7 -0
  49. package/dist/dimension/http.js +77 -0
  50. package/dist/dimension/image.d.ts +13 -0
  51. package/dist/dimension/image.js +43 -0
  52. package/dist/dimension/imageFiles.generated.d.ts +1 -0
  53. package/dist/dimension/imageFiles.generated.js +5 -0
  54. package/dist/dimension/paths.d.ts +5 -0
  55. package/dist/dimension/paths.js +7 -0
  56. package/dist/dimension/service.d.ts +22 -0
  57. package/dist/dimension/service.js +242 -0
  58. package/dist/dimension/storage.d.ts +39 -0
  59. package/dist/dimension/storage.js +78 -0
  60. package/dist/index.d.ts +18 -0
  61. package/dist/index.js +25 -0
  62. package/dist/lib/docker-api.d.ts +13787 -0
  63. package/dist/lib/docker-api.js +5 -0
  64. package/dist/lib/docker.d.ts +77 -0
  65. package/dist/lib/docker.js +214 -0
  66. package/dist/lib/image.d.ts +14 -0
  67. package/dist/lib/image.js +29 -0
  68. package/dist/lib/jsonDirectory.d.ts +28 -0
  69. package/dist/lib/jsonDirectory.js +102 -0
  70. package/dist/lib/sleep.d.ts +2 -0
  71. package/dist/lib/sleep.js +4 -0
  72. package/dist/lib/tar.d.ts +2 -0
  73. package/dist/lib/tar.js +65 -0
  74. package/dist/lib/version.d.ts +1 -0
  75. package/dist/lib/version.js +1 -0
  76. package/dist/portal-gateway/api.d.ts +98 -0
  77. package/dist/portal-gateway/api.js +12 -0
  78. package/dist/portal-gateway/fakes.d.ts +3 -0
  79. package/dist/portal-gateway/fakes.js +11 -0
  80. package/dist/portal-gateway/gatewayService.d.ts +37 -0
  81. package/dist/portal-gateway/gatewayService.js +380 -0
  82. package/dist/portal-gateway/gatewayStorage.d.ts +66 -0
  83. package/dist/portal-gateway/gatewayStorage.js +125 -0
  84. package/dist/portal-gateway/image.d.ts +6 -0
  85. package/dist/portal-gateway/image.js +13 -0
  86. package/dist/portal-gateway/imageFiles.generated.d.ts +1 -0
  87. package/dist/portal-gateway/imageFiles.generated.js +8 -0
  88. package/dist/portal-gateway/portalService.d.ts +48 -0
  89. package/dist/portal-gateway/portalService.js +327 -0
  90. package/dist/portal-gateway/portalStorage.d.ts +34 -0
  91. package/dist/portal-gateway/portalStorage.js +44 -0
  92. package/dist/portal-gateway/upstream.d.ts +157 -0
  93. package/dist/portal-gateway/upstream.js +4 -0
  94. package/dist/sandbox/api.d.ts +184 -0
  95. package/dist/sandbox/api.js +14 -0
  96. package/dist/sandbox/boatService.d.ts +52 -0
  97. package/dist/sandbox/boatService.js +335 -0
  98. package/dist/sandbox/containerService.d.ts +15 -0
  99. package/dist/sandbox/containerService.js +177 -0
  100. package/dist/sandbox/endpointProxy.d.ts +90 -0
  101. package/dist/sandbox/endpointProxy.js +701 -0
  102. package/dist/sandbox/exeDevAuth.d.ts +16 -0
  103. package/dist/sandbox/exeDevAuth.js +49 -0
  104. package/dist/sandbox/localService.d.ts +19 -0
  105. package/dist/sandbox/localService.js +182 -0
  106. package/dist/sandbox/sseConnection.d.ts +41 -0
  107. package/dist/sandbox/sseConnection.js +166 -0
  108. package/dist/sandbox/sshService.d.ts +30 -0
  109. package/dist/sandbox/sshService.js +647 -0
  110. package/dist/upstreams/anthropic.d.ts +7 -0
  111. package/dist/upstreams/anthropic.js +27 -0
  112. package/dist/upstreams/index.d.ts +6 -0
  113. package/dist/upstreams/index.js +7 -0
  114. package/dist/upstreams/openai.d.ts +7 -0
  115. package/dist/upstreams/openai.js +40 -0
  116. package/dist/vendor-types/claude/agent-sdk/agentSdkTypes.d.ts +1 -0
  117. package/dist/vendor-types/claude/agent-sdk/bridge.d.ts +388 -0
  118. package/dist/vendor-types/claude/agent-sdk/browser-sdk.d.ts +184 -0
  119. package/dist/vendor-types/claude/agent-sdk/core.d.ts +20 -0
  120. package/dist/vendor-types/claude/agent-sdk/extractFromBunfs.d.ts +1 -0
  121. package/dist/vendor-types/claude/agent-sdk/sdk-tools.d.ts +4153 -0
  122. package/dist/vendor-types/claude/agent-sdk/sdk.d.ts +9833 -0
  123. package/package.json +55 -0
@@ -0,0 +1,12 @@
1
+ /** The request can't be satisfied as sent (bad name, unsupported action, ...). */
2
+ export class GatewayInvalidError extends Error {
3
+ name = "GatewayInvalidError";
4
+ }
5
+ /** The name is taken with a different spec, or by a container hyperbrane doesn't own. */
6
+ export class GatewayConflictError extends Error {
7
+ name = "GatewayConflictError";
8
+ }
9
+ /** A gateway, rule, dimension or portal the request refers to doesn't exist. */
10
+ export class GatewayNotFoundError extends Error {
11
+ name = "GatewayNotFoundError";
12
+ }
@@ -0,0 +1,3 @@
1
+ import type { FakeShape } from "./upstream.ts";
2
+ /** A fake for an API key: the shape's prefix, then random characters as long as the real key. */
3
+ export declare function issueKeyFake(shape: FakeShape, key: string): string;
@@ -0,0 +1,11 @@
1
+ // Like the gateway addon (image/upstreams.py), which issues the fakes for captured tokens.
2
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3
+ function random(length) {
4
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
5
+ return Array.from(bytes, (byte) => ALPHABET[byte % ALPHABET.length]).join("");
6
+ }
7
+ /** A fake for an API key: the shape's prefix, then random characters as long as the real key. */
8
+ export function issueKeyFake(shape, key) {
9
+ const prefix = "prefix" in shape ? shape.prefix : "";
10
+ return prefix + random(Math.max(key.length - prefix.length, 16));
11
+ }
@@ -0,0 +1,37 @@
1
+ import { type DockerClient } from "../lib/docker.ts";
2
+ import type { ManagedImage } from "../lib/image.ts";
3
+ import { type Sandbox } from "../sandbox/api.ts";
4
+ import { type Gateway, type GatewayCredential, type TrafficRule, type Upstream } from "./api.ts";
5
+ import type { GatewayStorage } from "./gatewayStorage.ts";
6
+ import type { PortalStorage } from "./portalStorage.ts";
7
+ export interface GatewayService {
8
+ createGateway(gateway: Gateway): Promise<Gateway>;
9
+ destroyGateway(gatewayName: string): Promise<void>;
10
+ listGateways(): Promise<Gateway[]>;
11
+ restartGateway(gatewayName: string): Promise<void>;
12
+ listCredentials(gatewayName: string): Promise<GatewayCredential[]>;
13
+ deleteCredential(gatewayName: string, credentialName: string): Promise<void>;
14
+ registerUpstream(gatewayName: string, upstream: Upstream): Promise<Upstream>;
15
+ listUpstreams(gatewayName: string): Promise<Upstream[]>;
16
+ removeUpstream(gatewayName: string, upstreamName: string): Promise<void>;
17
+ setApiKey(gatewayName: string, upstreamName: string, keyName: string, key: string): Promise<{
18
+ fake: string;
19
+ }>;
20
+ deleteApiKey(gatewayName: string, upstreamName: string, keyName: string): Promise<void>;
21
+ createTrafficRule(gatewayName: string, rule: TrafficRule): Promise<TrafficRule>;
22
+ updateTrafficRule(gatewayName: string, rule: TrafficRule): Promise<TrafficRule>;
23
+ deleteTrafficRule(gatewayName: string, ruleName: string): Promise<void>;
24
+ listTrafficRules(gatewayName: string): Promise<TrafficRule[]>;
25
+ trafficRuleRespond(gatewayName: string, ruleName: string, action: "ALLOW" | "DENY"): Promise<void>;
26
+ }
27
+ /** Where the gateway's state directory is mounted in its container. */
28
+ export declare const GATEWAY_STATE_PATH = "/state";
29
+ export declare function gatewayContainerName(gatewayName: string): string;
30
+ /** Host path of the CA that signs everything the gateway intercepts; dimensions behind it must trust it. */
31
+ export declare function gatewayCaCertificatePath(stateDirectory: string): string;
32
+ export declare function assertValidName(kind: string, name: string): void;
33
+ /**
34
+ * `host` is the machine the docker daemon runs on, rooted at "/" so the state directory (a
35
+ * bind-mount source) resolves the same way docker resolves it.
36
+ */
37
+ export declare function createGatewayService(storage: GatewayStorage, portals: PortalStorage, docker: DockerClient, image: ManagedImage, host: Sandbox): GatewayService;
@@ -0,0 +1,380 @@
1
+ import { isIPv4 } from "node:net";
2
+ import { DockerError } from "../lib/docker.js";
3
+ import { InvalidNameError, checkName } from "../lib/jsonDirectory.js";
4
+ import { SandboxNotFoundError } from "../sandbox/api.js";
5
+ import { sleep } from "../lib/sleep.js";
6
+ import { HYPERBRANE_VERSION } from "../lib/version.js";
7
+ import { GatewayConflictError, GatewayInvalidError, GatewayNotFoundError, } from "./api.js";
8
+ import { issueKeyFake } from "./fakes.js";
9
+ import { BUILTIN_UPSTREAMS } from "../upstreams/index.js";
10
+ /** Where the gateway's state directory is mounted in its container. */
11
+ export const GATEWAY_STATE_PATH = "/state";
12
+ const PROXY_PORT = 8080;
13
+ const READY_TIMEOUT_MS = 60_000;
14
+ // "hybgw-" can't collide with a dimension's "hyb-<name>": dimension names start with a letter or digit.
15
+ export function gatewayContainerName(gatewayName) {
16
+ return `hybgw-${gatewayName}`;
17
+ }
18
+ /** Host path of the CA that signs everything the gateway intercepts; dimensions behind it must trust it. */
19
+ export function gatewayCaCertificatePath(stateDirectory) {
20
+ return `${stateDirectory}/mitmproxy/mitmproxy-ca-cert.pem`;
21
+ }
22
+ export function assertValidName(kind, name) {
23
+ try {
24
+ checkName(name);
25
+ }
26
+ catch (error) {
27
+ if (error instanceof InvalidNameError)
28
+ throw new GatewayInvalidError(`invalid ${kind} name: ${error.message}`);
29
+ throw error;
30
+ }
31
+ }
32
+ /**
33
+ * `host` is the machine the docker daemon runs on, rooted at "/" so the state directory (a
34
+ * bind-mount source) resolves the same way docker resolves it.
35
+ */
36
+ export function createGatewayService(storage, portals, docker, image, host) {
37
+ // Keeps no state between calls: the record is written first so a slow or crashed create stays
38
+ // visible, and every later step checks before it acts, so re-running converges.
39
+ async function createGateway(gateway) {
40
+ assertValidName("gateway", gateway.name);
41
+ const upstreams = (gateway.upstreams ?? BUILTIN_UPSTREAMS).map(validUpstream);
42
+ const name = gatewayContainerName(gateway.name);
43
+ const existing = await docker.inspectContainer(name);
44
+ if (existing)
45
+ assertOwnedContainer(existing, gateway.name);
46
+ const record = await storage.insertGateway({ name: gateway.name, state: "creating" });
47
+ const stateDirectory = await storage.ensureStateDirectory(gateway.name);
48
+ for (const upstream of upstreams)
49
+ await storage.saveUpstream(gateway.name, upstream);
50
+ const imageTag = await image.ensure();
51
+ await ensureContainer(gateway.name, stateDirectory, imageTag);
52
+ await docker.startContainer(name);
53
+ await awaitReady(gateway.name, stateDirectory);
54
+ if (record.state !== "ready")
55
+ await storage.saveGateway({ name: gateway.name, state: "ready" });
56
+ return { name: gateway.name };
57
+ }
58
+ // A container from an older image is replaced: everything worth keeping (CA, credentials, rules)
59
+ // is in the mounted state directory. Its portals are re-wired by the portal watcher, or by the
60
+ // restore every command runs first.
61
+ async function ensureContainer(gatewayName, stateDirectory, imageTag) {
62
+ const name = gatewayContainerName(gatewayName);
63
+ const existing = await docker.inspectContainer(name);
64
+ if (existing) {
65
+ assertOwnedContainer(existing, gatewayName);
66
+ if (existing.Config?.Image === imageTag)
67
+ return;
68
+ await docker.removeContainer(name);
69
+ }
70
+ try {
71
+ await docker.createContainer(name, {
72
+ Image: imageTag,
73
+ Hostname: name,
74
+ Labels: {
75
+ hyperbrane: "true",
76
+ "hyperbrane.version": HYPERBRANE_VERSION,
77
+ "hyperbrane.gateway": gatewayName,
78
+ },
79
+ HostConfig: {
80
+ // A mount, not a copy: rules and credentials written by the service take effect on the next
81
+ // request, and the CA survives the container being recreated.
82
+ Binds: [`${stateDirectory}:${GATEWAY_STATE_PATH}`],
83
+ CapAdd: ["NET_ADMIN"],
84
+ NetworkMode: "bridge",
85
+ Init: true,
86
+ RestartPolicy: { Name: "unless-stopped" },
87
+ },
88
+ });
89
+ }
90
+ catch (error) {
91
+ // Lost a race with another creator: fine as long as the winner is ours.
92
+ if (!(error instanceof DockerError && error.status === 409))
93
+ throw error;
94
+ assertOwnedContainer(await awaitContainer(name, error), gatewayName);
95
+ }
96
+ }
97
+ // Docker reserves the name before the winner's container becomes inspectable, so give it a moment.
98
+ async function awaitContainer(name, conflict) {
99
+ for (let attempt = 0; attempt < 50; attempt++) {
100
+ const container = await docker.inspectContainer(name);
101
+ if (container)
102
+ return container;
103
+ await sleep(100);
104
+ }
105
+ throw conflict;
106
+ }
107
+ // Ready = the proxy is listening (so the redirect rules are in) and the CA exists (so portals can
108
+ // install it into dimensions).
109
+ async function awaitReady(gatewayName, stateDirectory) {
110
+ const name = gatewayContainerName(gatewayName);
111
+ const deadline = Date.now() + READY_TIMEOUT_MS;
112
+ while (Date.now() < deadline) {
113
+ if ((await proxyListening(name)) && (await exists(gatewayCaCertificatePath(stateDirectory))))
114
+ return;
115
+ await sleep(250);
116
+ }
117
+ const state = (await docker.inspectContainer(name))?.State;
118
+ throw new Error(`gateway ${gatewayName} did not become ready: container ${state?.Status ?? "missing"}, exit code ${state?.ExitCode ?? "-"}`);
119
+ }
120
+ async function proxyListening(name) {
121
+ try {
122
+ const result = await docker.exec(name, { cmd: ["ss", "-Hltn", `sport = :${PROXY_PORT}`], user: "root" });
123
+ return result.exitCode === 0 && result.stdout.trim() !== "";
124
+ }
125
+ catch (error) {
126
+ // Not running (yet, or restarting after a failed start).
127
+ if (error instanceof DockerError && error.status === 409)
128
+ return false;
129
+ throw error;
130
+ }
131
+ }
132
+ async function exists(path) {
133
+ try {
134
+ await host.stat(path);
135
+ return true;
136
+ }
137
+ catch (error) {
138
+ if (error instanceof SandboxNotFoundError)
139
+ return false;
140
+ throw error;
141
+ }
142
+ }
143
+ // Container first: if this crashes before the record is removed, the gateway still shows up and
144
+ // the destroy can be retried. Its state (credentials included) goes with the record.
145
+ async function destroyGateway(gatewayName) {
146
+ assertValidName("gateway", gatewayName);
147
+ const attached = (await portals.listPortals()).filter((portal) => portal.gatewayName === gatewayName);
148
+ if (attached.length > 0) {
149
+ throw new GatewayConflictError(`gateway ${gatewayName} still has portals: ${attached.map((portal) => portal.name).join(", ")}`);
150
+ }
151
+ const name = gatewayContainerName(gatewayName);
152
+ const existing = await docker.inspectContainer(name);
153
+ if (existing) {
154
+ assertOwnedContainer(existing, gatewayName);
155
+ await docker.removeContainer(name);
156
+ }
157
+ await storage.removeGateway(gatewayName);
158
+ }
159
+ async function restartGateway(gatewayName) {
160
+ const record = await requireGateway(gatewayName);
161
+ const name = gatewayContainerName(gatewayName);
162
+ const container = await docker.inspectContainer(name);
163
+ if (!container)
164
+ throw new GatewayNotFoundError(`gateway ${gatewayName} has no container; create it again`);
165
+ assertOwnedContainer(container, gatewayName);
166
+ await docker.restartContainer(name);
167
+ await awaitReady(gatewayName, await storage.ensureStateDirectory(record.name));
168
+ }
169
+ async function listGateways() {
170
+ return (await storage.listGateways()).map(({ name }) => ({ name }));
171
+ }
172
+ async function listCredentials(gatewayName) {
173
+ await requireGateway(gatewayName);
174
+ // All of them at once: a read per upstream adds up on a remote machine.
175
+ const [upstreams, held] = await Promise.all([storage.listUpstreams(gatewayName), storage.listCredentials(gatewayName)]);
176
+ const credentials = [];
177
+ for (const upstream of upstreams) {
178
+ if (held.get(`${upstream.name}.oauth`))
179
+ credentials.push({ name: `${upstream.name}.oauth`, gatewayName, type: "oauth" });
180
+ const apiKeys = held.get(`${upstream.name}.api-keys`);
181
+ for (const [keyName, { fake }] of Object.entries(apiKeys?.keys ?? {})) {
182
+ credentials.push({ name: `${upstream.name}.key.${keyName}`, gatewayName, type: "apiKey", fake });
183
+ }
184
+ }
185
+ return credentials;
186
+ }
187
+ // Storage holds the only copy of a secret, and the gateway re-reads credentials per request. Without
188
+ // its OAuth credential, the upstream's next sign-in goes to the upstream again (switching accounts).
189
+ async function deleteCredential(gatewayName, credentialName) {
190
+ assertValidName("gateway", gatewayName);
191
+ const keyAt = credentialName.indexOf(".key.");
192
+ if (credentialName.endsWith(".oauth")) {
193
+ const upstreamName = credentialName.slice(0, -".oauth".length);
194
+ assertValidName("upstream", upstreamName);
195
+ await storage.removeOAuthCredential(gatewayName, upstreamName);
196
+ }
197
+ else if (keyAt > 0) {
198
+ await deleteApiKey(gatewayName, credentialName.slice(0, keyAt), credentialName.slice(keyAt + ".key.".length));
199
+ }
200
+ else {
201
+ throw new GatewayInvalidError(`credential names are <upstream>.oauth or <upstream>.key.<key>, not ${credentialName}`);
202
+ }
203
+ }
204
+ async function registerUpstream(gatewayName, upstream) {
205
+ await requireGateway(gatewayName);
206
+ const definition = validUpstream(upstream);
207
+ await storage.saveUpstream(gatewayName, definition);
208
+ return definition;
209
+ }
210
+ async function listUpstreams(gatewayName) {
211
+ await requireGateway(gatewayName);
212
+ return (await storage.listUpstreams(gatewayName)).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
213
+ }
214
+ async function removeUpstream(gatewayName, upstreamName) {
215
+ assertValidName("gateway", gatewayName);
216
+ assertValidName("upstream", upstreamName);
217
+ await storage.removeUpstream(gatewayName, upstreamName);
218
+ }
219
+ // A key keeps its fake when replaced, so dimensions already holding it keep working.
220
+ async function setApiKey(gatewayName, upstreamName, keyName, key) {
221
+ const mode = (await requireUpstream(gatewayName, upstreamName)).modes.apiKey;
222
+ if (!mode)
223
+ throw new GatewayInvalidError(`upstream ${upstreamName} has no API key mode`);
224
+ assertValidName("API key", keyName);
225
+ if (typeof key !== "string" || key === "")
226
+ throw new GatewayInvalidError("the API key is empty");
227
+ const stored = (await storage.readApiKeys(gatewayName, upstreamName)) ?? { keys: {} };
228
+ const fake = stored.keys[keyName]?.fake ?? issueKeyFake(mode.fake, key);
229
+ await storage.saveApiKeys(gatewayName, upstreamName, { keys: { ...stored.keys, [keyName]: { key, fake } } });
230
+ return { fake };
231
+ }
232
+ async function deleteApiKey(gatewayName, upstreamName, keyName) {
233
+ await requireUpstream(gatewayName, upstreamName);
234
+ assertValidName("API key", keyName);
235
+ const stored = await storage.readApiKeys(gatewayName, upstreamName);
236
+ if (!stored?.keys[keyName])
237
+ return;
238
+ const { [keyName]: _removed, ...keys } = stored.keys;
239
+ if (Object.keys(keys).length === 0)
240
+ await storage.removeApiKeys(gatewayName, upstreamName);
241
+ else
242
+ await storage.saveApiKeys(gatewayName, upstreamName, { keys });
243
+ }
244
+ async function createTrafficRule(gatewayName, rule) {
245
+ await requireGateway(gatewayName);
246
+ const requested = validTrafficRule(rule);
247
+ const stored = await storage.insertTrafficRule(gatewayName, requested);
248
+ if (!sameTrafficRule(stored, requested)) {
249
+ throw new GatewayConflictError(`traffic rule ${rule.name} already exists with a different spec`);
250
+ }
251
+ return requested;
252
+ }
253
+ async function updateTrafficRule(gatewayName, rule) {
254
+ await requireGateway(gatewayName);
255
+ const requested = validTrafficRule(rule);
256
+ await requireTrafficRule(gatewayName, rule.name);
257
+ await storage.saveTrafficRule(gatewayName, requested);
258
+ return requested;
259
+ }
260
+ // Requests an ASK rule is holding carry on once it's gone.
261
+ async function deleteTrafficRule(gatewayName, ruleName) {
262
+ assertValidName("gateway", gatewayName);
263
+ assertValidName("traffic rule", ruleName);
264
+ await storage.removeTrafficRule(gatewayName, ruleName);
265
+ }
266
+ async function listTrafficRules(gatewayName) {
267
+ await requireGateway(gatewayName);
268
+ const rules = await storage.listTrafficRules(gatewayName);
269
+ return rules.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
270
+ }
271
+ // Answers every request the rule is holding right now; later requests wait for a later answer.
272
+ async function trafficRuleRespond(gatewayName, ruleName, action) {
273
+ await requireGateway(gatewayName);
274
+ if (action !== "ALLOW" && action !== "DENY")
275
+ throw new GatewayInvalidError(`invalid response: ${action}`);
276
+ const rule = await requireTrafficRule(gatewayName, ruleName);
277
+ if (rule.action !== "ASK")
278
+ throw new GatewayInvalidError(`traffic rule ${ruleName} is not an ASK rule`);
279
+ await storage.saveTrafficRuleResponse(gatewayName, { name: ruleName, action, at: Date.now() });
280
+ }
281
+ async function requireGateway(gatewayName) {
282
+ assertValidName("gateway", gatewayName);
283
+ const record = await storage.readGateway(gatewayName);
284
+ if (!record)
285
+ throw new GatewayNotFoundError(`gateway ${gatewayName} does not exist`);
286
+ return record;
287
+ }
288
+ async function requireUpstream(gatewayName, upstreamName) {
289
+ await requireGateway(gatewayName);
290
+ assertValidName("upstream", upstreamName);
291
+ const upstream = await storage.readUpstream(gatewayName, upstreamName);
292
+ if (!upstream)
293
+ throw new GatewayNotFoundError(`upstream ${upstreamName} does not exist on gateway ${gatewayName}`);
294
+ return upstream;
295
+ }
296
+ async function requireTrafficRule(gatewayName, ruleName) {
297
+ assertValidName("traffic rule", ruleName);
298
+ const rule = await storage.readTrafficRule(gatewayName, ruleName);
299
+ if (!rule)
300
+ throw new GatewayNotFoundError(`traffic rule ${ruleName} does not exist on gateway ${gatewayName}`);
301
+ return rule;
302
+ }
303
+ return {
304
+ createGateway,
305
+ destroyGateway,
306
+ listGateways,
307
+ restartGateway,
308
+ listCredentials,
309
+ deleteCredential,
310
+ registerUpstream,
311
+ listUpstreams,
312
+ removeUpstream,
313
+ setApiKey,
314
+ deleteApiKey,
315
+ createTrafficRule,
316
+ updateTrafficRule,
317
+ deleteTrafficRule,
318
+ listTrafficRules,
319
+ trafficRuleRespond,
320
+ };
321
+ }
322
+ /** The checks the gateway relies on; the rest of the shape is the SDK client's (see upstream.ts). */
323
+ function validUpstream(upstream) {
324
+ assertValidName("upstream", upstream.name);
325
+ const { oauth, apiKey } = upstream.modes ?? {};
326
+ if (!oauth && !apiKey)
327
+ throw new GatewayInvalidError(`upstream ${upstream.name} needs an oauth or apiKey mode`);
328
+ for (const mode of [oauth, apiKey]) {
329
+ if (mode && (!Array.isArray(mode.hosts) || mode.hosts.length === 0 || !mode.present)) {
330
+ throw new GatewayInvalidError(`upstream ${upstream.name}: every mode needs hosts and present`);
331
+ }
332
+ }
333
+ if (oauth && (!oauth.tokenUrl || !oauth.fakes?.access)) {
334
+ throw new GatewayInvalidError(`upstream ${upstream.name}: oauth needs tokenUrl and fakes.access`);
335
+ }
336
+ if (apiKey && !apiKey.fake)
337
+ throw new GatewayInvalidError(`upstream ${upstream.name}: apiKey needs a fake shape`);
338
+ return upstream;
339
+ }
340
+ /** Returns just the rule's fields, so nothing unknown ends up in storage. */
341
+ function validTrafficRule(rule) {
342
+ assertValidName("traffic rule", rule.name);
343
+ if (typeof rule.urlMask !== "string" || rule.urlMask === "") {
344
+ throw new GatewayInvalidError(`traffic rule ${rule.name} needs a urlMask`);
345
+ }
346
+ if (rule.urlMask.startsWith(TCP_SCHEME))
347
+ assertValidTcpMask(rule.name, rule.urlMask);
348
+ if (rule.action === "INTERCEPT")
349
+ throw new GatewayInvalidError("INTERCEPT traffic rules are not supported yet");
350
+ if (rule.action !== "BLOCK" && rule.action !== "DELAY" && rule.action !== "ASK") {
351
+ throw new GatewayInvalidError(`invalid traffic rule action: ${rule.action}`);
352
+ }
353
+ if (!Number.isFinite(rule.delayMs) || rule.delayMs < 0) {
354
+ throw new GatewayInvalidError(`traffic rule ${rule.name} needs a delayMs of 0 or more`);
355
+ }
356
+ return { name: rule.name, urlMask: rule.urlMask, action: rule.action, delayMs: rule.delayMs };
357
+ }
358
+ const TCP_SCHEME = "tcp://";
359
+ /** "tcp://<address>:<port>": address is "*", an IPv4 address or a CIDR; port is "*" or a number. */
360
+ function assertValidTcpMask(ruleName, urlMask) {
361
+ const target = urlMask.slice(TCP_SCHEME.length);
362
+ const separator = target.lastIndexOf(":");
363
+ const address = target.slice(0, separator);
364
+ const port = target.slice(separator + 1);
365
+ const [ip, prefix, ...rest] = address.split("/");
366
+ const validAddress = address === "*" ||
367
+ (isIPv4(ip) && rest.length === 0 && (prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= 32)));
368
+ const validPort = port === "*" || (/^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535);
369
+ if (separator < 0 || !validAddress || !validPort) {
370
+ throw new GatewayInvalidError(`traffic rule ${ruleName}: a tcp:// urlMask is tcp://<IPv4 address, CIDR or *>:<port or *>, not ${urlMask}`);
371
+ }
372
+ }
373
+ function sameTrafficRule(a, b) {
374
+ return a.urlMask === b.urlMask && a.action === b.action && a.delayMs === b.delayMs;
375
+ }
376
+ function assertOwnedContainer(container, gatewayName) {
377
+ if (container.Config?.Labels?.["hyperbrane.gateway"] !== gatewayName) {
378
+ throw new GatewayConflictError(`container ${gatewayContainerName(gatewayName)} exists but is not managed by hyperbrane`);
379
+ }
380
+ }
@@ -0,0 +1,66 @@
1
+ import type { Sandbox } from "../sandbox/api.ts";
2
+ import type { Gateway, TrafficRule } from "./api.ts";
3
+ import type { ApiKeyCredential, OAuthCredential, UpstreamDefinition } from "./upstream.ts";
4
+ /** "creating" until the container has come up once; a create that crashed stays "creating" until retried. */
5
+ export type GatewayRecord = Gateway & {
6
+ state: "creating" | "ready";
7
+ };
8
+ export type TrafficRuleResponse = {
9
+ name: string;
10
+ action: "ALLOW" | "DENY";
11
+ at: number;
12
+ };
13
+ /** Every name must pass checkName; operations throw InvalidNameError otherwise. */
14
+ export interface GatewayStorage {
15
+ /** Stores the record unless one with that name exists; returns whichever record is stored afterwards. */
16
+ insertGateway(gateway: GatewayRecord): Promise<GatewayRecord>;
17
+ /** Replaces the record. */
18
+ saveGateway(gateway: GatewayRecord): Promise<void>;
19
+ /** Null if missing. */
20
+ readGateway(gatewayName: string): Promise<GatewayRecord | null>;
21
+ /** Removes the gateway's state (credentials, rules, CA), then its record. No-op if missing. */
22
+ removeGateway(gatewayName: string): Promise<void>;
23
+ listGateways(): Promise<GatewayRecord[]>;
24
+ /**
25
+ * Sandbox path of the directory mounted into the gateway container as /state. Creates it with
26
+ * its upstreams/, credentials/, traffic-rules/, traffic-responses/ and mitmproxy/ subdirectories.
27
+ */
28
+ ensureStateDirectory(gatewayName: string): Promise<string>;
29
+ /** Replaces the definition. */
30
+ saveUpstream(gatewayName: string, upstream: UpstreamDefinition): Promise<void>;
31
+ /** Null if missing. */
32
+ readUpstream(gatewayName: string, upstreamName: string): Promise<UpstreamDefinition | null>;
33
+ /** Removes the definition and its credentials. No-op if missing. */
34
+ removeUpstream(gatewayName: string, upstreamName: string): Promise<void>;
35
+ listUpstreams(gatewayName: string): Promise<UpstreamDefinition[]>;
36
+ /** Written by the gateway as it captures tokens; null until a client signs in. */
37
+ readOAuthCredential(gatewayName: string, upstreamName: string): Promise<OAuthCredential | null>;
38
+ /** No-op if missing. */
39
+ removeOAuthCredential(gatewayName: string, upstreamName: string): Promise<void>;
40
+ /** Null if none were ever set. */
41
+ readApiKeys(gatewayName: string, upstreamName: string): Promise<ApiKeyCredential | null>;
42
+ /** Every credential the gateway holds, by name ("<upstream>.oauth", "<upstream>.api-keys"), at once. */
43
+ listCredentials(gatewayName: string): Promise<Map<string, unknown>>;
44
+ /** Replaces the upstream's keys. */
45
+ saveApiKeys(gatewayName: string, upstreamName: string, keys: ApiKeyCredential): Promise<void>;
46
+ /** No-op if missing. */
47
+ removeApiKeys(gatewayName: string, upstreamName: string): Promise<void>;
48
+ /** Stores the record unless one with that name exists; returns whichever record is stored afterwards. */
49
+ insertTrafficRule(gatewayName: string, rule: TrafficRule): Promise<TrafficRule>;
50
+ /** Replaces the record. */
51
+ saveTrafficRule(gatewayName: string, rule: TrafficRule): Promise<void>;
52
+ /** Null if missing. */
53
+ readTrafficRule(gatewayName: string, ruleName: string): Promise<TrafficRule | null>;
54
+ /** Removes the rule and its last response. No-op if missing. */
55
+ removeTrafficRule(gatewayName: string, ruleName: string): Promise<void>;
56
+ listTrafficRules(gatewayName: string): Promise<TrafficRule[]>;
57
+ /** Replaces the rule's last response. */
58
+ saveTrafficRuleResponse(gatewayName: string, response: TrafficRuleResponse): Promise<void>;
59
+ }
60
+ /**
61
+ * `dir` is a sandbox path; defaults to ~/.local/share/hyperbrane of the sandbox's user. Layout:
62
+ * gateways/<gateway>.json, and gateway-state/<gateway>/ holding everything the container sees:
63
+ * upstreams/<upstream>.json, credentials/<upstream>.oauth.json and <upstream>.api-keys.json,
64
+ * traffic-rules/<rule>.json, traffic-responses/<rule>.json, mitmproxy/.
65
+ */
66
+ export declare function createGatewayStorage(sandbox: Sandbox, dir?: string): GatewayStorage;
@@ -0,0 +1,125 @@
1
+ import { checkName, createJsonDirectory, defaultDataDir } from "../lib/jsonDirectory.js";
2
+ /**
3
+ * `dir` is a sandbox path; defaults to ~/.local/share/hyperbrane of the sandbox's user. Layout:
4
+ * gateways/<gateway>.json, and gateway-state/<gateway>/ holding everything the container sees:
5
+ * upstreams/<upstream>.json, credentials/<upstream>.oauth.json and <upstream>.api-keys.json,
6
+ * traffic-rules/<rule>.json, traffic-responses/<rule>.json, mitmproxy/.
7
+ */
8
+ export function createGatewayStorage(sandbox, dir) {
9
+ async function dataDir() {
10
+ return dir ?? defaultDataDir(sandbox);
11
+ }
12
+ async function stateDir(gatewayName) {
13
+ return `${await dataDir()}/gateway-state/${checkName(gatewayName)}`;
14
+ }
15
+ const gateways = createJsonDirectory(sandbox, async () => `${await dataDir()}/gateways`);
16
+ function stateSubdirectory(gatewayName, subdirectory) {
17
+ checkName(gatewayName);
18
+ return createJsonDirectory(sandbox, async () => `${await stateDir(gatewayName)}/${subdirectory}`);
19
+ }
20
+ const upstreams = (gatewayName) => stateSubdirectory(gatewayName, "upstreams");
21
+ const credentials = (gatewayName) => stateSubdirectory(gatewayName, "credentials");
22
+ const trafficRules = (gatewayName) => stateSubdirectory(gatewayName, "traffic-rules");
23
+ const trafficResponses = (gatewayName) => stateSubdirectory(gatewayName, "traffic-responses");
24
+ async function insertGateway(gateway) {
25
+ return gateways.insert(gateway.name, gateway);
26
+ }
27
+ async function saveGateway(gateway) {
28
+ await gateways.save(gateway.name, gateway);
29
+ }
30
+ async function readGateway(gatewayName) {
31
+ return gateways.read(gatewayName);
32
+ }
33
+ async function removeGateway(gatewayName) {
34
+ await sandbox.removeDirectory(await stateDir(gatewayName));
35
+ await gateways.remove(gatewayName);
36
+ }
37
+ async function listGateways() {
38
+ return gateways.list();
39
+ }
40
+ async function ensureStateDirectory(gatewayName) {
41
+ const path = await stateDir(gatewayName);
42
+ for (const subdirectory of ["upstreams", "credentials", "traffic-rules", "traffic-responses", "mitmproxy"]) {
43
+ await sandbox.createDirectory(`${path}/${subdirectory}`);
44
+ }
45
+ return path;
46
+ }
47
+ async function saveUpstream(gatewayName, upstream) {
48
+ await upstreams(gatewayName).save(upstream.name, upstream);
49
+ }
50
+ async function readUpstream(gatewayName, upstreamName) {
51
+ return upstreams(gatewayName).read(upstreamName);
52
+ }
53
+ // Definition last: while any credential is left, the upstream is still there to retry the removal.
54
+ async function removeUpstream(gatewayName, upstreamName) {
55
+ await removeOAuthCredential(gatewayName, upstreamName);
56
+ await removeApiKeys(gatewayName, upstreamName);
57
+ await upstreams(gatewayName).remove(upstreamName);
58
+ }
59
+ async function listUpstreams(gatewayName) {
60
+ return upstreams(gatewayName).list();
61
+ }
62
+ // Credential names are "<upstream>.oauth" and "<upstream>.api-keys", checked as names like any other.
63
+ async function readOAuthCredential(gatewayName, upstreamName) {
64
+ return (await credentials(gatewayName).read(`${checkName(upstreamName)}.oauth`));
65
+ }
66
+ async function removeOAuthCredential(gatewayName, upstreamName) {
67
+ await credentials(gatewayName).remove(`${checkName(upstreamName)}.oauth`);
68
+ }
69
+ /** Every credential the gateway holds, by name ("<upstream>.oauth", "<upstream>.api-keys"), at once. */
70
+ async function listCredentials(gatewayName) {
71
+ return new Map(await credentials(gatewayName).entries());
72
+ }
73
+ async function readApiKeys(gatewayName, upstreamName) {
74
+ return (await credentials(gatewayName).read(`${checkName(upstreamName)}.api-keys`));
75
+ }
76
+ async function saveApiKeys(gatewayName, upstreamName, keys) {
77
+ await credentials(gatewayName).save(`${checkName(upstreamName)}.api-keys`, keys);
78
+ }
79
+ async function removeApiKeys(gatewayName, upstreamName) {
80
+ await credentials(gatewayName).remove(`${checkName(upstreamName)}.api-keys`);
81
+ }
82
+ async function insertTrafficRule(gatewayName, rule) {
83
+ return trafficRules(gatewayName).insert(rule.name, rule);
84
+ }
85
+ async function saveTrafficRule(gatewayName, rule) {
86
+ await trafficRules(gatewayName).save(rule.name, rule);
87
+ }
88
+ async function readTrafficRule(gatewayName, ruleName) {
89
+ return trafficRules(gatewayName).read(ruleName);
90
+ }
91
+ async function removeTrafficRule(gatewayName, ruleName) {
92
+ await trafficRules(gatewayName).remove(ruleName);
93
+ await trafficResponses(gatewayName).remove(ruleName);
94
+ }
95
+ async function listTrafficRules(gatewayName) {
96
+ return trafficRules(gatewayName).list();
97
+ }
98
+ async function saveTrafficRuleResponse(gatewayName, response) {
99
+ await trafficResponses(gatewayName).save(response.name, response);
100
+ }
101
+ return {
102
+ insertGateway,
103
+ saveGateway,
104
+ readGateway,
105
+ removeGateway,
106
+ listGateways,
107
+ ensureStateDirectory,
108
+ saveUpstream,
109
+ readUpstream,
110
+ removeUpstream,
111
+ listUpstreams,
112
+ readOAuthCredential,
113
+ removeOAuthCredential,
114
+ readApiKeys,
115
+ listCredentials,
116
+ saveApiKeys,
117
+ removeApiKeys,
118
+ insertTrafficRule,
119
+ saveTrafficRule,
120
+ readTrafficRule,
121
+ removeTrafficRule,
122
+ listTrafficRules,
123
+ saveTrafficRuleResponse,
124
+ };
125
+ }
@@ -0,0 +1,6 @@
1
+ import type { DockerClient } from "../lib/docker.ts";
2
+ import { type ManagedImage } from "../lib/image.ts";
3
+ /** Bump whenever anything under ./image changes (and run `bun run embed:images`). */
4
+ export declare const GATEWAY_IMAGE_VERSION = "v16";
5
+ export declare const GATEWAY_IMAGE_REPOSITORY = "hyperbrane-gateway";
6
+ export declare function createGatewayImage(docker: DockerClient): ManagedImage;
@@ -0,0 +1,13 @@
1
+ import { createManagedImage } from "../lib/image.js";
2
+ // The files under ./image, as strings (see dimension/image.ts).
3
+ import { IMAGE_FILES } from "./imageFiles.generated.js";
4
+ /** Bump whenever anything under ./image changes (and run `bun run embed:images`). */
5
+ export const GATEWAY_IMAGE_VERSION = "v16";
6
+ export const GATEWAY_IMAGE_REPOSITORY = "hyperbrane-gateway";
7
+ export function createGatewayImage(docker) {
8
+ return createManagedImage(docker, {
9
+ repository: GATEWAY_IMAGE_REPOSITORY,
10
+ version: GATEWAY_IMAGE_VERSION,
11
+ context: IMAGE_FILES,
12
+ });
13
+ }
@@ -0,0 +1 @@
1
+ export declare const IMAGE_FILES: Record<string, string>;