@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.
- package/dist/components/android-runtime.mjs +2 -0
- package/dist/components/android-trust.mjs +126 -0
- package/dist/components/android.d.ts +2 -1
- package/dist/components/android.js +7 -1
- package/dist/daemon.js +8 -13
- package/dist/harness/service-network.d.ts +21 -0
- package/dist/harness/service-network.js +94 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/resolver.js +3 -2
- package/package.json +1 -1
- package/src/components/android-runtime.mjs +2 -0
- package/src/components/android-trust.mjs +126 -0
- package/src/components/android-trust.test.ts +113 -0
- package/src/components/android.test.ts +2 -0
- package/src/components/android.ts +7 -1
- package/src/daemon.ts +8 -14
- package/src/harness/service-network.test.ts +103 -0
- package/src/harness/service-network.ts +111 -0
- package/src/index.ts +7 -0
- package/src/resolver.ts +3 -2
|
@@ -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
|
-
"{{
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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(
|
|
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
|
@@ -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
|
-
"{{
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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(
|
|
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
|
|