Browse Source

Add dual tunnel support: SSH + Dashboard remote access

Tunnel Architecture:
- SSH Tunnel: ports 50000-59999, forwards localhost:22
- Dashboard Tunnel: ports 60000-65535, forwards localhost:8080
- Both use same SSH key (/etc/beacon/ssh_tunnel_ed25519)
- Server allocates fixed remote_port per device
- Tunnels managed independently (enable/disable)

Implementation Changes:
- Refactor SSHTunnel to be universal (TunnelConfig struct)
- Add DashboardTunnel config section
- Create two tunnel instances with different local ports
- Remove port auto-allocation (use server-assigned ports)
- Remove port reporting endpoints (not needed)
- Simplified tunnel lifecycle management

Config Updates:
- Added dashboard_tunnel section to Config
- Added dashboard_tunnel to ServerConfig
- Both tunnels controlled by server config
- Auto-reconnect with configurable delay

Main Changes:
- Daemon has sshTunnel + dashboardTunnel
- Both started/stopped based on server config
- Config changes trigger tunnel restart
- Proper shutdown handling for both

Benefits:
- SSH access for terminal (via tunnel port 50xxx)
- Dashboard access via browser (via tunnel port 60xxx)
- No VPN needed for behind-NAT devices
- Server tracks device → port mapping
- Admin controls both tunnels independently

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 1 month ago
parent
commit
4e0482407a
4 changed files with 194 additions and 179 deletions
  1. 8 0
      cmd/beacon-daemon/client.go
  2. 15 1
      cmd/beacon-daemon/config.go
  3. 108 33
      cmd/beacon-daemon/main.go
  4. 63 145
      cmd/beacon-daemon/ssh_tunnel.go

+ 8 - 0
cmd/beacon-daemon/client.go

@@ -114,6 +114,14 @@ type ServerConfig struct {
 		RemotePort        int    `json:"remote_port"`
 		KeepaliveInterval int    `json:"keepalive_interval"`
 	} `json:"ssh_tunnel"`
+	DashboardTunnel struct {
+		Enabled           bool   `json:"enabled"`
+		Server            string `json:"server"`
+		Port              int    `json:"port"`
+		User              string `json:"user"`
+		RemotePort        int    `json:"remote_port"`
+		KeepaliveInterval int    `json:"keepalive_interval"`
+	} `json:"dashboard_tunnel"`
 	Dashboard struct {
 		Enabled bool `json:"enabled"`
 	} `json:"dashboard"`

+ 15 - 1
cmd/beacon-daemon/config.go

@@ -33,7 +33,7 @@ type Config struct {
 		BatchIntervalMs int    `json:"batch_interval_ms"`
 	} `json:"wifi"`
 
-	// SSH Tunnel settings
+	// SSH Tunnel settings (for terminal access)
 	SSHTunnel struct {
 		Enabled           bool   `json:"enabled"`
 		Server            string `json:"server"`
@@ -45,6 +45,17 @@ type Config struct {
 		ReconnectDelay    int    `json:"reconnect_delay"`
 	} `json:"ssh_tunnel"`
 
+	// Dashboard Tunnel settings (for web dashboard access)
+	DashboardTunnel struct {
+		Enabled           bool   `json:"enabled"`
+		Server            string `json:"server"`
+		Port              int    `json:"port"`
+		User              string `json:"user"`
+		RemotePort        int    `json:"remote_port"`
+		KeepaliveInterval int    `json:"keepalive_interval"`
+		ReconnectDelay    int    `json:"reconnect_delay"`
+	} `json:"dashboard_tunnel"`
+
 	// Network settings (eth0 is ALWAYS local, never from server)
 	Network struct {
 		NTPServers []string `json:"ntp_servers"`
@@ -90,6 +101,9 @@ func DefaultConfig() *Config {
 	cfg.SSHTunnel.Port = 22
 	cfg.SSHTunnel.KeepaliveInterval = 30
 	cfg.SSHTunnel.ReconnectDelay = 5
+	cfg.DashboardTunnel.Port = 22
+	cfg.DashboardTunnel.KeepaliveInterval = 30
+	cfg.DashboardTunnel.ReconnectDelay = 5
 	cfg.Network.NTPServers = []string{"pool.ntp.org"}
 	cfg.APFallback.Password = "mybeacon"
 	cfg.Dashboard.Enabled = true

+ 108 - 33
cmd/beacon-daemon/main.go

@@ -28,14 +28,15 @@ const (
 )
 
 type Daemon struct {
-	cfg       *Config
-	state     *DeviceState
-	client    *APIClient
-	spooler   *Spooler
-	tunnel    *SSHTunnel
-	scanners  *ScannerManager
-	api       *APIServer
-	netmgr    *NetworkManager
+	cfg             *Config
+	state           *DeviceState
+	client          *APIClient
+	spooler         *Spooler
+	sshTunnel       *SSHTunnel
+	dashboardTunnel *SSHTunnel
+	scanners        *ScannerManager
+	api             *APIServer
+	netmgr          *NetworkManager
 
 	bleEvents  []interface{}
 	wifiEvents []interface{}
@@ -108,8 +109,28 @@ func main() {
 	// Create API client
 	client := NewAPIClient(cfg.APIBase)
 
-	// Create SSH tunnel manager (will be started after registration)
-	tunnel := NewSSHTunnel(cfg, client, state.DeviceID)
+	// Create SSH tunnel managers (will be started when enabled in config)
+	sshTunnelCfg := &TunnelConfig{
+		Enabled:           cfg.SSHTunnel.Enabled,
+		Server:            cfg.SSHTunnel.Server,
+		Port:              cfg.SSHTunnel.Port,
+		User:              cfg.SSHTunnel.User,
+		RemotePort:        cfg.SSHTunnel.RemotePort,
+		KeepaliveInterval: cfg.SSHTunnel.KeepaliveInterval,
+		ReconnectDelay:    cfg.SSHTunnel.ReconnectDelay,
+	}
+	sshTunnel := NewSSHTunnel("ssh", 22, sshTunnelCfg)
+
+	dashboardTunnelCfg := &TunnelConfig{
+		Enabled:           cfg.DashboardTunnel.Enabled,
+		Server:            cfg.DashboardTunnel.Server,
+		Port:              cfg.DashboardTunnel.Port,
+		User:              cfg.DashboardTunnel.User,
+		RemotePort:        cfg.DashboardTunnel.RemotePort,
+		KeepaliveInterval: cfg.DashboardTunnel.KeepaliveInterval,
+		ReconnectDelay:    cfg.DashboardTunnel.ReconnectDelay,
+	}
+	dashboardTunnel := NewSSHTunnel("dashboard", 8080, dashboardTunnelCfg)
 
 	// Create scanner manager
 	scanners := NewScannerManager(*binDir, cfg.Debug)
@@ -119,17 +140,18 @@ func main() {
 
 	// Create daemon
 	daemon := &Daemon{
-		cfg:        cfg,
-		state:      state,
-		client:     client,
-		spooler:    spooler,
-		tunnel:     tunnel,
-		scanners:   scanners,
-		netmgr:     netmgr,
-		configPath: *configPath,
-		statePath:  *statePath,
-		httpAddr:   *httpAddr,
-		stopChan:   make(chan struct{}),
+		cfg:             cfg,
+		state:           state,
+		client:          client,
+		spooler:         spooler,
+		sshTunnel:       sshTunnel,
+		dashboardTunnel: dashboardTunnel,
+		scanners:        scanners,
+		netmgr:          netmgr,
+		configPath:      *configPath,
+		statePath:       *statePath,
+		httpAddr:        *httpAddr,
+		stopChan:        make(chan struct{}),
 	}
 
 	// Create API server
@@ -148,7 +170,8 @@ func main() {
 		log.Println("Shutting down...")
 		daemon.api.Stop()
 		daemon.scanners.StopAll()
-		daemon.tunnel.Stop()
+		daemon.sshTunnel.Stop()
+		daemon.dashboardTunnel.Stop()
 		daemon.netmgr.Stop()
 		close(daemon.stopChan)
 	}()
@@ -172,8 +195,13 @@ func main() {
 	daemon.netmgr.Start()
 
 	// Start SSH tunnel if enabled
-	if cfg.SSHTunnel.Enabled {
-		daemon.tunnel.Start()
+	if cfg.SSHTunnel.Enabled && cfg.SSHTunnel.RemotePort != 0 {
+		daemon.sshTunnel.Start()
+	}
+
+	// Start Dashboard tunnel if enabled
+	if cfg.DashboardTunnel.Enabled && cfg.DashboardTunnel.RemotePort != 0 {
+		daemon.dashboardTunnel.Start()
 	}
 
 	// Start BLE scanner if enabled (not managed by network manager)
@@ -343,10 +371,11 @@ func (d *Daemon) fetchAndApplyConfig() {
 		}
 	}
 	// LAN mode: local settings have priority, we keep what's in d.cfg
-	// Only SSH tunnel comes from server (for remote support)
+	// Only tunnels come from server (for remote support)
 
 	// SSH tunnel ALWAYS from server (for remote support access)
-	sshChanged := d.cfg.SSHTunnel.Enabled != serverCfg.SSHTunnel.Enabled
+	sshChanged := d.cfg.SSHTunnel.Enabled != serverCfg.SSHTunnel.Enabled ||
+		d.cfg.SSHTunnel.RemotePort != serverCfg.SSHTunnel.RemotePort
 	if serverCfg.SSHTunnel.Enabled {
 		d.cfg.SSHTunnel.Enabled = true
 		d.cfg.SSHTunnel.Server = serverCfg.SSHTunnel.Server
@@ -358,7 +387,21 @@ func (d *Daemon) fetchAndApplyConfig() {
 		d.cfg.SSHTunnel.Enabled = false
 	}
 
-	// Dashboard ALWAYS from server (for remote management)
+	// Dashboard tunnel ALWAYS from server (for remote dashboard access)
+	dashboardTunnelChanged := d.cfg.DashboardTunnel.Enabled != serverCfg.DashboardTunnel.Enabled ||
+		d.cfg.DashboardTunnel.RemotePort != serverCfg.DashboardTunnel.RemotePort
+	if serverCfg.DashboardTunnel.Enabled {
+		d.cfg.DashboardTunnel.Enabled = true
+		d.cfg.DashboardTunnel.Server = serverCfg.DashboardTunnel.Server
+		d.cfg.DashboardTunnel.Port = serverCfg.DashboardTunnel.Port
+		d.cfg.DashboardTunnel.User = serverCfg.DashboardTunnel.User
+		d.cfg.DashboardTunnel.RemotePort = serverCfg.DashboardTunnel.RemotePort
+		d.cfg.DashboardTunnel.KeepaliveInterval = serverCfg.DashboardTunnel.KeepaliveInterval
+	} else {
+		d.cfg.DashboardTunnel.Enabled = false
+	}
+
+	// Dashboard HTTP API ALWAYS from server (for remote management)
 	dashboardChanged := d.cfg.Dashboard.Enabled != serverCfg.Dashboard.Enabled
 	d.cfg.Dashboard.Enabled = serverCfg.Dashboard.Enabled
 
@@ -387,15 +430,47 @@ func (d *Daemon) fetchAndApplyConfig() {
 		log.Println("Network Manager will apply changes automatically")
 	}
 
-	// Update tunnel config
-	d.tunnel.UpdateConfig(d.cfg)
+	// Update SSH tunnel config
+	sshTunnelCfg := &TunnelConfig{
+		Enabled:           d.cfg.SSHTunnel.Enabled,
+		Server:            d.cfg.SSHTunnel.Server,
+		Port:              d.cfg.SSHTunnel.Port,
+		User:              d.cfg.SSHTunnel.User,
+		RemotePort:        d.cfg.SSHTunnel.RemotePort,
+		KeepaliveInterval: d.cfg.SSHTunnel.KeepaliveInterval,
+		ReconnectDelay:    d.cfg.SSHTunnel.ReconnectDelay,
+	}
+	d.sshTunnel.UpdateConfig(sshTunnelCfg)
+
 	if sshChanged {
-		if d.cfg.SSHTunnel.Enabled {
-			log.Println("SSH tunnel enabled by server")
-			d.tunnel.Start()
+		if d.cfg.SSHTunnel.Enabled && d.cfg.SSHTunnel.RemotePort != 0 {
+			log.Printf("SSH tunnel enabled by server (port %d)", d.cfg.SSHTunnel.RemotePort)
+			d.sshTunnel.Restart()
 		} else {
 			log.Println("SSH tunnel disabled by server")
-			d.tunnel.Stop()
+			d.sshTunnel.Stop()
+		}
+	}
+
+	// Update Dashboard tunnel config
+	dashboardTunnelCfg := &TunnelConfig{
+		Enabled:           d.cfg.DashboardTunnel.Enabled,
+		Server:            d.cfg.DashboardTunnel.Server,
+		Port:              d.cfg.DashboardTunnel.Port,
+		User:              d.cfg.DashboardTunnel.User,
+		RemotePort:        d.cfg.DashboardTunnel.RemotePort,
+		KeepaliveInterval: d.cfg.DashboardTunnel.KeepaliveInterval,
+		ReconnectDelay:    d.cfg.DashboardTunnel.ReconnectDelay,
+	}
+	d.dashboardTunnel.UpdateConfig(dashboardTunnelCfg)
+
+	if dashboardTunnelChanged {
+		if d.cfg.DashboardTunnel.Enabled && d.cfg.DashboardTunnel.RemotePort != 0 {
+			log.Printf("Dashboard tunnel enabled by server (port %d)", d.cfg.DashboardTunnel.RemotePort)
+			d.dashboardTunnel.Restart()
+		} else {
+			log.Println("Dashboard tunnel disabled by server")
+			d.dashboardTunnel.Stop()
 		}
 	}
 

+ 63 - 145
cmd/beacon-daemon/ssh_tunnel.go

@@ -1,47 +1,55 @@
 package main
 
 import (
-	"bufio"
-	"bytes"
-	"encoding/json"
 	"fmt"
-	"io"
 	"log"
-	"net/http"
 	"os"
 	"os/exec"
-	"regexp"
-	"strconv"
-	"strings"
 	"sync"
 	"time"
 )
 
+// TunnelConfig contains configuration for a specific tunnel
+type TunnelConfig struct {
+	Enabled           bool
+	Server            string
+	Port              int
+	User              string
+	RemotePort        int
+	KeepaliveInterval int
+	ReconnectDelay    int
+}
+
 // SSHTunnel manages reverse SSH tunnel to server
 type SSHTunnel struct {
-	cfg           *Config
-	client        *APIClient
-	deviceID      string
-	cmd           *exec.Cmd
-	stopChan      chan struct{}
-	allocatedPort int
-	mu            sync.Mutex
+	name      string // "ssh" or "dashboard"
+	localPort int    // Local port to forward (22 for SSH, 8080 for dashboard)
+
+	cfg       *TunnelConfig
+	cmd       *exec.Cmd
+	stopChan  chan struct{}
+	mu        sync.Mutex
 }
 
 // NewSSHTunnel creates a new SSH tunnel manager
-func NewSSHTunnel(cfg *Config, client *APIClient, deviceID string) *SSHTunnel {
+func NewSSHTunnel(name string, localPort int, cfg *TunnelConfig) *SSHTunnel {
 	return &SSHTunnel{
-		cfg:      cfg,
-		client:   client,
-		deviceID: deviceID,
-		stopChan: make(chan struct{}),
+		name:      name,
+		localPort: localPort,
+		cfg:       cfg,
+		stopChan:  make(chan struct{}),
 	}
 }
 
 // Start initiates the SSH tunnel
 func (t *SSHTunnel) Start() error {
-	if !t.cfg.SSHTunnel.Enabled {
-		log.Println("[tunnel] SSH tunnel disabled")
+	if !t.cfg.Enabled {
+		log.Printf("[%s-tunnel] Tunnel disabled", t.name)
+		return nil
+	}
+
+	if t.cfg.RemotePort == 0 {
+		log.Printf("[%s-tunnel] Remote port not allocated yet, waiting...", t.name)
 		return nil
 	}
 
@@ -52,36 +60,29 @@ func (t *SSHTunnel) Start() error {
 		return fmt.Errorf("SSH key not found: %s", keyPath)
 	}
 
+	// Build reverse tunnel string: remote_port:localhost:local_port
+	reverseSpec := fmt.Sprintf("%d:localhost:%d", t.cfg.RemotePort, t.localPort)
+
 	args := []string{
 		"-N", // No command execution
-		"-v", // Verbose (to parse allocated port from stderr)
-		"-R", "0:localhost:22", // Reverse tunnel with auto-allocated port
-		"-o", fmt.Sprintf("ServerAliveInterval=%d", t.cfg.SSHTunnel.KeepaliveInterval),
+		"-R", reverseSpec, // Reverse tunnel with fixed port
+		"-o", fmt.Sprintf("ServerAliveInterval=%d", t.cfg.KeepaliveInterval),
 		"-o", "ServerAliveCountMax=3",
 		"-o", "ExitOnForwardFailure=yes",
 		"-o", "StrictHostKeyChecking=accept-new",
 		"-i", keyPath,
-		"-p", fmt.Sprintf("%d", t.cfg.SSHTunnel.Port),
-		fmt.Sprintf("%s@%s", t.cfg.SSHTunnel.User, t.cfg.SSHTunnel.Server),
+		"-p", fmt.Sprintf("%d", t.cfg.Port),
+		fmt.Sprintf("%s@%s", t.cfg.User, t.cfg.Server),
 	}
 
 	t.cmd = exec.Command("ssh", args...)
 
-	// Capture stderr to parse allocated port
-	stderr, err := t.cmd.StderrPipe()
-	if err != nil {
-		return err
-	}
-
 	if err := t.cmd.Start(); err != nil {
 		return err
 	}
 
-	log.Printf("[tunnel] SSH tunnel started (server=%s:%d, user=%s)",
-		t.cfg.SSHTunnel.Server, t.cfg.SSHTunnel.Port, t.cfg.SSHTunnel.User)
-
-	// Parse stderr for allocated port
-	go t.parseStderr(stderr)
+	log.Printf("[%s-tunnel] Started: %s:%d -> localhost:%d (remote_port=%d)",
+		t.name, t.cfg.Server, t.cfg.Port, t.localPort, t.cfg.RemotePort)
 
 	// Monitor process
 	go t.monitor()
@@ -89,148 +90,65 @@ func (t *SSHTunnel) Start() error {
 	return nil
 }
 
-// parseStderr reads SSH debug output and extracts allocated port
-func (t *SSHTunnel) parseStderr(r io.Reader) {
-	scanner := bufio.NewScanner(r)
-	for scanner.Scan() {
-		line := scanner.Text()
-
-		// Log all SSH debug output
-		if strings.Contains(line, "debug") {
-			log.Printf("[tunnel] %s", line)
-		}
-
-		// Parse allocated port
-		// Example: "Allocated port 12345 for remote forward to localhost:22"
-		if strings.Contains(line, "Allocated port") {
-			re := regexp.MustCompile(`Allocated port (\d+)`)
-			if matches := re.FindStringSubmatch(line); len(matches) > 1 {
-				port, _ := strconv.Atoi(matches[1])
-				t.mu.Lock()
-				t.allocatedPort = port
-				t.mu.Unlock()
-
-				log.Printf("[tunnel] Allocated port: %d", port)
-
-				// Report to server
-				go t.reportPort(port)
-			}
-		}
-	}
-}
-
-// reportPort sends allocated port to server
-func (t *SSHTunnel) reportPort(port int) {
-	body, err := json.Marshal(map[string]interface{}{
-		"device_id": t.deviceID,
-		"port":      port,
-		"status":    "connected",
-	})
-	if err != nil {
-		log.Printf("[tunnel] Failed to marshal port report: %v", err)
-		return
-	}
-
-	req, err := http.NewRequest("POST", t.client.baseURL+"/tunnel-port", bytes.NewReader(body))
-	if err != nil {
-		log.Printf("[tunnel] Failed to create request: %v", err)
-		return
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", "Bearer "+t.client.token)
-
-	resp, err := t.client.httpClient.Do(req)
-	if err != nil {
-		log.Printf("[tunnel] Failed to report port: %v", err)
-		return
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != 200 && resp.StatusCode != 201 {
-		respBody, _ := io.ReadAll(resp.Body)
-		log.Printf("[tunnel] Failed to report port (status=%d): %s", resp.StatusCode, string(respBody))
-		return
-	}
-
-	log.Printf("[tunnel] Port %d reported to server", port)
-}
-
 // monitor watches SSH process and handles reconnection
 func (t *SSHTunnel) monitor() {
 	err := t.cmd.Wait()
 
-	// Clear allocated port
-	t.mu.Lock()
-	t.allocatedPort = 0
-	t.mu.Unlock()
-
 	if err != nil {
-		log.Printf("[tunnel] SSH tunnel exited with error: %v", err)
+		log.Printf("[%s-tunnel] Tunnel exited with error: %v", t.name, err)
 	} else {
-		log.Println("[tunnel] SSH tunnel exited")
+		log.Printf("[%s-tunnel] Tunnel exited", t.name)
 	}
 
-	// Report disconnection
-	go t.reportDisconnected()
-
 	// Auto-reconnect after delay
 	select {
 	case <-t.stopChan:
-		log.Println("[tunnel] Tunnel stopped, not reconnecting")
+		log.Printf("[%s-tunnel] Stopped, not reconnecting", t.name)
 		return
-	case <-time.After(time.Duration(t.cfg.SSHTunnel.ReconnectDelay) * time.Second):
-		log.Printf("[tunnel] Reconnecting in %ds...", t.cfg.SSHTunnel.ReconnectDelay)
+	case <-time.After(time.Duration(t.cfg.ReconnectDelay) * time.Second):
+		log.Printf("[%s-tunnel] Reconnecting in %ds...", t.name, t.cfg.ReconnectDelay)
 		if err := t.Start(); err != nil {
-			log.Printf("[tunnel] Reconnect failed: %v", err)
+			log.Printf("[%s-tunnel] Reconnect failed: %v", t.name, err)
 			// Will retry after another delay via monitor()
 		}
 	}
 }
 
-// reportDisconnected notifies server that tunnel is down
-func (t *SSHTunnel) reportDisconnected() {
-	body, _ := json.Marshal(map[string]interface{}{
-		"device_id": t.deviceID,
-		"status":    "disconnected",
-	})
+// Stop terminates the SSH tunnel
+func (t *SSHTunnel) Stop() {
+	log.Printf("[%s-tunnel] Stopping...", t.name)
 
-	req, _ := http.NewRequest("POST", t.client.baseURL+"/tunnel-port", bytes.NewReader(body))
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", "Bearer "+t.client.token)
+	t.mu.Lock()
+	defer t.mu.Unlock()
 
-	resp, err := t.client.httpClient.Do(req)
-	if err == nil {
-		resp.Body.Close()
+	select {
+	case <-t.stopChan:
+		// Already closed
+		return
+	default:
+		close(t.stopChan)
 	}
-}
-
-// Stop terminates the SSH tunnel
-func (t *SSHTunnel) Stop() {
-	log.Println("[tunnel] Stopping SSH tunnel...")
-	close(t.stopChan)
 
 	if t.cmd != nil && t.cmd.Process != nil {
 		t.cmd.Process.Kill()
 	}
 }
 
-// GetAllocatedPort returns currently allocated port
-func (t *SSHTunnel) GetAllocatedPort() int {
-	t.mu.Lock()
-	defer t.mu.Unlock()
-	return t.allocatedPort
-}
-
 // Restart restarts the tunnel (useful when config changes)
 func (t *SSHTunnel) Restart() error {
 	t.Stop()
 	time.Sleep(2 * time.Second)
+
+	// Recreate stop channel
+	t.mu.Lock()
+	t.stopChan = make(chan struct{})
+	t.mu.Unlock()
+
 	return t.Start()
 }
 
 // UpdateConfig updates the tunnel configuration
-func (t *SSHTunnel) UpdateConfig(cfg *Config) {
+func (t *SSHTunnel) UpdateConfig(cfg *TunnelConfig) {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 	t.cfg = cfg