#!/usr/bin/env bash # tests/e2e/13_publish.sh -- ingress: a port the sandbox listens on, reached from the host. # # Publish is the one place where something arrives *from* the host, so it is tested with a # real container and a real client. --dry-run can print --publish all day and the port can # still answer nothing, and the ruleset can reject the very rule that carries it (it did, # once: nft wants the verdict before the comment, and the sandbox correctly refused to start # at exit 77 -- correctly, but nobody sees that as "the feature works"). # # Two of the assertions below are the measurements quoted by docs/networking.md: # * with the full enforcement ruleset installed, a published port answers on the host; # * a server bound to 127.0.0.1 *inside* does not, because pasta delivers the connection # to the container's own address, not to its loopback. # If either changes on a new host, the docs are wrong and this file is what says so. source "$TESTS/lib.sh" PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1 export AISB_CONFIG_DIR="$WORK/cfg-empty"; mkdir -p "$AISB_CONFIG_DIR" if ! curl -m8 -sS -o /dev/null http://example.com 2>/dev/null; then echo " (skipped: no outbound internet, so the enforced run this needs cannot start)" summary; exit 0 fi free_port() { python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()'; } listening() { ss -H -ltn 2>/dev/null | grep -c "[:.]${1}\$"; } # Start a sandbox in the background running an HTTP server, and hold the container open # until the host writes $PROJ/.stop -- the port only exists while the container does, so the # probe and the run have to overlap. $AISB_TEST_BIND says what the server binds to inside. hold_server() { # hold_server [extra flags...] local out="$1" hp="$2" bind="$3"; shift 3 rm -f "$PROJ/.stop" "$AISB" --net allow --allow example.com --env "AISB_TEST_BIND=$bind" "$@" --publish "$hp:8000" -- \ bash -lc ' python3 -m http.server 8000 --bind "$AISB_TEST_BIND" >/tmp/srv.log 2>&1 & for i in $(seq 40); do curl -sf -m1 -o /dev/null http://127.0.0.1:8000/ && break; sleep 0.25; done echo "inside=$(curl -s -o /dev/null -w %{http_code} -m2 http://127.0.0.1:8000/)" echo "egress_denied=$(curl -s -o /dev/null -w %{http_code} -m8 http://example.org 2>/dev/null)" echo "egress_allowed=$(curl -s -o /dev/null -w %{http_code} -m15 http://example.com 2>/dev/null)" echo "uid=$(id -u) capeff=$(grep CapEff /proc/self/status | cut -f2)" i=0; while [ $i -lt 160 ] && [ ! -f .stop ]; do sleep 0.25; i=$((i+1)); done echo "srvlog=$(tail -1 /tmp/srv.log | cut -c1-60)"' > "$out" 2>&1 & } # Probe from the host, which is the whole point: nothing inside this container can answer # the question "can a browser on the host reach it". host_probe() { # host_probe [tries] -> the last http code seen local hp="$1" n="${2:-60}" i code=000 for ((i = 0; i < n; i++)); do code="$(curl -s -m2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$hp/" 2>/dev/null || echo 000)" [[ "$code" == 200 ]] && break sleep 0.25 done printf '%s' "$code" } finish() { touch "$PROJ/.stop"; wait "$1" 2>/dev/null; sleep 1; } t_name "a published port answers on the host, with enforcement installed" HP="$(free_port)" hold_server "$WORK/a.out" "$HP" 0.0.0.0 CODE="$(host_probe "$HP")" finish $! A="$(cat "$WORK/a.out")" assert_eq "200" "$CODE" "the host reaches the server the sandbox is running" printf '%s' "$A" | grep -q 'enforce=nft' && _ok "the ruleset installed -- the publish rule is valid nft" \ || _bad "the ruleset installed" "$(printf '%s' "$A" | grep -A3 FATAL | head -4)" printf '%s' "$A" | grep -q 'ingress: input accepts tcp 8000' && _ok "the entrypoint says which ports it opened" \ || _bad "the entrypoint reports the ingress" printf '%s' "$A" | grep -q 'srvlog=.*"GET / HTTP/1.1" 200' && _ok "the request landed in the server's own log" \ || _bad "the request landed in the server log" "$(printf '%s' "$A" | grep srvlog)" # The promise the feature has to keep: it is ingress *only*. The same run that served the # host still refused a non-allowlisted domain and still ran unprivileged. printf '%s' "$A" | grep -q 'egress_denied=403' && _ok "egress outside the allowlist is still refused by the gate" \ || _bad "egress still refused" "$(printf '%s' "$A" | grep egress_denied)" printf '%s' "$A" | grep -q 'egress_allowed=200' && _ok "and the allowlist still works in the same run" \ || _bad "allowlisted egress" "$(printf '%s' "$A" | grep egress_allowed)" printf '%s' "$A" | grep -q 'uid=1000' && _ok "the server runs as the sandbox user" || _bad "uid" printf '%s' "$A" | grep -q 'capeff=0000000000000000' && _ok "with no capabilities -- it cannot widen its own port" \ || _bad "capeff" "$(printf '%s' "$A" | grep capeff)" assert_eq "0" "$(listening "$HP")" "the host listener is gone when the container exits (no door left watching nothing)" t_name "a server bound to loopback inside is not reachable from the host" # Measured, and the doc says it: pasta delivers a published connection to the container's own # address, so `npm run dev` with its default 127.0.0.1 answers inside and answers nothing # outside. A preview that is silently not reachable is worse than one that says bind 0.0.0.0. HP2="$(free_port)" hold_server "$WORK/b.out" "$HP2" 127.0.0.1 CODE2="$(host_probe "$HP2" 12)" finish $! B="$(cat "$WORK/b.out")" printf '%s' "$B" | grep -q 'inside=200' && _ok "the server does listen -- inside its own loopback" || _bad "listens inside" [[ "$CODE2" != 200 ]] && _ok "and the published port does not reach it (bind 0.0.0.0 inside)" \ || _bad "a loopback-only server was reachable from the host" "code=$CODE2 -- update docs/networking.md" t_name "--net none refuses to publish instead of starting with nothing listening" snapshot_containers sb_timeout 30 --net none --publish "$(free_port)" -- true assert_rc 1 "--net none + --publish is an error" assert_contains "cannot forward a port" "and it says why: podman drops the mapping and runs anyway" assert_no_new_containers "and no container was started to find that out" t_name "a host port already in use is named before the container starts" HP3="$(free_port)" python3 -m http.server "$HP3" --bind 127.0.0.1 --directory "$WORK" >/dev/null 2>&1 & SRV=$! sleep 1 snapshot_containers sb_timeout 30 --net allow --allow example.com --publish "$HP3:8000" -- true assert_rc 1 "a busy host port refuses the run" assert_contains "already in use" "and names the port" assert_no_new_containers "without starting a container first" kill "$SRV" 2>/dev/null summary