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
+}