loop: automatic host/container ssh port tunneling

Fix for #47

Add comprehensive port event monitoring and automatic SSH tunnel management
system that enables real-time port forwarding for container services.

Container processes need automatic port forwarding when services start or stop
listening on ports during agent execution. Previously, users had to manually
create SSH tunnels using commands like 'ssh -L8000:localhost:8888 container',
which required manual intervention and knowledge of when ports become available.

- Extended PortMonitor with thread-safe event storage using circular buffer
- Added PortEvent struct with type (opened/closed), port info, and timestamps
- Maintained backward compatibility with existing logging functionality
- Events stored in 100-item circular buffer with efficient timestamp filtering

- Added /port-events endpoint in loophttp.go for container-to-host communication
- Supports optional 'since' query parameter for incremental event fetching
- Returns JSON array of recent port events with proper error handling
- Integrated with existing Agent interface via GetPortMonitor() method

- Created TunnelManager component for host-side tunnel orchestration
- Polls container /port-events endpoint every 10 seconds for new events
- Automatically creates SSH tunnels when ports open using same port numbers
- Properly cleans up tunnels when ports close or context cancels
- Skips common system ports (SSH, HTTP, SMTP) to avoid conflicts

- Integrated TunnelManager into dockerimg.LaunchContainer() workflow
- Starts tunnel manager alongside existing container management goroutines
- Only activates when SSH is available and configured properly
- Uses existing SSH infrastructure and container naming conventions

- Container PortMonitor detects port changes via ss -lntu command
- Events stored with RFC3339 timestamps for precise filtering
- Thread-safe access patterns with dedicated mutex protection
- Circular buffer prevents unbounded memory growth

- RESTful GET /port-events endpoint with time-based filtering
- Proper JSON encoding/decoding with error handling
- Integration with existing HTTP server infrastructure
- Non-blocking polling pattern with configurable intervals

- Uses existing SSH theater configuration and host keys
- Creates tunnels with format: ssh -L hostPort:localhost:containerPort container
- Background monitoring of tunnel processes with automatic cleanup
- Proper context cancellation and resource management

- Added comprehensive port event storage and filtering tests
- HTTP endpoint testing with mock agents and proper status codes
- Verified thread-safe access patterns and circular buffer behavior
- All existing loop package tests continue to pass

- Confirmed HTTP endpoint returns proper JSON responses
- Validated tunnel manager integrates with container launch process
- Verified SSH tunnel creation follows existing authentication patterns
- Build verification confirms no regressions in existing functionality

- Automatic port forwarding eliminates manual SSH tunnel management
- Real-time port detection provides immediate service accessibility
- Transparent integration with existing Sketch container workflow
- Maintains all existing SSH functionality and manual override options

- Clean separation between container monitoring and host tunnel management
- Extensible event-based architecture for future port-related features
- Minimal performance impact with efficient polling and filtering
- Robust error handling and graceful degradation when SSH unavailable

This enhancement provides seamless port forwarding automation while maintaining
the reliability and security of the existing SSH infrastructure, significantly
improving the developer experience when working with containerized services.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s6bc363ed64835e5dk
diff --git a/dockerimg/tunnel_manager_test.go b/dockerimg/tunnel_manager_test.go
new file mode 100644
index 0000000..f430559
--- /dev/null
+++ b/dockerimg/tunnel_manager_test.go
@@ -0,0 +1,196 @@
+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 GetActiveTunnels returns empty initially
+	activeTunnels := tm.GetActiveTunnels()
+	if len(activeTunnels) != 0 {
+		t.Errorf("Expected 0 active tunnels initially, got %d", len(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
+	activeTunnels = tm.GetActiveTunnels()
+	if len(activeTunnels) != 2 {
+		t.Errorf("Expected 2 active tunnels, got %d", len(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)
+	activeTunnels = tm.GetActiveTunnels()
+	if len(activeTunnels) != 2 {
+		t.Errorf("Expected exactly 2 active tunnels after limit enforcement, got %d", len(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.GetActiveTunnels()) != 2 {
+		t.Fatalf("Setup failed: expected 2 active tunnels, got %d", len(tm.GetActiveTunnels()))
+	}
+
+	// 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.GetActiveTunnels()) != 2 {
+		t.Errorf("createTunnel should have been blocked by limit, but tunnel count changed from 2 to %d", len(tm.GetActiveTunnels()))
+	}
+
+	// Verify we're at the limit
+	if len(tm.GetActiveTunnels()) != 2 {
+		t.Fatalf("Expected 2 active tunnels, got %d", len(tm.GetActiveTunnels()))
+	}
+
+	// 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
+	activeTunnels := tm.GetActiveTunnels()
+	if len(activeTunnels) != 2 {
+		t.Errorf("Expected exactly 2 active tunnels after limit enforcement, got %d", len(activeTunnels))
+	}
+
+	// Verify the original tunnels are still there
+	if _, exists := activeTunnels["8080"]; !exists {
+		t.Error("Expected original tunnel for port 8080 to still exist")
+	}
+	if _, exists := activeTunnels["9090"]; !exists {
+		t.Error("Expected original tunnel for port 9090 to still exist")
+	}
+
+	// Verify the new tunnels were NOT created
+	if _, exists := activeTunnels["3000"]; exists {
+		t.Error("Expected tunnel for port 3000 to NOT be created due to limit")
+	}
+	if _, exists := activeTunnels["4000"]; exists {
+		t.Error("Expected tunnel for port 4000 to NOT be created due to limit")
+	}
+}