@specific.dev/spectest 0.123.0 → 0.125.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.
@@ -3,6 +3,7 @@ import http from "node:http";
3
3
  import { spawn, execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { mkdir, stat, open, rm } from "node:fs/promises";
6
+ import { installAndroidTrust } from "./spectest-android-trust.mjs";
6
7
  const exec = promisify(execFile);
7
8
  const serial = process.env.ANDROID_SERIAL;
8
9
  const directory = "/recordings";
@@ -28,6 +29,7 @@ async function bootstrap() {
28
29
  "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
30
  );
30
31
  }
32
+ await installAndroidTrust(adb, process.env.ANDROID_CA_PATH);
31
33
  await adb("install", "-r", "/spectest/app.apk");
32
34
  await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
35
  for (const setting of [
@@ -0,0 +1,126 @@
1
+ // Runtime-only trust provisioning for the private Android device. Never change
2
+ // the image or disable certificate/hostname verification in the application.
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ export const ANDROID_TRUST_SCRIPT = `#!/system/bin/sh
12
+ set -eu
13
+ cert=/data/local/tmp/spectest-ca.crt
14
+ store=/data/local/tmp/spectest-system-cacerts
15
+ system=/system/etc/security/cacerts
16
+ apex=/apex/com.android.conscrypt/cacerts
17
+ hash="$1"
18
+ test "$(id -u)" = 0
19
+ test -s "$cert"
20
+
21
+ # Keep a private copy of the active Android root set, including its public CAs.
22
+ # The directory and namespace mounts are part of the VM snapshot, not the image.
23
+ if [ ! -d "$store" ]; then
24
+ original="$system"
25
+ if [ -d "$apex" ] && [ -n "$(ls -A "$apex")" ]; then original="$apex"; fi
26
+ copy=$(mktemp -d /data/local/tmp/spectest-cacerts.XXXXXX)
27
+ trap 'rm -rf "$copy"' EXIT
28
+ cp "$original"/* "$copy"/
29
+ mv "$copy" "$store"
30
+ trap - EXIT
31
+ fi
32
+
33
+ # Android uses the old OpenSSL subject hash, with numbered collisions. Never
34
+ # overwrite a public root with the same subject hash, or duplicate our own CA.
35
+ index=0
36
+ while [ -f "$store/$hash.$index" ]; do
37
+ if cmp -s "$cert" "$store/$hash.$index"; then break; fi
38
+ index=$((index + 1))
39
+ done
40
+ cp "$cert" "$store/$hash.$index"
41
+ chown 0:0 "$store" "$store"/*
42
+ chmod 0755 "$store"
43
+ chmod 0644 "$store"/*
44
+ if [ "$(getenforce)" != Disabled ]; then
45
+ chcon u:object_r:system_file:s0 "$store" "$store"/*
46
+ fi
47
+
48
+ # Android 14 loads roots from Conscrypt's APEX. Each zygote has a private mount
49
+ # namespace, inherited by new apps and the pre-forked USAP pool. Enter those
50
+ # namespaces, including existing descendants, before the APK is launched.
51
+ zygotes="$(pidof zygote zygote64 || true)"
52
+ test -n "$zygotes" || { echo 'Android TLS: no zygote process found' >&2; exit 1; }
53
+ # Mount each generation before enumerating its children. A process forked
54
+ # after enumeration then inherits the mounted store, closing the USAP race.
55
+ frontier="$zygotes"
56
+ while [ -n "$frontier" ]; do
57
+ for pid in $frontier; do
58
+ [ -d "/proc/$pid" ] || continue
59
+ for target in "$system" "$apex"; do
60
+ [ -d "/proc/$pid/root$target" ] || continue
61
+ if ! cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"; then
62
+ # setns alone can retain the caller's filesystem root.
63
+ if ! nsenter --mount="/proc/$pid/ns/mnt" -- chroot "/proc/$pid/root" mount --bind "$store" "$target"; then
64
+ [ ! -d "/proc/$pid" ] && break
65
+ echo "Android TLS: cannot install trust in process $pid" >&2
66
+ exit 1
67
+ fi
68
+ fi
69
+ done
70
+ done
71
+ # Android 14's toybox has no awk. Walk with the system shell instead.
72
+ processes="$(ps -A -o PID,PPID)"
73
+ next=""
74
+ while read -r pid parent; do
75
+ case " $frontier " in *" $parent "*) next="$next $pid" ;; esac
76
+ done <<PROCESSES
77
+ $processes
78
+ PROCESSES
79
+ frontier="$next"
80
+ done
81
+ for pid in $zygotes; do
82
+ target="$system"
83
+ if [ -d "/proc/$pid/root$apex" ]; then target="$apex"; fi
84
+ cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"
85
+ done
86
+ echo 'spectest-android-trust-ready'
87
+ `;
88
+
89
+ /** Install the environment CA before app installation/launch. Throws on failure. */
90
+ export async function installAndroidTrust(adb, caPath, run = exec) {
91
+ try {
92
+ const { stdout } = await run("openssl", ["x509", "-in", caPath, "-subject_hash_old", "-noout"]);
93
+ const hash = stdout.trim();
94
+ if (!/^[a-f0-9]{8}$/.test(hash)) throw new Error("invalid CA subject hash");
95
+
96
+ let root = [];
97
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
98
+ try {
99
+ if ((await adb("shell", "su", "0", "id", "-u")).stdout.trim() !== "0") throw new Error("not root");
100
+ root = ["su", "0"];
101
+ } catch {
102
+ await adb("root");
103
+ await adb("wait-for-device");
104
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
105
+ throw new Error("the Android image must provide root ADB or su access");
106
+ }
107
+ }
108
+ }
109
+ const directory = await mkdtemp(join(tmpdir(), "spectest-android-trust-"));
110
+ try {
111
+ const script = join(directory, "trust.sh");
112
+ await writeFile(script, ANDROID_TRUST_SCRIPT);
113
+ await adb("push", caPath, "/data/local/tmp/spectest-ca.crt");
114
+ await adb("push", script, "/data/local/tmp/spectest-trust.sh");
115
+ const result = await adb("shell", ...root, "sh", "/data/local/tmp/spectest-trust.sh", hash);
116
+ if (!result.stdout.includes("spectest-android-trust-ready")) {
117
+ throw new Error("system CA provisioning did not confirm success");
118
+ }
119
+ } finally {
120
+ await rm(directory, { recursive: true, force: true });
121
+ await adb("shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh").catch(() => {});
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(`Android TLS trust provisioning failed: ${cause.message}`, { cause });
125
+ }
126
+ }
@@ -19,7 +19,7 @@ export interface AndroidOptions {
19
19
  recording?: false | AndroidRecordingOptions;
20
20
  dependsOn?: string[];
21
21
  }
22
- export declare const ANDROID_TOOLS_DOCKERFILE = "FROM node:22-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*\nRUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*\nENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium\nRUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0\n";
22
+ export declare const ANDROID_TOOLS_DOCKERFILE = "FROM node:22-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*\nRUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*\nENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium\nRUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0\n";
23
23
  /** Native Android, Appium and software recording, all inside the environment VM. */
24
24
  export declare function android(options: AndroidOptions): import("../index.js").ServiceGroup<{
25
25
  device: {
@@ -60,6 +60,7 @@ export declare function android(options: AndroidOptions): import("../index.js").
60
60
  inputs: string[];
61
61
  env: {
62
62
  ANDROID_SERIAL: string;
63
+ ANDROID_CA_PATH: string;
63
64
  ANDROID_ACTIVITY: string;
64
65
  ANDROID_SHOW_TOUCHES: string;
65
66
  };
@@ -3,8 +3,9 @@ import { posix } from "node:path";
3
3
  import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp } from "../android.js";
6
+ import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
6
7
  export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
7
- RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
8
+ RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
8
9
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
9
10
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
10
11
  RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
@@ -61,6 +62,10 @@ export function android(options) {
61
62
  path: "/spectest-android.mjs",
62
63
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
63
64
  },
65
+ {
66
+ path: "/spectest-android-trust.mjs",
67
+ content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
68
+ },
64
69
  ],
65
70
  volumes: [
66
71
  {
@@ -72,6 +77,7 @@ export function android(options) {
72
77
  inputs: [apk],
73
78
  env: {
74
79
  ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
80
+ ANDROID_CA_PATH: ENVIRONMENT_CA_PATH,
75
81
  ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
76
82
  ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
77
83
  },
package/dist/daemon.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ensureServiceNetwork, serviceIpv4Gateway } from "./harness/service-network.js";
1
2
  import { collectSecretRefs } from "./fake-upstream.js";
2
3
  import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.js";
3
4
  import { runWithBackgroundFailures } from "./harness/background-failures.js";
@@ -504,15 +505,6 @@ function qualifyImageRef(ref) {
504
505
  return `docker.io/${ref}`;
505
506
  return ref;
506
507
  }
507
- async function ensureNetwork() {
508
- const inspect = await docker(["network", "inspect", NETWORK_NAME], 30_000);
509
- if (inspect.code === 0)
510
- return;
511
- const create = await docker(["network", "create", NETWORK_NAME], 60_000);
512
- if (create.code !== 0) {
513
- throw new Error(`docker network create ${NETWORK_NAME} failed: ${create.stderr.trim() || create.stdout.trim()}`);
514
- }
515
- }
516
508
  /** Host directory backing one volume mount. Rules (notably which volumes
517
509
  * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
518
510
  function resolveHostPath(service, vol) {
@@ -1490,15 +1482,15 @@ async function bridgeGatewayIp() {
1490
1482
  "network",
1491
1483
  "inspect",
1492
1484
  "--format",
1493
- "{{(index .IPAM.Config 0).Gateway}}",
1485
+ "{{json .IPAM.Config}}",
1494
1486
  NETWORK_NAME,
1495
1487
  ], 10_000);
1496
1488
  if (out.code !== 0) {
1497
1489
  throw new Error(`docker network inspect ${NETWORK_NAME} failed (rc=${out.code}): ${out.stderr.trim()}`);
1498
1490
  }
1499
- const ip = out.stdout.trim();
1491
+ const ip = serviceIpv4Gateway(JSON.parse(out.stdout));
1500
1492
  if (!ip)
1501
- throw new Error(`no gateway IP returned for network ${NETWORK_NAME}`);
1493
+ throw new Error(`no IPv4 gateway IP returned for network ${NETWORK_NAME}`);
1502
1494
  cachedGatewayIp = ip;
1503
1495
  return ip;
1504
1496
  }
@@ -2591,7 +2583,10 @@ async function bootstrapInner(prepared, identity) {
2591
2583
  // wouldn't need it — but the writes are sub-millisecond so we just
2592
2584
  // gate image prep behind both.
2593
2585
  await Promise.all([
2594
- ensureNetwork(),
2586
+ (async () => {
2587
+ await ensureServiceNetwork(NETWORK_NAME, cfg.network, docker, shx);
2588
+ cachedGatewayIp = null;
2589
+ })(),
2595
2590
  (async () => {
2596
2591
  await fs.mkdir(WORKSPACE, { recursive: true });
2597
2592
  // Never overwrite the project's own root `.dockerignore`. BuildKit
@@ -0,0 +1,21 @@
1
+ /** The service LAN lives entirely inside one VM and is cloned with it. */
2
+ export interface ServiceNetworkOptions {
3
+ /** Add VM-private IPv6 addresses alongside IPv4 (no external IPv6 route). */
4
+ ipv6?: boolean;
5
+ /** Flood multicast within the service bridge, including IPv4/IPv6 mDNS. */
6
+ multicast?: boolean;
7
+ }
8
+ export declare const SERVICE_IPV6_SUBNET = "fd53:7065:6374::/64";
9
+ type CommandResult = {
10
+ code: number;
11
+ stdout: string;
12
+ stderr: string;
13
+ };
14
+ type Docker = (args: string[], timeoutMs: number) => Promise<CommandResult>;
15
+ export declare function validateServiceNetwork(options: ServiceNetworkOptions | undefined): void;
16
+ export declare function ensureServiceNetwork(name: string, options: ServiceNetworkOptions | undefined, docker: Docker, command: (file: string, args: string[], timeoutMs: number) => Promise<CommandResult>): Promise<void>;
17
+ /** Ingress and resolver listeners remain IPv4 on a dual-stack bridge. */
18
+ export declare function serviceIpv4Gateway(config: {
19
+ Gateway?: string;
20
+ }[]): string | undefined;
21
+ export {};
@@ -0,0 +1,94 @@
1
+ import { isIPv4 } from "node:net";
2
+ // Identical in each VM, like the guest's IPv4 identity. Never advertise outside
3
+ // the guest: siblings retain their sockets/addresses without sharing a LAN.
4
+ export const SERVICE_IPV6_SUBNET = "fd53:7065:6374::/64";
5
+ const MULTICAST_LABEL = "dev.specific.spectest.multicast";
6
+ export function validateServiceNetwork(options) {
7
+ if (options === undefined)
8
+ return;
9
+ if (!options || typeof options !== "object" || Array.isArray(options))
10
+ throw new Error("network must be an object with optional ipv6 and multicast booleans");
11
+ for (const key of Object.keys(options)) {
12
+ if (key !== "ipv6" && key !== "multicast")
13
+ throw new Error(`unknown network option: ${key}`);
14
+ if (typeof options[key] !== "boolean")
15
+ throw new Error(`network.${key} must be a boolean`);
16
+ }
17
+ }
18
+ export async function ensureServiceNetwork(name, options, docker, command) {
19
+ validateServiceNetwork(options);
20
+ const ipv6 = options?.ipv6 === true;
21
+ const multicast = options?.multicast === true;
22
+ const run = async (args, timeoutMs = 30_000) => {
23
+ const result = await docker(args, timeoutMs);
24
+ if (result.code !== 0)
25
+ throw new Error(`docker ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`);
26
+ return result;
27
+ };
28
+ const parse = (stdout) => {
29
+ const network = JSON.parse(stdout)?.[0];
30
+ if (!network || typeof network.Id !== "string")
31
+ throw new Error(`Invalid Docker network inspection for ${name}`);
32
+ return network;
33
+ };
34
+ const inspected = await docker(["network", "inspect", name], 30_000);
35
+ let network = inspected.code === 0 ? parse(inspected.stdout) : undefined;
36
+ let ipv4Pool;
37
+ if (network) {
38
+ const expectedSubnet = !ipv6 || network.IPAM?.Config?.some(c => c.Subnet === SERVICE_IPV6_SUBNET);
39
+ const matches = network.Driver === "bridge" && !!network.EnableIPv6 === ipv6 && expectedSubnet &&
40
+ (network.Labels?.[MULTICAST_LABEL] === "true") === multicast;
41
+ if (!matches) {
42
+ // Changing an empty stale project network is safe; never detach live
43
+ // endpoints, or replace an externally supplied network configuration.
44
+ if (name !== "spectest-net" || network.Driver !== "bridge" ||
45
+ (network.IPAM?.Driver && network.IPAM.Driver !== "default") ||
46
+ Object.keys(network.Options ?? {}).length > 0 ||
47
+ Object.keys(network.Containers ?? {}).length > 0) {
48
+ throw new Error(`Network ${name} is incompatible with network.ipv6=${ipv6}, network.multicast=${multicast}; start a fresh environment. Active or custom networks are never replaced.`);
49
+ }
50
+ // Preserve the old IPv4 identity: the independent DNS process may
51
+ // already have a listener bound to this bridge gateway.
52
+ ipv4Pool = network.IPAM?.Config?.find(c => c.Subnet && isIPv4(c.Subnet.split("/")[0]));
53
+ await run(["network", "rm", name]);
54
+ network = undefined;
55
+ }
56
+ }
57
+ if (!network) {
58
+ const args = ["network", "create"];
59
+ if (ipv4Pool?.Subnet) {
60
+ args.push("--subnet", ipv4Pool.Subnet);
61
+ if (ipv4Pool.Gateway)
62
+ args.push("--gateway", ipv4Pool.Gateway);
63
+ }
64
+ if (ipv6)
65
+ args.push("--ipv6", "--subnet", SERVICE_IPV6_SUBNET);
66
+ if (multicast)
67
+ args.push("--label", `${MULTICAST_LABEL}=true`);
68
+ args.push(name);
69
+ await run(args, 60_000);
70
+ if (multicast)
71
+ network = parse((await run(["network", "inspect", name])).stdout);
72
+ }
73
+ if (multicast && network) {
74
+ const bridge = network.Options?.["com.docker.network.bridge.name"] ?? `br-${network.Id.slice(0, 12)}`;
75
+ if (!/^[a-zA-Z0-9_.-]{1,15}$/.test(bridge))
76
+ throw new Error(`Invalid Docker bridge name: ${bridge}`);
77
+ // No external querier exists on this LAN. Flooding avoids membership
78
+ // expiration and discovery races after a paused snapshot is restored.
79
+ // Keep the setting scoped to this bridge; never alter host forwarding.
80
+ try {
81
+ // Use netlink: some supported VM runtimes mount sysfs read-only.
82
+ const result = await command("ip", ["link", "set", "dev", bridge, "type", "bridge", "mcast_snooping", "0"], 10_000);
83
+ if (result.code !== 0)
84
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `ip exited ${result.code}`);
85
+ }
86
+ catch (error) {
87
+ throw new Error(`Cannot enable multicast on ${name} (${bridge}): ${error}`);
88
+ }
89
+ }
90
+ }
91
+ /** Ingress and resolver listeners remain IPv4 on a dual-stack bridge. */
92
+ export function serviceIpv4Gateway(config) {
93
+ return config.find(c => c.Gateway && isIPv4(c.Gateway))?.Gateway;
94
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type ServiceNetworkOptions } from "./harness/service-network.js";
2
+ export type { ServiceNetworkOptions } from "./harness/service-network.js";
1
3
  import type { InjectRule, SignConfig } from "./fake-upstream.js";
2
4
  export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
3
5
  export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
@@ -71,6 +73,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
71
73
  * string used in `dependsOn` references.
72
74
  */
73
75
  services: S;
76
+ /** Private service LAN. IPv4 remains enabled; IPv6 and multicast are opt-in. */
77
+ network?: ServiceNetworkOptions;
74
78
  /** Sandbox timeout in seconds (default 1h). */
75
79
  timeoutSecs?: number;
76
80
  /** Display metadata only; entrypoints retain their author-declared order. */
@@ -1754,6 +1758,8 @@ export interface EnvironmentInput<SI extends InputServicesMap, F extends FakesMa
1754
1758
  * and use 000000 as the code". */
1755
1759
  instructions?: string;
1756
1760
  };
1761
+ /** Private service LAN. IPv4 remains enabled; IPv6 and multicast are opt-in. */
1762
+ network?: ServiceNetworkOptions;
1757
1763
  /** Sandbox timeout in seconds (default 1h). */
1758
1764
  timeoutSecs?: number;
1759
1765
  /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateServiceNetwork } from "./harness/service-network.js";
1
2
  import { stripVTControlCharacters } from "node:util";
2
3
  import { resolveEntrypoints } from "./interactive.js";
3
4
  // Spectest SDK. The user's single `spectest/index.ts` calls
@@ -310,6 +311,7 @@ export function validateServiceImage(serviceName, input) {
310
311
  return image;
311
312
  }
312
313
  function validateEnvironmentConfig(config) {
314
+ validateServiceNetwork(config.network);
313
315
  const entries = Object.entries(config.services);
314
316
  const serviceNames = new Set(entries.map(([n]) => n));
315
317
  const claimedBy = new Map();
package/dist/resolver.js CHANGED
@@ -25,6 +25,7 @@
25
25
  // from a kernel-chosen source (127.0.0.1), and glibc's resolver drops the
26
26
  // answer because its source doesn't match the queried server.
27
27
  import { createSocket } from "node:dgram";
28
+ import { isIPv4 } from "node:net";
28
29
  import { request as httpRequest } from "node:http";
29
30
  import { readFile, stat } from "node:fs/promises";
30
31
  import { lookup } from "./harness/names-registry.js";
@@ -203,7 +204,7 @@ async function handleQuery(sock, msg, rinfo) {
203
204
  ? await dockerLookupByName(name)
204
205
  : await dockerLookupByAlias(name));
205
206
  if (ip) {
206
- // For AAAA we still answer empty — Docker bridges are IPv4 only, but
207
+ // For AAAA we still answer empty — the ingress registry is IPv4, and
207
208
  // we own this name so libc should fall back to A instead of chasing
208
209
  // a stray upstream AAAA for the real public hostname.
209
210
  const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
@@ -277,7 +278,7 @@ function bindListener(addr, fatalOnError) {
277
278
  * uses to reach the VM host. Null until the network exists. */
278
279
  async function bridgeGatewayIp() {
279
280
  const net = (await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`));
280
- const gw = net?.IPAM?.Config?.find((c) => c.Gateway)?.Gateway;
281
+ const gw = net?.IPAM?.Config?.find(c => c.Gateway && isIPv4(c.Gateway))?.Gateway;
281
282
  return gw && gw.length > 0 ? gw : null;
282
283
  }
283
284
  // Primary listener: the loopback nameserver. Fatal if it can't bind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.123.0",
3
+ "version": "0.125.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -3,6 +3,7 @@ import http from "node:http";
3
3
  import { spawn, execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { mkdir, stat, open, rm } from "node:fs/promises";
6
+ import { installAndroidTrust } from "./spectest-android-trust.mjs";
6
7
  const exec = promisify(execFile);
7
8
  const serial = process.env.ANDROID_SERIAL;
8
9
  const directory = "/recordings";
@@ -28,6 +29,7 @@ async function bootstrap() {
28
29
  "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
30
  );
30
31
  }
32
+ await installAndroidTrust(adb, process.env.ANDROID_CA_PATH);
31
33
  await adb("install", "-r", "/spectest/app.apk");
32
34
  await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
35
  for (const setting of [
@@ -0,0 +1,126 @@
1
+ // Runtime-only trust provisioning for the private Android device. Never change
2
+ // the image or disable certificate/hostname verification in the application.
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ export const ANDROID_TRUST_SCRIPT = `#!/system/bin/sh
12
+ set -eu
13
+ cert=/data/local/tmp/spectest-ca.crt
14
+ store=/data/local/tmp/spectest-system-cacerts
15
+ system=/system/etc/security/cacerts
16
+ apex=/apex/com.android.conscrypt/cacerts
17
+ hash="$1"
18
+ test "$(id -u)" = 0
19
+ test -s "$cert"
20
+
21
+ # Keep a private copy of the active Android root set, including its public CAs.
22
+ # The directory and namespace mounts are part of the VM snapshot, not the image.
23
+ if [ ! -d "$store" ]; then
24
+ original="$system"
25
+ if [ -d "$apex" ] && [ -n "$(ls -A "$apex")" ]; then original="$apex"; fi
26
+ copy=$(mktemp -d /data/local/tmp/spectest-cacerts.XXXXXX)
27
+ trap 'rm -rf "$copy"' EXIT
28
+ cp "$original"/* "$copy"/
29
+ mv "$copy" "$store"
30
+ trap - EXIT
31
+ fi
32
+
33
+ # Android uses the old OpenSSL subject hash, with numbered collisions. Never
34
+ # overwrite a public root with the same subject hash, or duplicate our own CA.
35
+ index=0
36
+ while [ -f "$store/$hash.$index" ]; do
37
+ if cmp -s "$cert" "$store/$hash.$index"; then break; fi
38
+ index=$((index + 1))
39
+ done
40
+ cp "$cert" "$store/$hash.$index"
41
+ chown 0:0 "$store" "$store"/*
42
+ chmod 0755 "$store"
43
+ chmod 0644 "$store"/*
44
+ if [ "$(getenforce)" != Disabled ]; then
45
+ chcon u:object_r:system_file:s0 "$store" "$store"/*
46
+ fi
47
+
48
+ # Android 14 loads roots from Conscrypt's APEX. Each zygote has a private mount
49
+ # namespace, inherited by new apps and the pre-forked USAP pool. Enter those
50
+ # namespaces, including existing descendants, before the APK is launched.
51
+ zygotes="$(pidof zygote zygote64 || true)"
52
+ test -n "$zygotes" || { echo 'Android TLS: no zygote process found' >&2; exit 1; }
53
+ # Mount each generation before enumerating its children. A process forked
54
+ # after enumeration then inherits the mounted store, closing the USAP race.
55
+ frontier="$zygotes"
56
+ while [ -n "$frontier" ]; do
57
+ for pid in $frontier; do
58
+ [ -d "/proc/$pid" ] || continue
59
+ for target in "$system" "$apex"; do
60
+ [ -d "/proc/$pid/root$target" ] || continue
61
+ if ! cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"; then
62
+ # setns alone can retain the caller's filesystem root.
63
+ if ! nsenter --mount="/proc/$pid/ns/mnt" -- chroot "/proc/$pid/root" mount --bind "$store" "$target"; then
64
+ [ ! -d "/proc/$pid" ] && break
65
+ echo "Android TLS: cannot install trust in process $pid" >&2
66
+ exit 1
67
+ fi
68
+ fi
69
+ done
70
+ done
71
+ # Android 14's toybox has no awk. Walk with the system shell instead.
72
+ processes="$(ps -A -o PID,PPID)"
73
+ next=""
74
+ while read -r pid parent; do
75
+ case " $frontier " in *" $parent "*) next="$next $pid" ;; esac
76
+ done <<PROCESSES
77
+ $processes
78
+ PROCESSES
79
+ frontier="$next"
80
+ done
81
+ for pid in $zygotes; do
82
+ target="$system"
83
+ if [ -d "/proc/$pid/root$apex" ]; then target="$apex"; fi
84
+ cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"
85
+ done
86
+ echo 'spectest-android-trust-ready'
87
+ `;
88
+
89
+ /** Install the environment CA before app installation/launch. Throws on failure. */
90
+ export async function installAndroidTrust(adb, caPath, run = exec) {
91
+ try {
92
+ const { stdout } = await run("openssl", ["x509", "-in", caPath, "-subject_hash_old", "-noout"]);
93
+ const hash = stdout.trim();
94
+ if (!/^[a-f0-9]{8}$/.test(hash)) throw new Error("invalid CA subject hash");
95
+
96
+ let root = [];
97
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
98
+ try {
99
+ if ((await adb("shell", "su", "0", "id", "-u")).stdout.trim() !== "0") throw new Error("not root");
100
+ root = ["su", "0"];
101
+ } catch {
102
+ await adb("root");
103
+ await adb("wait-for-device");
104
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
105
+ throw new Error("the Android image must provide root ADB or su access");
106
+ }
107
+ }
108
+ }
109
+ const directory = await mkdtemp(join(tmpdir(), "spectest-android-trust-"));
110
+ try {
111
+ const script = join(directory, "trust.sh");
112
+ await writeFile(script, ANDROID_TRUST_SCRIPT);
113
+ await adb("push", caPath, "/data/local/tmp/spectest-ca.crt");
114
+ await adb("push", script, "/data/local/tmp/spectest-trust.sh");
115
+ const result = await adb("shell", ...root, "sh", "/data/local/tmp/spectest-trust.sh", hash);
116
+ if (!result.stdout.includes("spectest-android-trust-ready")) {
117
+ throw new Error("system CA provisioning did not confirm success");
118
+ }
119
+ } finally {
120
+ await rm(directory, { recursive: true, force: true });
121
+ await adb("shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh").catch(() => {});
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(`Android TLS trust provisioning failed: ${cause.message}`, { cause });
125
+ }
126
+ }
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { ANDROID_TRUST_SCRIPT, installAndroidTrust } from "./android-trust.mjs";
6
+
7
+ test("Android CA provisioning requires confirmed installation and supports root ADB restart", async () => {
8
+ const calls: string[][] = [];
9
+ let rooted = false;
10
+ const adb = async (...args: string[]) => {
11
+ calls.push(args);
12
+ if (args[0] === "root") rooted = true;
13
+ if (args.includes("su")) throw new Error("no su");
14
+ return { stdout: args.includes("id") ? (rooted ? "0\n" : "2000\n") : "" };
15
+ };
16
+ await expect(installAndroidTrust(adb, "/fixture/ca.crt", async () => ({ stdout: "1234abcd\n" })))
17
+ .rejects.toThrow("did not confirm success");
18
+ expect(calls.findIndex(args => args[0] === "root")).toBeLessThan(calls.findIndex(args => args[0] === "push"));
19
+ expect(calls).toContainEqual(["wait-for-device"]);
20
+ expect(calls.at(-1)).toEqual(["shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh"]);
21
+ });
22
+
23
+ test("invalid CA data fails before accessing Android", async () => {
24
+ let called = false;
25
+ await expect(installAndroidTrust(async () => { called = true; }, "/fixture/ca.crt", async () => ({ stdout: "not-a-certificate" })))
26
+ .rejects.toThrow("invalid CA subject hash");
27
+ expect(called).toBe(false);
28
+ });
29
+
30
+ const canMount = process.platform === "linux" && Bun.spawnSync(["sh", "-c", "command -v unshare >/dev/null && unshare -Urnm true"], { stderr: "ignore" }).exitCode === 0;
31
+ if (process.env.SPECTEST_REQUIRE_ANDROID_TRUST_MOUNTS === "1" && !canMount) {
32
+ throw new Error("Android trust CI requires Linux user/mount namespaces (unshare -Urnm); refusing to skip mount coverage");
33
+ }
34
+
35
+ // Exercise real bind mounts and private zygote/child mount namespaces.
36
+ // This is intentionally more than an assertion about the generated script.
37
+ test.skipIf(!canMount)("Android trust preserves roots and hash collisions, reaches private app namespaces and is idempotent", async () => {
38
+ const directory = mkdtempSync(join(tmpdir(), "spectest-android-mounts-"));
39
+ for (const name of ["system", "apex", "bin"]) mkdirSync(join(directory, name));
40
+ writeFileSync(join(directory, "apex", "1234abcd.0"), "public root with the same subject hash\n");
41
+ writeFileSync(join(directory, "apex", "deadbeef.0"), "another public root\n");
42
+ writeFileSync(join(directory, "spectest-ca.crt"), "the environment CA\n");
43
+ writeFileSync(join(directory, "bin", "getenforce"), "#!/bin/sh\necho Disabled\n", { mode: 0o755 });
44
+ // Linux procps calls these fields lowercase; Android toybox uses uppercase.
45
+ writeFileSync(join(directory, "bin", "ps"), `#!/bin/sh
46
+ set -eu
47
+ snapshot=$(/bin/ps -A -o pid,ppid)
48
+ if [ ! -e '${directory}/race.triggered' ]; then
49
+ touch '${directory}/race.triggered'
50
+ kill -USR1 $(cat '${directory}/zygote.pid')
51
+ while [ ! -e '${directory}/late.ready' ]; do sleep 0.01; done
52
+ fi
53
+ # The late app is deliberately absent from this process-list snapshot. It
54
+ # must inherit the parent's trust even though it gets its own namespace.
55
+ printf '%s\\n' "$snapshot"
56
+ `, { mode: 0o755 });
57
+ writeFileSync(join(directory, "bin", "pidof"), `#!/bin/sh\ncat '${directory}/zygote.pid'\n`, { mode: 0o755 });
58
+ writeFileSync(join(directory, "trust.sh"), ANDROID_TRUST_SCRIPT
59
+ .replaceAll("/system/etc/security/cacerts", `${directory}/system`)
60
+ .replaceAll("/apex/com.android.conscrypt/cacerts", `${directory}/apex`)
61
+ .replaceAll("/data/local/tmp", directory));
62
+ writeFileSync(join(directory, "late.sh"), `#!/bin/sh
63
+ set -eu
64
+ mount --make-rprivate /
65
+ touch '${directory}/late.ready'
66
+ exec sleep 120
67
+ `);
68
+ writeFileSync(join(directory, "zygote.sh"), `#!/bin/sh
69
+ set -eu
70
+ mount --make-rprivate /
71
+ late_app() {
72
+ unshare -m sh '${directory}/late.sh' &
73
+ echo $! > '${directory}/late.pid'
74
+ }
75
+ trap late_app USR1
76
+ echo $$ > '${directory}/zygote.pid'
77
+ unshare -m sh -c 'mount --make-rprivate /; touch "${directory}/app.ready"; exec sleep 120' &
78
+ echo $! > '${directory}/app.pid'
79
+ while true; do wait || true; done
80
+ `);
81
+ writeFileSync(join(directory, "verify.sh"), `#!/bin/sh
82
+ set -eu
83
+ export PATH='${directory}/bin':"$PATH":/usr/sbin:/sbin
84
+ unshare -m sh '${directory}/zygote.sh' &
85
+ child=$!
86
+ trap 'kill "$child" $(cat "${directory}/app.pid" "${directory}/late.pid" 2>/dev/null) 2>/dev/null || true' EXIT
87
+ while [ ! -f '${directory}/app.ready' ]; do sleep 0.01; done
88
+ sh '${directory}/trust.sh' 1234abcd
89
+ for pid in $(cat '${directory}/zygote.pid' '${directory}/app.pid' '${directory}/late.pid'); do
90
+ for store in system apex; do
91
+ cmp '${directory}/spectest-ca.crt' "/proc/$pid/root${directory}/$store/1234abcd.1"
92
+ test "$(cat "/proc/$pid/root${directory}/$store/1234abcd.0")" = 'public root with the same subject hash'
93
+ test "$(cat "/proc/$pid/root${directory}/$store/deadbeef.0")" = 'another public root'
94
+ done
95
+ done
96
+ before=$(wc -l < /proc/$child/mountinfo)
97
+ sh '${directory}/trust.sh' 1234abcd
98
+ after=$(wc -l < /proc/$child/mountinfo)
99
+ test "$before" = "$after"
100
+ test ! -e '${directory}/spectest-system-cacerts/1234abcd.2'
101
+ # A populated shell trust store is insufficient without a usable zygote.
102
+ : > '${directory}/zygote.pid'
103
+ if sh '${directory}/trust.sh' 1234abcd; then exit 1; fi
104
+ `);
105
+ try {
106
+ const child = Bun.spawn(["timeout", "10s", "unshare", "-Urnm", "sh", join(directory, "verify.sh")], { stdout: "pipe", stderr: "pipe" });
107
+ const [stdout, stderr, status] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
108
+ expect({ status, stderr: stderr.replace("Android TLS: no zygote process found\n", "") }).toEqual({ status: 0, stderr: "" });
109
+ expect(stdout.match(/spectest-android-trust-ready/g)?.length).toBe(2);
110
+ } finally {
111
+ rmSync(directory, { recursive: true, force: true });
112
+ }
113
+ }, 20_000);
@@ -42,6 +42,7 @@ test("Android expands into two VM services with an APK input and private Appium/
42
42
  expect(tools.env?.ANDROID_SERIAL).toBe("phone-device.internal:5555");
43
43
  expect(tools.env?.ANDROID_ACTIVITY).toBe("com.example.app/.MainActivity");
44
44
  expect(tools.env?.ANDROID_SHOW_TOUCHES).toBe("1");
45
+ expect(tools.env?.ANDROID_CA_PATH).toBe(process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt");
45
46
  expect(tools.inputs).toContain("app.apk");
46
47
  expect(tools.volumes).toContainEqual({
47
48
  source: apk,
@@ -50,6 +51,7 @@ test("Android expands into two VM services with an APK input and private Appium/
50
51
  });
51
52
  expect(tools.readyCheck).toMatchObject({ type: "http", port: 4725 });
52
53
  expect(tools.files?.[0].content).toContain("--no-window");
54
+ expect(tools.files?.map((file: { path: string }) => file.path)).toContain("/spectest-android-trust.mjs");
53
55
  } finally {
54
56
  rmSync(dir, { recursive: true, force: true });
55
57
  }
@@ -3,6 +3,7 @@ import { posix } from "node:path";
3
3
  import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp, type AndroidApp, type AndroidRecordingOptions } from "../android.js";
6
+ import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
6
7
 
7
8
  export interface AndroidOptions {
8
9
  /** Project-relative path to an APK built for the worker's native ABI. */
@@ -26,7 +27,7 @@ export interface AndroidOptions {
26
27
  }
27
28
 
28
29
  export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
29
- RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
30
+ RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
30
31
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
31
32
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
32
33
  RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
@@ -91,6 +92,10 @@ export function android(options: AndroidOptions) {
91
92
  path: "/spectest-android.mjs",
92
93
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
93
94
  },
95
+ {
96
+ path: "/spectest-android-trust.mjs",
97
+ content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
98
+ },
94
99
  ],
95
100
  volumes: [
96
101
  {
@@ -102,6 +107,7 @@ export function android(options: AndroidOptions) {
102
107
  inputs: [apk],
103
108
  env: {
104
109
  ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
110
+ ANDROID_CA_PATH: ENVIRONMENT_CA_PATH,
105
111
  ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
106
112
  ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
107
113
  },
package/src/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ensureServiceNetwork, serviceIpv4Gateway } from "./harness/service-network.js";
1
2
  import { collectSecretRefs } from "./fake-upstream.js";
2
3
  import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecording } from "./phone.js";
3
4
  import { runWithBackgroundFailures } from "./harness/background-failures.js";
@@ -760,16 +761,6 @@ function qualifyImageRef(ref: string): string {
760
761
  return ref;
761
762
  }
762
763
 
763
- async function ensureNetwork(): Promise<void> {
764
- const inspect = await docker(["network", "inspect", NETWORK_NAME], 30_000);
765
- if (inspect.code === 0) return;
766
- const create = await docker(["network", "create", NETWORK_NAME], 60_000);
767
- if (create.code !== 0) {
768
- throw new Error(
769
- `docker network create ${NETWORK_NAME} failed: ${create.stderr.trim() || create.stdout.trim()}`,
770
- );
771
- }
772
- }
773
764
 
774
765
  /** Host directory backing one volume mount. Rules (notably which volumes
775
766
  * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
@@ -1876,7 +1867,7 @@ async function bridgeGatewayIp(): Promise<string> {
1876
1867
  "network",
1877
1868
  "inspect",
1878
1869
  "--format",
1879
- "{{(index .IPAM.Config 0).Gateway}}",
1870
+ "{{json .IPAM.Config}}",
1880
1871
  NETWORK_NAME,
1881
1872
  ],
1882
1873
  10_000,
@@ -1886,8 +1877,8 @@ async function bridgeGatewayIp(): Promise<string> {
1886
1877
  `docker network inspect ${NETWORK_NAME} failed (rc=${out.code}): ${out.stderr.trim()}`,
1887
1878
  );
1888
1879
  }
1889
- const ip = out.stdout.trim();
1890
- if (!ip) throw new Error(`no gateway IP returned for network ${NETWORK_NAME}`);
1880
+ const ip = serviceIpv4Gateway(JSON.parse(out.stdout));
1881
+ if (!ip) throw new Error(`no IPv4 gateway IP returned for network ${NETWORK_NAME}`);
1891
1882
  cachedGatewayIp = ip;
1892
1883
  return ip;
1893
1884
  }
@@ -3090,7 +3081,10 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3090
3081
  // wouldn't need it — but the writes are sub-millisecond so we just
3091
3082
  // gate image prep behind both.
3092
3083
  await Promise.all([
3093
- ensureNetwork(),
3084
+ (async () => {
3085
+ await ensureServiceNetwork(NETWORK_NAME, cfg.network, docker, shx);
3086
+ cachedGatewayIp = null;
3087
+ })(),
3094
3088
  (async () => {
3095
3089
  await fs.mkdir(WORKSPACE, { recursive: true });
3096
3090
  // Never overwrite the project's own root `.dockerignore`. BuildKit
@@ -0,0 +1,103 @@
1
+ import { expect, test } from "bun:test";
2
+ import { defineEnvironment } from "../index";
3
+ import { ensureServiceNetwork, SERVICE_IPV6_SUBNET } from "./service-network";
4
+
5
+ const id = "0123456789abcdef";
6
+ const base = { Id: id, Driver: "bridge", EnableIPv6: false, Containers: {}, IPAM: { Driver: "default", Config: [] } };
7
+ function fixture(existing: object | undefined) {
8
+ const calls: string[][] = [], writes: string[][] = [];
9
+ let network = existing;
10
+ return { calls, writes, async docker(args: string[]) {
11
+ calls.push(args);
12
+ if (args[1] === "inspect") return network ? { code: 0, stdout: JSON.stringify([network]), stderr: "" } : { code: 1, stdout: "", stderr: "missing" };
13
+ if (args[1] === "rm") network = undefined;
14
+ if (args[1] === "create") network = { ...base, EnableIPv6: args.includes("--ipv6"), Labels: args.includes("--label") ? { "dev.specific.spectest.multicast": "true" } : {} };
15
+ return { code: 0, stdout: id, stderr: "" };
16
+ }, async command(file: string, args: string[]) { writes.push([file, ...args]); return { code: 0, stdout: "", stderr: "" }; } };
17
+ }
18
+
19
+ test("default networks keep IPv4 creation and reuse behavior", async () => {
20
+ const f = fixture(undefined);
21
+ await ensureServiceNetwork("spectest-net", undefined, f.docker, f.command);
22
+ expect(f.calls).toEqual([["network", "inspect", "spectest-net"], ["network", "create", "spectest-net"]]);
23
+ expect(f.writes).toEqual([]);
24
+ const reused = fixture(base);
25
+ await ensureServiceNetwork("spectest-net", undefined, reused.docker, reused.command);
26
+ expect(reused.calls).toHaveLength(1);
27
+ });
28
+
29
+ test("dual stack creates the fixed ULA and scopes multicast to its bridge", async () => {
30
+ const f = fixture(undefined);
31
+ await ensureServiceNetwork("spectest-net", { ipv6: true, multicast: true }, f.docker, f.command);
32
+ expect(f.calls[1]).toEqual(["network", "create", "--ipv6", "--subnet", SERVICE_IPV6_SUBNET, "--label", "dev.specific.spectest.multicast=true", "spectest-net"]);
33
+ expect(f.writes).toEqual([["ip", "link", "set", "dev", `br-${id.slice(0, 12)}`, "type", "bridge", "mcast_snooping", "0"]]);
34
+ });
35
+
36
+ test("multicast supports IPv4 without requiring IPv6", async () => {
37
+ const f = fixture(undefined);
38
+ await ensureServiceNetwork("spectest-net", { multicast: true }, f.docker, f.command);
39
+ expect(f.calls[1]).not.toContain("--ipv6");
40
+ expect(f.writes).toHaveLength(1);
41
+ });
42
+
43
+ test("only an empty ordinary bridge can be migrated", async () => {
44
+ const f = fixture(base);
45
+ await ensureServiceNetwork("spectest-net", { ipv6: true }, f.docker, f.command);
46
+ expect(f.calls[1]).toEqual(["network", "rm", "spectest-net"]);
47
+ for (const [name, network] of [
48
+ ["spectest-net", { ...base, Containers: { live: {} } }],
49
+ ["custom", base],
50
+ ["spectest-net", { ...base, Driver: "macvlan" }],
51
+ ["spectest-net", { ...base, Options: { "com.docker.network.bridge.name": "custom0" } }],
52
+ ["spectest-net", { ...base, IPAM: { Driver: "plugin" } }],
53
+ ] as const) {
54
+ const live = fixture(network);
55
+ await expect(ensureServiceNetwork(name, { ipv6: true }, live.docker, live.command)).rejects.toThrow("never replaced");
56
+ expect(live.calls).toHaveLength(1);
57
+ }
58
+ });
59
+
60
+ test("incorrect IPv6 prefixes and mode downgrades are not silently reused", async () => {
61
+ for (const options of [{ ipv6: true }, {}]) {
62
+ const f = fixture({ ...base, EnableIPv6: true, Containers: { live: {} }, IPAM: { Config: [{ Subnet: "fd00::/64" }] } });
63
+ await expect(ensureServiceNetwork("spectest-net", options, f.docker, f.command)).rejects.toThrow("incompatible");
64
+ }
65
+ });
66
+
67
+ test("a matching live network is reused, multicast is reasserted", async () => {
68
+ const f = fixture({ ...base, EnableIPv6: true, Containers: { live: {} },
69
+ IPAM: { Config: [{ Subnet: SERVICE_IPV6_SUBNET }] }, Labels: { "dev.specific.spectest.multicast": "true" } });
70
+ await ensureServiceNetwork("spectest-net", { ipv6: true, multicast: true }, f.docker, f.command);
71
+ expect(f.calls).toHaveLength(1);
72
+ expect(f.writes).toHaveLength(1);
73
+ });
74
+
75
+ test("configuration rejects invalid options before boot", () => {
76
+ for (const network of [null, [], { ipv6: "true" }, { multicast: 1 }, { subnet: "anything" }]) {
77
+ expect(() => defineEnvironment({ name: "invalid", services: {}, network: network as any })).toThrow();
78
+ }
79
+ expect(defineEnvironment({ name: "valid", services: {}, network: { ipv6: true } }).config.network).toEqual({ ipv6: true });
80
+ });
81
+
82
+ test("multicast configuration errors fail setup rather than silently disabling discovery", async () => {
83
+ const f = fixture(undefined);
84
+ await expect(ensureServiceNetwork("spectest-net", { multicast: true }, f.docker, async () => { throw Error("read-only"); })).rejects.toThrow("Cannot enable multicast");
85
+ });
86
+
87
+ test("IPv4 ingress gateway is selected even when Docker lists IPv6 first", async () => {
88
+ const { serviceIpv4Gateway } = await import("./service-network");
89
+ expect(serviceIpv4Gateway([{ Gateway: "fd53:7065:6374::1" }, { Gateway: "172.18.0.1" }])).toBe("172.18.0.1");
90
+ expect(serviceIpv4Gateway([{ Gateway: "fd53:7065:6374::1" }])).toBeUndefined();
91
+ });
92
+
93
+ test("empty-network migration preserves the IPv4 gateway used by the DNS listener", async () => {
94
+ const f = fixture({ ...base, IPAM: { Driver: "default", Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] } });
95
+ await ensureServiceNetwork("spectest-net", { ipv6: true }, f.docker, f.command);
96
+ expect(f.calls[2]).toEqual(["network", "create", "--subnet", "172.18.0.0/16", "--gateway", "172.18.0.1", "--ipv6", "--subnet", SERVICE_IPV6_SUBNET, "spectest-net"]);
97
+ });
98
+
99
+ test("a rejected netlink command fails setup with its diagnostic", async () => {
100
+ const f = fixture(undefined);
101
+ await expect(ensureServiceNetwork("spectest-net", { multicast: true }, f.docker,
102
+ async () => ({ code: 2, stdout: "", stderr: "RTNETLINK answers: Operation not permitted" }))).rejects.toThrow("Operation not permitted");
103
+ });
@@ -0,0 +1,111 @@
1
+ import { isIPv4 } from "node:net";
2
+
3
+ /** The service LAN lives entirely inside one VM and is cloned with it. */
4
+ export interface ServiceNetworkOptions {
5
+ /** Add VM-private IPv6 addresses alongside IPv4 (no external IPv6 route). */
6
+ ipv6?: boolean;
7
+ /** Flood multicast within the service bridge, including IPv4/IPv6 mDNS. */
8
+ multicast?: boolean;
9
+ }
10
+
11
+ // Identical in each VM, like the guest's IPv4 identity. Never advertise outside
12
+ // the guest: siblings retain their sockets/addresses without sharing a LAN.
13
+ export const SERVICE_IPV6_SUBNET = "fd53:7065:6374::/64";
14
+ const MULTICAST_LABEL = "dev.specific.spectest.multicast";
15
+
16
+ type CommandResult = { code: number; stdout: string; stderr: string };
17
+ type Docker = (args: string[], timeoutMs: number) => Promise<CommandResult>;
18
+ interface Network {
19
+ Id: string;
20
+ Driver: string;
21
+ EnableIPv6?: boolean;
22
+ IPAM?: { Driver?: string; Config?: { Subnet?: string; Gateway?: string }[] };
23
+ Options?: Record<string, string>;
24
+ Labels?: Record<string, string>;
25
+ Containers?: Record<string, unknown>;
26
+ }
27
+
28
+ export function validateServiceNetwork(options: ServiceNetworkOptions | undefined): void {
29
+ if (options === undefined) return;
30
+ if (!options || typeof options !== "object" || Array.isArray(options))
31
+ throw new Error("network must be an object with optional ipv6 and multicast booleans");
32
+ for (const key of Object.keys(options)) {
33
+ if (key !== "ipv6" && key !== "multicast") throw new Error(`unknown network option: ${key}`);
34
+ if (typeof options[key] !== "boolean") throw new Error(`network.${key} must be a boolean`);
35
+ }
36
+ }
37
+
38
+ export async function ensureServiceNetwork(
39
+ name: string,
40
+ options: ServiceNetworkOptions | undefined,
41
+ docker: Docker,
42
+ command: (file: string, args: string[], timeoutMs: number) => Promise<CommandResult>,
43
+ ): Promise<void> {
44
+ validateServiceNetwork(options);
45
+ const ipv6 = options?.ipv6 === true;
46
+ const multicast = options?.multicast === true;
47
+ const run = async (args: string[], timeoutMs = 30_000) => {
48
+ const result = await docker(args, timeoutMs);
49
+ if (result.code !== 0) throw new Error(`docker ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`);
50
+ return result;
51
+ };
52
+ const parse = (stdout: string): Network => {
53
+ const network = JSON.parse(stdout)?.[0];
54
+ if (!network || typeof network.Id !== "string") throw new Error(`Invalid Docker network inspection for ${name}`);
55
+ return network;
56
+ };
57
+ const inspected = await docker(["network", "inspect", name], 30_000);
58
+ let network = inspected.code === 0 ? parse(inspected.stdout) : undefined;
59
+ let ipv4Pool: { Subnet?: string; Gateway?: string } | undefined;
60
+ if (network) {
61
+ const expectedSubnet = !ipv6 || network.IPAM?.Config?.some(c => c.Subnet === SERVICE_IPV6_SUBNET);
62
+ const matches = network.Driver === "bridge" && !!network.EnableIPv6 === ipv6 && expectedSubnet &&
63
+ (network.Labels?.[MULTICAST_LABEL] === "true") === multicast;
64
+ if (!matches) {
65
+ // Changing an empty stale project network is safe; never detach live
66
+ // endpoints, or replace an externally supplied network configuration.
67
+ if (name !== "spectest-net" || network.Driver !== "bridge" ||
68
+ (network.IPAM?.Driver && network.IPAM.Driver !== "default") ||
69
+ Object.keys(network.Options ?? {}).length > 0 ||
70
+ Object.keys(network.Containers ?? {}).length > 0) {
71
+ throw new Error(`Network ${name} is incompatible with network.ipv6=${ipv6}, network.multicast=${multicast}; start a fresh environment. Active or custom networks are never replaced.`);
72
+ }
73
+ // Preserve the old IPv4 identity: the independent DNS process may
74
+ // already have a listener bound to this bridge gateway.
75
+ ipv4Pool = network.IPAM?.Config?.find(c => c.Subnet && isIPv4(c.Subnet.split("/")[0]));
76
+ await run(["network", "rm", name]);
77
+ network = undefined;
78
+ }
79
+ }
80
+ if (!network) {
81
+ const args = ["network", "create"];
82
+ if (ipv4Pool?.Subnet) {
83
+ args.push("--subnet", ipv4Pool.Subnet);
84
+ if (ipv4Pool.Gateway) args.push("--gateway", ipv4Pool.Gateway);
85
+ }
86
+ if (ipv6) args.push("--ipv6", "--subnet", SERVICE_IPV6_SUBNET);
87
+ if (multicast) args.push("--label", `${MULTICAST_LABEL}=true`);
88
+ args.push(name);
89
+ await run(args, 60_000);
90
+ if (multicast) network = parse((await run(["network", "inspect", name])).stdout);
91
+ }
92
+ if (multicast && network) {
93
+ const bridge = network.Options?.["com.docker.network.bridge.name"] ?? `br-${network.Id.slice(0, 12)}`;
94
+ if (!/^[a-zA-Z0-9_.-]{1,15}$/.test(bridge)) throw new Error(`Invalid Docker bridge name: ${bridge}`);
95
+ // No external querier exists on this LAN. Flooding avoids membership
96
+ // expiration and discovery races after a paused snapshot is restored.
97
+ // Keep the setting scoped to this bridge; never alter host forwarding.
98
+ try {
99
+ // Use netlink: some supported VM runtimes mount sysfs read-only.
100
+ const result = await command("ip", ["link", "set", "dev", bridge, "type", "bridge", "mcast_snooping", "0"], 10_000);
101
+ if (result.code !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `ip exited ${result.code}`);
102
+ } catch (error) {
103
+ throw new Error(`Cannot enable multicast on ${name} (${bridge}): ${error}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Ingress and resolver listeners remain IPv4 on a dual-stack bridge. */
109
+ export function serviceIpv4Gateway(config: { Gateway?: string }[]): string | undefined {
110
+ return config.find(c => c.Gateway && isIPv4(c.Gateway))?.Gateway;
111
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { validateServiceNetwork, type ServiceNetworkOptions } from "./harness/service-network.js";
2
+ export type { ServiceNetworkOptions } from "./harness/service-network.js";
1
3
  import { stripVTControlCharacters } from "node:util";
2
4
  import type { LocatorAssertionRequest } from "./playwright-assertions.js";
3
5
  import type { InjectRule, SignConfig } from "./fake-upstream.js";
@@ -241,6 +243,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
241
243
  * string used in `dependsOn` references.
242
244
  */
243
245
  services: S;
246
+ /** Private service LAN. IPv4 remains enabled; IPv6 and multicast are opt-in. */
247
+ network?: ServiceNetworkOptions;
244
248
  /** Sandbox timeout in seconds (default 1h). */
245
249
  timeoutSecs?: number;
246
250
  /** Display metadata only; entrypoints retain their author-declared order. */
@@ -1627,6 +1631,7 @@ export interface FakeContext {
1627
1631
  function validateEnvironmentConfig<S extends ServicesMap>(
1628
1632
  config: EnvironmentConfig<S>,
1629
1633
  ): void {
1634
+ validateServiceNetwork(config.network);
1630
1635
  const entries = Object.entries(config.services);
1631
1636
  const serviceNames = new Set(entries.map(([n]) => n));
1632
1637
  const claimedBy = new Map<string, string>();
@@ -2520,6 +2525,8 @@ export interface EnvironmentInput<
2520
2525
  * and use 000000 as the code". */
2521
2526
  instructions?: string;
2522
2527
  };
2528
+ /** Private service LAN. IPv4 remains enabled; IPv6 and multicast are opt-in. */
2529
+ network?: ServiceNetworkOptions;
2523
2530
  /** Sandbox timeout in seconds (default 1h). */
2524
2531
  timeoutSecs?: number;
2525
2532
  /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
package/src/resolver.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  // answer because its source doesn't match the queried server.
27
27
 
28
28
  import { createSocket, type RemoteInfo } from "node:dgram";
29
+ import { isIPv4 } from "node:net";
29
30
  import { request as httpRequest } from "node:http";
30
31
  import { readFile, stat } from "node:fs/promises";
31
32
  import { lookup } from "./harness/names-registry.js";
@@ -241,7 +242,7 @@ async function handleQuery(
241
242
  : await dockerLookupByAlias(name));
242
243
 
243
244
  if (ip) {
244
- // For AAAA we still answer empty — Docker bridges are IPv4 only, but
245
+ // For AAAA we still answer empty — the ingress registry is IPv4, and
245
246
  // we own this name so libc should fall back to A instead of chasing
246
247
  // a stray upstream AAAA for the real public hostname.
247
248
  const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
@@ -320,7 +321,7 @@ async function bridgeGatewayIp(): Promise<string | null> {
320
321
  const net = (await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`)) as
321
322
  | { IPAM?: { Config?: Array<{ Gateway?: string }> } }
322
323
  | null;
323
- const gw = net?.IPAM?.Config?.find((c) => c.Gateway)?.Gateway;
324
+ const gw = net?.IPAM?.Config?.find(c => c.Gateway && isIPv4(c.Gateway))?.Gateway;
324
325
  return gw && gw.length > 0 ? gw : null;
325
326
  }
326
327