项目文件夹

文件
2026-07-13 13:13:07 +08:00

737 行
30 KiB
Bash
可执行文件

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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 "<exit_code> <elapsed_ms>".
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 <vm_name> <printf_query_string>
#
# 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"