From 83f82e5703003e1c4917dd074df84898a14a50d8 Mon Sep 17 00:00:00 2001 From: SeniorStackOverflow <273332294+SeniorStackOverflow@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:23:09 +0300 Subject: [PATCH] Give native Docker containers IPv4 internet access --- .github/workflows/offline.yml | 2 +- Makefile | 7 +- README.md | 6 +- README.ru.md | 4 +- docs/build.md | 2 +- docs/docker.md | 76 +++++++++-- evidence/docker-internet-summary.json | 87 +++++++++++++ evidence/docker-summary.json | 2 +- probes/docker/launcher.c | 4 +- scripts/docker-network.sh | 175 ++++++++++++++++++++++++++ scripts/start-docker-test.sh | 36 +++++- scripts/stop-docker-test.sh | 18 +++ tools/validate_docker_internet.py | 100 +++++++++++++++ 13 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 evidence/docker-internet-summary.json create mode 100644 scripts/docker-network.sh create mode 100644 scripts/stop-docker-test.sh create mode 100644 tools/validate_docker_internet.py diff --git a/.github/workflows/offline.yml b/.github/workflows/offline.yml index fbfb722..815a61b 100644 --- a/.github/workflows/offline.yml +++ b/.github/workflows/offline.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install cross tools and QEMU - run: sudo apt-get update && sudo apt-get install -y binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm + run: sudo apt-get update && sudo apt-get install -y binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm shellcheck - name: Build and exercise EL2 handoff run: make check - name: Upload our generated code and test output diff --git a/Makefile b/Makefile index 263c149..e845bcc 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ OBJDUMP := $(CROSS)objdump NM := $(CROSS)nm CC := $(CROSS)gcc -.PHONY: all gateway probes docker-probes test check +.PHONY: all gateway probes docker-probes test check shell-check all: gateway probes gateway: $(BUILD)/gateway.bin $(BUILD)/publisher.bin $(BUILD)/marker.bin $(BUILD)/symbols.txt probes: $(BUILD)/kvm-guest-probe $(BUILD)/kvm-timer-probe $(BUILD)/kvm-timer-guest.bin @@ -63,6 +63,9 @@ test: gateway $(BUILD)/test-gateway.bin python3 tools/uh_image.py check-gateway --build $(BUILD) python3 -m unittest discover -s tests -p 'test_*.py' -v -check: all docker-probes test +shell-check: + shellcheck -s sh scripts/docker-network.sh scripts/start-docker-test.sh scripts/stop-docker-test.sh + +check: all docker-probes test shell-check python3 -m py_compile tools/*.py python3 tools/check_docker_config.py diff --git a/README.md b/README.md index 25b819c..86e1e27 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ Tested on **SM-A515F, Android 11, A515FXXU5EUJ4**, unlocked bootloader, kernel * An optional [native Docker profile](docs/docker.md) uses kernel `4.14.113-22755563-docker` and preserves this KVM backend. Hardware checks include an actual Docker container with overlay2, default seccomp, CPU affinity/quota, -enforced memory/process limits and veth/bridge traffic. See the +enforced memory/process limits and veth/bridge traffic. Containers also have +[verified IPv4 internet access](evidence/docker-internet-summary.json): registry +pulls, DNS, HTTP(S) and package downloads on default and user-created bridges. See the [Docker evidence](evidence/docker-summary.json) and build/Android compatibility details. The [sanitized hardware evidence](evidence/hardware-summary.json) records the measurements. This is a research prototype for **one audited firmware layout**. Full Linux guests, SMP guests, long-running workloads, suspend-to-RAM and other A51 variants remain untested. CI exercises a synthetic EL2 harness, separate from the handset results. @@ -56,7 +58,7 @@ On Ubuntu or WSL Ubuntu: ```sh sudo apt-get update -sudo apt-get install -y make python3 git binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm +sudo apt-get install -y make python3 git binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm shellcheck git clone https://github.com/SeniorStackOverflow/a51-kvm.git cd a51-kvm make check diff --git a/README.ru.md b/README.ru.md index 45d599c..257f029 100644 --- a/README.ru.md +++ b/README.ru.md @@ -4,7 +4,7 @@ **Аппаратная виртуализация заработала на настоящем SM-A515F с Exynos 9611.** Здесь сохранены код, патч ядра, точная конфигурация, инструменты сборки и результаты проверок — чтобы следующий человек мог повторить работу и понять, почему она сработала. -Доступен отдельный [профиль ядра для нативного Docker](docs/docker.md): cgroups, namespaces, OverlayFS, veth, bridge, netfilter и seccomp. Проверены настоящий контейнер Docker, ограничения памяти и процессов, CPU quota/affinity и обмен данными через bridge. KVM сохранён; [результаты](evidence/docker-summary.json) включают повторные аппаратные проверки. +Доступен отдельный [профиль ядра для нативного Docker](docs/docker.md): cgroups, namespaces, OverlayFS, veth, bridge, netfilter и seccomp. Проверены настоящий контейнер Docker, ограничения памяти и процессов, CPU quota/affinity и обмен данными через bridge. У контейнеров есть [проверенный выход в интернет по IPv4](evidence/docker-internet-summary.json): загрузка образов, DNS, HTTP(S) и загрузка пакетов в стандартной и отдельно созданной bridge-сети. KVM сохранён; [результаты](evidence/docker-summary.json) включают повторные аппаратные проверки. ## Проверенный результат @@ -33,7 +33,7 @@ ```sh sudo apt-get update -sudo apt-get install -y make python3 git binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm +sudo apt-get install -y make python3 git binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm shellcheck git clone https://github.com/SeniorStackOverflow/a51-kvm.git cd a51-kvm make check diff --git a/docs/build.md b/docs/build.md index 1b48e03..0e409ca 100644 --- a/docs/build.md +++ b/docs/build.md @@ -6,7 +6,7 @@ The handset result used Linux 4.14.113, Samsung's A515FXXU5EUJ4 source, Android ```sh sudo apt-get update -sudo apt-get install -y make python3 git binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm \ +sudo apt-get install -y make python3 git binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu qemu-system-arm shellcheck \ gcc-11 g++-11 bc bison flex libssl-dev libelf-dev curl xz-utils make check ``` diff --git a/docs/docker.md b/docs/docker.md index 9a2d0bc..3a65069 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -72,7 +72,7 @@ compatibility-warning entries. Its reflection-based probe is in `probes/docker/VintfProbe.java` and can be compiled to dex using JDK and Android D8, then run as root with `app_process`. SELinux remained `Enforcing`. -## Reproduce the isolated native-container test +## Run native Docker with internet access The tested userspace is the official static **Docker 27.5.1 aarch64** archive and official **runc 1.3.6 arm64** binary. Download them from @@ -91,6 +91,8 @@ make docker-probes adb -s "$SERIAL" push docker /data/local/tmp/codex-a51-docker-bin adb -s "$SERIAL" push build/docker-daemon-launcher /data/local/tmp/docker-daemon-launcher adb -s "$SERIAL" push scripts/start-docker-test.sh /data/local/tmp/start-docker-test.sh +adb -s "$SERIAL" push scripts/stop-docker-test.sh /data/local/tmp/stop-docker-test.sh +adb -s "$SERIAL" push scripts/docker-network.sh /data/local/tmp/docker-network.sh adb -s "$SERIAL" shell su -c 'chmod 755 /data/local/tmp/codex-a51-docker-bin/* /data/local/tmp/docker-daemon-launcher' ``` @@ -110,11 +112,70 @@ kernel; ext4 supplies a supported persistent upperdir while the backing file stays within encrypted `/data`. The daemon has private mount and network namespaces. Existing Android cgroup -controllers are bound into its view; Docker adds its own subgroups. Android -tasks and its firewall stay in their existing namespaces. The test network -has a bridge and containers, with **no uplink to Android's internet connection**. -This launcher is a reproducible test setup, not a boot service or a production -network deployment. +controllers are bound into its view; Docker adds its own subgroups. Docker +owns its bridge and firewall in its private network namespace. + +The start script waits for the Docker API and connects this namespace to Android +with a veth pair (`a51-dk0` / `a51-dk1`, `10.231.43.0/30`). The network supervisor +adds only two private firewall chains and three scoped policy rules at priorities +9000–9002. It requests forwarding through netd as the `a51-docker` requester, +so shutdown releases that request without disabling another tethering requester. +Android's existing chains, default policies, routes, VPN and application traffic +are preserved. The supervisor refuses collisions with its interface, chains, +rule priorities or subnet. + +Every five seconds the supervisor asks Android where root's ordinary IPv4 +internet traffic goes and selects that routing table and outgoing interface. +It refreshes the uplink when Android's default network changes; no available +IPv4 route leaves Docker's outgoing traffic blocked. A terminal unreachable rule +prevents accidental routing through a different Android table. This follows +root's internet route; it does not replicate Android's per-application VPN policy +or provide a VPN kill switch for containers. Docker networks are still subject +to their native rules, including `--internal` isolation. + +Containers use public DNS servers `1.1.1.1` and `8.8.8.8`. The daemon uses +Android's system CA directories to verify registry HTTPS. Container HTTPS uses +the CA certificates supplied by its image. The ordinary default bridge and +user-created bridge networks have IPv4 internet access. Container IPv6 and +inbound connections from the LAN are outside this uplink's scope. + +Use the CLI from Android root: + +```sh +adb -s "$SERIAL" shell su -c '/data/local/tmp/codex-a51-docker-bin/docker --host unix:///data/local/tmp/codex-a51-docker/docker.sock pull alpine:3.22' +adb -s "$SERIAL" shell su -c '/data/local/tmp/codex-a51-docker-bin/docker --host unix:///data/local/tmp/codex-a51-docker/docker.sock run --rm alpine:3.22 wget -qO- https://example.com' +adb -s "$SERIAL" shell su -c 'sh /data/local/tmp/docker-network.sh status' +python3 tools/validate_docker_internet.py --serial "$SERIAL" +``` + +The internet validator exercises a real registry pull, external DNS, HTTP to an +IPv4 address, certificate-verified HTTPS (including rejection of an untrusted +certificate) and `apk update` on both the default +bridge and a user-created bridge. It also checks container-name DNS, ICMP between +containers, lack of internet on an internal bridge, Android connectivity, +unchanged boot ID and SELinux `Enforcing`. + +For the original offline test, start with +`sh /data/local/tmp/start-docker-test.sh --isolated`. The launcher is manually +started; it does not install a boot service. The network supervisor automatically +removes its uplink and rules after the daemon exits. Explicit stop also cleans +up the network: + +```sh +adb -s "$SERIAL" shell su -c 'sh /data/local/tmp/stop-docker-test.sh' +``` + +Network logs are in `/data/local/tmp/codex-a51-docker-network.log`. If a supervisor +was forcibly killed, run `docker-network.sh stop` before starting again; it +recovers stale state only when the interface has its expected ownership alias. +No global firewall flushing is used. + +The routing and firewall design follows Android's +[netd forwarding API](https://android.googlesource.com/platform/system/netd/+/451debdc6bff2ffda2f4c85f7de244d52b05c806/server/CommandListener.cpp) +and Docker's [bridge networking](https://docs.docker.com/engine/network/drivers/bridge/) +and [iptables behavior](https://docs.docker.com/engine/network/firewall-iptables/). + +## Validate native container resources ```sh python3 tools/validate_docker_device.py --serial "$SERIAL" @@ -130,8 +191,7 @@ CPU affinity/quota, 32-task enforcement and a UDP round trip through veth/bridge Memory and memory+swap are both limited to 64 MiB, so only the over-limit child is killed and Android's zRAM cannot absorb the test allocation. -Stop the test daemon through the PID in its own `dockerd.pid`, after verifying -the process command line uses this test socket. The launcher has no autostart. +Stop through `stop-docker-test.sh`; it verifies the daemon uses this test socket. Results and exact reference hashes are in [docker-summary.json](../evidence/docker-summary.json). Moby 27.5.1's official `contrib/check-config.sh` reported every generally diff --git a/evidence/docker-internet-summary.json b/evidence/docker-internet-summary.json new file mode 100644 index 0000000..3449d28 --- /dev/null +++ b/evidence/docker-internet-summary.json @@ -0,0 +1,87 @@ +{ + "recorded_utc": "2026-10-06T09:22:13.816016+00:00", + "model": "SM-A515F", + "firmware": "A515FXXU5EUJ4", + "kernel": "4.14.113-22755563-docker", + "docker_version": "27.5.1", + "image": "alpine:3.22", + "image_digests": [ + "alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8" + ], + "platform": "arm64", + "uplink": "IPv4 through Android root default route, verified over Wi-Fi", + "registry_pull_verified": true, + "registry_download_digest": "sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8", + "default_bridge_checks": [ + "PASS external DNS", + "PASS IPv4 HTTP without DNS", + "PASS HTTPS with certificate verification", + "PASS untrusted HTTPS certificate rejected", + "PASS package repository downloads" + ], + "user_bridge_checks": [ + "PASS container-name DNS and bridge round trip", + "PASS external DNS", + "PASS IPv4 HTTP without DNS", + "PASS HTTPS with certificate verification", + "PASS untrusted HTTPS certificate rejected", + "PASS package repository downloads" + ], + "internal_bridge_check": "PASS internal bridge has no internet egress", + "lifecycle": { + "passed": true, + "checks": [ + "internet survives full daemon restart", + "duplicate start rejected", + "missing Android route blocks container egress", + "internet recovers automatically after route returns", + "killed supervisor recovered without changing Android rules or forwarding state", + "daemon exit automatically removes uplink and restores Android state", + "isolated start retains original offline behavior", + "explicit stop restores exact Android firewall, policy rules and forwarding state", + "final daemon left running with verified HTTPS internet and unchanged Android boot" + ], + "boot_id_unchanged": true, + "selinux": "Enforcing", + "baseline_sha256": { + "rules": "a108a1926439488e5bd2d811b78f8360af5852574b2283eae76895143a8ab5a2", + "filter": "67837c037765019a217f2e8134f2d4b46bc3aa8b39cc2e5a911257d6b02b17b2", + "nat": "53b6f8ff12e122069537aa6378555e901c2aced2905baf998463efc4734dae71", + "mangle": "815dd17c50282a984dfdf8ac6facccaefc63450821bcba2603e423dd4fa5d898", + "raw": "1d591ad0e4ce7ddb4c979ab89f32ccec8a5fe40b8cf42163f2babe46710f8662", + "forwarding": "758008efb9c8566d26b4ae6683afde9ae00feb61c1d2dbd7cb8c09bda30d2627" + } + }, + "kvm_regression": { + "passed": true, + "boot_id_unchanged": true, + "guest_cycles": 12, + "guest_cpus": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "timer_cpus": [ + 0, + 4 + ], + "idle_migration_passed": true + }, + "android_connectivity_verified": true, + "selinux": "Enforcing", + "boot_id_unchanged": true, + "source_sha256": { + "scripts/docker-network.sh": "34685437a66a297091d13ee1a5f3465049c77d3d79b2e2cc9129553f380efd2d", + "scripts/start-docker-test.sh": "737c5825ae70f92f4fdbd65baac94b68b950cd82f170431020905ea7bd51565d", + "scripts/stop-docker-test.sh": "a70ad1e3d6898a93b22be6153661dada1b795b6ac013fb48259e52a47fc6f2fe", + "probes/docker/launcher.c": "9f843066a78d4a681b12c9d7336b3e9576c19c6045c93a7cb0a45b3ae0e29896", + "tools/validate_docker_internet.py": "b35c46cd64867f3ecd1de30633e8c0f1cd276351ae3eec80f7e82e5da01abc3f" + }, + "scope": "Real registry download, DNS, HTTP, certificate-verified HTTPS and APK repository downloads; default/user-defined IPv4 bridges and internal-network isolation; recovery after route loss, daemon exit and supervisor kill. Wi-Fi measured; mobile handover, container IPv6, LAN ingress, per-app VPN policy and long-term workloads not measured. Daemon left running with internet access.", + "passed": true +} diff --git a/evidence/docker-summary.json b/evidence/docker-summary.json index 154f326..5865106 100644 --- a/evidence/docker-summary.json +++ b/evidence/docker-summary.json @@ -59,5 +59,5 @@ 4 ] }, - "scope": "Kernel families and native local Docker container verified; isolated test network has no Android uplink; no production workload or Docker Swarm coverage" + "scope": "Original native Docker kernel/container validation used an isolated network without Android uplink; IPv4 internet and cleanup validation are recorded separately in docker-internet-summary.json; no production workload or Docker Swarm coverage" } diff --git a/probes/docker/launcher.c b/probes/docker/launcher.c index c105959..7d5722c 100644 --- a/probes/docker/launcher.c +++ b/probes/docker/launcher.c @@ -23,9 +23,11 @@ int main(int argc,char**argv){ const char*names[]={"cpu","cpuacct","cpuset","memory","freezer","devices","pids","blkio"}; const char*android[]={"/dev/cpuctl","/acct","/dev/cpuset","/dev/memcg","/dev/freezer",NULL,NULL,NULL}; for(int i=0;i<8;i++){char p[256];snprintf(p,sizeof(p),"/sys/fs/cgroup/%s",names[i]);dir(p);if(android[i])ck(mount(android[i],p,NULL,MS_BIND|MS_REC,NULL),names[i]);else ck(mount("cgroup",p,"cgroup",0,names[i]),names[i]);} - // Host namespaces, mounts and Android firewall are untouched. Docker owns a private netns. + // Docker owns a private netns; the host-side supervisor adds a scoped uplink. setenv("PATH","/data/local/tmp/codex-a51-docker-bin:/system/bin:/system/xbin",1); setenv("DOCKER_TMPDIR","/data/local/tmp/codex-a51-docker/tmp",1); + // Go's Linux defaults cannot discover Android's CA directory in this layout. + setenv("SSL_CERT_DIR","/system/etc/security/cacerts:/apex/com.android.conscrypt/cacerts",1); dir("/data/local/tmp/codex-a51-docker");dir("/data/local/tmp/codex-a51-docker/tmp"); if(runsh("/system/bin/ip link set lo up"))return 1; dir("/data/local/tmp/codex-a51-docker/data"); diff --git a/scripts/docker-network.sh b/scripts/docker-network.sh new file mode 100644 index 0000000..9b2f9dc --- /dev/null +++ b/scripts/docker-network.sh @@ -0,0 +1,175 @@ +#!/system/bin/sh +# IPv4 uplink for the private A51 Docker namespace. Run as Android root. +set -eu +PATH=/system/bin:/system/xbin +export PATH +state=/data/local/tmp/codex-a51-docker-network +self=/data/local/tmp/docker-network.sh +host=a51-dk0 +peer=a51-dk1 +address=10.231.43.2 +fwd=A51_DOCKER_FWD +nat=A51_DOCKER_NAT +socket=/data/local/tmp/codex-a51-docker/docker.sock +ipt() { iptables -w 5 "$@"; } +die() { echo "$*" >&2; exit 1; } +starttime() { awk '{print $22}' "/proc/$1/stat"; } +daemon_ok() { + [ -r "/proc/$daemon/stat" ] && [ "$(starttime "$daemon")" = "$birth" ] && + tr '\000' ' ' <"/proc/$daemon/cmdline" | grep -q "dockerd.*$socket" +} +net() { nsenter -t "$daemon" -n -- "$@"; } +subnet_in_use() { + # Compare ranges, including broader routes such as a VPN's 10.0.0.0/8. + awk ' + function number(s, a) { split(s,a,"."); return a[1]*16777216+a[2]*65536+a[3]*256+a[4] } + BEGIN { low=number("10.231.43.0"); high=low+3; found=0 } + { + split($1,cidr,"/"); + if(cidr[1] !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) next; + bits=cidr[2]=="" ? 32 : cidr[2]; + if(bits==0) next; + size=2^(32-bits); start=int(number(cidr[1])/size)*size; + if(start<=high && start+size-1>=low) found=1; + } + END { exit !found }' +} +cleanup() { + set +e + rm -f "$state/ready" + # Delete only our exact selectors and chains, never Android's rules. + ip -4 rule del pref 9001 iif "$host" 2>/dev/null + ip -4 rule del pref 9002 iif "$host" unreachable 2>/dev/null + ip -4 rule del pref 9000 to "$address/32" lookup main 2>/dev/null + ipt -D FORWARD -i "$host" -j "$fwd" 2>/dev/null + ipt -D FORWARD -o "$host" -j "$fwd" 2>/dev/null + ipt -t nat -D POSTROUTING -s "$address/32" -j "$nat" 2>/dev/null + if [ "$owned" = 1 ]; then + ipt -F "$fwd" 2>/dev/null; ipt -X "$fwd" 2>/dev/null + ipt -t nat -F "$nat" 2>/dev/null; ipt -t nat -X "$nat" 2>/dev/null + if ip -d link show "$host" 2>/dev/null | grep -q 'alias a51-docker-uplink'; then + ip link del "$host" + fi + ndc ipfwd disable a51-docker >/dev/null + fi + rm -f "$state/pid" "$state/daemon" "$state/ready" "$state/egress" + rmdir "$state" +} +refresh() { + # Ask Android where root's ordinary internet traffic goes. Do not assume + # a Linux main-table default route or hard-code a Wi-Fi/mobile interface. + route=$(ip -4 route get 1.1.1.1 uid 0 2>/dev/null | head -n 1) || route= + uplink=$(echo "$route" | awk '{for(i=1;i/dev/null || true + ipt -F "$fwd" + ipt -t nat -F "$nat" + if [ -n "$uplink" ]; then + # The terminal rule below fails closed while no uplink exists. + ipt -A "$fwd" -i "$host" -s "$address/32" -o "$uplink" -j ACCEPT + ipt -A "$fwd" -o "$host" -d "$address/32" -i "$uplink" \ + -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + ipt -t nat -A "$nat" -o "$uplink" -j MASQUERADE + ip -4 rule add pref 9001 iif "$host" lookup "$table" + fi + ipt -A "$fwd" -j DROP + printf '%s\n' "$current" >"$state/egress" + echo "Docker uplink: ${uplink:-offline} (Android table $table)" + previous=$current + fi + # Android may regenerate top-level netd hooks during network changes. + ipt -C FORWARD -i "$host" -j "$fwd" 2>/dev/null || ipt -I FORWARD 1 -i "$host" -j "$fwd" + ipt -C FORWARD -o "$host" -j "$fwd" 2>/dev/null || ipt -I FORWARD 1 -o "$host" -j "$fwd" + ipt -t nat -C POSTROUTING -s "$address/32" -j "$nat" 2>/dev/null || + ipt -t nat -I POSTROUTING 1 -s "$address/32" -j "$nat" +} +[ "$(id -u)" = 0 ] || die 'Android root required' +[ "$(uname -r)" = 4.14.113-22755563-docker ] || die 'Audited Docker kernel required' +case "${1:-}" in +start) + daemon=${2:-} + case "$daemon" in ''|*[!0-9]*) die 'usage: docker-network.sh start DOCKERD_PID';; esac + [ ! -e "$state" ] || die 'Network supervisor state exists; inspect status or stop it first' + birth=$(starttime "$daemon") + daemon_ok || die 'Expected private A51 dockerd' + nohup sh "$self" watch "$daemon" "$birth" \ + >/data/local/tmp/codex-a51-docker-network.log 2>&1 /dev/null || break + sleep 1 + done + kill "$watcher" 2>/dev/null || true + cat /data/local/tmp/codex-a51-docker-network.log >&2 + die 'Docker uplink did not become ready' + ;; +watch) + daemon=$2; birth=$3 + daemon_ok || die 'Private daemon disappeared' + # Refuse collisions before installing cleanup traps or changing anything. + ! ip link show "$host" >/dev/null 2>&1 || die 'Uplink interface already exists' + ! ipt -S "$fwd" >/dev/null 2>&1 || die 'Forward chain already exists' + ! ipt -t nat -S "$nat" >/dev/null 2>&1 || die 'NAT chain already exists' + ! ip -4 rule show | grep -qE '^900[012]:' || die 'Policy priorities 9000-9002 are occupied' + ! ip -4 route show table all | subnet_in_use || die 'Uplink subnet is occupied on Android' + ! net ip -4 route show table all | subnet_in_use || die 'Uplink subnet is occupied in Docker' + umask 077 + mkdir "$state" + owned=1 + trap cleanup EXIT + trap 'exit 0' INT TERM HUP + echo "$$" >"$state/pid" + echo "$daemon" >"$state/daemon" + ipt -N "$fwd" + ipt -A "$fwd" -j DROP + ipt -t nat -N "$nat" + ip link add "$host" type veth peer name "$peer" + ip link set "$host" alias a51-docker-uplink + ip link set "$peer" netns "$daemon" + ip addr add 10.231.43.1/30 dev "$host" + ip link set "$host" up + net ip addr add "$address/30" dev "$peer" + net ip link set "$peer" up + net ip route add default via 10.231.43.1 dev "$peer" + ip -4 rule add pref 9000 to "$address/32" lookup main + ip -4 rule add pref 9002 iif "$host" unreachable + ndc ipfwd enable a51-docker | grep -q '^200 ' || die 'netd rejected forwarding request' + [ "$(cat /proc/sys/net/ipv4/ip_forward)" = 1 ] || die 'IPv4 forwarding unavailable' + previous='unset' + refresh + touch "$state/ready" + while daemon_ok; do sleep 5; refresh; done + ;; +stop) + [ -d "$state" ] || exit 0 + watcher=$(cat "$state/pid") + case "$watcher" in ''|*[!0-9]*) die 'Invalid supervisor PID';; esac + if [ -r "/proc/$watcher/cmdline" ]; then + tr '\000' ' ' <"/proc/$watcher/cmdline" | grep -q "$self watch " || die 'Supervisor PID was reused' + kill "$watcher" + for _attempt in $(seq 1 15); do [ -d "$state" ] || exit 0; sleep 1; done + die 'Supervisor cleanup has not completed; inspect network log' + fi + # A killed supervisor can leave scoped rules behind. Recover only if the + # interface still carries our ownership alias. + ip -d link show "$host" | grep -q 'alias a51-docker-uplink' || die 'Stale state needs inspection' + owned=1; cleanup + ;; +status) + [ -f "$state/ready" ] || die 'Docker uplink is stopped' + watcher=$(cat "$state/pid") + case "$watcher" in ''|*[!0-9]*) die 'Invalid supervisor PID';; esac + [ -r "/proc/$watcher/cmdline" ] || die 'Supervisor stopped; run docker-network.sh stop to clean up' + tr '\000' ' ' <"/proc/$watcher/cmdline" | grep -q "$self watch " || die 'Supervisor PID was reused' + cat "$state/egress" + ip -4 rule show | grep -E '^900[012]:' + ipt -L "$fwd" -nv + ;; +*) die 'usage: docker-network.sh start DOCKERD_PID | stop | status';; +esac diff --git a/scripts/start-docker-test.sh b/scripts/start-docker-test.sh index faae46a..af433e0 100644 --- a/scripts/start-docker-test.sh +++ b/scripts/start-docker-test.sh @@ -4,6 +4,12 @@ set -eu [ "$(uname -r)" = 4.14.113-22755563-docker ] [ "$(getprop ro.product.model)" = SM-A515F ] [ "$(getprop ro.bootloader)" = A515FXXU5EUJ4 ] +case "${1:-}" in + '') network=internet;; + --isolated) network=isolated;; + *) echo 'usage: start-docker-test.sh [--isolated]' >&2; exit 2;; +esac +[ "$#" -le 1 ] if pidof dockerd >/dev/null; then echo 'A Docker daemon is already running; inspect it before starting another' >&2 exit 1 @@ -11,6 +17,11 @@ fi [ -f /data/local/tmp/codex-a51-docker-storage.ext4 ] [ -x /data/local/tmp/docker-daemon-launcher ] [ -x /data/local/tmp/codex-a51-docker-bin/dockerd ] +if [ "$network" = internet ]; then + [ -f /data/local/tmp/docker-network.sh ] + [ -f /data/local/tmp/stop-docker-test.sh ] + [ ! -e /data/local/tmp/codex-a51-docker-network ] +fi nohup /data/local/tmp/docker-daemon-launcher \ /data/local/tmp/codex-a51-docker-bin/dockerd \ --host unix:///data/local/tmp/codex-a51-docker/docker.sock \ @@ -18,6 +29,29 @@ nohup /data/local/tmp/docker-daemon-launcher \ --data-root /data/local/tmp/codex-a51-docker/data \ --exec-root /data/local/tmp/codex-a51-docker/exec \ --pidfile /data/local/tmp/codex-a51-docker/dockerd.pid \ + --dns 1.1.1.1 --dns 8.8.8.8 \ --storage-driver overlay2 --exec-opt native.cgroupdriver=cgroupfs \ >/data/local/tmp/codex-a51-dockerd.log 2>&1 /dev/null 2>&1; then + ready=1; break + fi + kill -0 "$launcher" 2>/dev/null || break + sleep 1 +done +if [ "$ready" != 1 ]; then + kill "$launcher" 2>/dev/null || true + cat /data/local/tmp/codex-a51-dockerd.log >&2 + exit 1 +fi +if [ "$network" = internet ]; then + daemon=$(cat /data/local/tmp/codex-a51-docker/dockerd.pid) + if ! sh /data/local/tmp/docker-network.sh start "$daemon"; then + sh /data/local/tmp/stop-docker-test.sh + exit 1 + fi +fi +echo "Docker ready ($network) on unix:///data/local/tmp/codex-a51-docker/docker.sock" diff --git a/scripts/stop-docker-test.sh b/scripts/stop-docker-test.sh new file mode 100644 index 0000000..4d25e96 --- /dev/null +++ b/scripts/stop-docker-test.sh @@ -0,0 +1,18 @@ +#!/system/bin/sh +set -eu +[ "$(id -u)" = 0 ] +sh /data/local/tmp/docker-network.sh stop +pidfile=/data/local/tmp/codex-a51-docker/dockerd.pid +[ -f "$pidfile" ] || exit 0 +daemon=$(cat "$pidfile") +case "$daemon" in ''|*[!0-9]*) echo 'Invalid dockerd PID' >&2; exit 1;; esac +[ -r "/proc/$daemon/cmdline" ] || exit 0 +tr '\000' ' ' <"/proc/$daemon/cmdline" | grep -q \ + 'dockerd.*unix:///data/local/tmp/codex-a51-docker/docker.sock' +kill "$daemon" +for _attempt in $(seq 1 30); do + [ -e "/proc/$daemon" ] || { echo 'Docker stopped; uplink removed'; exit 0; } + sleep 1 +done +echo 'Docker has not finished stopping; inspect its log' >&2 +exit 1 diff --git a/tools/validate_docker_internet.py b/tools/validate_docker_internet.py new file mode 100644 index 0000000..6001b8f --- /dev/null +++ b/tools/validate_docker_internet.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Exercise real registry, DNS, HTTP(S), and bridge egress on the prepared A51.""" +import argparse +import json +from pathlib import Path +import shlex +import subprocess +import time + +REPO = Path(__file__).resolve().parents[1] +PREFIX = '/data/local/tmp/codex-a51-docker-bin/docker --host unix:///data/local/tmp/codex-a51-docker/docker.sock ' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--serial', required=True) + parser.add_argument('--image', default='alpine:3.22') + parser.add_argument('--out', type=Path, default=REPO / 'build/docker-internet.json') + args = parser.parse_args() + + def su(command, timeout=180): + result = subprocess.run( + ['adb', '-s', args.serial, 'shell'], input='su -c ' + shlex.quote(command) + '\n', + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout) + if result.returncode: + raise RuntimeError(f'{command}\n{result.stdout}\n{result.stderr}') + return result.stdout.strip() + + def docker(*words, timeout=180): + return su(PREFIX + shlex.join(words), timeout) + + if su('getprop ro.product.model') != 'SM-A515F' or su('getprop ro.bootloader') != 'A515FXXU5EUJ4': + raise RuntimeError('Audited SM-A515F / A515FXXU5EUJ4 required') + if su('uname -r') != '4.14.113-22755563-docker' or su('getprop sys.boot_completed') != '1': + raise RuntimeError('Booted Docker kernel required') + before = su('cat /proc/sys/kernel/random/boot_id') + status = su('sh /data/local/tmp/docker-network.sh status') + if docker('network', 'ls', '--format', '{{.Name}}').splitlines().count('a51-internet-check'): + raise RuntimeError('Test network already exists; inspect it first') + pull = docker('pull', args.image, timeout=300) + image = json.loads(docker('image', 'inspect', args.image))[0] + script = '''set -eu +nslookup example.com +echo 'PASS external DNS' +wget -q -O /tmp/http http://1.1.1.1/cdn-cgi/trace +test -s /tmp/http +echo 'PASS IPv4 HTTP without DNS' +wget -q -O /tmp/https https://example.com +grep -q 'Example Domain' /tmp/https +echo 'PASS HTTPS with certificate verification' +if wget -T 10 -q -O /tmp/untrusted https://self-signed.badssl.com 2>/tmp/tls-error; then + echo 'FAIL untrusted HTTPS certificate was accepted' >&2 + exit 1 +fi +grep -q 'certificate verify failed' /tmp/tls-error +echo 'PASS untrusted HTTPS certificate rejected' +apk update +echo 'PASS package repository downloads' +''' + default = docker('run', '--rm', '--memory', '64m', '--pids-limit', '64', args.image, 'sh', '-ec', script) + docker('network', 'create', 'a51-internet-check') + try: + # Prove Docker's embedded DNS and egress on a user-created bridge too. + docker('run', '-d', '--rm', '--name', 'a51-internet-peer', '--network', 'a51-internet-check', + '--memory', '32m', '--pids-limit', '32', args.image, 'sleep', '120') + time.sleep(1) + custom = docker('run', '--rm', '--network', 'a51-internet-check', '--memory', '64m', + '--pids-limit', '64', args.image, 'sh', '-ec', + "ping -c 1 -W 5 a51-internet-peer; " + "echo 'PASS container-name DNS and bridge round trip'; " + script) + finally: + docker('rm', '-f', 'a51-internet-peer') + docker('network', 'rm', 'a51-internet-check') + docker('network', 'create', '--internal', 'a51-internet-check') + try: + internal = docker('run', '--rm', '--network', 'a51-internet-check', args.image, 'sh', '-ec', + 'if timeout 5 wget -qO- http://1.1.1.1/cdn-cgi/trace; then exit 1; fi; ' + "echo 'PASS internal bridge has no internet egress'") + finally: + docker('network', 'rm', 'a51-internet-check') + android = su('ping -c 1 -W 5 1.1.1.1') + if before != su('cat /proc/sys/kernel/random/boot_id') or su('getenforce') != 'Enforcing': + raise RuntimeError('Phone rebooted or SELinux state changed') + result = { + 'kernel': su('uname -r'), 'docker_version': docker('version', '--format', '{{.Server.Version}}'), + 'image': args.image, 'image_digests': image['RepoDigests'], 'platform': image['Architecture'], + 'registry_pull': pull, 'uplink_status': status, 'default_bridge': default, + 'user_bridge': custom, 'internal_bridge': internal, 'android_ping': android, + 'boot_id_unchanged': True, 'selinux': 'Enforcing', 'passed': True, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + print(default) + print(custom) + print(internal) + print('PASS Docker internet; evidence:', args.out) + + +if __name__ == '__main__': + main()