#!/bin/bash # ===================================================================== # Runs INSIDE the sandbox VM. Proves the guest cannot reach this host or # the LAN, and - just as important - that it CAN reach the internet. # # WHY THE POSITIVE CONTROLS ARE NOT OPTIONAL # # A suite made only of "this must fail" passes perfectly on a machine # with no network at all, on a machine that is switched off, and on a # machine where the test harness is broken. Every must-fail target here # is paired with a must-succeed one, so a pass means "the filter works" # rather than "nothing works". # # ping IS EXPECTED TO FAIL, AND THAT IS NOT THE FILTER # # net.ipv4.ping_group_range on the host is "1 0", so no group may open an # unprivileged ICMP socket and libslirp cannot forward one. ping fails # here permanently, on a machine whose HTTPS works perfectly. It is # asserted as a known-fail rather than left to be rediscovered as a # mystery by everyone who ever debugs this. # ===================================================================== fails=0 pass() { printf ' ok %s\n' "$*"; } fail() { printf ' FAIL %s\n' "$*"; fails=$((fails+1)); } # A TCP connect with a short timeout, using bash's own /dev/tcp so the # result does not depend on nc, curl or python being installed. reach() { timeout 4 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null } echo "== must be UNREACHABLE: the host, the LAN, and the stack ==" # 10.0.2.2 and 10.0.2.3 are SLIRP's aliases for the host's loopback and # the host's DNS forwarder. They are the closest thing to a back door # this design has, and the deny on 127.0.0.0/8 is what closes them. for target in \ "10.0.2.2:22|SLIRP alias for the host's loopback (sshd)" \ "10.0.2.2:3000|SLIRP alias for the host's loopback (Open WebUI)" \ "192.168.1.10:22|this host on the LAN (sshd)" \ "192.168.1.10:3000|this host on the LAN (Open WebUI)" \ "192.168.1.10:7200|this host on the LAN (Gitea)" \ "192.168.1.10:9443|this host on the LAN (Portainer)" \ "192.168.1.1:80|the LAN gateway" \ "172.17.0.1:11434|Ollama on the docker bridge" \ "172.22.0.10:8000|the owuitools gateway" \ "169.254.169.254:80|cloud metadata" ; do addr="${target%%|*}"; what="${target#*|}" host="${addr%:*}"; port="${addr##*:}" if reach "$host" "$port"; then fail "REACHED $addr - $what"; else pass "blocked $addr ($what)"; fi done echo echo "== must be REACHABLE: the internet (or this suite proves nothing) ==" for target in "1.1.1.1:443|Cloudflare DNS over TLS" "8.8.8.8:53|Google DNS" "1.1.1.1:80|a plain HTTP port"; do addr="${target%%|*}"; what="${target#*|}" host="${addr%:*}"; port="${addr##*:}" if reach "$host" "$port"; then pass "reachable $addr ($what)"; else fail "UNREACHABLE $addr - $what: the positive control failed, so the negatives above mean nothing"; fi done echo echo "== name resolution and a real fetch ==" if getent hosts archive.ubuntu.com >/dev/null 2>&1; then pass "DNS resolves (static 1.1.1.1, not SLIRP's forwarder)"; else fail "DNS does not resolve"; fi if timeout 20 curl -fsS -o /dev/null https://example.com/ 2>/dev/null; then pass "HTTPS to a public host works"; else fail "HTTPS to a public host failed"; fi echo echo "== known-fail, documented: ICMP ==" if timeout 5 ping -c1 -W2 8.8.8.8 >/dev/null 2>&1; then fail "ping WORKED - ping_group_range must have changed; update the docs and check_internet's note" else pass "ping fails as expected (host ping_group_range is '1 0'; use curl instead)" fi echo echo "== no IPv6, and no neighbours on the LAN ==" if ip -6 addr show scope global 2>/dev/null | grep -q inet6; then fail "the guest has a global IPv6 address"; else pass "no global IPv6"; fi neighbours=$(ip neigh show 2>/dev/null | grep -cvE '^\s*$|10\.0\.2\.' || true) if [ "${neighbours:-0}" -gt 0 ]; then fail "the guest can see $neighbours neighbour(s) outside its own SLIRP subnet"; else pass "no neighbours outside 10.0.2.0/24"; fi echo if [ "$fails" -gt 0 ]; then echo "FAILED $fails"; exit 1; fi echo "the guest is isolated as designed"