#!/usr/bin/env bash # # Network and Egress Tests # # Part of the smolvm test suite. Run with: ./tests/test_network.sh # source "$(dirname "$0")/common.sh" init_smolvm log_info "Pre-flight cleanup: killing orphan processes..." kill_orphan_smolvm_processes trap cleanup_machine EXIT echo "" echo "==========================================" echo " Network and Egress Tests" echo "==========================================" echo "" # Assert that egress from a VM to a destination outside its allowlist is # blocked by the egress policy (EACCES) — not merely failing or timing out. # # The probe deliberately targets a real, reachable, NON-resolver web host on # port 443 (resolved on the host, where egress is open). A public-DNS IP such # as 8.8.8.8 is NOT a valid "blocked" example: the policy auto-adds the host's # own DNS resolver to every allowlist (so VMs can resolve names), so on a host # whose resolver is 8.8.8.8 that IP is silently permitted. A web IP is never # auto-added, so it is a sound block target regardless of the host's DNS config. # # A policy block returns EACCES immediately; an unreachable host would hang # until the timeout. We require BOTH a nonzero exit AND a fast failure, so a # timeout (or any non-policy failure) can never masquerade as a real block. assert_egress_blocked() { local vm_name="$1" local web_ip web_ip=$(resolve_host_ipv4 example.com) if [[ -z "$web_ip" ]]; then echo "FAIL: could not resolve a non-resolver block-probe target on host" return 1 fi # Probe inside the guest; emit " ". local result result=$($SMOLVM machine exec --name "$vm_name" -- sh -c \ "s=\$(date +%s%N); nc -w 4 -z $web_ip 443 >/dev/null 2>&1; r=\$?; e=\$(date +%s%N); echo \"\$r \$(((e-s)/1000000))\"" \ 2>/dev/null | tail -1) if [[ -z "$result" ]]; then echo "FAIL: egress probe to $web_ip:443 produced no result (exec failed?)" return 1 fi local rc=${result%% *} ms=${result##* } if [[ "$rc" == "0" ]]; then echo "FAIL: egress to non-allowlisted $web_ip:443 was permitted (policy not enforced)" return 1 fi if [[ -z "$ms" ]] || [[ "$ms" -ge 2000 ]]; then echo "FAIL: egress to $web_ip:443 failed by timeout (${ms}ms), not a policy block (EACCES)" return 1 fi return 0 } test_machine_network_disabled_by_default() { local vm_name="net-disabled-test-$$" # Clean up any existing $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM without --net (network disabled by default) $SMOLVM machine create --name "$vm_name" 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # DNS resolution should fail when network is disabled local exit_code=0 $SMOLVM machine exec --name "$vm_name" -- nslookup cloudflare.com 2>&1 || exit_code=$? # Clean up $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" # Should fail (non-zero exit code) because network is disabled [[ $exit_code -ne 0 ]] } test_machine_network_dns_resolution() { local vm_name="net-dns-test-$$" # Clean up any existing $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM with --net (network enabled) $SMOLVM machine create --name "$vm_name" --net 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Test DNS resolution local output exit_code=0 output=$($SMOLVM machine exec --name "$vm_name" -- nslookup cloudflare.com 2>&1) || exit_code=$? # Clean up $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" # Should succeed and contain resolved address info [[ $exit_code -eq 0 ]] && [[ "$output" == *"Address"* ]] } test_machine_network_multiple_dns_lookups() { local vm_name="net-multi-dns-test-$$" # Clean up any existing $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM with --net (network enabled) $SMOLVM machine create --name "$vm_name" --net 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Test multiple DNS lookups local output exit_code=0 output=$($SMOLVM machine exec --name "$vm_name" -- sh -c "nslookup google.com && nslookup github.com" 2>&1) || exit_code=$? # Clean up $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" # Should succeed and contain addresses for both [[ $exit_code -eq 0 ]] && [[ "$output" == *"Address"* ]] } test_machine_egress_allow_cidr_permitted() { local vm_name="egress-allow-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM allowing only Cloudflare DNS $SMOLVM machine create --name "$vm_name" --allow-cidr 1.1.1.1/32 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # DNS lookup to allowed IP should succeed local output exit_code=0 output=$($SMOLVM machine exec --name "$vm_name" -- nslookup cloudflare.com 1.1.1.1 2>&1) || exit_code=$? $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $exit_code -eq 0 ]] && [[ "$output" == *"Address"* ]] } test_machine_egress_allow_cidr_blocked() { local vm_name="egress-block-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM allowing only a private range. ensure_dns_in_cidrs also # auto-adds the host's DNS resolver so the VM can still resolve names. $SMOLVM machine create --name "$vm_name" --allow-cidr 10.0.0.0/8 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # A destination outside 10.0.0.0/8 (and not the auto-added resolver) blocked. local blocked_rc=0 assert_egress_blocked "$vm_name" || blocked_rc=1 $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $blocked_rc -eq 0 ]] } test_machine_egress_outbound_localhost_only() { local vm_name="egress-localhost-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true $SMOLVM machine create --name "$vm_name" --outbound-localhost-only 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Loopback-only egress: every external destination must be blocked. local blocked_rc=0 assert_egress_blocked "$vm_name" || blocked_rc=1 $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $blocked_rc -eq 0 ]] } test_machine_egress_invalid_cidr_rejected() { local vm_name="egress-invalid-test-$$" local output exit_code=0 output=$($SMOLVM machine create --name "$vm_name" --allow-cidr "not-a-cidr" 2>&1) || exit_code=$? $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true [[ $exit_code -ne 0 ]] && [[ "$output" == *"invalid"* ]] } test_machine_egress_allow_host_permitted() { local vm_name="egress-host-allow-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM allowing only one.one.one.one (resolves to 1.1.1.1) $SMOLVM machine create --name "$vm_name" --allow-host one.one.one.one 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Resolving the ALLOWED host must succeed. (Query one.one.one.one — the host # we allow-listed — not some other domain: the allow-host filter correctly # returns NXDOMAIN for any non-allowed name, so querying e.g. cloudflare.com # here would be a self-inflicted failure, not a policy bug.) local output exit_code=0 output=$($SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.1.1.1 2>&1) || exit_code=$? $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $exit_code -eq 0 ]] && [[ "$output" == *"Address"* ]] } test_machine_egress_allow_host_blocked() { local vm_name="egress-host-block-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM allowing only one.one.one.one. A host outside the allowlist # (and not the auto-added resolver) must be blocked. $SMOLVM machine create --name "$vm_name" --allow-host one.one.one.one 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } local blocked_rc=0 assert_egress_blocked "$vm_name" || blocked_rc=1 $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $blocked_rc -eq 0 ]] } test_machine_egress_allow_host_invalid_rejected() { local vm_name="egress-host-invalid-test-$$" local output exit_code=0 output=$($SMOLVM machine create --name "$vm_name" --allow-host "this-does-not-exist.invalid" 2>&1) || exit_code=$? $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Should fail with a resolution error (hard error, not warning) [[ $exit_code -ne 0 ]] && [[ "$output" == *"failed to resolve"* ]] } test_machine_egress_allow_host_port_rejected() { local vm_name="egress-host-port-test-$$" local output exit_code=0 output=$($SMOLVM machine create --name "$vm_name" --allow-host "example.com:443" 2>&1) || exit_code=$? $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Should fail — port suffixes are not supported [[ $exit_code -ne 0 ]] && [[ "$output" == *"port suffixes are not supported"* ]] } test_machine_dns_filter_blocks_resolution() { local vm_name="dns-filter-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create VM allowing only one.one.one.one $SMOLVM machine create --name "$vm_name" --allow-host one.one.one.one 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Resolving an allowed domain should work local exit_code_allowed=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.1.1.1 2>&1 || exit_code_allowed=$? # Resolving a non-allowed domain should fail (DNS proxy returns NXDOMAIN, # or if agent doesn't have DNS proxy, TSI still blocks the IP) local exit_code_blocked=0 $SMOLVM machine exec --name "$vm_name" -- nslookup attacker-test.example 2>&1 || exit_code_blocked=$? $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $exit_code_allowed -eq 0 ]] && [[ $exit_code_blocked -ne 0 ]] } # Helper: send a raw TCP DNS query to 127.0.0.1:53 inside a running VM and # return the RCODE (0–15) from the response, or 255 on error. # # Usage: dns_tcp_rcode # # The query string must be a printf-compatible byte sequence including the # 2-byte big-endian length prefix followed by the raw DNS query bytes. # Response RCODE lives in the lower nibble of byte 5 of the TCP stream # (= 2-byte length prefix + 3 DNS header bytes: ID×2, flags-byte-0). _dns_tcp_rcode() { local vm_name="$1" local query_printf="$2" local resp_file resp_file=$(mktemp) # Query the configured resolver (resolv.conf nameserver = the smolvm gateway, # which enforces allow-host filtering) over TCP, exactly as a real client # would on DNS/TCP fallback. The dst is resolved inside the guest so the test # is independent of the gateway IP. $SMOLVM machine exec --name "$vm_name" -- sh -c \ "ns=\$(awk '/^nameserver/{print \$2; exit}' /etc/resolv.conf); printf '$query_printf' | nc -w 2 \"\$ns\" 53" \ >"$resp_file" 2>/dev/null || true local rcode=255 if [[ -s "$resp_file" ]]; then local hex hex=$(dd if="$resp_file" bs=1 skip=5 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n') [[ -n "$hex" ]] && rcode=$(( 16#${hex} & 0x0F )) fi rm -f "$resp_file" echo "$rcode" } test_dns_filter_tcp_allowed() { local vm_name="dns-tcp-allowed-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true $SMOLVM machine create --name "$vm_name" --allow-host one.one.one.one 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 \ || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Minimal A query for "one.one.one.one" (33 DNS bytes = 0x21). # TCP DNS framing: 2-byte BE length prefix + raw DNS query. # Header: ID=0x1234, RD=1, QDCOUNT=1. Name: \x03one×4 + \x00. # Host filter allows one.one.one.one → should return RCODE=0 (NOERROR). local rcode rcode=$(_dns_tcp_rcode "$vm_name" \ '\x00\x21\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03one\x03one\x03one\x03one\x00\x00\x01\x00\x01') $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ "$rcode" -eq 0 ]] || { echo "FAIL: expected RCODE=0 (NOERROR), got RCODE=$rcode"; return 1; } } test_dns_filter_tcp_blocked() { local vm_name="dns-tcp-blocked-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true $SMOLVM machine create --name "$vm_name" --allow-host one.one.one.one 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 \ || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # A query for "attacker.invalid" (34 DNS bytes = 0x22). # Name: \x08attacker (9 bytes) + \x07invalid (8 bytes) + \x00 (1 byte) = 18 bytes. # Not in the allowlist → host filter returns NXDOMAIN → RCODE=3. local rcode rcode=$(_dns_tcp_rcode "$vm_name" \ '\x00\x22\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x08attacker\x07invalid\x00\x00\x01\x00\x01') $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ "$rcode" -eq 3 ]] || { echo "FAIL: expected RCODE=3 (NXDOMAIN), got RCODE=$rcode"; return 1; } } test_machine_allow_host_persists_across_restart() { local vm_name="dns-persist-test-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Create with --allow-host, start, stop, start again $SMOLVM machine create --name "$vm_name" --allow-host one.one.one.one 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Verify egress works local exit_code=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.1.1.1 2>&1 || exit_code=$? [[ $exit_code -ne 0 ]] && { $SMOLVM machine stop --name "$vm_name" 2>/dev/null; $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Stop and restart — config should persist from VmRecord $SMOLVM machine stop --name "$vm_name" 2>&1 || return 1 $SMOLVM machine start --name "$vm_name" 2>&1 || { $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Egress restriction must still hold after the restart. local blocked_rc=0 assert_egress_blocked "$vm_name" || blocked_rc=1 $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true ensure_data_dir_deleted "$vm_name" [[ $blocked_rc -eq 0 ]] } test_smolfile_allow_hosts_stale_cidr_regression() { local vm_name="allow-hosts-stale-test-$$" # sqlite3 is required to inject stale CIDRs into the DB if ! command -v sqlite3 >/dev/null 2>&1; then echo "SKIP: sqlite3 not available" return 0 fi $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true local tmpdir tmpdir=$(mktemp -d) # Smolfile with allow_hosts — no explicit allow_cidrs. # one.one.one.one resolves to 1.1.1.1 / 1.0.0.1 (Cloudflare DNS). cat > "$tmpdir/Smolfile.toml" <<'EOF' [network] allow_hosts = ["one.one.one.one"] EOF ( cd "$tmpdir" $SMOLVM machine create --name "$vm_name" -s Smolfile.toml 2>&1 ) || { rm -rf "$tmpdir"; return 1; } # Determine DB path (matches SmolvmDb::default_path logic) local db_path if [[ "$(uname)" == "Darwin" ]]; then db_path="$HOME/Library/Application Support/smolvm/server/smolvm.db" else db_path="$HOME/.local/share/smolvm/server/smolvm.db" fi # Inject stale CIDRs — 192.0.2.0/24 is RFC 5737 TEST-NET, never routed. # This simulates the old bug: CIDRs resolved at create time that are now # stale due to CDN IP rotation. # The data column is stored as BLOB. json_set returns TEXT, so we must # CAST both ways: TEXT for JSON manipulation, then back to BLOB for storage. sqlite3 "$db_path" \ "UPDATE vms SET data = CAST(json_set(CAST(data AS TEXT), '$.allowed_cidrs', json('[\"192.0.2.0/24\"]')) AS BLOB) WHERE name = '$vm_name'" \ 2>&1 || { echo "sqlite3 update failed"; rm -rf "$tmpdir"; $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Start — fix must re-resolve allow_hosts and override the stale CIDRs $SMOLVM machine start --name "$vm_name" 2>&1 \ || { rm -rf "$tmpdir"; $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Probe egress using 1.0.0.1 as the resolver — also a valid IP for # one.one.one.one, but NOT auto-added by ensure_dns_in_cidrs (which only # injects 1.1.1.1). With stale CIDRs and no re-resolution, 1.0.0.1 is # blocked; with the fix, fresh resolution adds it and the query succeeds. local exit_code=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.0.0.1 2>&1 || exit_code=$? $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true # Fallback: if machine delete --name failed due to a corrupt DB row (e.g. from a # botched sqlite3 update), remove the row directly so it doesn't poison # subsequent test runs that scan all rows. sqlite3 "$db_path" "DELETE FROM vms WHERE name = '$vm_name'" 2>/dev/null || true rm -rf "$tmpdir" ensure_data_dir_deleted "$vm_name" [[ $exit_code -eq 0 ]] } test_smolfile_allow_hosts_egress_basic() { local vm_name="allow-hosts-sf-basic-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true local tmpdir tmpdir=$(mktemp -d) cat > "$tmpdir/Smolfile.toml" <<'EOF' [network] allow_hosts = ["one.one.one.one"] EOF ( cd "$tmpdir" $SMOLVM machine create --name "$vm_name" -s Smolfile.toml 2>&1 ) || { rm -rf "$tmpdir"; return 1; } $SMOLVM machine start --name "$vm_name" 2>&1 \ || { rm -rf "$tmpdir"; $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Probe 1: 1.1.1.1 — always in the egress policy via ensure_dns_in_cidrs. # Verifies the policy doesn't block what it should allow, but does NOT # prove that hostname resolution ran (1.1.1.1 is auto-added regardless). local exit_code_allowed=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.1.1.1 2>&1 || exit_code_allowed=$? # Probe 2: 1.0.0.1 — a real IP of one.one.one.one, but NOT auto-added by # ensure_dns_in_cidrs. This probe passes only if allow_hosts DNS resolution # actually ran and added 1.0.0.1 to the CIDR list. It is the definitive # proof that the hostname-to-CIDR path works end-to-end. local exit_code_resolution=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.0.0.1 2>&1 || exit_code_resolution=$? # Egress to a destination outside the allowlist must be blocked. local blocked_rc=0 assert_egress_blocked "$vm_name" || blocked_rc=1 $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true rm -rf "$tmpdir" ensure_data_dir_deleted "$vm_name" [[ $exit_code_allowed -eq 0 ]] && [[ $exit_code_resolution -eq 0 ]] && [[ $blocked_rc -eq 0 ]] } test_egress_refresh_thread_stability() { skip_if_slow && return 0 local vm_name="egress-refresh-smoke-$$" $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true local tmpdir tmpdir=$(mktemp -d) cat > "$tmpdir/Smolfile.toml" <<'EOF' [network] allow_hosts = ["one.one.one.one"] EOF ( cd "$tmpdir" $SMOLVM machine create --name "$vm_name" -s Smolfile.toml 2>&1 ) || { rm -rf "$tmpdir"; return 1; } # Start with a 10-second refresh interval so the thread fires twice during # the test window without making the test slow. SMOLVM_EGRESS_REFRESH_SECS=10 $SMOLVM machine start --name "$vm_name" 2>&1 \ || { rm -rf "$tmpdir"; $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null; return 1; } # Verify egress works immediately after start. local exit_code_before=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.1.1.1 2>&1 \ || exit_code_before=$? # Wait for two refresh cycles to fire (2 × 10 s + 5 s buffer). echo " Waiting 25s for two egress refresh cycles..." sleep 25 # Egress must still work after refreshes — the thread must not have # wiped or corrupted the existing CIDR list. local exit_code_after=0 $SMOLVM machine exec --name "$vm_name" -- nslookup one.one.one.one 1.1.1.1 2>&1 \ || exit_code_after=$? $SMOLVM machine stop --name "$vm_name" 2>/dev/null || true $SMOLVM machine delete --name "$vm_name" -f 2>/dev/null || true rm -rf "$tmpdir" ensure_data_dir_deleted "$vm_name" [[ $exit_code_before -eq 0 ]] && [[ $exit_code_after -eq 0 ]] } test_proxy_flag_routes_pull_through_proxy() { skip_if_slow && return 0 if ! command -v python3 >/dev/null 2>&1; then echo "SKIP: python3 not available" return 0 fi local proxy_log proxy_pid proxy_port pull_output pull_exit proxy_log=$(mktemp) proxy_port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') # Real CONNECT-tunneling proxy: logs each request line, opens a TCP # connection to the target host:port, returns 200, and bidirectionally # relays bytes. Exercises the full path the user hits in production — # a corporate forward proxy doing CONNECT for TLS image pulls. python3 - "$proxy_port" "$proxy_log" <<'PYEOF' & import socketserver, socket, sys, threading port = int(sys.argv[1]); log_path = sys.argv[2] def relay(src, dst): try: while True: data = src.recv(65536) if not data: break dst.sendall(data) except (BrokenPipeError, ConnectionResetError, OSError): pass finally: try: dst.shutdown(socket.SHUT_WR) except OSError: pass class P(socketserver.StreamRequestHandler): timeout = 60 def handle(self): line = self.rfile.readline().decode('ascii', errors='replace').strip() with open(log_path, 'a') as f: f.write(line + "\n"); f.flush() while True: h = self.rfile.readline() if not h or h in (b"\r\n", b"\n"): break parts = line.split() if len(parts) < 2 or parts[0].upper() != "CONNECT": self.wfile.write(b"HTTP/1.1 400 Bad Request\r\n\r\n"); return host, _, port_s = parts[1].rpartition(":") try: target = socket.create_connection((host, int(port_s)), timeout=30) except Exception: self.wfile.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); return self.wfile.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") self.wfile.flush() client = self.request t1 = threading.Thread(target=relay, args=(client, target), daemon=True) t2 = threading.Thread(target=relay, args=(target, client), daemon=True) t1.start(); t2.start() t1.join(); t2.join() target.close() class S(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True S(("127.0.0.1", port), P).serve_forever() PYEOF proxy_pid=$! # Wait for the proxy to bind (up to ~1s). local ready=0 i for i in 1 2 3 4 5 6 7 8 9 10; do if (echo > /dev/tcp/127.0.0.1/$proxy_port) 2>/dev/null; then ready=1; break fi sleep 0.1 done if [[ $ready -ne 1 ]]; then kill $proxy_pid 2>/dev/null rm -f "$proxy_log" echo "FAIL: proxy never bound to 127.0.0.1:$proxy_port" return 1 fi # Run the pull. With a real tunneling proxy, this must succeed — # both the manifest fetch and the blob downloads flow through CONNECT. pull_output=$($SMOLVM machine run --net \ --proxy "http://127.0.0.1:$proxy_port" \ --image alpine -- echo ok 2>&1) pull_exit=$? kill $proxy_pid 2>/dev/null wait $proxy_pid 2>/dev/null # Two conditions must hold for the feature to be considered working: # 1. The pull succeeded (exit 0 + workload printed "ok") # 2. The proxy log contains a CONNECT for a docker registry, proving # the request actually went through the proxy (not direct DNS). local connect_seen=0 if grep -qiE '^CONNECT[[:space:]]+([a-z0-9.-]*\.)?docker\.io:443' "$proxy_log"; then connect_seen=1 fi local result=0 if [[ $pull_exit -ne 0 ]] || [[ "$pull_output" != *"ok"* ]] || [[ $connect_seen -ne 1 ]]; then result=1 echo " pull exit code: $pull_exit" echo " workload output: $(echo "$pull_output" | tail -1)" echo " CONNECT logged: $connect_seen" echo " proxy log contents:" sed 's/^/ /' "$proxy_log" fi rm -f "$proxy_log" return $result } test_grpcio_channel_ready() { skip_if_slow && return 0 local output output=$($SMOLVM machine run --net --mem 4096 --image python:3.12-alpine -- sh -c ' pip install grpcio > /dev/null 2>&1 python3 -c " import os os.environ[\"GRPC_DNS_RESOLVER\"] = \"native\" import grpc ch = grpc.secure_channel(\"google.com:443\", grpc.ssl_channel_credentials()) grpc.channel_ready_future(ch).result(timeout=10) print(\"grpcio_channel_ready: PASS\") " ' 2>&1) echo "$output" [[ "$output" == *"grpcio_channel_ready: PASS"* ]] } run_test "Network: disabled by default" test_machine_network_disabled_by_default || true run_test "Network: DNS resolution" test_machine_network_dns_resolution || true run_test "Network: multiple DNS lookups" test_machine_network_multiple_dns_lookups || true run_test "Egress: allow-cidr permits matching traffic" test_machine_egress_allow_cidr_permitted || true run_test "Egress: allow-cidr blocks non-matching traffic" test_machine_egress_allow_cidr_blocked || true run_test "Egress: --outbound-localhost-only blocks external" test_machine_egress_outbound_localhost_only || true run_test "Egress: invalid CIDR rejected at create" test_machine_egress_invalid_cidr_rejected || true run_test "Egress: allow-host permits matching traffic" test_machine_egress_allow_host_permitted || true run_test "Egress: allow-host blocks non-matching traffic" test_machine_egress_allow_host_blocked || true run_test "Egress: invalid hostname rejected at create" test_machine_egress_allow_host_invalid_rejected || true run_test "Egress: host:port syntax rejected" test_machine_egress_allow_host_port_rejected || true run_test "DNS filter: blocks resolution of non-allowed domains" test_machine_dns_filter_blocks_resolution || true run_test "DNS filter: TCP/53 allowed domain returns NOERROR" test_dns_filter_tcp_allowed || true run_test "DNS filter: TCP/53 blocked domain returns NXDOMAIN" test_dns_filter_tcp_blocked || true run_test "DNS filter: allow-host persists across restart" test_machine_allow_host_persists_across_restart || true run_test "Smolfile: allow_hosts basic egress permitted/blocked" test_smolfile_allow_hosts_egress_basic || true run_test "Smolfile: allow_hosts re-resolves stale CIDRs on start (issue #124)" test_smolfile_allow_hosts_stale_cidr_regression || true run_test "Egress refresh thread: stability across refresh cycles" test_egress_refresh_thread_stability || true run_test "Proxy: --proxy flag routes image pull through proxy" test_proxy_flag_routes_pull_through_proxy || true run_test "grpcio: secure channel ready (ilyaterin grpc test)" test_grpcio_channel_ready || true print_summary "Network Tests"