Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion internal/changeset/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package changeset
import (
"fmt"
"io"
"sort"
"strings"
)

Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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, ", "))
}
}
163 changes: 160 additions & 3 deletions internal/changeset/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package changeset
import (
"bufio"
"encoding/json"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"}
Expand Down
Loading
Loading