From 8b63d9954993eaf6fc24c493a0c50f2804c117fd Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Tue, 24 Feb 2026 17:10:53 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20session:=20add=20serialization?= =?UTF-8?q?=20tests=20and=20network=20log=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/changeset/display.go | 78 +++++++++++++++++++++++- internal/changeset/snapshot.go | 74 ++++++++++++++++++++++- internal/changeset/snapshot_test.go | 50 ++++++++++++++++ internal/cmd/start.go | 10 +++- internal/guest/init.go | 20 +++++++ internal/guest/init_test.go | 71 ++++++++++++++++++++++ internal/session/types_test.go | 92 +++++++++++++++++++++++++++++ 7 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 internal/session/types_test.go diff --git a/internal/changeset/display.go b/internal/changeset/display.go index 93c5de5..2a342fe 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,73 @@ 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)) + + // Count by type and collect unique destinations + var conns, denies []NetworkEvent + for _, e := range events { + switch e.Action { + case "DENY": + denies = append(denies, e) + default: + conns = append(conns, e) + } + } + + // DNS queries (UDP port 53) + var dnsCount int + dnsServers := make(map[string]int) + for _, e := range conns { + if e.DstPort == 53 { + dnsCount++ + dnsServers[e.DstIP]++ + } + } + if dnsCount > 0 { + serverParts := make([]string, 0, len(dnsServers)) + for ip, count := range dnsServers { + serverParts = append(serverParts, fmt.Sprintf("%s: %d", ip, count)) + } + _, _ = fmt.Fprintf(w, " DNS queries: %d (%s)\n", dnsCount, strings.Join(serverParts, ", ")) + } + + // Non-DNS connections + var connCount int + connDests := make(map[string]bool) + for _, e := range conns { + if e.DstPort != 53 { + connCount++ + connDests[fmt.Sprintf("%s:%d", e.DstIP, e.DstPort)] = true + } + } + if connCount > 0 { + 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", connCount, display) + } + + // Denied connections + if len(denies) > 0 { + denyDests := make(map[string]bool) + for _, e := range denies { + denyDests[fmt.Sprintf("%s:%d", e.DstIP, 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(denies), strings.Join(destList, ", ")) + } +} diff --git a/internal/changeset/snapshot.go b/internal/changeset/snapshot.go index 44c4b0f..5b7b3a3 100644 --- a/internal/changeset/snapshot.go +++ b/internal/changeset/snapshot.go @@ -5,7 +5,9 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" "sort" + "strconv" "strings" "time" ) @@ -154,11 +156,22 @@ 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" or "DENY" + Proto string `json:"proto"` // "TCP", "UDP" + DstIP string `json:"dst_ip"` + DstPort int `json:"dst_port"` + SrcPort int `json:"src_port,omitempty"` +} + // 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 +248,61 @@ 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 +} + // 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..21d4dbc 100644 --- a/internal/changeset/snapshot_test.go +++ b/internal/changeset/snapshot_test.go @@ -239,3 +239,53 @@ 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) +} diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 3ecc244..a9df1f4 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -373,10 +373,14 @@ 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 log from bootstrap dir + networkEvents, _ := changeset.ParseNetworkLog(filepath.Join(bootstrapDir, "network.log")) + 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..c05fab6 100644 --- a/internal/guest/init.go +++ b/internal/guest/init.go @@ -117,6 +117,8 @@ 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 child processes gracefully\n") sb.WriteString(" kill -TERM $(jobs -p) 2>/dev/null || true\n") sb.WriteString(" wait 2>/dev/null || true\n") @@ -249,6 +251,8 @@ 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) @@ -265,6 +269,8 @@ 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\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 +339,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..f542ae5 100644 --- a/internal/guest/init_test.go +++ b/internal/guest/init_test.go @@ -230,3 +230,74 @@ 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) + } + }) + } +} 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") + }) +} From 7c6d882deaa52f42112b4f9bc911879887ce0903 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Tue, 24 Feb 2026 19:19:38 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9A=80=20build:=20add=20dnsmasq=20and?= =?UTF-8?q?=20tests=20for=20DNS/network=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/changeset/display.go | 56 +++++++----- internal/changeset/snapshot.go | 90 ++++++++++++++++++- internal/changeset/snapshot_test.go | 91 +++++++++++++++++++ internal/cmd/start.go | 4 +- internal/guest/init.go | 43 ++++++--- internal/guest/init_test.go | 130 +++++++++++++++++++++++----- scripts/build-claude-rootfs.sh | 2 +- 7 files changed, 353 insertions(+), 63 deletions(-) diff --git a/internal/changeset/display.go b/internal/changeset/display.go index 2a342fe..9e880c4 100644 --- a/internal/changeset/display.go +++ b/internal/changeset/display.go @@ -137,10 +137,12 @@ func printNetworkSummary(w io.Writer, events []NetworkEvent) { _, _ = fmt.Fprintln(w, "\nNetwork activity") _, _ = fmt.Fprintln(w, strings.Repeat("─", 40)) - // Count by type and collect unique destinations - var conns, denies []NetworkEvent + // 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: @@ -148,33 +150,35 @@ func printNetworkSummary(w io.Writer, events []NetworkEvent) { } } - // DNS queries (UDP port 53) - var dnsCount int - dnsServers := make(map[string]int) - for _, e := range conns { - if e.DstPort == 53 { - dnsCount++ - dnsServers[e.DstIP]++ + // DNS queries — show domain names + if len(dnsEvents) > 0 { + domains := make([]string, 0, len(dnsEvents)) + for _, e := range dnsEvents { + domains = append(domains, e.Domain) } - } - if dnsCount > 0 { - serverParts := make([]string, 0, len(dnsServers)) - for ip, count := range dnsServers { - serverParts = append(serverParts, fmt.Sprintf("%s: %d", ip, count)) + 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", dnsCount, strings.Join(serverParts, ", ")) + _, _ = fmt.Fprintf(w, " DNS queries: %d (%s)\n", len(dnsEvents), display) } - // Non-DNS connections - var connCount int - connDests := make(map[string]bool) + // Non-DNS connections — show domain when available, fall back to IP + var nonDNSConns []NetworkEvent for _, e := range conns { if e.DstPort != 53 { - connCount++ - connDests[fmt.Sprintf("%s:%d", e.DstIP, e.DstPort)] = true + nonDNSConns = append(nonDNSConns, e) } } - if connCount > 0 { + 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) @@ -184,14 +188,18 @@ func printNetworkSummary(w io.Writer, events []NetworkEvent) { if len(destList) > 5 { display = strings.Join(destList[:5], ", ") + fmt.Sprintf(" (+%d more)", len(destList)-5) } - _, _ = fmt.Fprintf(w, " Connections: %d (%s)\n", connCount, display) + _, _ = fmt.Fprintf(w, " Connections: %d (%s)\n", len(nonDNSConns), display) } - // Denied connections + // Denied connections — same domain annotation if len(denies) > 0 { denyDests := make(map[string]bool) for _, e := range denies { - denyDests[fmt.Sprintf("%s:%d", e.DstIP, e.DstPort)] = true + 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 { diff --git a/internal/changeset/snapshot.go b/internal/changeset/snapshot.go index 5b7b3a3..58d7bb2 100644 --- a/internal/changeset/snapshot.go +++ b/internal/changeset/snapshot.go @@ -159,11 +159,12 @@ type MountChanges struct { // NetworkEvent represents a parsed network event from guest-side iptables LOG rules. type NetworkEvent struct { Timestamp string `json:"timestamp"` - Action string `json:"action"` // "CONN" or "DENY" - Proto string `json:"proto"` // "TCP", "UDP" - DstIP string `json:"dst_ip"` - DstPort int `json:"dst_port"` + 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. @@ -303,6 +304,87 @@ func ParseNetworkLog(path string) ([]NetworkEvent, error) { 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 IPv4 addresses (skip CNAME and other reply types) + if !strings.Contains(ip, ":") && ip != "" { + 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 { + // Parse DNS log → get DNS events + IP→domain map + dnsEvents, ipToDomain, _ := ParseDNSLog(filepath.Join(bootstrapDir, "dns.log")) + + // Parse iptables network log → get connection/deny events + netEvents, _ := ParseNetworkLog(filepath.Join(bootstrapDir, "network.log")) + + // 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 +} + // 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 21d4dbc..ff0c75e 100644 --- a/internal/changeset/snapshot_test.go +++ b/internal/changeset/snapshot_test.go @@ -289,3 +289,94 @@ func TestParseNetworkLog_EmptyFile(t *testing.T) { 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 := CollectNetworkEvents(dir) + + // 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 a9df1f4..f407da0 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -373,8 +373,8 @@ 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 log from bootstrap dir - networkEvents, _ := changeset.ParseNetworkLog(filepath.Join(bootstrapDir, "network.log")) + // Read network + DNS logs from bootstrap dir + networkEvents := changeset.CollectNetworkEvents(bootstrapDir) cs := &changeset.SessionChangeset{ SessionID: sess.ID, diff --git a/internal/guest/init.go b/internal/guest/init.go index c05fab6..3f5d827 100644 --- a/internal/guest/init.go +++ b/internal/guest/init.go @@ -119,6 +119,8 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic 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(" 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") @@ -222,12 +224,33 @@ 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\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") @@ -259,11 +282,9 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic 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") diff --git a/internal/guest/init_test.go b/internal/guest/init_test.go index f542ae5..d9ebedd 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") } @@ -301,3 +298,94 @@ func TestGenerateClaudeInitScript_NetworkLogRules(t *testing.T) { }) } } + +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, "killall dnsmasq") + 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 always have dnsmasq kill (it's unconditional in cleanup) + if !hasDnsmasqKill { + t.Error("Missing dnsmasq kill in cleanup handler") + } + + 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/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 From 583d40c83b85ce5514eb479eb57d775be303c694 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Tue, 24 Feb 2026 19:30:53 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A7=20changeset:=20Fix=20logging,?= =?UTF-8?q?=20dnsmasq=20control,=20and=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/changeset/display.go | 4 ++-- internal/changeset/snapshot.go | 19 +++++++++++++------ internal/changeset/snapshot_test.go | 3 ++- internal/cmd/start.go | 5 ++++- internal/guest/init.go | 5 +++-- internal/guest/init_test.go | 8 ++++---- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/changeset/display.go b/internal/changeset/display.go index 9e880c4..f3e2a6c 100644 --- a/internal/changeset/display.go +++ b/internal/changeset/display.go @@ -188,7 +188,7 @@ func printNetworkSummary(w io.Writer, events []NetworkEvent) { if len(destList) > 5 { display = strings.Join(destList[:5], ", ") + fmt.Sprintf(" (+%d more)", len(destList)-5) } - _, _ = fmt.Fprintf(w, " Connections: %d (%s)\n", len(nonDNSConns), display) + _, _ = fmt.Fprintf(w, " Connections: %d (%s)\n", len(connDests), display) } // Denied connections — same domain annotation @@ -206,6 +206,6 @@ func printNetworkSummary(w io.Writer, events []NetworkEvent) { destList = append(destList, dest) } sort.Strings(destList) - _, _ = fmt.Fprintf(w, " Denied: %d (%s)\n", len(denies), strings.Join(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 58d7bb2..b20c0f3 100644 --- a/internal/changeset/snapshot.go +++ b/internal/changeset/snapshot.go @@ -3,6 +3,7 @@ package changeset import ( "bufio" "encoding/json" + "net" "os" "path/filepath" "regexp" @@ -346,8 +347,8 @@ func ParseDNSLog(path string) (events []NetworkEvent, ipToDomain map[string]stri if rm := dnsReplyRe.FindStringSubmatch(line); rm != nil { domain := rm[2] ip := rm[3] - // Only map IPv4 addresses (skip CNAME and other reply types) - if !strings.Contains(ip, ":") && ip != "" { + // Only map valid IP addresses (skip CNAME and other reply types) + if net.ParseIP(ip) != nil { ipToDomain[ip] = domain } } @@ -364,12 +365,18 @@ func ParseDNSLog(path string) (events []NetworkEvent, ipToDomain map[string]stri // 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 { +func CollectNetworkEvents(bootstrapDir string) ([]NetworkEvent, error) { // Parse DNS log → get DNS events + IP→domain map - dnsEvents, ipToDomain, _ := ParseDNSLog(filepath.Join(bootstrapDir, "dns.log")) + dnsEvents, ipToDomain, err := ParseDNSLog(filepath.Join(bootstrapDir, "dns.log")) + if err != nil { + return nil, err + } // Parse iptables network log → get connection/deny events - netEvents, _ := ParseNetworkLog(filepath.Join(bootstrapDir, "network.log")) + 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 { @@ -382,7 +389,7 @@ func CollectNetworkEvents(bootstrapDir string) []NetworkEvent { var all []NetworkEvent all = append(all, dnsEvents...) all = append(all, netEvents...) - return all + return all, nil } // defaultIgnorePrefixes are path prefixes for internal state that should not diff --git a/internal/changeset/snapshot_test.go b/internal/changeset/snapshot_test.go index ff0c75e..ada4eb5 100644 --- a/internal/changeset/snapshot_test.go +++ b/internal/changeset/snapshot_test.go @@ -354,7 +354,8 @@ Feb 24 12:00:02 dnsmasq[42]: reply github.com is 140.82.114.4 ` _ = os.WriteFile(filepath.Join(dir, "network.log"), []byte(netContent), 0644) - events := CollectNetworkEvents(dir) + events, err := CollectNetworkEvents(dir) + require.NoError(t, err) // Should have: 2 DNS events + 3 network events = 5 total require.Len(t, events, 5) diff --git a/internal/cmd/start.go b/internal/cmd/start.go index f407da0..c725dcb 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -374,7 +374,10 @@ func runStart(cmd *cobra.Command, args []string) error { guestChanges, _ := changeset.ParseGuestChanges(filepath.Join(bootstrapDir, "guest-changes.txt")) // Read network + DNS logs from bootstrap dir - networkEvents := changeset.CollectNetworkEvents(bootstrapDir) + networkEvents, netErr := changeset.CollectNetworkEvents(bootstrapDir) + if netErr != nil { + Debug("Failed to collect network events: %v", netErr) + } cs := &changeset.SessionChangeset{ SessionID: sess.ID, diff --git a/internal/guest/init.go b/internal/guest/init.go index 3f5d827..8842f6b 100644 --- a/internal/guest/init.go +++ b/internal/guest/init.go @@ -120,7 +120,7 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic 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(" killall dnsmasq 2>/dev/null || true\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") @@ -240,7 +240,8 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic sb.WriteString("pid-file=\n") sb.WriteString("DNSMASQ_EOF\n\n") sb.WriteString("# Start dnsmasq (daemonizes by default)\n") - sb.WriteString("dnsmasq\n\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 { diff --git a/internal/guest/init_test.go b/internal/guest/init_test.go index d9ebedd..1a21a87 100644 --- a/internal/guest/init_test.go +++ b/internal/guest/init_test.go @@ -359,7 +359,7 @@ func TestGenerateClaudeInitScript_DnsmasqSetup(t *testing.T) { 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, "killall dnsmasq") + hasDnsmasqKill := strings.Contains(script, "DNSMASQ_RUNNING=1") hasLogQueries := strings.Contains(script, "log-queries") hasDNSLogFacility := strings.Contains(script, "log-facility=/mnt/bootstrap/dns.log") @@ -373,9 +373,9 @@ func TestGenerateClaudeInitScript_DnsmasqSetup(t *testing.T) { t.Errorf("resolv.conf localhost = %v, want %v", hasLocalhost, tt.wantLocalhost) } - // Cleanup handler should always have dnsmasq kill (it's unconditional in cleanup) - if !hasDnsmasqKill { - t.Error("Missing dnsmasq kill in cleanup handler") + // 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 {