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/dockerimg.go b/dockerimg/dockerimg.go
index 66fd691..ad18246 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -385,6 +385,16 @@
 		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
new file mode 100644
index 0000000..6cd50ff
--- /dev/null
+++ b/dockerimg/tunnel_manager.go
@@ -0,0 +1,262 @@
+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) {
+		slog.DebugContext(ctx, "Skipping system port", "port", 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()
+		slog.DebugContext(ctx, "Tunnel already exists for port", "port", containerPort)
+		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 {
+		slog.DebugContext(ctx, "No tunnel to remove for port", "port", containerPort)
+		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)
+	}
+}
+
+// GetActiveTunnels returns a list of currently active tunnels
+func (tm *TunnelManager) GetActiveTunnels() map[string]string {
+	tm.mu.Lock()
+	defer tm.mu.Unlock()
+
+	result := make(map[string]string)
+	for containerPort, tunnel := range tm.activeTunnels {
+		result[containerPort] = tunnel.hostPort
+	}
+	return result
+}
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")
+	}
+}