sketch: add /proc filesystem fallback for port monitoring when ss command unavailable

Implements /proc/net/tcp* parsing as fallback when ss command fails to eliminate
dependency on ss being installed in container environments.

Problems Solved:

ss Command Dependency:
- PortMonitor.updatePortState() relied on 'ss -lntu' command for port detection
- Failed on systems where ss (iproute2 package) is not installed
- No fallback mechanism when ss command execution failed
- Port monitoring became non-functional in minimal container environments

Limited Container Support:
- Many minimal container images don't include ss command
- Port monitoring silently failed without providing any functionality
- No way to detect listening ports without external command dependencies

Solution Architecture:

/proc Filesystem Parsing:
- Added getListeningPortsFromProc() method to read /proc/net/tcp* files
- Parses /proc/net/tcp, /proc/net/tcp6, /proc/net/udp, /proc/net/udp6
- Hex address decoding for both IPv4 and IPv6 addresses
- Socket state filtering to identify listening sockets (state 0x0A for TCP, 0x07 for UDP)

Fallback Implementation:
- updatePortState() tries ss command first, falls back to /proc on failure
- parseAddress() handles little-endian hex encoding from /proc files
- Generated output format matches ss command output for compatibility
- Maintains existing parseSSPorts() functionality for ss output

Implementation Details:

Address Parsing:
- IPv4: 8-character hex string representing little-endian 32-bit address
- IPv6: 32-character hex string with little-endian 32-bit chunks
- Port numbers stored as big-endian hex values
- Special address handling: 0.0.0.0 and :: converted to '*'

Socket State Detection:
- TCP listening sockets: state 0x0A (TCP_LISTEN)
- UDP bound sockets: state 0x07 (TCP_CLOSE for UDP)
- Filters out non-listening connections and states

Error Handling:
- Graceful fallback when ss command fails
- Logs debug messages for command failures
- Continues with /proc parsing if available
- Handles missing /proc files gracefully

Testing:

Comprehensive Test Coverage:
- TestParseAddress() verifies hex address decoding for IPv4/IPv6
- TestParseProcData() validates /proc file parsing with mock data
- TestGetListeningPortsFromProcFallback() tests complete fallback functionality
- TestUpdatePortStateWithFallback() validates end-to-end behavior

Address Parsing Validation:
- IPv4 localhost (0100007F:0050 -> 127.0.0.1:80)
- IPv4 wildcard (00000000:0016 -> *:22)
- IPv6 wildcard and specific addresses
- Error handling for invalid formats and hex values

Files Modified:
- sketch/loop/port_monitor.go: Added /proc parsing methods and fallback logic
- sketch/loop/port_monitor_test.go: Added comprehensive tests for new functionality

The implementation ensures port monitoring works reliably in any Linux environment
regardless of whether ss command is available, using native /proc filesystem access.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s72dd58a0b3f4304bk
diff --git a/loop/port_monitor.go b/loop/port_monitor.go
index 9614db2..105e1e7 100644
--- a/loop/port_monitor.go
+++ b/loop/port_monitor.go
@@ -4,7 +4,10 @@
 	"context"
 	"fmt"
 	"log/slog"
+	"net"
+	"os"
 	"os/exec"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -54,17 +57,27 @@
 }
 
 // updatePortState runs ss and checks for changes in listening ports
+// Falls back to /proc/net/tcp* parsing if ss is not available
 func (pm *PortMonitor) updatePortState(ctx context.Context) {
+	var currentPorts string
+	var err error
+
+	// Try ss command first
 	cmd := exec.CommandContext(ctx, "ss", "-lntu")
 	output, err := cmd.Output()
 	if err != nil {
-		// Log the error but don't fail - port monitoring is not critical
-		slog.DebugContext(ctx, "Failed to run ss command", "error", err)
-		return
+		// ss command failed, try /proc filesystem fallback
+		slog.DebugContext(ctx, "ss command failed, trying /proc fallback", "error", err)
+		currentPorts, err = pm.getListeningPortsFromProc()
+		if err != nil {
+			// Both methods failed - log and return
+			slog.DebugContext(ctx, "Failed to get listening ports", "ss_error", err)
+			return
+		}
+	} else {
+		currentPorts = string(output)
 	}
 
-	currentPorts := string(output)
-
 	pm.mu.Lock()
 	defer pm.mu.Unlock()
 
@@ -140,6 +153,11 @@
 	return result
 }
 
+// UpdatePortState is a public wrapper for updatePortState for testing purposes
+func (pm *PortMonitor) UpdatePortState(ctx context.Context) {
+	pm.updatePortState(ctx)
+}
+
 // GetAllRecentEvents returns a copy of all recent port events
 func (pm *PortMonitor) GetAllRecentEvents() []PortEvent {
 	pm.mu.Lock()
@@ -177,3 +195,150 @@
 
 	return ports
 }
+
+// getListeningPortsFromProc reads /proc/net/tcp and /proc/net/tcp6 to find listening ports
+// Returns output in a format similar to ss -lntu
+func (pm *PortMonitor) getListeningPortsFromProc() (string, error) {
+	var result strings.Builder
+	result.WriteString("Netid State  Recv-Q Send-Q Local Address:Port Peer Address:Port\n")
+
+	// Parse IPv4 listening ports
+	if err := pm.parseProc("/proc/net/tcp", "tcp", &result); err != nil {
+		return "", fmt.Errorf("failed to parse /proc/net/tcp: %w", err)
+	}
+
+	// Parse IPv6 listening ports
+	if err := pm.parseProc("/proc/net/tcp6", "tcp", &result); err != nil {
+		// IPv6 might not be available, log but don't fail
+		slog.Debug("Failed to parse /proc/net/tcp6", "error", err)
+	}
+
+	// Parse UDP ports
+	if err := pm.parseProc("/proc/net/udp", "udp", &result); err != nil {
+		slog.Debug("Failed to parse /proc/net/udp", "error", err)
+	}
+
+	if err := pm.parseProc("/proc/net/udp6", "udp", &result); err != nil {
+		slog.Debug("Failed to parse /proc/net/udp6", "error", err)
+	}
+
+	return result.String(), nil
+}
+
+// parseProc parses a /proc/net/* file for listening sockets
+func (pm *PortMonitor) parseProc(filename, protocol string, result *strings.Builder) error {
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		return err
+	}
+
+	lines := strings.Split(string(data), "\n")
+	for i, line := range lines {
+		if i == 0 || strings.TrimSpace(line) == "" {
+			continue // Skip header and empty lines
+		}
+
+		fields := strings.Fields(line)
+		if len(fields) < 4 {
+			continue
+		}
+
+		// Parse socket state (4th field, index 3)
+		stateHex := fields[3]
+		state, err := strconv.ParseInt(stateHex, 16, 32)
+		if err != nil {
+			continue
+		}
+
+		// Check if socket is in LISTEN state (0x0A for TCP) or bound state for UDP
+		isListening := false
+		if protocol == "tcp" && state == 0x0A {
+			isListening = true
+		} else if protocol == "udp" && state == 0x07 {
+			// UDP sockets in state 0x07 (TCP_CLOSE) are bound/listening
+			isListening = true
+		}
+
+		if !isListening {
+			continue
+		}
+
+		// Parse local address (2nd field, index 1)
+		localAddr := fields[1]
+		addr, port, err := pm.parseAddress(localAddr, strings.Contains(filename, "6"))
+		if err != nil {
+			continue
+		}
+
+		// Format similar to ss output
+		result.WriteString(fmt.Sprintf("%s   LISTEN 0      0          %s:%d        0.0.0.0:*\n",
+			protocol, addr, port))
+	}
+
+	return nil
+}
+
+// parseAddress parses hex-encoded address:port from /proc/net files
+func (pm *PortMonitor) parseAddress(addrPort string, isIPv6 bool) (string, int, error) {
+	parts := strings.Split(addrPort, ":")
+	if len(parts) != 2 {
+		return "", 0, fmt.Errorf("invalid address:port format: %s", addrPort)
+	}
+
+	// Parse port (stored in hex, big-endian)
+	portHex := parts[1]
+	port, err := strconv.ParseInt(portHex, 16, 32)
+	if err != nil {
+		return "", 0, fmt.Errorf("invalid port hex: %s", portHex)
+	}
+
+	// Parse IP address
+	addrHex := parts[0]
+	var addr string
+
+	if isIPv6 {
+		// IPv6: 32 hex chars representing 16 bytes
+		if len(addrHex) != 32 {
+			return "", 0, fmt.Errorf("invalid IPv6 address hex length: %d", len(addrHex))
+		}
+		// Convert hex to IPv6 address
+		var ipBytes [16]byte
+		for i := 0; i < 16; i++ {
+			b, err := strconv.ParseInt(addrHex[i*2:(i+1)*2], 16, 8)
+			if err != nil {
+				return "", 0, fmt.Errorf("invalid IPv6 hex: %s", addrHex)
+			}
+			ipBytes[i] = byte(b)
+		}
+		// /proc stores IPv6 in little-endian 32-bit chunks, need to reverse each chunk
+		for i := 0; i < 16; i += 4 {
+			ipBytes[i], ipBytes[i+1], ipBytes[i+2], ipBytes[i+3] = ipBytes[i+3], ipBytes[i+2], ipBytes[i+1], ipBytes[i]
+		}
+		addr = net.IP(ipBytes[:]).String()
+	} else {
+		// IPv4: 8 hex chars representing 4 bytes in little-endian
+		if len(addrHex) != 8 {
+			return "", 0, fmt.Errorf("invalid IPv4 address hex length: %d", len(addrHex))
+		}
+		// Parse as little-endian 32-bit integer
+		addrInt, err := strconv.ParseInt(addrHex, 16, 64)
+		if err != nil {
+			return "", 0, fmt.Errorf("invalid IPv4 hex: %s", addrHex)
+		}
+		// Convert to IP address (reverse byte order for little-endian)
+		addr = fmt.Sprintf("%d.%d.%d.%d",
+			addrInt&0xFF,
+			(addrInt>>8)&0xFF,
+			(addrInt>>16)&0xFF,
+			(addrInt>>24)&0xFF)
+	}
+
+	// Handle special addresses
+	if addr == "0.0.0.0" {
+		addr = "*"
+	} else if addr == "::" {
+		addr = "*"
+	}
+
+	return addr, int(port), nil
+}