remove port monitoring and automatic tunneling features
Remove port_monitor, TunnelManager, and /port-events handler to eliminate
automatic port tunneling functionality that bridges outtie to innie environments.
Sketch got confused when I asked it to change how this works; removing
and re-adding was easier!
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s78f868b27a44cb2bk
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 59d3e8d..7b9d549 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -413,16 +413,6 @@
gitSrv.ps1URL.Store(&ps1URL)
}()
- // Start automatic port tunneling if SSH is available
- if sshAvailable {
- go func() {
- containerURL := "http://" + localAddr
- tunnelManager := NewTunnelManager(containerURL, cntrName, 10) // Allow up to 10 concurrent tunnels
- tunnelManager.Start(ctx)
- slog.InfoContext(ctx, "Started automatic port tunnel manager", "container", cntrName)
- }()
- }
-
go func() {
cmd := exec.CommandContext(ctx, "docker", "attach", cntrName)
cmd.Stdin = os.Stdin
diff --git a/dockerimg/tunnel_manager.go b/dockerimg/tunnel_manager.go
deleted file mode 100644
index 6262263..0000000
--- a/dockerimg/tunnel_manager.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package dockerimg
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "net/http"
- "os/exec"
- "sync"
- "time"
-
- "sketch.dev/loop"
-)
-
-// skipPorts defines system ports that should not be auto-tunneled
-var skipPorts = map[string]bool{
- "22": true, // SSH
- "80": true, // HTTP (this is the main sketch web interface)
- "443": true, // HTTPS
- "25": true, // SMTP
- "53": true, // DNS
- "110": true, // POP3
- "143": true, // IMAP
- "993": true, // IMAPS
- "995": true, // POP3S
-}
-
-// TunnelManager manages automatic SSH tunnels for container ports
-type TunnelManager struct {
- mu sync.Mutex
- containerURL string // HTTP URL to container (e.g., "http://localhost:8080")
- containerSSHHost string // SSH hostname for container (e.g., "sketch-abcd-efgh")
- activeTunnels map[string]*sshTunnel // port -> tunnel mapping
- lastPollTime time.Time
- maxActiveTunnels int // maximum number of concurrent tunnels allowed
-}
-
-// sshTunnel represents an active SSH tunnel
-type sshTunnel struct {
- containerPort string
- hostPort string
- cmd *exec.Cmd
- cancel context.CancelFunc
-}
-
-// NewTunnelManager creates a new tunnel manager
-func NewTunnelManager(containerURL, containerSSHHost string, maxActiveTunnels int) *TunnelManager {
- return &TunnelManager{
- containerURL: containerURL,
- containerSSHHost: containerSSHHost,
- activeTunnels: make(map[string]*sshTunnel),
- lastPollTime: time.Now(),
- maxActiveTunnels: maxActiveTunnels,
- }
-}
-
-// Start begins monitoring port events and managing tunnels
-func (tm *TunnelManager) Start(ctx context.Context) {
- go func() {
- ticker := time.NewTicker(10 * time.Second) // Poll every 10 seconds
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- tm.cleanupAllTunnels()
- return
- case <-ticker.C:
- tm.pollPortEvents(ctx)
- }
- }
- }()
-}
-
-// pollPortEvents fetches recent port events from container and updates tunnels
-func (tm *TunnelManager) pollPortEvents(ctx context.Context) {
- // Build URL with since parameter
- url := fmt.Sprintf("%s/port-events?since=%s", tm.containerURL, tm.lastPollTime.Format(time.RFC3339))
-
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
- if err != nil {
- slog.DebugContext(ctx, "Failed to create port events request", "error", err)
- return
- }
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- slog.DebugContext(ctx, "Failed to fetch port events", "error", err)
- return
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- slog.DebugContext(ctx, "Port events request failed", "status", resp.StatusCode)
- return
- }
-
- var events []loop.PortEvent
- if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
- slog.DebugContext(ctx, "Failed to decode port events", "error", err)
- return
- }
-
- // Process each event
- for _, event := range events {
- tm.processPortEvent(ctx, event)
- tm.mu.Lock()
- // Update last poll time to the latest event timestamp
- if event.Timestamp.After(tm.lastPollTime) {
- tm.lastPollTime = event.Timestamp
- }
- tm.mu.Unlock()
- }
-
- // Update poll time even if no events, to avoid re-fetching old events
- if len(events) == 0 {
- tm.lastPollTime = time.Now()
- }
-}
-
-// processPortEvent handles a single port event
-func (tm *TunnelManager) processPortEvent(ctx context.Context, event loop.PortEvent) {
- // Extract port number from event.Port (format: "tcp:0.0.0.0:8080")
- containerPort := tm.extractPortNumber(event.Port)
- if containerPort == "" {
- slog.DebugContext(ctx, "Could not extract port number", "port", event.Port)
- return
- }
-
- // Skip common system ports that we don't want to tunnel
- if tm.shouldSkipPort(containerPort) {
- return
- }
-
- switch event.Type {
- case "opened":
- tm.createTunnel(ctx, containerPort)
- case "closed":
- tm.removeTunnel(ctx, containerPort)
- default:
- slog.DebugContext(ctx, "Unknown port event type", "type", event.Type)
- }
-}
-
-// extractPortNumber extracts port number from ss format like "tcp:0.0.0.0:8080"
-func (tm *TunnelManager) extractPortNumber(portStr string) string {
- // Expected format: "tcp:0.0.0.0:8080" or "tcp:[::]:8080"
- // Find the last colon and extract the port
- for i := len(portStr) - 1; i >= 0; i-- {
- if portStr[i] == ':' {
- return portStr[i+1:]
- }
- }
- return ""
-}
-
-// shouldSkipPort returns true for ports we don't want to auto-tunnel
-func (tm *TunnelManager) shouldSkipPort(port string) bool {
- return skipPorts[port]
-}
-
-// createTunnel creates an SSH tunnel for the given container port
-func (tm *TunnelManager) createTunnel(ctx context.Context, containerPort string) {
- tm.mu.Lock()
- // Check if tunnel already exists
- if _, exists := tm.activeTunnels[containerPort]; exists {
- tm.mu.Unlock()
- return
- }
-
- // Check if we've reached the maximum number of active tunnels
- if len(tm.activeTunnels) >= tm.maxActiveTunnels {
- tm.mu.Unlock()
- slog.WarnContext(ctx, "Maximum active tunnels reached, skipping port", "port", containerPort, "max", tm.maxActiveTunnels, "active", len(tm.activeTunnels))
- return
- }
- tm.mu.Unlock()
-
- // Use the same port on host as container for simplicity
- hostPort := containerPort
-
- // Create SSH tunnel command: ssh -L hostPort:127.0.0.1:containerPort containerSSHHost
- tunnelCtx, cancel := context.WithCancel(ctx)
- cmd := exec.CommandContext(tunnelCtx, "ssh",
- "-L", fmt.Sprintf("%s:127.0.0.1:%s", hostPort, containerPort),
- "-N", // Don't execute remote commands
- "-T", // Don't allocate TTY
- tm.containerSSHHost,
- )
-
- // Start the tunnel
- if err := cmd.Start(); err != nil {
- slog.ErrorContext(ctx, "Failed to start SSH tunnel", "port", containerPort, "error", err)
- cancel()
- return
- }
-
- // Store tunnel info
- tunnel := &sshTunnel{
- containerPort: containerPort,
- hostPort: hostPort,
- cmd: cmd,
- cancel: cancel,
- }
- tm.mu.Lock()
- tm.activeTunnels[containerPort] = tunnel
- tm.mu.Unlock()
-
- slog.InfoContext(ctx, "Created SSH tunnel", "container_port", containerPort, "host_port", hostPort)
-
- // Monitor tunnel in background
- go func() {
- err := cmd.Wait()
- tm.mu.Lock()
- delete(tm.activeTunnels, containerPort)
- tm.mu.Unlock()
- if err != nil && tunnelCtx.Err() == nil {
- slog.ErrorContext(ctx, "SSH tunnel exited with error", "port", containerPort, "error", err)
- }
- }()
-}
-
-// removeTunnel removes an SSH tunnel for the given container port
-func (tm *TunnelManager) removeTunnel(ctx context.Context, containerPort string) {
- tunnel, exists := tm.activeTunnels[containerPort]
- if !exists {
- return
- }
-
- // Cancel the tunnel context and clean up
- tunnel.cancel()
- delete(tm.activeTunnels, containerPort)
-
- slog.InfoContext(ctx, "Removed SSH tunnel", "container_port", containerPort, "host_port", tunnel.hostPort)
-}
-
-// cleanupAllTunnels stops all active tunnels
-func (tm *TunnelManager) cleanupAllTunnels() {
- tm.mu.Lock()
- defer tm.mu.Unlock()
-
- for port, tunnel := range tm.activeTunnels {
- tunnel.cancel()
- delete(tm.activeTunnels, port)
- }
-}
diff --git a/dockerimg/tunnel_manager_test.go b/dockerimg/tunnel_manager_test.go
deleted file mode 100644
index 6408ce5..0000000
--- a/dockerimg/tunnel_manager_test.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package dockerimg
-
-import (
- "context"
- "testing"
- "time"
-
- "sketch.dev/loop"
-)
-
-// TestTunnelManagerMaxLimit tests that the tunnel manager respects the max tunnels limit
-func TestTunnelManagerMaxLimit(t *testing.T) {
- tm := NewTunnelManager("http://localhost:8080", "test-container", 2) // Max 2 tunnels
-
- if tm.maxActiveTunnels != 2 {
- t.Errorf("Expected maxActiveTunnels to be 2, got %d", tm.maxActiveTunnels)
- }
-
- // Test that active tunnels map is empty initially
- if len(tm.activeTunnels) != 0 {
- t.Errorf("Expected 0 active tunnels initially, got %d", len(tm.activeTunnels))
- }
-
- // Simulate adding tunnels beyond the limit by directly manipulating the internal map
- // This tests the limit logic without actually starting SSH processes
- // Add 2 tunnels (up to the limit)
- tm.activeTunnels["8080"] = &sshTunnel{containerPort: "8080", hostPort: "8080"}
- tm.activeTunnels["9090"] = &sshTunnel{containerPort: "9090", hostPort: "9090"}
-
- // Verify we have 2 active tunnels
- if len(tm.activeTunnels) != 2 {
- t.Errorf("Expected 2 active tunnels, got %d", len(tm.activeTunnels))
- }
-
- // Now test that the limit check works - attempt to add a third tunnel
- // Check if we've reached the maximum (this simulates the check in createTunnel)
- shouldBlock := len(tm.activeTunnels) >= tm.maxActiveTunnels
-
- if !shouldBlock {
- t.Error("Expected tunnel creation to be blocked when at max limit, but limit check failed")
- }
-
- // Verify that attempting to add beyond the limit doesn't actually add more
- if len(tm.activeTunnels) < tm.maxActiveTunnels {
- // This should not happen since we're at the limit
- tm.activeTunnels["3000"] = &sshTunnel{containerPort: "3000", hostPort: "3000"}
- }
-
- // Verify we still have only 2 active tunnels (didn't exceed limit)
- if len(tm.activeTunnels) != 2 {
- t.Errorf("Expected exactly 2 active tunnels after limit enforcement, got %d", len(tm.activeTunnels))
- }
-}
-
-// TestNewTunnelManagerParams tests that NewTunnelManager correctly sets all parameters
-func TestNewTunnelManagerParams(t *testing.T) {
- containerURL := "http://localhost:9090"
- containerSSHHost := "test-ssh-host"
- maxTunnels := 5
-
- tm := NewTunnelManager(containerURL, containerSSHHost, maxTunnels)
-
- if tm.containerURL != containerURL {
- t.Errorf("Expected containerURL %s, got %s", containerURL, tm.containerURL)
- }
- if tm.containerSSHHost != containerSSHHost {
- t.Errorf("Expected containerSSHHost %s, got %s", containerSSHHost, tm.containerSSHHost)
- }
- if tm.maxActiveTunnels != maxTunnels {
- t.Errorf("Expected maxActiveTunnels %d, got %d", maxTunnels, tm.maxActiveTunnels)
- }
- if tm.activeTunnels == nil {
- t.Error("Expected activeTunnels map to be initialized")
- }
- if tm.lastPollTime.IsZero() {
- t.Error("Expected lastPollTime to be initialized")
- }
-}
-
-// TestShouldSkipPort tests the port skipping logic
-func TestShouldSkipPort(t *testing.T) {
- tm := NewTunnelManager("http://localhost:8080", "test-container", 10)
-
- // Test that system ports are skipped
- systemPorts := []string{"22", "80", "443", "25", "53"}
- for _, port := range systemPorts {
- if !tm.shouldSkipPort(port) {
- t.Errorf("Expected port %s to be skipped", port)
- }
- }
-
- // Test that application ports are not skipped
- appPorts := []string{"8080", "3000", "9090", "8000"}
- for _, port := range appPorts {
- if tm.shouldSkipPort(port) {
- t.Errorf("Expected port %s to NOT be skipped", port)
- }
- }
-}
-
-// TestExtractPortNumber tests port number extraction from ss format
-func TestExtractPortNumber(t *testing.T) {
- tm := NewTunnelManager("http://localhost:8080", "test-container", 10)
-
- tests := []struct {
- input string
- expected string
- }{
- {"tcp:0.0.0.0:8080", "8080"},
- {"tcp:127.0.0.1:3000", "3000"},
- {"tcp:[::]:9090", "9090"},
- {"udp:0.0.0.0:53", "53"},
- {"invalid", ""},
- {"", ""},
- }
-
- for _, test := range tests {
- result := tm.extractPortNumber(test.input)
- if result != test.expected {
- t.Errorf("extractPortNumber(%q) = %q, expected %q", test.input, result, test.expected)
- }
- }
-}
-
-// TestTunnelManagerLimitEnforcement tests that createTunnel enforces the max limit
-func TestTunnelManagerLimitEnforcement(t *testing.T) {
- tm := NewTunnelManager("http://localhost:8080", "test-container", 2) // Max 2 tunnels
- ctx := context.Background()
-
- // Add tunnels manually to reach the limit (simulating successful SSH setup)
- // In real usage, these would be added by createTunnel after successful SSH
- tm.activeTunnels["8080"] = &sshTunnel{containerPort: "8080", hostPort: "8080"}
- tm.activeTunnels["9090"] = &sshTunnel{containerPort: "9090", hostPort: "9090"}
-
- // Verify we're now at the limit
- if len(tm.activeTunnels) != 2 {
- t.Fatalf("Setup failed: expected 2 active tunnels, got %d", len(tm.activeTunnels))
- }
-
- // Now test that createTunnel respects the limit by calling it directly
- // This should hit the limit check and return early without attempting SSH
- tm.createTunnel(ctx, "3000")
- tm.createTunnel(ctx, "4000")
-
- // Verify no additional tunnels were added (limit enforcement worked)
- if len(tm.activeTunnels) != 2 {
- t.Errorf("createTunnel should have been blocked by limit, but tunnel count changed from 2 to %d", len(tm.activeTunnels))
- }
-
- // Verify we're at the limit
- if len(tm.activeTunnels) != 2 {
- t.Fatalf("Expected 2 active tunnels, got %d", len(tm.activeTunnels))
- }
-
- // Now try to process more port events that would create additional tunnels
- // These should be blocked by the limit check in createTunnel
- portEvent1 := loop.PortEvent{
- Type: "opened",
- Port: "tcp:0.0.0.0:3000",
- Timestamp: time.Now(),
- }
- portEvent2 := loop.PortEvent{
- Type: "opened",
- Port: "tcp:0.0.0.0:4000",
- Timestamp: time.Now(),
- }
-
- // Process these events - they should be blocked by the limit
- tm.processPortEvent(ctx, portEvent1)
- tm.processPortEvent(ctx, portEvent2)
-
- // Verify that no additional tunnels were created
- if len(tm.activeTunnels) != 2 {
- t.Errorf("Expected exactly 2 active tunnels after limit enforcement, got %d", len(tm.activeTunnels))
- }
-
- // Verify the original tunnels are still there
- if _, exists := tm.activeTunnels["8080"]; !exists {
- t.Error("Expected original tunnel for port 8080 to still exist")
- }
- if _, exists := tm.activeTunnels["9090"]; !exists {
- t.Error("Expected original tunnel for port 9090 to still exist")
- }
-
- // Verify the new tunnels were NOT created
- if _, exists := tm.activeTunnels["3000"]; exists {
- t.Error("Expected tunnel for port 3000 to NOT be created due to limit")
- }
- if _, exists := tm.activeTunnels["4000"]; exists {
- t.Error("Expected tunnel for port 4000 to NOT be created due to limit")
- }
-}
diff --git a/loop/agent.go b/loop/agent.go
index f01e601..8f96924 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -152,8 +152,7 @@
// CompactConversation compacts the current conversation by generating a summary
// and restarting the conversation with that summary as the initial context
CompactConversation(ctx context.Context) error
- // GetPortMonitor returns the port monitor instance for accessing port events
- GetPortMonitor() *PortMonitor
+
// SkabandAddr returns the skaband address if configured
SkabandAddr() string
}
@@ -460,9 +459,6 @@
// Track outstanding tool calls by ID with their names
outstandingToolCalls map[string]string
-
- // Port monitoring
- portMonitor *PortMonitor
}
// NewIterator implements CodingAgent.
@@ -1071,8 +1067,8 @@
stateMachine: NewStateMachine(),
workingDir: config.WorkingDir,
outsideHTTP: config.OutsideHTTP,
- portMonitor: NewPortMonitor(),
- mcpManager: mcp.NewMCPManager(),
+
+ mcpManager: mcp.NewMCPManager(),
}
return agent
}
@@ -1526,12 +1522,6 @@
}
func (a *Agent) Loop(ctxOuter context.Context) {
- // Start port monitoring when the agent loop begins
- // Only monitor ports when running in a container
- if a.IsInContainer() {
- a.portMonitor.Start(ctxOuter)
- }
-
// Set up cleanup when context is done
defer func() {
if a.mcpManager != nil {
@@ -2540,11 +2530,6 @@
return nil
}
-// GetPortMonitor returns the port monitor instance for accessing port events
-func (a *Agent) GetPortMonitor() *PortMonitor {
- return a.portMonitor
-}
-
// SkabandAddr returns the skaband address if configured
func (a *Agent) SkabandAddr() string {
if a.config.SkabandClient != nil {
diff --git a/loop/port_monitor.go b/loop/port_monitor.go
deleted file mode 100644
index 105e1e7..0000000
--- a/loop/port_monitor.go
+++ /dev/null
@@ -1,344 +0,0 @@
-package loop
-
-import (
- "context"
- "fmt"
- "log/slog"
- "net"
- "os"
- "os/exec"
- "strconv"
- "strings"
- "sync"
- "time"
-)
-
-// PortEvent represents a port change event
-type PortEvent struct {
- Type string `json:"type"` // "opened" or "closed"
- Port string `json:"port"` // "proto:address:port" format
- Timestamp time.Time `json:"timestamp"` // when the event occurred
-}
-
-// PortMonitor handles periodic monitoring of listening ports in containers
-type PortMonitor struct {
- mu sync.Mutex // protects following
- lastPorts string // last netstat/ss output for comparison
- events []PortEvent // circular buffer of recent port events
- maxEvents int // maximum events to keep in buffer
-}
-
-// NewPortMonitor creates a new PortMonitor instance
-func NewPortMonitor() *PortMonitor {
- return &PortMonitor{
- maxEvents: 100, // keep last 100 events
- events: make([]PortEvent, 0, 100),
- }
-}
-
-// Start begins periodic port monitoring in a background goroutine
-func (pm *PortMonitor) Start(ctx context.Context) {
- go func() {
- ticker := time.NewTicker(5 * time.Second) // Check every 5 seconds
- defer ticker.Stop()
-
- // Get initial port state
- pm.updatePortState(ctx)
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- pm.updatePortState(ctx)
- }
- }
- }()
-}
-
-// 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 {
- // 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)
- }
-
- pm.mu.Lock()
- defer pm.mu.Unlock()
-
- // Check if ports have changed
- if pm.lastPorts != "" && pm.lastPorts != currentPorts {
- // Ports have changed, log the difference
- slog.InfoContext(ctx, "Container port changes detected",
- slog.String("previous_ports", pm.lastPorts),
- slog.String("current_ports", currentPorts))
-
- // Parse and compare the port lists for more detailed logging
- pm.logPortDifferences(ctx, pm.lastPorts, currentPorts)
- }
-
- pm.lastPorts = currentPorts
-}
-
-// logPortDifferences parses ss output and logs specific port changes
-func (pm *PortMonitor) logPortDifferences(ctx context.Context, oldPorts, newPorts string) {
- oldPortSet := parseSSPorts(oldPorts)
- newPortSet := parseSSPorts(newPorts)
- now := time.Now()
-
- // Find newly opened ports
- for port := range newPortSet {
- if !oldPortSet[port] {
- slog.InfoContext(ctx, "New port detected", slog.String("port", port))
- pm.addEvent(PortEvent{
- Type: "opened",
- Port: port,
- Timestamp: now,
- })
- }
- }
-
- // Find closed ports
- for port := range oldPortSet {
- if !newPortSet[port] {
- slog.InfoContext(ctx, "Port closed", slog.String("port", port))
- pm.addEvent(PortEvent{
- Type: "closed",
- Port: port,
- Timestamp: now,
- })
- }
- }
-}
-
-// addEvent adds a port event to the circular buffer (must be called with mutex held)
-func (pm *PortMonitor) addEvent(event PortEvent) {
- // If buffer is full, remove oldest event
- if len(pm.events) >= pm.maxEvents {
- // Shift all events left by 1 to remove oldest
- copy(pm.events, pm.events[1:])
- pm.events = pm.events[:len(pm.events)-1]
- }
- // Add new event
- pm.events = append(pm.events, event)
-}
-
-// GetRecentEvents returns a copy of recent port events since the given timestamp
-func (pm *PortMonitor) GetRecentEvents(since time.Time) []PortEvent {
- pm.mu.Lock()
- defer pm.mu.Unlock()
-
- // Find events since the given timestamp
- var result []PortEvent
- for _, event := range pm.events {
- if event.Timestamp.After(since) {
- result = append(result, event)
- }
- }
- 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()
- defer pm.mu.Unlock()
-
- // Return a copy of all events
- result := make([]PortEvent, len(pm.events))
- copy(result, pm.events)
- return result
-}
-
-// parseSSPorts extracts listening ports from ss -lntu output
-// Returns a map with "proto:address:port" as keys
-// ss output format: Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
-func parseSSPorts(output string) map[string]bool {
- ports := make(map[string]bool)
- lines := strings.Split(output, "\n")
-
- for _, line := range lines {
- fields := strings.Fields(line)
- if len(fields) < 5 {
- continue
- }
-
- // Skip header line and non-LISTEN states
- if fields[0] == "Netid" || fields[1] != "LISTEN" {
- continue
- }
-
- proto := fields[0]
- localAddr := fields[4] // Local Address:Port
- portKey := fmt.Sprintf("%s:%s", proto, localAddr)
- ports[portKey] = true
- }
-
- 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
-}
diff --git a/loop/port_monitor_test.go b/loop/port_monitor_test.go
deleted file mode 100644
index e0f7ff1..0000000
--- a/loop/port_monitor_test.go
+++ /dev/null
@@ -1,388 +0,0 @@
-package loop
-
-import (
- "context"
- "os"
- "strings"
- "testing"
- "time"
-)
-
-// TestPortMonitoring tests the port monitoring functionality
-func TestPortMonitoring(t *testing.T) {
- // Test with ss output format
- ssOutput := `Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
-tcp LISTEN 0 1024 127.0.0.1:40975 0.0.0.0:*
-tcp LISTEN 0 4096 *:22 *:*
-tcp LISTEN 0 4096 *:80 *:*
-udp UNCONN 0 0 127.0.0.1:123 0.0.0.0:*
-`
-
- expected := map[string]bool{
- "tcp:127.0.0.1:40975": true,
- "tcp:*:22": true,
- "tcp:*:80": true,
- }
-
- result := parseSSPorts(ssOutput)
-
- // Check that all expected ports are found
- for port := range expected {
- if !result[port] {
- t.Errorf("Expected port %s not found in ss parsed output", port)
- }
- }
-
- // Check that UDP port is not included (since it's UNCONN, not LISTEN)
- if result["udp:127.0.0.1:123"] {
- t.Errorf("UDP UNCONN port should not be included in listening ports")
- }
-
- // Check that no extra ports are found
- for port := range result {
- if !expected[port] {
- t.Errorf("Unexpected port %s found in parsed output", port)
- }
- }
-}
-
-// TestPortMonitoringLogDifferences tests the port difference logging
-func TestPortMonitoringLogDifferences(t *testing.T) {
- ctx := context.Background()
-
- oldPorts := `Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
-tcp LISTEN 0 4096 *:22 *:*
-tcp LISTEN 0 1024 127.0.0.1:8080 0.0.0.0:*
-`
-
- newPorts := `Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
-tcp LISTEN 0 4096 *:22 *:*
-tcp LISTEN 0 1024 127.0.0.1:9090 0.0.0.0:*
-`
-
- // Create a port monitor to test the logPortDifferences method
- pm := NewPortMonitor()
-
- // This test mainly ensures the method doesn't panic and processes the differences
- // The actual logging output would need to be captured via a test logger to verify fully
- pm.logPortDifferences(ctx, oldPorts, newPorts)
-
- // Test with no differences
- pm.logPortDifferences(ctx, oldPorts, oldPorts)
-}
-
-// TestPortMonitorCreation tests creating a new port monitor
-func TestPortMonitorCreation(t *testing.T) {
- pm := NewPortMonitor()
- if pm == nil {
- t.Error("NewPortMonitor() returned nil")
- }
-
- // Verify initial state
- pm.mu.Lock()
- defer pm.mu.Unlock()
- if pm.lastPorts != "" {
- t.Error("NewPortMonitor() should have empty lastPorts initially")
- }
-}
-
-// TestParseSSPortsEdgeCases tests edge cases in ss output parsing
-func TestParseSSPortsEdgeCases(t *testing.T) {
- tests := []struct {
- name string
- output string
- expected map[string]bool
- }{
- {
- name: "empty output",
- output: "",
- expected: map[string]bool{},
- },
- {
- name: "header only",
- output: "Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess",
- expected: map[string]bool{},
- },
- {
- name: "non-listen states filtered out",
- output: "tcp ESTAB 0 0 127.0.0.1:8080 127.0.0.1:45678\nudp UNCONN 0 0 127.0.0.1:123 0.0.0.0:*",
- expected: map[string]bool{},
- },
- {
- name: "insufficient fields",
- output: "tcp LISTEN 0",
- expected: map[string]bool{},
- },
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- result := parseSSPorts(test.output)
- if len(result) != len(test.expected) {
- t.Errorf("Expected %d ports, got %d", len(test.expected), len(result))
- }
- for port := range test.expected {
- if !result[port] {
- t.Errorf("Expected port %s not found", port)
- }
- }
- for port := range result {
- if !test.expected[port] {
- t.Errorf("Unexpected port %s found", port)
- }
- }
- })
- }
-}
-
-// TestPortEventStorage tests the new event storage functionality
-func TestPortEventStorage(t *testing.T) {
- pm := NewPortMonitor()
-
- // Initially should have no events
- allEvents := pm.GetAllRecentEvents()
- if len(allEvents) != 0 {
- t.Errorf("Expected 0 events initially, got %d", len(allEvents))
- }
-
- // Simulate port changes that would add events
- ctx := context.Background()
- oldPorts := "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*"
- newPorts := "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:9090 0.0.0.0:*"
-
- pm.logPortDifferences(ctx, oldPorts, newPorts)
-
- // Should now have events
- allEvents = pm.GetAllRecentEvents()
- if len(allEvents) != 2 {
- t.Errorf("Expected 2 events (1 opened, 1 closed), got %d", len(allEvents))
- }
-
- // Check event types
- foundOpened := false
- foundClosed := false
- for _, event := range allEvents {
- if event.Type == "opened" && event.Port == "tcp:0.0.0.0:9090" {
- foundOpened = true
- }
- if event.Type == "closed" && event.Port == "tcp:0.0.0.0:8080" {
- foundClosed = true
- }
- }
-
- if !foundOpened {
- t.Error("Expected to find 'opened' event for port tcp:0.0.0.0:9090")
- }
- if !foundClosed {
- t.Error("Expected to find 'closed' event for port tcp:0.0.0.0:8080")
- }
-}
-
-// TestPortEventFiltering tests the time-based filtering
-func TestPortEventFiltering(t *testing.T) {
- pm := NewPortMonitor()
- ctx := context.Background()
-
- // Record time before adding events
- beforeTime := time.Now()
- time.Sleep(1 * time.Millisecond) // Small delay to ensure timestamp difference
-
- // Add some events
- oldPorts := "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*"
- newPorts := "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:9090 0.0.0.0:*"
- pm.logPortDifferences(ctx, oldPorts, newPorts)
-
- // Get events since beforeTime - should get all events
- recentEvents := pm.GetRecentEvents(beforeTime)
- if len(recentEvents) != 2 {
- t.Errorf("Expected 2 recent events, got %d", len(recentEvents))
- }
-
- // Get events since now - should get no events
- nowTime := time.Now()
- recentEvents = pm.GetRecentEvents(nowTime)
- if len(recentEvents) != 0 {
- t.Errorf("Expected 0 recent events since now, got %d", len(recentEvents))
- }
-}
-
-// TestParseAddress tests the hex address parsing for /proc/net files
-func TestParseAddress(t *testing.T) {
- pm := NewPortMonitor()
-
- tests := []struct {
- name string
- addrPort string
- isIPv6 bool
- expectIP string
- expectPort int
- expectErr bool
- }{
- {
- name: "IPv4 localhost:80",
- addrPort: "0100007F:0050", // 127.0.0.1:80 in little-endian hex
- isIPv6: false,
- expectIP: "127.0.0.1",
- expectPort: 80,
- expectErr: false,
- },
- {
- name: "IPv4 any:22",
- addrPort: "00000000:0016", // 0.0.0.0:22
- isIPv6: false,
- expectIP: "*",
- expectPort: 22,
- expectErr: false,
- },
- {
- name: "IPv4 high port",
- addrPort: "0100007F:1F90", // 127.0.0.1:8080
- isIPv6: false,
- expectIP: "127.0.0.1",
- expectPort: 8080,
- expectErr: false,
- },
- {
- name: "IPv6 any port 22",
- addrPort: "00000000000000000000000000000000:0016", // [::]:22
- isIPv6: true,
- expectIP: "*",
- expectPort: 22,
- expectErr: false,
- },
- {
- name: "Invalid format - no colon",
- addrPort: "0100007F0050",
- isIPv6: false,
- expectErr: true,
- },
- {
- name: "Invalid port hex",
- addrPort: "0100007F:ZZZZ",
- isIPv6: false,
- expectErr: true,
- },
- {
- name: "Invalid IPv4 hex length",
- addrPort: "0100:0050",
- isIPv6: false,
- expectErr: true,
- },
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- ip, port, err := pm.parseAddress(test.addrPort, test.isIPv6)
- if test.expectErr {
- if err == nil {
- t.Errorf("Expected error but got none")
- }
- return
- }
-
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- return
- }
-
- if ip != test.expectIP {
- t.Errorf("Expected IP %s, got %s", test.expectIP, ip)
- }
-
- if port != test.expectPort {
- t.Errorf("Expected port %d, got %d", test.expectPort, port)
- }
- })
- }
-}
-
-// TestParseProcData tests parsing of mock /proc/net data
-func TestParseProcData(t *testing.T) {
- pm := NewPortMonitor()
-
- // Test TCP data with listening sockets
- tcpData := ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
- 0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1
- 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2
- 2: 0100007F:1F90 0200007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 3`
-
- var result strings.Builder
- result.WriteString("Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\n")
-
- // Create temp file with test data
- tmpFile := "/tmp/test_tcp"
- err := os.WriteFile(tmpFile, []byte(tcpData), 0o644)
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile)
-
- err = pm.parseProc(tmpFile, "tcp", &result)
- if err != nil {
- t.Fatalf("parseProc failed: %v", err)
- }
-
- output := result.String()
- t.Logf("Generated output:\n%s", output)
-
- // Should contain listening ports (state 0A = LISTEN)
- if !strings.Contains(output, "127.0.0.1:80") {
- t.Error("Expected to find 127.0.0.1:80 in output")
- }
- if !strings.Contains(output, "*:22") {
- t.Error("Expected to find *:22 in output")
- }
- // Should not contain established connection (state 01)
- if strings.Contains(output, "127.0.0.1:8080") {
- t.Error("Should not find established connection 127.0.0.1:8080 in output")
- }
-}
-
-// TestGetListeningPortsFromProcFallback tests the complete /proc fallback
-func TestGetListeningPortsFromProcFallback(t *testing.T) {
- pm := NewPortMonitor()
-
- // This test verifies the method runs without error
- // The actual files may or may not exist, but it should handle both cases gracefully
- output, err := pm.getListeningPortsFromProc()
- if err != nil {
- t.Logf("getListeningPortsFromProc failed (may be expected if /proc/net files don't exist): %v", err)
- // Don't fail the test - this might be expected in some environments
- return
- }
-
- t.Logf("Generated /proc fallback output:\n%s", output)
-
- // Should at least have a header
- if !strings.Contains(output, "Netid State") {
- t.Error("Expected header in /proc fallback output")
- }
-}
-
-// TestUpdatePortStateWithFallback tests updatePortState with both ss and /proc fallback
-func TestUpdatePortStateWithFallback(t *testing.T) {
- pm := NewPortMonitor()
- ctx := context.Background()
-
- // Call updatePortState - should try ss first, then fall back to /proc if ss fails
- pm.updatePortState(ctx)
-
- // The method should complete without panicking
- // We can't easily test the exact behavior without mocking, but we can ensure it runs
- // Check if any port state was captured
- pm.mu.Lock()
- lastPorts := pm.lastPorts
- pm.mu.Unlock()
-
- t.Logf("Captured port state (length %d):", len(lastPorts))
- if len(lastPorts) > 0 {
- t.Logf("First 200 chars: %s", lastPorts[:min(200, len(lastPorts))])
- }
-}
-
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 7f31401..b6e2259 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -307,44 +307,6 @@
io.WriteString(w, "{}\n")
})
- // Handler for /port-events - returns recent port change events
- s.mux.HandleFunc("/port-events", func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
-
- // Get the 'since' query parameter for filtering events
- sinceParam := r.URL.Query().Get("since")
- var events []loop.PortEvent
-
- // Get port monitor from agent
- portMonitor := agent.GetPortMonitor()
- if portMonitor == nil {
- // Return empty array if port monitor not available
- events = []loop.PortEvent{}
- } else if sinceParam != "" {
- // Parse the since timestamp
- sinceTime, err := time.Parse(time.RFC3339, sinceParam)
- if err != nil {
- http.Error(w, fmt.Sprintf("Invalid 'since' timestamp format: %v", err), http.StatusBadRequest)
- return
- }
- events = portMonitor.GetRecentEvents(sinceTime)
- } else {
- // Return all recent events
- events = portMonitor.GetAllRecentEvents()
- }
-
- // Encode and return the events
- if err := json.NewEncoder(w).Encode(events); err != nil {
- slog.ErrorContext(r.Context(), "Error encoding port events response", slog.Any("err", err))
- http.Error(w, "Internal server error", http.StatusInternalServerError)
- }
- })
-
// Handler for /messages?start=N&end=M (start/end are optional)
s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index adda4e3..f6ec8c7 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -3,7 +3,6 @@
import (
"bufio"
"context"
- "encoding/json"
"net/http"
"net/http/httptest"
"slices"
@@ -261,10 +260,9 @@
m.retryNumber++
}
-func (m *mockAgent) GetPortMonitor() *loop.PortMonitor { return loop.NewPortMonitor() }
-func (m *mockAgent) SkabandAddr() string { return m.skabandAddr }
-func (m *mockAgent) LinkToGitHub() bool { return false }
-func (m *mockAgent) DiffStats() (int, int) { return 0, 0 }
+func (m *mockAgent) SkabandAddr() string { return m.skabandAddr }
+func (m *mockAgent) LinkToGitHub() bool { return false }
+func (m *mockAgent) DiffStats() (int, int) { return 0, 0 }
// TestSSEStream tests the SSE stream endpoint
func TestSSEStream(t *testing.T) {
@@ -512,76 +510,6 @@
t.Log("Mock CompactConversation works correctly")
}
-// TestPortEventsEndpoint tests the /port-events HTTP endpoint
-func TestPortEventsEndpoint(t *testing.T) {
- // Create a mock agent that implements the CodingAgent interface
- agent := &mockAgent{
- branchPrefix: "sketch/",
- }
-
- // Create a server with the mock agent
- server, err := server.New(agent, nil)
- if err != nil {
- t.Fatalf("Failed to create server: %v", err)
- }
-
- // Test GET /port-events
- req, err := http.NewRequest("GET", "/port-events", nil)
- if err != nil {
- t.Fatalf("Failed to create request: %v", err)
- }
-
- rr := httptest.NewRecorder()
- server.ServeHTTP(rr, req)
-
- // Should return 200 OK
- if status := rr.Code; status != http.StatusOK {
- t.Errorf("Expected status code %d, got %d", http.StatusOK, status)
- }
-
- // Should return JSON content type
- contentType := rr.Header().Get("Content-Type")
- if contentType != "application/json" {
- t.Errorf("Expected Content-Type application/json, got %s", contentType)
- }
-
- // Should return valid JSON (empty array since mock returns no events)
- var events []any
- if err := json.Unmarshal(rr.Body.Bytes(), &events); err != nil {
- t.Errorf("Failed to parse JSON response: %v", err)
- }
-
- // Should be empty array for mock agent
- if len(events) != 0 {
- t.Errorf("Expected empty events array, got %d events", len(events))
- }
-}
-
-// TestPortEventsEndpointMethodNotAllowed tests that non-GET requests are rejected
-func TestPortEventsEndpointMethodNotAllowed(t *testing.T) {
- agent := &mockAgent{
- branchPrefix: "sketch/",
- }
- server, err := server.New(agent, nil)
- if err != nil {
- t.Fatalf("Failed to create server: %v", err)
- }
-
- // Test POST /port-events (should be rejected)
- req, err := http.NewRequest("POST", "/port-events", nil)
- if err != nil {
- t.Fatalf("Failed to create request: %v", err)
- }
-
- rr := httptest.NewRecorder()
- server.ServeHTTP(rr, req)
-
- // Should return 405 Method Not Allowed
- if status := rr.Code; status != http.StatusMethodNotAllowed {
- t.Errorf("Expected status code %d, got %d", http.StatusMethodNotAllowed, status)
- }
-}
-
func TestParsePortProxyHost(t *testing.T) {
tests := []struct {
name string