737 lines
30 KiB
Bash
Executable File
737 lines
30 KiB
Bash
Executable File
#!/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"
|