diff --git a/internal/changeset/display.go b/internal/changeset/display.go index 93c5de5..f3e2a6c 100644 --- a/internal/changeset/display.go +++ b/internal/changeset/display.go @@ -3,6 +3,7 @@ package changeset import ( "fmt" "io" + "sort" "strings" ) @@ -19,7 +20,7 @@ func PrintSummary(w io.Writer, cs *SessionChangeset) { totalChanges += len(mc.Changes) } - if totalChanges == 0 { + if totalChanges == 0 && len(cs.NetworkEvents) == 0 { _, _ = fmt.Fprintln(w, "\nNo changes detected.") return } @@ -37,6 +38,11 @@ func PrintSummary(w io.Writer, cs *SessionChangeset) { _, _ = fmt.Fprintf(w, "\n%s (%s → %s):\n", label, mc.Source, mc.Target) printChanges(w, mc.Changes) } + + // Print network activity summary + if len(cs.NetworkEvents) > 0 { + printNetworkSummary(w, cs.NetworkEvents) + } } // mountLabel returns a human-friendly label based on the guest mount target @@ -125,3 +131,81 @@ func formatSize(bytes int64) string { return fmt.Sprintf("%d B", bytes) } } + +// printNetworkSummary prints a summary of network events grouped by action type. +func printNetworkSummary(w io.Writer, events []NetworkEvent) { + _, _ = fmt.Fprintln(w, "\nNetwork activity") + _, _ = fmt.Fprintln(w, strings.Repeat("─", 40)) + + // Separate by type + var dnsEvents, conns, denies []NetworkEvent + for _, e := range events { + switch e.Action { + case "DNS": + dnsEvents = append(dnsEvents, e) + case "DENY": + denies = append(denies, e) + default: + conns = append(conns, e) + } + } + + // DNS queries — show domain names + if len(dnsEvents) > 0 { + domains := make([]string, 0, len(dnsEvents)) + for _, e := range dnsEvents { + domains = append(domains, e.Domain) + } + display := strings.Join(domains, ", ") + if len(domains) > 5 { + display = strings.Join(domains[:5], ", ") + fmt.Sprintf(", +%d more", len(domains)-5) + } + _, _ = fmt.Fprintf(w, " DNS queries: %d (%s)\n", len(dnsEvents), display) + } + + // Non-DNS connections — show domain when available, fall back to IP + var nonDNSConns []NetworkEvent + for _, e := range conns { + if e.DstPort != 53 { + nonDNSConns = append(nonDNSConns, e) + } + } + if len(nonDNSConns) > 0 { + connDests := make(map[string]bool) + for _, e := range nonDNSConns { + host := e.DstIP + if e.Domain != "" { + host = e.Domain + } + connDests[fmt.Sprintf("%s:%d", host, e.DstPort)] = true + } + destList := make([]string, 0, len(connDests)) + for dest := range connDests { + destList = append(destList, dest) + } + sort.Strings(destList) + display := strings.Join(destList, ", ") + if len(destList) > 5 { + display = strings.Join(destList[:5], ", ") + fmt.Sprintf(" (+%d more)", len(destList)-5) + } + _, _ = fmt.Fprintf(w, " Connections: %d (%s)\n", len(connDests), display) + } + + // Denied connections — same domain annotation + if len(denies) > 0 { + denyDests := make(map[string]bool) + for _, e := range denies { + host := e.DstIP + if e.Domain != "" { + host = e.Domain + } + denyDests[fmt.Sprintf("%s:%d", host, e.DstPort)] = true + } + destList := make([]string, 0, len(denyDests)) + for dest := range denyDests { + destList = append(destList, dest) + } + sort.Strings(destList) + _, _ = fmt.Fprintf(w, " Denied: %d (%s)\n", len(denyDests), strings.Join(destList, ", ")) + } +} diff --git a/internal/changeset/snapshot.go b/internal/changeset/snapshot.go index 44c4b0f..b20c0f3 100644 --- a/internal/changeset/snapshot.go +++ b/internal/changeset/snapshot.go @@ -3,9 +3,12 @@ package changeset import ( "bufio" "encoding/json" + "net" "os" "path/filepath" + "regexp" "sort" + "strconv" "strings" "time" ) @@ -154,11 +157,23 @@ type MountChanges struct { Changes []Change `json:"changes"` } +// NetworkEvent represents a parsed network event from guest-side iptables LOG rules. +type NetworkEvent struct { + Timestamp string `json:"timestamp"` + Action string `json:"action"` // "CONN", "DENY", or "DNS" + Proto string `json:"proto,omitempty"` // "TCP", "UDP" + DstIP string `json:"dst_ip,omitempty"` + DstPort int `json:"dst_port,omitempty"` + SrcPort int `json:"src_port,omitempty"` + Domain string `json:"domain,omitempty"` // from dnsmasq query log +} + // SessionChangeset is the complete changeset for a session. type SessionChangeset struct { - SessionID string `json:"session_id"` - MountChanges []MountChanges `json:"mount_changes"` - GuestChanges []string `json:"guest_changes"` // lines from guest-changes.txt + SessionID string `json:"session_id"` + MountChanges []MountChanges `json:"mount_changes"` + GuestChanges []string `json:"guest_changes"` // lines from guest-changes.txt + NetworkEvents []NetworkEvent `json:"network_events,omitempty"` } // Save persists a snapshot to JSON file. @@ -235,6 +250,148 @@ func ParseGuestChanges(path string) ([]string, error) { return lines, nil } +// networkLogRe matches iptables LOG lines from dmesg with FAIZE_ prefixes. +// Example line: "FAIZE_NET: IN= OUT=eth0 SRC=10.0.2.15 DST=140.82.114.4 ... PROTO=TCP SPT=45678 DPT=443" +// Example line: "FAIZE_DENY: IN= OUT=eth0 SRC=10.0.2.15 DST=1.2.3.4 ... PROTO=TCP SPT=12345 DPT=80" +var networkLogRe = regexp.MustCompile( + `FAIZE_(NET|DENY):.*?SRC=(\S+)\s+DST=(\S+).*?PROTO=(\S+)(?:.*?SPT=(\d+))?(?:.*?DPT=(\d+))?`, +) + +// ParseNetworkLog reads a network.log file (dmesg output with FAIZE_ prefixes) +// and returns structured NetworkEvent entries. +// Returns empty slice and nil error if the file doesn't exist. +func ParseNetworkLog(path string) ([]NetworkEvent, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return []NetworkEvent{}, nil + } + return nil, err + } + defer func() { _ = f.Close() }() + + var events []NetworkEvent + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + matches := networkLogRe.FindStringSubmatch(line) + if matches == nil { + continue + } + + action := "CONN" + if matches[1] == "DENY" { + action = "DENY" + } + + dstPort, _ := strconv.Atoi(matches[6]) + srcPort, _ := strconv.Atoi(matches[5]) + + events = append(events, NetworkEvent{ + Action: action, + Proto: matches[4], + DstIP: matches[3], + DstPort: dstPort, + SrcPort: srcPort, + }) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + if events == nil { + return []NetworkEvent{}, nil + } + return events, nil +} + +// dnsQueryRe matches dnsmasq query lines: "Feb 24 12:00:01 dnsmasq[42]: query[A] api.anthropic.com from 127.0.0.1" +var dnsQueryRe = regexp.MustCompile(`^(\w+ \d+ [\d:]+) dnsmasq\[\d+\]: query\[\w+\] (\S+) from`) + +// dnsReplyRe matches dnsmasq reply lines: "Feb 24 12:00:01 dnsmasq[42]: reply api.anthropic.com is 104.18.32.47" +var dnsReplyRe = regexp.MustCompile(`^(\w+ \d+ [\d:]+) dnsmasq\[\d+\]: reply (\S+) is (\S+)`) + +// ParseDNSLog reads a dnsmasq query log and returns DNS events and an IP→domain mapping. +func ParseDNSLog(path string) (events []NetworkEvent, ipToDomain map[string]string, err error) { + ipToDomain = make(map[string]string) + + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return []NetworkEvent{}, ipToDomain, nil + } + return nil, nil, err + } + defer func() { _ = f.Close() }() + + seen := make(map[string]bool) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + + // Parse query lines + if qm := dnsQueryRe.FindStringSubmatch(line); qm != nil { + domain := qm[2] + if !seen[domain] { + seen[domain] = true + events = append(events, NetworkEvent{ + Timestamp: qm[1], + Action: "DNS", + Domain: domain, + }) + } + continue + } + + // Parse reply lines → build IP→domain map + if rm := dnsReplyRe.FindStringSubmatch(line); rm != nil { + domain := rm[2] + ip := rm[3] + // Only map valid IP addresses (skip CNAME and other reply types) + if net.ParseIP(ip) != nil { + ipToDomain[ip] = domain + } + } + } + if err := scanner.Err(); err != nil { + return nil, nil, err + } + + if events == nil { + events = []NetworkEvent{} + } + return events, ipToDomain, nil +} + +// CollectNetworkEvents reads both network.log (iptables) and dns.log (dnsmasq), +// then annotates iptables connection events with domain names from DNS replies. +func CollectNetworkEvents(bootstrapDir string) ([]NetworkEvent, error) { + // Parse DNS log → get DNS events + IP→domain map + dnsEvents, ipToDomain, err := ParseDNSLog(filepath.Join(bootstrapDir, "dns.log")) + if err != nil { + return nil, err + } + + // Parse iptables network log → get connection/deny events + netEvents, err := ParseNetworkLog(filepath.Join(bootstrapDir, "network.log")) + if err != nil { + return nil, err + } + + // Annotate connection events with domain names from DNS replies + for i := range netEvents { + if domain, ok := ipToDomain[netEvents[i].DstIP]; ok { + netEvents[i].Domain = domain + } + } + + // Return DNS events followed by annotated connection events + var all []NetworkEvent + all = append(all, dnsEvents...) + all = append(all, netEvents...) + return all, nil +} + // defaultIgnorePrefixes are path prefixes for internal state that should not // appear in user-facing change summaries. var defaultIgnorePrefixes = []string{".git", ".omc", ".claude"} diff --git a/internal/changeset/snapshot_test.go b/internal/changeset/snapshot_test.go index c752e6f..ada4eb5 100644 --- a/internal/changeset/snapshot_test.go +++ b/internal/changeset/snapshot_test.go @@ -239,3 +239,145 @@ func TestFilterPaths_ExactPrefixMatch(t *testing.T) { assert.Equal(t, ".github/workflows/ci.yml", filtered[0].Path) assert.Equal(t, ".gitignore", filtered[1].Path) } + +func TestParseNetworkLog_MissingFile(t *testing.T) { + events, err := ParseNetworkLog("/nonexistent/network.log") + require.NoError(t, err) + assert.Empty(t, events) +} + +func TestParseNetworkLog_ParsesEvents(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "network.log") + content := `[ 123.456] FAIZE_NET: IN= OUT=eth0 SRC=10.0.2.15 DST=140.82.114.4 LEN=60 TOS=0x00 PROTO=TCP SPT=45678 DPT=443 +[ 124.789] FAIZE_NET: IN= OUT=eth0 SRC=10.0.2.15 DST=8.8.8.8 LEN=56 TOS=0x00 PROTO=UDP SPT=34567 DPT=53 +[ 125.012] FAIZE_DENY: IN= OUT=eth0 SRC=10.0.2.15 DST=1.2.3.4 LEN=60 TOS=0x00 PROTO=TCP SPT=12345 DPT=80 +some garbage line that should be skipped +` + _ = os.WriteFile(path, []byte(content), 0644) + + events, err := ParseNetworkLog(path) + require.NoError(t, err) + require.Len(t, events, 3) + + // First event: TCP connection to github + assert.Equal(t, "CONN", events[0].Action) + assert.Equal(t, "TCP", events[0].Proto) + assert.Equal(t, "140.82.114.4", events[0].DstIP) + assert.Equal(t, 443, events[0].DstPort) + assert.Equal(t, 45678, events[0].SrcPort) + + // Second event: DNS query + assert.Equal(t, "CONN", events[1].Action) + assert.Equal(t, "UDP", events[1].Proto) + assert.Equal(t, "8.8.8.8", events[1].DstIP) + assert.Equal(t, 53, events[1].DstPort) + + // Third event: denied connection + assert.Equal(t, "DENY", events[2].Action) + assert.Equal(t, "TCP", events[2].Proto) + assert.Equal(t, "1.2.3.4", events[2].DstIP) + assert.Equal(t, 80, events[2].DstPort) +} + +func TestParseNetworkLog_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "network.log") + _ = os.WriteFile(path, []byte(""), 0644) + + events, err := ParseNetworkLog(path) + require.NoError(t, err) + assert.Empty(t, events) +} + +func TestParseDNSLog_ParsesQueries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dns.log") + content := `Feb 24 12:00:01 dnsmasq[42]: query[A] api.anthropic.com from 127.0.0.1 +Feb 24 12:00:01 dnsmasq[42]: reply api.anthropic.com is 104.18.32.47 +Feb 24 12:00:02 dnsmasq[42]: query[A] github.com from 127.0.0.1 +Feb 24 12:00:02 dnsmasq[42]: reply github.com is 140.82.114.4 +Feb 24 12:00:03 dnsmasq[42]: query[AAAA] api.anthropic.com from 127.0.0.1 +` + _ = os.WriteFile(path, []byte(content), 0644) + + events, _, err := ParseDNSLog(path) + require.NoError(t, err) + // Should deduplicate: api.anthropic.com appears twice (A + AAAA) but only one event + require.Len(t, events, 2) + assert.Equal(t, "DNS", events[0].Action) + assert.Equal(t, "api.anthropic.com", events[0].Domain) + assert.Equal(t, "DNS", events[1].Action) + assert.Equal(t, "github.com", events[1].Domain) +} + +func TestParseDNSLog_BuildsIPMap(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dns.log") + content := `Feb 24 12:00:01 dnsmasq[42]: query[A] api.anthropic.com from 127.0.0.1 +Feb 24 12:00:01 dnsmasq[42]: reply api.anthropic.com is 104.18.32.47 +Feb 24 12:00:01 dnsmasq[42]: reply api.anthropic.com is 104.18.32.48 +Feb 24 12:00:02 dnsmasq[42]: query[A] github.com from 127.0.0.1 +Feb 24 12:00:02 dnsmasq[42]: reply github.com is 140.82.114.4 +` + _ = os.WriteFile(path, []byte(content), 0644) + + _, ipMap, err := ParseDNSLog(path) + require.NoError(t, err) + assert.Equal(t, "api.anthropic.com", ipMap["104.18.32.47"]) + assert.Equal(t, "api.anthropic.com", ipMap["104.18.32.48"]) + assert.Equal(t, "github.com", ipMap["140.82.114.4"]) +} + +func TestParseDNSLog_MissingFile(t *testing.T) { + events, ipMap, err := ParseDNSLog("/nonexistent/dns.log") + require.NoError(t, err) + assert.Empty(t, events) + assert.Empty(t, ipMap) +} + +func TestCollectNetworkEvents_AnnotatesConnections(t *testing.T) { + dir := t.TempDir() + + // Write DNS log + dnsContent := `Feb 24 12:00:01 dnsmasq[42]: query[A] api.anthropic.com from 127.0.0.1 +Feb 24 12:00:01 dnsmasq[42]: reply api.anthropic.com is 104.18.32.47 +Feb 24 12:00:02 dnsmasq[42]: query[A] github.com from 127.0.0.1 +Feb 24 12:00:02 dnsmasq[42]: reply github.com is 140.82.114.4 +` + _ = os.WriteFile(filepath.Join(dir, "dns.log"), []byte(dnsContent), 0644) + + // Write network log (iptables) + netContent := `[ 123.456] FAIZE_NET: IN= OUT=eth0 SRC=10.0.2.15 DST=104.18.32.47 LEN=60 TOS=0x00 PROTO=TCP SPT=45678 DPT=443 +[ 124.789] FAIZE_NET: IN= OUT=eth0 SRC=10.0.2.15 DST=140.82.114.4 LEN=60 TOS=0x00 PROTO=TCP SPT=45679 DPT=443 +[ 125.012] FAIZE_DENY: IN= OUT=eth0 SRC=10.0.2.15 DST=1.2.3.4 LEN=60 TOS=0x00 PROTO=TCP SPT=12345 DPT=80 +` + _ = os.WriteFile(filepath.Join(dir, "network.log"), []byte(netContent), 0644) + + events, err := CollectNetworkEvents(dir) + require.NoError(t, err) + + // Should have: 2 DNS events + 3 network events = 5 total + require.Len(t, events, 5) + + // First 2 are DNS events + assert.Equal(t, "DNS", events[0].Action) + assert.Equal(t, "api.anthropic.com", events[0].Domain) + assert.Equal(t, "DNS", events[1].Action) + assert.Equal(t, "github.com", events[1].Domain) + + // Connection to 104.18.32.47 should be annotated with api.anthropic.com + assert.Equal(t, "CONN", events[2].Action) + assert.Equal(t, "104.18.32.47", events[2].DstIP) + assert.Equal(t, "api.anthropic.com", events[2].Domain) + + // Connection to 140.82.114.4 should be annotated with github.com + assert.Equal(t, "CONN", events[3].Action) + assert.Equal(t, "140.82.114.4", events[3].DstIP) + assert.Equal(t, "github.com", events[3].Domain) + + // Denied connection to unknown IP should have no domain + assert.Equal(t, "DENY", events[4].Action) + assert.Equal(t, "1.2.3.4", events[4].DstIP) + assert.Equal(t, "", events[4].Domain) +} diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 3ecc244..c725dcb 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -373,10 +373,17 @@ func runStart(cmd *cobra.Command, args []string) error { bootstrapDir := filepath.Join(home, ".faize", "sessions", sess.ID, "bootstrap") guestChanges, _ := changeset.ParseGuestChanges(filepath.Join(bootstrapDir, "guest-changes.txt")) + // Read network + DNS logs from bootstrap dir + networkEvents, netErr := changeset.CollectNetworkEvents(bootstrapDir) + if netErr != nil { + Debug("Failed to collect network events: %v", netErr) + } + cs := &changeset.SessionChangeset{ - SessionID: sess.ID, - MountChanges: mountChanges, - GuestChanges: guestChanges, + SessionID: sess.ID, + MountChanges: mountChanges, + GuestChanges: guestChanges, + NetworkEvents: networkEvents, } // Display summary diff --git a/internal/guest/init.go b/internal/guest/init.go index 0a4f05a..8842f6b 100644 --- a/internal/guest/init.go +++ b/internal/guest/init.go @@ -117,6 +117,10 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic sb.WriteString(" echo 'Shutting down...'\n") sb.WriteString(" # Kill resize watcher if running\n") sb.WriteString(" [ -n \"$RESIZE_WATCHER_PID\" ] && kill $RESIZE_WATCHER_PID 2>/dev/null || true\n") + sb.WriteString(" # Kill network log collector if running\n") + sb.WriteString(" [ -n \"$NETLOG_PID\" ] && kill $NETLOG_PID 2>/dev/null || true\n") + sb.WriteString(" # Kill dnsmasq if running\n") + sb.WriteString(" [ -n \"$DNSMASQ_RUNNING\" ] && killall dnsmasq 2>/dev/null || true\n") sb.WriteString(" # Kill child processes gracefully\n") sb.WriteString(" kill -TERM $(jobs -p) 2>/dev/null || true\n") sb.WriteString(" wait 2>/dev/null || true\n") @@ -220,12 +224,34 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic sb.WriteString(" fi\n") sb.WriteString("fi\n\n") - // Ensure DNS is configured (only inject public DNS if DHCP didn't provide any) - sb.WriteString("# Ensure DNS configuration (only inject public DNS if DHCP didn't provide any)\n") - sb.WriteString("if ! grep -q nameserver /etc/resolv.conf 2>/dev/null; then\n") - sb.WriteString(" echo 'nameserver 8.8.8.8' > /etc/resolv.conf\n") - sb.WriteString(" echo 'nameserver 1.1.1.1' >> /etc/resolv.conf\n") - sb.WriteString("fi\n\n") + // DNS configuration — either dnsmasq local forwarder or direct public DNS + if policy != nil && !policy.AllowAll { + // Use dnsmasq as logging DNS forwarder for network-restricted sessions + sb.WriteString("# Configure dnsmasq as logging DNS forwarder\n") + sb.WriteString("cat > /etc/dnsmasq.conf << 'DNSMASQ_EOF'\n") + sb.WriteString("listen-address=127.0.0.1\n") + sb.WriteString("port=53\n") + sb.WriteString("no-resolv\n") + sb.WriteString("server=8.8.8.8\n") + sb.WriteString("server=1.1.1.1\n") + sb.WriteString("log-queries\n") + sb.WriteString("log-facility=/mnt/bootstrap/dns.log\n") + sb.WriteString("cache-size=200\n") + sb.WriteString("pid-file=\n") + sb.WriteString("DNSMASQ_EOF\n\n") + sb.WriteString("# Start dnsmasq (daemonizes by default)\n") + sb.WriteString("dnsmasq || { echo 'dnsmasq: failed to start' >&2; exit 1; }\n") + sb.WriteString("DNSMASQ_RUNNING=1\n\n") + sb.WriteString("# Point DNS at local dnsmasq\n") + sb.WriteString("echo 'nameserver 127.0.0.1' > /etc/resolv.conf\n\n") + } else { + // No network restrictions — use public DNS directly if DHCP didn't set any + sb.WriteString("# Ensure DNS configuration (only inject public DNS if DHCP didn't provide any)\n") + sb.WriteString("if ! grep -q nameserver /etc/resolv.conf 2>/dev/null; then\n") + sb.WriteString(" echo 'nameserver 8.8.8.8' > /etc/resolv.conf\n") + sb.WriteString(" echo 'nameserver 1.1.1.1' >> /etc/resolv.conf\n") + sb.WriteString("fi\n\n") + } // Test connectivity (with DNS stabilization delay and retries) sb.WriteString("# Brief wait for network/DNS to stabilize after DHCP\n") @@ -249,22 +275,24 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic sb.WriteString("iptables -P OUTPUT DROP\n") sb.WriteString("iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT\n") sb.WriteString("iptables -A OUTPUT -o lo -j ACCEPT\n") + sb.WriteString("# Log denied connections\n") + sb.WriteString("iptables -A OUTPUT -j LOG --log-prefix \"FAIZE_DENY: \" --log-level 4 -m limit --limit 5/sec 2>/dev/null || echo 'Warning: network logging unavailable (missing xt_LOG kernel module)'\n") sb.WriteString("echo 'Network blocked (loopback only)'\n\n") } else if len(policy.Domains) > 0 || len(policy.Wildcards) > 0 { // Domain-based allowlist (with optional wildcards) sb.WriteString("# === Network Policy: Domain Allowlist ===\n") sb.WriteString("[ \"$FAIZE_DEBUG\" = \"1\" ] && echo 'Applying network policy: domain allowlist'\n\n") - // Force DNS to use public resolvers that we allow through iptables - // This is necessary because DHCP may have set a different DNS server - sb.WriteString("# Force DNS to use public resolvers (iptables will only allow these)\n") - sb.WriteString("echo 'nameserver 8.8.8.8' > /etc/resolv.conf\n") - sb.WriteString("echo 'nameserver 1.1.1.1' >> /etc/resolv.conf\n\n") + // DNS already pointing to localhost dnsmasq (configured above) + // dnsmasq forwards to 8.8.8.8/1.1.1.1 which iptables allows + sb.WriteString("# DNS goes through local dnsmasq → 8.8.8.8/1.1.1.1 (allowed by iptables)\n\n") sb.WriteString("# Default: drop all outbound except established connections\n") sb.WriteString("iptables -P OUTPUT DROP\n") sb.WriteString("iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT\n") sb.WriteString("iptables -A OUTPUT -o lo -j ACCEPT\n\n") + sb.WriteString("# Log all new outbound connections (non-terminating)\n") + sb.WriteString("iptables -A OUTPUT -m state --state NEW -j LOG --log-prefix \"FAIZE_NET: \" --log-level 4 -m limit --limit 10/sec 2>/dev/null || echo 'Warning: network logging unavailable (missing xt_LOG kernel module)'\n\n") sb.WriteString("# Allow DNS queries only to known resolvers\n") sb.WriteString("iptables -A OUTPUT -p udp -d 8.8.8.8 --dport 53 -j ACCEPT\n") sb.WriteString("iptables -A OUTPUT -p udp -d 1.1.1.1 --dport 53 -j ACCEPT\n") @@ -333,10 +361,24 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic sb.WriteString(" echo '=== iptables OUTPUT rules ==='\n") sb.WriteString(" iptables -L OUTPUT -n 2>/dev/null | head -20 || echo 'Failed to list iptables rules'\n") sb.WriteString("fi\n\n") + sb.WriteString("# Log denied connections (catch-all before policy DROP)\n") + sb.WriteString("iptables -A OUTPUT -j LOG --log-prefix \"FAIZE_DENY: \" --log-level 4 -m limit --limit 5/sec 2>/dev/null || echo 'Warning: network logging unavailable (missing xt_LOG kernel module)'\n\n") sb.WriteString("[ \"$FAIZE_DEBUG\" = \"1\" ] && echo 'Network policy applied'\n\n") } } + // Start network log collector (only when iptables rules are active) + if policy != nil && !policy.AllowAll { + sb.WriteString("# Background network log collector\n") + sb.WriteString("(\n") + sb.WriteString(" while true; do\n") + sb.WriteString(" dmesg -c 2>/dev/null | grep 'FAIZE_' >> /mnt/bootstrap/network.log 2>/dev/null\n") + sb.WriteString(" sleep 2\n") + sb.WriteString(" done\n") + sb.WriteString(") &\n") + sb.WriteString("NETLOG_PID=$!\n\n") + } + // Fix ownership for writable directories sb.WriteString("# Fix ownership for claude user\n") sb.WriteString("chown -R claude:claude /home/claude 2>/dev/null || true\n") diff --git a/internal/guest/init_test.go b/internal/guest/init_test.go index 1631d45..1a21a87 100644 --- a/internal/guest/init_test.go +++ b/internal/guest/init_test.go @@ -9,9 +9,9 @@ import ( ) func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { - // This test verifies that when a domain allowlist is applied, - // /etc/resolv.conf is forced to use 8.8.8.8/1.1.1.1 BEFORE iptables rules. - // This prevents the bug where DHCP sets a different DNS server that iptables then blocks. + // This test verifies that when network restrictions are active, + // dnsmasq is configured as a local DNS forwarder BEFORE iptables rules. + // dnsmasq forwards to 8.8.8.8/1.1.1.1 which iptables allows through. tests := []struct { name string @@ -20,7 +20,7 @@ func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { wantIPTables bool }{ { - name: "domain allowlist forces DNS", + name: "domain allowlist forces DNS via dnsmasq", policy: &network.Policy{ Domains: []string{"api.anthropic.com", "github.com"}, }, @@ -28,7 +28,7 @@ func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { wantIPTables: true, }, { - name: "wildcard allowlist forces DNS", + name: "wildcard allowlist forces DNS via dnsmasq", policy: &network.Policy{ Wildcards: []string{"*.example.com"}, }, @@ -36,7 +36,7 @@ func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { wantIPTables: true, }, { - name: "mixed domains and wildcards forces DNS", + name: "mixed domains and wildcards forces DNS via dnsmasq", policy: &network.Policy{ Domains: []string{"api.anthropic.com"}, Wildcards: []string{"*.internal.company.com"}, @@ -45,12 +45,12 @@ func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { wantIPTables: true, }, { - name: "blocked policy does not force DNS", + name: "blocked policy forces DNS via dnsmasq", policy: &network.Policy{ Blocked: true, }, - wantDNSForced: false, - wantIPTables: true, // Still has iptables rules for blocking + wantDNSForced: true, + wantIPTables: true, }, { name: "allow all does not force DNS", @@ -78,11 +78,9 @@ func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { nil, ) - // Check for DNS forcing in the allowlist section (the specific pattern that fixes the DHCP/iptables mismatch) - // This must appear BEFORE the iptables rules when allowlist is active - // We look for the allowlist-specific DNS force, not the generic DHCP fallback - allowlistDNSMarker := "# Force DNS to use public resolvers (iptables will only allow these)" - dnsForceIndex := strings.Index(script, allowlistDNSMarker) + // Check for dnsmasq DNS forcing (replaces the old direct resolv.conf forcing) + dnsmasqMarker := "# Configure dnsmasq as logging DNS forwarder" + dnsForceIndex := strings.Index(script, dnsmasqMarker) hasDNSForce := dnsForceIndex != -1 // Check for iptables OUTPUT policy @@ -91,24 +89,23 @@ func TestGenerateClaudeInitScript_DNSForcedWithAllowlist(t *testing.T) { hasIPTables := iptablesIndex != -1 if hasDNSForce != tt.wantDNSForced { - t.Errorf("DNS force = %v, want %v", hasDNSForce, tt.wantDNSForced) + t.Errorf("DNS force (dnsmasq) = %v, want %v", hasDNSForce, tt.wantDNSForced) } if hasIPTables != tt.wantIPTables { t.Errorf("iptables rules = %v, want %v", hasIPTables, tt.wantIPTables) } - // Critical check: DNS forcing must come BEFORE iptables rules - // This is the actual regression test for the DNS/iptables mismatch bug + // Critical check: dnsmasq setup must come BEFORE iptables rules if tt.wantDNSForced && hasDNSForce && hasIPTables { if dnsForceIndex > iptablesIndex { - t.Errorf("DNS forcing appears AFTER iptables rules - this will cause DNS to fail!\n"+ - "DNS force at index %d, iptables at index %d", dnsForceIndex, iptablesIndex) + t.Errorf("dnsmasq setup appears AFTER iptables rules - DNS queries will be blocked!\n"+ + "dnsmasq at index %d, iptables at index %d", dnsForceIndex, iptablesIndex) } } - // Verify DNS rules allow the forced resolvers - if tt.wantDNSForced { + // Verify iptables allows DNS to upstream resolvers (for domain/wildcard policies) + if tt.wantDNSForced && hasIPTables && !tt.policy.Blocked { if !strings.Contains(script, "iptables -A OUTPUT -p udp -d 8.8.8.8 --dport 53 -j ACCEPT") { t.Error("Missing iptables rule to allow DNS to 8.8.8.8") } @@ -230,3 +227,165 @@ func TestGenerateRCLocal(t *testing.T) { t.Error("Missing exit 0") } } + +func TestGenerateClaudeInitScript_NetworkLogRules(t *testing.T) { + tests := []struct { + name string + policy *network.Policy + wantNetLog bool // FAIZE_NET LOG rule + wantDenyLog bool // FAIZE_DENY LOG rule + wantDmesgWatch bool // background dmesg watcher + }{ + { + name: "domain allowlist has both LOG rules and watcher", + policy: &network.Policy{ + Domains: []string{"api.anthropic.com"}, + }, + wantNetLog: true, + wantDenyLog: true, + wantDmesgWatch: true, + }, + { + name: "blocked policy has deny LOG and watcher", + policy: &network.Policy{ + Blocked: true, + }, + wantNetLog: false, // blocked doesn't need NEW connection logging + wantDenyLog: true, + wantDmesgWatch: true, + }, + { + name: "allow all has no LOG rules and no watcher", + policy: &network.Policy{ + AllowAll: true, + }, + wantNetLog: false, + wantDenyLog: false, + wantDmesgWatch: false, + }, + { + name: "nil policy has no LOG rules and no watcher", + policy: nil, + wantNetLog: false, + wantDenyLog: false, + wantDmesgWatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + script := GenerateClaudeInitScript( + []session.VMMount{}, + "/workspace", + tt.policy, + false, + nil, + ) + + hasNetLog := strings.Contains(script, "FAIZE_NET: ") + hasDenyLog := strings.Contains(script, "FAIZE_DENY: ") + hasDmesgWatch := strings.Contains(script, "dmesg -c") && strings.Contains(script, "NETLOG_PID") + + if hasNetLog != tt.wantNetLog { + t.Errorf("FAIZE_NET LOG rule = %v, want %v", hasNetLog, tt.wantNetLog) + } + if hasDenyLog != tt.wantDenyLog { + t.Errorf("FAIZE_DENY LOG rule = %v, want %v", hasDenyLog, tt.wantDenyLog) + } + if hasDmesgWatch != tt.wantDmesgWatch { + t.Errorf("dmesg watcher = %v, want %v", hasDmesgWatch, tt.wantDmesgWatch) + } + }) + } +} + +func TestGenerateClaudeInitScript_DnsmasqSetup(t *testing.T) { + tests := []struct { + name string + policy *network.Policy + wantDnsmasq bool + wantLocalhost bool // resolv.conf points to 127.0.0.1 + }{ + { + name: "domain allowlist configures dnsmasq", + policy: &network.Policy{ + Domains: []string{"api.anthropic.com", "github.com"}, + }, + wantDnsmasq: true, + wantLocalhost: true, + }, + { + name: "wildcard allowlist configures dnsmasq", + policy: &network.Policy{ + Wildcards: []string{"*.example.com"}, + }, + wantDnsmasq: true, + wantLocalhost: true, + }, + { + name: "blocked policy configures dnsmasq", + policy: &network.Policy{ + Blocked: true, + }, + wantDnsmasq: true, + wantLocalhost: true, + }, + { + name: "allow all does NOT configure dnsmasq", + policy: &network.Policy{ + AllowAll: true, + }, + wantDnsmasq: false, + wantLocalhost: false, + }, + { + name: "nil policy does NOT configure dnsmasq", + policy: nil, + wantDnsmasq: false, + wantLocalhost: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + script := GenerateClaudeInitScript( + []session.VMMount{}, + "/workspace", + tt.policy, + false, + nil, + ) + + hasDnsmasqConfig := strings.Contains(script, "cat > /etc/dnsmasq.conf") + hasDnsmasqStart := strings.Contains(script, "dnsmasq\n") + hasLocalhost := strings.Contains(script, "echo 'nameserver 127.0.0.1' > /etc/resolv.conf") + hasDnsmasqKill := strings.Contains(script, "DNSMASQ_RUNNING=1") + hasLogQueries := strings.Contains(script, "log-queries") + hasDNSLogFacility := strings.Contains(script, "log-facility=/mnt/bootstrap/dns.log") + + if hasDnsmasqConfig != tt.wantDnsmasq { + t.Errorf("dnsmasq config = %v, want %v", hasDnsmasqConfig, tt.wantDnsmasq) + } + if hasDnsmasqStart != tt.wantDnsmasq { + t.Errorf("dnsmasq start = %v, want %v", hasDnsmasqStart, tt.wantDnsmasq) + } + if hasLocalhost != tt.wantLocalhost { + t.Errorf("resolv.conf localhost = %v, want %v", hasLocalhost, tt.wantLocalhost) + } + + // Cleanup handler should have conditional dnsmasq kill only when dnsmasq is configured + if hasDnsmasqKill != tt.wantDnsmasq { + t.Errorf("dnsmasq kill in cleanup = %v, want %v", hasDnsmasqKill, tt.wantDnsmasq) + } + + if tt.wantDnsmasq { + if !hasLogQueries { + t.Error("Missing log-queries in dnsmasq config") + } + if !hasDNSLogFacility { + t.Error("Missing log-facility in dnsmasq config") + } + } + }) + } +} diff --git a/internal/session/types_test.go b/internal/session/types_test.go new file mode 100644 index 0000000..3b2bc1c --- /dev/null +++ b/internal/session/types_test.go @@ -0,0 +1,92 @@ +package session + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionSerialization(t *testing.T) { + now := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + stopped := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + + t.Run("serializes all fields including new ones", func(t *testing.T) { + s := Session{ + ID: "test-session-1", + ProjectDir: "/home/user/project", + Mounts: []VMMount{ + {Source: "/host/path", Target: "/guest/path", ReadOnly: true, Tag: "mount0"}, + }, + Network: []string{"npm", "github"}, + CPUs: 2, + Memory: "4GB", + Status: "stopped", + StartedAt: now, + ClaudeMode: true, + Timeout: "2h", + StoppedAt: &stopped, + ExitReason: "timeout", + } + + data, err := json.Marshal(s) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + + assert.Equal(t, "test-session-1", m["id"]) + assert.Equal(t, "2h", m["timeout"]) + assert.Equal(t, "timeout", m["exit_reason"]) + assert.NotEmpty(t, m["stopped_at"]) + }) + + t.Run("deserializes new fields from JSON", func(t *testing.T) { + input := `{ + "id": "test-session-2", + "project_dir": "/tmp/proj", + "mounts": null, + "network": null, + "cpus": 4, + "memory": "8GB", + "status": "stopped", + "started_at": "2024-01-15T10:00:00Z", + "claude_mode": false, + "timeout": "1h", + "stopped_at": "2024-01-15T11:00:00Z", + "exit_reason": "normal" + }` + + var s Session + require.NoError(t, json.Unmarshal([]byte(input), &s)) + + assert.Equal(t, "test-session-2", s.ID) + assert.Equal(t, "1h", s.Timeout) + assert.Equal(t, "normal", s.ExitReason) + require.NotNil(t, s.StoppedAt) + assert.Equal(t, time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), *s.StoppedAt) + }) + + t.Run("omitempty omits zero-value new fields", func(t *testing.T) { + s := Session{ + ID: "test-session-3", + ProjectDir: "/tmp/proj", + CPUs: 2, + Memory: "4GB", + Status: "running", + StartedAt: now, + } + + data, err := json.Marshal(s) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + + assert.NotContains(t, m, "timeout") + assert.NotContains(t, m, "stopped_at") + assert.NotContains(t, m, "exit_reason") + }) +} diff --git a/scripts/build-claude-rootfs.sh b/scripts/build-claude-rootfs.sh index f0a8219..cc0eb06 100755 --- a/scripts/build-claude-rootfs.sh +++ b/scripts/build-claude-rootfs.sh @@ -38,7 +38,7 @@ if [ -n "$EXTRA_DEPS" ]; then fi docker run --rm -v "$WORK_DIR/rootfs:/out" alpine:latest sh -c " # Install packages - BASE_PKGS=\"bash curl ca-certificates git build-base python3 coreutils nodejs npm util-linux iptables ip6tables\" + BASE_PKGS=\"bash curl ca-certificates git build-base python3 coreutils nodejs npm util-linux iptables ip6tables dnsmasq\" apk add --no-cache \$BASE_PKGS $EXTRA_DEPS >/dev/null 2>&1 # Copy the entire root filesystem structure