hyperbrane 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/agent.js CHANGED
@@ -101,6 +101,19 @@ export const agentCommands = {
101
101
  await forEach(backends, (backend) => agents.install(agent, backendArg(backend)));
102
102
  },
103
103
  },
104
+ backends: {
105
+ summary: "Show the backends installed in an agent's dimension",
106
+ args: ["AGENT"],
107
+ flags: QUIET_FLAG,
108
+ async run([agent], flags, { agents }) {
109
+ const rows = (await agents.listBackends(agent)).map(({ backend, version, ready }) => [
110
+ backend,
111
+ version ?? "-",
112
+ ready ? "ready" : version ? "incomplete or outdated: install it" : "not installed",
113
+ ]);
114
+ printList(flags, ["BACKEND", "VERSION", "STATUS"], rows);
115
+ },
116
+ },
104
117
  rm: {
105
118
  summary: "Stop one or more agents' workers (their sessions stay in the dimension)",
106
119
  args: ["NAME..."],
@@ -21,13 +21,14 @@ export function createServices(host = createLocalSandbox("/")) {
21
21
  const portalStorage = createPortalStorage(host);
22
22
  const gatewayImage = createGatewayImage(docker);
23
23
  const portals = createPortalService(portalStorage, gatewayStorage, docker, gatewayImage, host);
24
- const gateways = createGatewayService(gatewayStorage, portalStorage, docker, gatewayImage, host);
25
- const dimensions = createDimensionService(createFileStorage(host), docker, createDimensionImage(docker), host);
24
+ // Each restart they make, and each worker an agent revives, re-wires what it took: the portals.
25
+ const gateways = createGatewayService(gatewayStorage, portalStorage, docker, gatewayImage, host, portals);
26
+ const dimensions = createDimensionService(createFileStorage(host), docker, createDimensionImage(docker), host, portals);
26
27
  return {
27
28
  docker,
28
29
  dimensions,
29
30
  gateways,
30
31
  portals,
31
- agents: createAgentService(createAgentStorage(host), docker, host, createGatewaySignIns(portals, gateways)),
32
+ agents: createAgentService(createAgentStorage(host), docker, host, createGatewaySignIns(portals, gateways), portals),
32
33
  };
33
34
  }
@@ -4,6 +4,7 @@ import { type Dimension, type DimensionInfo, type DimensionLimits } from "./api.
4
4
  import type { DimensionImage } from "./image.ts";
5
5
  import type { Storage } from "./storage.ts";
6
6
  import { RUN_PATH, STATE_PATH, WORKSPACE_PATH } from "./paths.ts";
7
+ import type { PortalService } from "../portal-gateway/portalService.ts";
7
8
  export { RUN_PATH, STATE_PATH, WORKSPACE_PATH };
8
9
  /** @sealed */
9
10
  export interface DimensionService {
@@ -20,4 +21,6 @@ export declare function containerName(dimensionName: string): string;
20
21
  * `host` is the machine the docker daemon runs on, rooted at "/" so dimension directories
21
22
  * (bind-mount sources) resolve the same way docker resolves them.
22
23
  */
23
- export declare function createDimensionService(storage: Storage, docker: DockerClient, image: DimensionImage, host: Sandbox): DimensionService;
24
+ export declare function createDimensionService(storage: Storage, docker: DockerClient, image: DimensionImage, host: Sandbox,
25
+ /** Re-wires the portals of a dimension whose container restarted (portal-gateway/portalService.ts). */
26
+ restoring?: Pick<PortalService, "restorePortals">): DimensionService;
@@ -15,7 +15,9 @@ export function containerName(dimensionName) {
15
15
  * `host` is the machine the docker daemon runs on, rooted at "/" so dimension directories
16
16
  * (bind-mount sources) resolve the same way docker resolves them.
17
17
  */
18
- export function createDimensionService(storage, docker, image, host) {
18
+ export function createDimensionService(storage, docker, image, host,
19
+ /** Re-wires the portals of a dimension whose container restarted (portal-gateway/portalService.ts). */
20
+ restoring) {
19
21
  async function getDimensions() {
20
22
  return Promise.all((await storage.getDimensions()).map(describeDimension));
21
23
  }
@@ -159,6 +161,8 @@ export function createDimensionService(storage, docker, image, host) {
159
161
  throw new DimensionNotFoundError(`dimension ${dimensionName} has no container; create it again`);
160
162
  assertOwnedContainer(existing, dimensionName);
161
163
  await docker.restartContainer(containerName(dimensionName));
164
+ // The restart took its end of every portal with its network namespace.
165
+ await restoring?.restorePortals({ dimensionName });
162
166
  }
163
167
  // Container first: a limit docker refuses leaves the record as it was.
164
168
  async function updateDimension(dimensionName, limits) {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { parseAccessibleHost, proxyCredentialsAuth, type SandboxEndpointAuth, ty
11
11
  export { exeDevProxyAuth, type ExeDevAuthOptions } from "./sandbox/exeDevAuth.ts";
12
12
  export { EndpointLoginRequiredError, endpointRequest, exeDevLoginUrl, openSseConnection, type EndpointAccess } from "./sandbox/sseConnection.ts";
13
13
  export * from "./agent/client.ts";
14
+ export { createPortalWatcher, type PortalWatcher } from "./portal-gateway/portalService.ts";
14
15
  export { createDockerClient, DockerError, type DockerClient } from "./lib/docker.ts";
15
16
  export { BUILTIN_UPSTREAMS } from "./upstreams/index.ts";
16
17
  export { HYPERBRANE_VERSION } from "./lib/version.ts";
package/dist/index.js CHANGED
@@ -19,6 +19,9 @@ export { exeDevProxyAuth } from "./sandbox/exeDevAuth.js";
19
19
  export { EndpointLoginRequiredError, endpointRequest, exeDevLoginUrl, openSseConnection } from "./sandbox/sseConnection.js";
20
20
  // An agent's worker, reached through its endpoint (AgentService.exposeAgent), from a server or a browser.
21
21
  export * from "./agent/client.js";
22
+ // Portals come back by themselves after the services' own restarts; after any other (docker's own, a
23
+ // reboot), restorePortals, or a watcher that runs it for each container that starts.
24
+ export { createPortalWatcher } from "./portal-gateway/portalService.js";
22
25
  export { createDockerClient, DockerError } from "./lib/docker.js";
23
26
  export { BUILTIN_UPSTREAMS } from "./upstreams/index.js";
24
27
  export { HYPERBRANE_VERSION } from "./lib/version.js";
@@ -4,6 +4,7 @@ import { type Sandbox } from "../sandbox/api.ts";
4
4
  import { type Gateway, type GatewayCredential, type TrafficRule, type Upstream } from "./api.ts";
5
5
  import type { GatewayStorage } from "./gatewayStorage.ts";
6
6
  import type { PortalStorage } from "./portalStorage.ts";
7
+ import type { PortalService } from "./portalService.ts";
7
8
  /** @sealed */
8
9
  export interface GatewayService {
9
10
  createGateway(gateway: Gateway): Promise<Gateway>;
@@ -35,4 +36,6 @@ export declare function assertValidName(kind: string, name: string): void;
35
36
  * `host` is the machine the docker daemon runs on, rooted at "/" so the state directory (a
36
37
  * bind-mount source) resolves the same way docker resolves it.
37
38
  */
38
- export declare function createGatewayService(storage: GatewayStorage, portals: PortalStorage, docker: DockerClient, image: ManagedImage, host: Sandbox): GatewayService;
39
+ export declare function createGatewayService(storage: GatewayStorage, portals: PortalStorage, docker: DockerClient, image: ManagedImage, host: Sandbox,
40
+ /** Re-wires the portals of a gateway whose container restarted or was replaced. */
41
+ restoring?: Pick<PortalService, "restorePortals">): GatewayService;
@@ -10,6 +10,7 @@ import { BUILTIN_UPSTREAMS } from "../upstreams/index.js";
10
10
  /** Where the gateway's state directory is mounted in its container. */
11
11
  export const GATEWAY_STATE_PATH = "/state";
12
12
  const PROXY_PORT = 8080;
13
+ const DNS_PORT = 5353;
13
14
  const READY_TIMEOUT_MS = 60_000;
14
15
  // "hybgw-" can't collide with a dimension's "hyb-<name>": dimension names start with a letter or digit.
15
16
  export function gatewayContainerName(gatewayName) {
@@ -33,7 +34,9 @@ export function assertValidName(kind, name) {
33
34
  * `host` is the machine the docker daemon runs on, rooted at "/" so the state directory (a
34
35
  * bind-mount source) resolves the same way docker resolves it.
35
36
  */
36
- export function createGatewayService(storage, portals, docker, image, host) {
37
+ export function createGatewayService(storage, portals, docker, image, host,
38
+ /** Re-wires the portals of a gateway whose container restarted or was replaced. */
39
+ restoring) {
37
40
  // Keeps no state between calls: the record is written first so a slow or crashed create stays
38
41
  // visible, and every later step checks before it acts, so re-running converges.
39
42
  async function createGateway(gateway) {
@@ -53,11 +56,12 @@ export function createGatewayService(storage, portals, docker, image, host) {
53
56
  await awaitReady(gateway.name, stateDirectory);
54
57
  if (record.state !== "ready")
55
58
  await storage.saveGateway({ name: gateway.name, state: "ready" });
59
+ // A replaced container (an older image's) came without them; still wired, this is one look.
60
+ await restoring?.restorePortals({ gatewayName: gateway.name });
56
61
  return { name: gateway.name };
57
62
  }
58
63
  // 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.
64
+ // is in the mounted state directory. createGateway re-wires its portals once it's ready.
61
65
  async function ensureContainer(gatewayName, stateDirectory, imageTag) {
62
66
  const name = gatewayContainerName(gatewayName);
63
67
  const existing = await docker.inspectContainer(name);
@@ -104,8 +108,8 @@ export function createGatewayService(storage, portals, docker, image, host) {
104
108
  }
105
109
  throw conflict;
106
110
  }
107
- // Ready = the proxy is listening (so the redirect rules are in) and the CA exists (so portals can
108
- // install it into dimensions).
111
+ // Ready = the proxy and its resolver are listening (so the redirect rules are in, and nothing
112
+ // through a portal is refused) and the CA exists (so portals can install it into dimensions).
109
113
  async function awaitReady(gatewayName, stateDirectory) {
110
114
  const name = gatewayContainerName(gatewayName);
111
115
  const deadline = Date.now() + READY_TIMEOUT_MS;
@@ -119,8 +123,9 @@ export function createGatewayService(storage, portals, docker, image, host) {
119
123
  }
120
124
  async function proxyListening(name) {
121
125
  try {
122
- const result = await docker.exec(name, { cmd: ["ss", "-Hltn", `sport = :${PROXY_PORT}`], user: "root" });
123
- return result.exitCode === 0 && result.stdout.trim() !== "";
126
+ const result = await docker.exec(name, { cmd: ["ss", "-Hln", `( sport = :${PROXY_PORT} or sport = :${DNS_PORT} )`], user: "root" });
127
+ // Both: TCP for the proxy, UDP for the resolver.
128
+ return result.exitCode === 0 && /^tcp\b/m.test(result.stdout) && /^udp\b/m.test(result.stdout);
124
129
  }
125
130
  catch (error) {
126
131
  // Not running (yet, or restarting after a failed start).
@@ -165,6 +170,8 @@ export function createGatewayService(storage, portals, docker, image, host) {
165
170
  assertOwnedContainer(container, gatewayName);
166
171
  await docker.restartContainer(name);
167
172
  await awaitReady(gatewayName, await storage.ensureStateDirectory(record.name));
173
+ // The restart took its end of every portal with its network namespace.
174
+ await restoring?.restorePortals({ gatewayName });
168
175
  }
169
176
  async function listGateways() {
170
177
  return (await storage.listGateways()).map(({ name }) => ({ name }));
@@ -322,14 +329,19 @@ export function createGatewayService(storage, portals, docker, image, host) {
322
329
  /** The checks the gateway relies on; the rest of the shape is the SDK client's (see upstream.ts). */
323
330
  function validUpstream(upstream) {
324
331
  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`);
332
+ const { oauth, apiKey, forced } = upstream.modes ?? {};
333
+ if (!oauth && !apiKey && !forced)
334
+ throw new GatewayInvalidError(`upstream ${upstream.name} needs an oauth, apiKey or forced mode`);
335
+ for (const mode of [oauth, apiKey, forced]) {
336
+ if (mode && (!Array.isArray(mode.hosts) || mode.hosts.length === 0))
337
+ throw new GatewayInvalidError(`upstream ${upstream.name}: every mode needs hosts`);
338
+ }
328
339
  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
- }
340
+ if (mode && !mode.present)
341
+ throw new GatewayInvalidError(`upstream ${upstream.name}: oauth and apiKey modes need present`);
332
342
  }
343
+ if (forced && !forced.force)
344
+ throw new GatewayInvalidError(`upstream ${upstream.name}: a forced mode needs force`);
333
345
  if (oauth && (!oauth.tokenUrl || !oauth.fakes?.access)) {
334
346
  throw new GatewayInvalidError(`upstream ${upstream.name}: oauth needs tokenUrl and fakes.access`);
335
347
  }
@@ -1,6 +1,6 @@
1
1
  import type { DockerClient } from "../lib/docker.ts";
2
2
  import { type ManagedImage } from "../lib/image.ts";
3
3
  /** Bump whenever anything under ./image changes (and run `bun run embed:images`). */
4
- export declare const GATEWAY_IMAGE_VERSION = "v16";
4
+ export declare const GATEWAY_IMAGE_VERSION = "v17";
5
5
  export declare const GATEWAY_IMAGE_REPOSITORY = "hyperbrane-gateway";
6
6
  export declare function createGatewayImage(docker: DockerClient): ManagedImage;
@@ -2,7 +2,7 @@ import { createManagedImage } from "../lib/image.js";
2
2
  // The files under ./image, as strings (see dimension/image.ts).
3
3
  import { IMAGE_FILES } from "./imageFiles.generated.js";
4
4
  /** Bump whenever anything under ./image changes (and run `bun run embed:images`). */
5
- export const GATEWAY_IMAGE_VERSION = "v16";
5
+ export const GATEWAY_IMAGE_VERSION = "v17";
6
6
  export const GATEWAY_IMAGE_REPOSITORY = "hyperbrane-gateway";
7
7
  export function createGatewayImage(docker) {
8
8
  return createManagedImage(docker, {
@@ -4,5 +4,5 @@ export const IMAGE_FILES = {
4
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
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",
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
  };
@@ -180,17 +180,25 @@ export function createPortalService(storage, gateways, docker, image, host) {
180
180
  return;
181
181
  if (record.state !== "destroying")
182
182
  await storage.savePortal({ ...record, state: "destroying" });
183
+ // A restore under way finishes first (seeing the mark, it undoes what it wired), so nothing it
184
+ // does lands after this returns; one that starts from now on sees the mark and does nothing.
185
+ while (restoring.has(portalName))
186
+ await restoring.get(portalName).catch(() => { });
183
187
  if (record.subnet !== undefined) {
184
188
  await ignoringStopped(() => unwire(record.gatewayName, portalLink(portalName, record.subnet)));
185
189
  await storage.releaseSubnet(record.subnet);
186
190
  }
187
- const sameGateway = (await storage.listPortals()).some((other) => other.name !== portalName &&
191
+ await untrustUnlessShared(record);
192
+ await storage.removePortal(portalName);
193
+ }
194
+ /** Takes the gateway's CA out of the dimension, unless another of its portals goes to that gateway. */
195
+ async function untrustUnlessShared(record) {
196
+ const sameGateway = (await storage.listPortals()).some((other) => other.name !== record.name &&
188
197
  other.dimensionName === record.dimensionName &&
189
198
  other.gatewayName === record.gatewayName);
190
199
  if (!sameGateway) {
191
200
  await ignoringStopped(() => trustGateway(dimensionContainerName(record.dimensionName), record.gatewayName, false));
192
201
  }
193
- await storage.removePortal(portalName);
194
202
  }
195
203
  async function unwire(gatewayName, link) {
196
204
  const result = await docker.exec(gatewayContainerName(gatewayName), {
@@ -217,38 +225,62 @@ export function createPortalService(storage, gateways, docker, image, host) {
217
225
  await Promise.all(records.map(async (record) => {
218
226
  if ((await gatewayInterfaces(record.gatewayName))?.has(portalLink(record.name, record.subnet).gatewayIf))
219
227
  return;
220
- let pids;
228
+ await restoreOnce(record);
229
+ }));
230
+ }
231
+ // One restore of a portal at a time, however many ask (a restart's own and the watcher's): two
232
+ // wiring the same link at once fail each other, and the retry could land after a destroy. Each
233
+ // waits for the one before rather than joining it: that one may have given up on a container that
234
+ // has since restarted, so once it's done the link is looked at again.
235
+ const restoring = new Map();
236
+ async function restoreOnce(record) {
237
+ while (restoring.has(record.name)) {
238
+ await restoring.get(record.name).catch(() => { });
239
+ const link = portalLink(record.name, record.subnet);
240
+ if ((await listInterfaces(gatewayContainerName(record.gatewayName)))?.has(link.gatewayIf))
241
+ return;
242
+ }
243
+ const restored = restore(record).finally(() => restoring.delete(record.name));
244
+ restoring.set(record.name, restored);
245
+ return restored;
246
+ }
247
+ async function restore(listed) {
248
+ // As it is now: one being destroyed is left alone.
249
+ const record = await storage.readPortal(listed.name);
250
+ if (record?.state !== "ready")
251
+ return;
252
+ let pids;
253
+ try {
254
+ pids = await runningPids(record);
255
+ }
256
+ catch (error) {
257
+ // A stopped or removed container gets wired when it starts again, or never.
258
+ if (error instanceof GatewayConflictError || error instanceof GatewayNotFoundError)
259
+ return;
260
+ throw error;
261
+ }
262
+ for (let attempt = 1;; attempt++) {
221
263
  try {
222
- pids = await runningPids(record);
264
+ await connect(record, pids);
265
+ // Destroyed meanwhile: neither the link nor the trust just made may outlive it (see destroyPortal).
266
+ if ((await storage.readPortal(record.name))?.state !== "ready") {
267
+ await ignoringStopped(() => unwire(record.gatewayName, portalLink(record.name, record.subnet)));
268
+ await untrustUnlessShared(record);
269
+ }
270
+ return;
223
271
  }
224
272
  catch (error) {
225
- // A stopped or removed container gets wired when it starts again, or never.
226
- if (error instanceof GatewayConflictError || error instanceof GatewayNotFoundError)
273
+ // A container that stopped or restarted meanwhile (killing what ran in it) is restored by
274
+ // its own start event. Docker kills a restarting container's processes before its state
275
+ // changes, so give it a moment before deciding it's still the same container.
276
+ await sleep(RESTORE_RECHECK_MS);
277
+ const now = await runningPids(record).catch(() => null);
278
+ if (now?.gateway !== pids.gateway || now?.dimension !== pids.dimension)
227
279
  return;
228
- throw error;
280
+ if (attempt === 2)
281
+ return console.warn(`restoring portal ${record.name} failed:`, error);
229
282
  }
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
- }));
283
+ }
252
284
  }
253
285
  /** Null when the container isn't running. */
254
286
  async function listInterfaces(container) {
@@ -19,8 +19,20 @@ export type UpstreamDefinition = {
19
19
  modes: {
20
20
  oauth?: OAuthMode;
21
21
  apiKey?: ApiKeyMode;
22
+ forced?: ForcedMode;
22
23
  };
23
24
  };
25
+ /** What to force onto every matching request, whatever the client sent. */
26
+ export type UpstreamForce = {
27
+ /** Retarget the request, e.g. "https://api.internal:8443". */
28
+ target?: string;
29
+ /** Prefix prepended to the request path, so dimensions needn't know where the API is mounted. */
30
+ path?: string;
31
+ /** Query params the client doesn't get to choose. */
32
+ query?: Record<string, string>;
33
+ /** Headers whose values the dimension never sees. */
34
+ headers?: Record<string, string>;
35
+ };
24
36
  type ModeBase = {
25
37
  /**
26
38
  * Requests to these hosts get the credential. Matched on host alone: v1 also matched the
@@ -34,17 +46,16 @@ type ModeBase = {
34
46
  } | {
35
47
  query: string;
36
48
  };
37
- /** What to force onto every matching request, whatever the client sent. */
38
- force?: {
39
- /** Retarget the request, e.g. "https://api.internal:8443". */
40
- target?: string;
41
- /** Prefix prepended to the request path, so dimensions needn't know where the API is mounted. */
42
- path?: string;
43
- /** Query params the client doesn't get to choose. */
44
- query?: Record<string, string>;
45
- /** Headers the client never sees the values of. */
46
- headers?: Record<string, string>;
47
- };
49
+ force?: UpstreamForce;
50
+ };
51
+ /**
52
+ * No credential of the gateway's own, so nothing to present and no fakes: only what `force` writes
53
+ * onto every request to `hosts`, e.g. a service's own auth header, set here and never seen by the
54
+ * dimension. Applies as soon as it's registered.
55
+ */
56
+ export type ForcedMode = {
57
+ hosts: string[];
58
+ force: UpstreamForce;
48
59
  };
49
60
  export type OAuthMode = ModeBase & {
50
61
  /**
@@ -97,12 +97,14 @@ export type SandboxEndpoint = {
97
97
  readonly id: string;
98
98
  /**
99
99
  * Where the client reaches the endpoint, directly over the network (not through the backend's own
100
- * connection), at the host the sandbox was created with.
100
+ * connection), at the host the sandbox was created with. It lasts until the endpoint is closed,
101
+ * but what serves it may stop (a reboot, an archived Boat sandbox): ensureEndpoint starts it again.
102
+ * Until then a browser reports a request to it as a CORS error, since nothing of the endpoint's answers.
101
103
  */
102
104
  readonly url: string;
103
105
  /** What the client presents; requests without it are refused before reaching the target. */
104
106
  readonly credentials: SandboxEndpointCredentials;
105
- /** As requested: fixed for the endpoint's life. */
107
+ /** As requested, or as updateEndpoint changed them since. */
106
108
  readonly allowedOrigins: string[];
107
109
  };
108
110
  /**
@@ -177,6 +179,13 @@ export interface SandboxAccessibleEndpoint {
177
179
  * and credentials; false if it was closed, or its port has since been taken (expose another).
178
180
  */
179
181
  ensureEndpoint(endpointId: string): Promise<boolean>;
182
+ /**
183
+ * Changes which web origins may call the endpoint, in place: its URL and credentials stay, and so
184
+ * do connections already open. False if it was closed.
185
+ */
186
+ updateEndpoint(endpointId: string, update: {
187
+ allowedOrigins: string[];
188
+ }): Promise<boolean>;
180
189
  }
181
190
  /**
182
191
  * A sandbox that stops itself on a timer (Boat).
@@ -185,6 +185,21 @@ export function createProxyEndpoints(commands, options, where) {
185
185
  if (result.exitCode === 10 && firewall)
186
186
  await ufw("delete", "allow", `${port}/tcp`);
187
187
  }
188
+ // The proxy reads the record on every request, so the change applies to the next one.
189
+ async function updateEndpoint(endpointId, { allowedOrigins }) {
190
+ const { port, name } = parseEndpointId(endpointId);
191
+ const read = await sh(READ_SCRIPT, { P: port, N: name });
192
+ if (read.exitCode !== 0)
193
+ return false;
194
+ const record = { ...JSON.parse(read.stdout), origins: allowedOrigins };
195
+ const written = await sh(WRITE_SCRIPT, { P: port, N: name }, JSON.stringify(record));
196
+ if (written.exitCode === 44)
197
+ return false; // its proxy's directory went with its last endpoint
198
+ if (written.exitCode !== 0) {
199
+ throw new Error(`updating endpoint ${endpointId} on ${where} failed (exit ${written.exitCode}): ${written.stderr.trim()}`);
200
+ }
201
+ return true;
202
+ }
188
203
  // Its record says what to serve; the proxy is started again on the port the endpoint's URL names.
189
204
  async function ensureEndpoint(endpointId) {
190
205
  const { port, name } = parseEndpointId(endpointId);
@@ -202,7 +217,7 @@ export function createProxyEndpoints(commands, options, where) {
202
217
  await closeEndpoint(endpointId);
203
218
  return false;
204
219
  }
205
- return { accessibleHost: options.accessibleHost, exposeEndpoint, exposeEndpointAs, closeEndpoint, ensureEndpoint };
220
+ return { accessibleHost: options.accessibleHost, exposeEndpoint, exposeEndpointAs, closeEndpoint, ensureEndpoint, updateEndpoint };
206
221
  }
207
222
  /** The credentials a client presents for a recorded check; `none` when the endpoint checks nothing itself. */
208
223
  function credentialsOf(check, none) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperbrane",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Dimensions, gateways and agents on a machine reached locally, over SSH or on Boat: SDK and CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",