REVERSE_SHELL.md 36 KB

Reverse SSH Tunnels для удалённого доступа

Система поддерживает два независимых reverse туннеля для доступа к устройствам за NAT:

  1. SSH Tunnel (порты 50000-59999) - доступ к терминалу устройства (localhost:22)
  2. Dashboard Tunnel (порты 60000-65535) - доступ к веб-дашборду (localhost:80)

Оба туннеля управляются серверной конфигурацией и используют один ED25519 SSH ключ.

Архитектура

┌─────────────────────────────────────────────────────────────────┐
│                          SERVER                                 │
│                                                                 │
│  sshd (port 22) ───────────> main SSH (admin access)           │
│                                                                 │
│  Tunnel Access:                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ SSH Tunnel Ports (50000-59999)                           │  │
│  │   ssh -p {allocated_port} root@server                    │  │
│  │   → устройство localhost:22 (терминал)                   │  │
│  │                                                           │  │
│  │ Dashboard Tunnel Ports (60000-65535)                     │  │
│  │   http://server:{allocated_port}/                        │  │
│  │   → устройство localhost:80 (веб-интерфейс)             │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  /home/tunnel/.ssh/authorized_keys                             │
│    (device ED25519 pubkeys)                                    │
│                                                                 │
│  Port Allocation:                                              │
│  ┌────────────────────────────────────┐                        │
│  │ Device: 38:54:39:4b:1b:ac          │                        │
│  │ - SSH Tunnel Port: 50000           │                        │
│  │ - Dashboard Tunnel Port: 60000     │                        │
│  │ Status: Connected                  │                        │
│  └────────────────────────────────────┘                        │
│                                                                 │
│  SSH Config (/etc/ssh/sshd_config):                            │
│  - AllowTcpForwarding yes                                      │
│  - GatewayPorts yes                                            │
│  - PermitTunnel yes                                            │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                          DEVICE                                 │
│                                                                 │
│  Registration:                                                 │
│    1. Generate ED25519 key pair                                │
│    2. POST /api/v1/registration                                │
│       {                                                        │
│         "device_id": "38:54:39:4b:1b:ac",                     │
│         "ssh_public_key": "ssh-ed25519 AAAAC3..."            │
│       }                                                        │
│    3. Save private key to /opt/mybeacon/etc/tunnel_key        │
│       (permissions: 0600, owner: root)                        │
│                                                                 │
│  SSH Tunnel (when enabled via server config):                 │
│    # Туннель 1: SSH доступ (терминал)                         │
│    ssh -N -R {ssh_remote_port}:localhost:22 \                 │
│        -p 22 \                                                 │
│        -o ServerAliveInterval=30 \                             │
│        -o ServerAliveCountMax=3 \                              │
│        -o ExitOnForwardFailure=yes \                           │
│        -i /etc/beacon/ssh_tunnel_ed25519 \                     │
│        tunnel@server.com                                       │
│                                                                 │
│  Dashboard Tunnel (when enabled via server config):           │
│    # Туннель 2: Dashboard доступ (веб-интерфейс)              │
│    ssh -N -R {dashboard_remote_port}:localhost:80 \           │
│        -p 22 \                                                 │
│        -o ServerAliveInterval=30 \                             │
│        -o ServerAliveCountMax=3 \                              │
│        -o ExitOnForwardFailure=yes \                           │
│        -i /etc/beacon/ssh_tunnel_ed25519 \                     │
│        tunnel@server.com                                       │
│                                                                 │
│  Порты выделяются сервером (фиксированные):                   │
│    - ssh_remote_port: 50000-59999                              │
│    - dashboard_remote_port: 60000-65535                        │
│                                                                 │
│  Auto-reconnect on disconnect (ReconnectDelay: 5s)            │
│  Max session lifetime: 24 hours                               │
└─────────────────────────────────────────────────────────────────┘

Безопасность

Принципы

  1. Минимальные привилегии: Пользователь tunnel на сервере может ТОЛЬКО форвардить порты, никаких shell команд
  2. Ключи вместо паролей: ED25519 ключи (более безопасные чем RSA, компактные)
  3. Изоляция: Отдельный sshd процесс на порту 2222
  4. Временные сессии: SSH сессии через браузер имеют TTL (1 час)
  5. Аудит: Все SSH сессии логируются (device_id, admin, timestamp)
  6. Ограниченный доступ: authorized_keys с принудительными restrictions

Worst Case сценарий

Если злоумышленник:

  • Вытащит eMMC из устройства
  • Прочитает /opt/mybeacon/etc/tunnel_key
  • Подключится к серверу

Он сможет:

  • НЕ сможет получить shell на сервере (ForceCommand, PermitTTY no)
  • НЕ сможет форвардить на другие хосты (PermitOpen localhost:*)
  • Сможет создать reverse tunnel на свой localhost:22

Сервер не пострадает. Злоумышленник откроет только свой локальный SSH порт.

Реализация на устройстве

1. Генерация SSH ключа

Файл: cmd/beacon-daemon/client.go

package main

import (
    "crypto/ed25519"
    "crypto/rand"
    "encoding/pem"
    "golang.org/x/crypto/ssh"
)

// GenerateSSHKeyPair generates ED25519 SSH key pair for tunnel
func GenerateSSHKeyPair() (privateKeyPEM, publicKeySSH string, err error) {
    // Generate ED25519 key (more secure and compact than RSA)
    pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
    if err != nil {
        return "", "", err
    }

    // Encode private key (OpenSSH format)
    privPEM, err := ssh.MarshalPrivateKey(privKey, "")
    if err != nil {
        return "", "", err
    }
    privateKeyPEM = string(pem.EncodeToMemory(privPEM))

    // Encode public key (OpenSSH authorized_keys format)
    sshPubKey, err := ssh.NewPublicKey(pubKey)
    if err != nil {
        return "", "", err
    }
    publicKeySSH = string(ssh.MarshalAuthorizedKey(sshPubKey))

    return privateKeyPEM, publicKeySSH, nil
}

2. Обновить структуру регистрации

Файл: cmd/beacon-daemon/client.go

type RegistrationRequest struct {
    DeviceID     string  `json:"device_id"`
    EthIP        *string `json:"eth_ip,omitempty"`
    WlanIP       *string `json:"wlan_ip,omitempty"`
    SSHPublicKey string  `json:"ssh_public_key"` // Новое поле
}

3. Генерация и сохранение ключа при регистрации

Файл: cmd/beacon-daemon/main.go

В функции где происходит регистрация:

// Check if already registered
state, err := LoadDeviceState(statePath)
if err != nil {
    log.Fatalf("Failed to load device state: %v", err)
}

if state.DeviceToken == "" {
    log.Println("Device not registered, registering...")

    // Generate SSH key pair for tunnel
    privKey, pubKey, err := GenerateSSHKeyPair()
    if err != nil {
        log.Fatalf("Failed to generate SSH key: %v", err)
    }

    // Save private key
    keyPath := filepath.Join(filepath.Dir(configPath), "tunnel_key")
    if err := os.WriteFile(keyPath, []byte(privKey), 0600); err != nil {
        log.Fatalf("Failed to save tunnel key: %v", err)
    }
    log.Printf("SSH tunnel key saved to %s", keyPath)

    // Register with server
    ethIP := getInterfaceIP("eth0")
    wlanIP := getInterfaceIP("wlan0")

    regResp, err := client.Register(&RegistrationRequest{
        DeviceID:     deviceID,
        EthIP:        &ethIP,
        WlanIP:       &wlanIP,
        SSHPublicKey: pubKey,
    })
    if err != nil {
        log.Fatalf("Registration failed: %v", err)
    }

    // Save credentials
    state.DeviceID = deviceID
    state.DeviceToken = regResp.DeviceToken
    state.DevicePassword = regResp.DevicePassword

    if err := SaveDeviceState(statePath, state); err != nil {
        log.Fatalf("Failed to save device state: %v", err)
    }

    log.Printf("Device registered successfully: %s", deviceID)
}

4. SSH Tunnel Manager

Файл: cmd/beacon-daemon/ssh_tunnel.go

package main

import (
    "bufio"
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "os/exec"
    "regexp"
    "strconv"
    "strings"
    "sync"
    "time"
)

type SSHTunnel struct {
    cfg           *Config
    client        *APIClient
    deviceID      string
    cmd           *exec.Cmd
    stopChan      chan struct{}
    allocatedPort int
    mu            sync.Mutex
}

func NewSSHTunnel(cfg *Config, client *APIClient, deviceID string) *SSHTunnel {
    return &SSHTunnel{
        cfg:      cfg,
        client:   client,
        deviceID: deviceID,
        stopChan: make(chan struct{}),
    }
}

func (t *SSHTunnel) Start() error {
    if !t.cfg.SSHTunnel.Enabled {
        log.Println("[tunnel] SSH tunnel disabled")
        return nil
    }

    keyPath := t.cfg.SSHTunnel.KeyPath
    if keyPath == "" {
        keyPath = "/opt/mybeacon/etc/tunnel_key"
    }

    // Verify key exists
    if _, err := os.Stat(keyPath); os.IsNotExist(err) {
        return fmt.Errorf("SSH key not found: %s", keyPath)
    }

    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),
        "-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),
    }

    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)

    // Monitor process
    go t.monitor()

    return nil
}

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

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

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)
    } else {
        log.Println("[tunnel] SSH tunnel exited")
    }

    // Report disconnection
    go t.reportDisconnected()

    // Auto-reconnect after delay
    select {
    case <-t.stopChan:
        log.Println("[tunnel] Tunnel stopped, not reconnecting")
        return
    case <-time.After(time.Duration(t.cfg.SSHTunnel.ReconnectDelay) * time.Second):
        log.Printf("[tunnel] Reconnecting in %ds...", t.cfg.SSHTunnel.ReconnectDelay)
        if err := t.Start(); err != nil {
            log.Printf("[tunnel] Reconnect failed: %v", err)
            // Will retry after another delay via monitor()
        }
    }
}

func (t *SSHTunnel) reportDisconnected() {
    body, _ := json.Marshal(map[string]interface{}{
        "device_id": t.deviceID,
        "status":    "disconnected",
    })

    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)

    resp, err := t.client.httpClient.Do(req)
    if err == nil {
        resp.Body.Close()
    }
}

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()
    }
}

func (t *SSHTunnel) GetAllocatedPort() int {
    t.mu.Lock()
    defer t.mu.Unlock()
    return t.allocatedPort
}

5. Интеграция в main.go

// В main()
tunnel := NewSSHTunnel(cfg, client, state.DeviceID)

// Start tunnel if enabled
if cfg.SSHTunnel.Enabled {
    if err := tunnel.Start(); err != nil {
        log.Printf("Failed to start SSH tunnel: %v", err)
    }
}

// В конце main(), перед <-stopChan
defer tunnel.Stop()

6. Обновление конфига при изменении ssh_tunnel

В fetchAndApplyConfig():

// SSH Tunnel (ALWAYS from server)
tunnelChanged := d.cfg.SSHTunnel.Enabled != serverCfg.SSHTunnel.Enabled ||
    d.cfg.SSHTunnel.Server != serverCfg.SSHTunnel.Server ||
    d.cfg.SSHTunnel.Port != serverCfg.SSHTunnel.Port

d.cfg.SSHTunnel.Enabled = serverCfg.SSHTunnel.Enabled
d.cfg.SSHTunnel.Server = serverCfg.SSHTunnel.Server
d.cfg.SSHTunnel.Port = serverCfg.SSHTunnel.Port
d.cfg.SSHTunnel.User = serverCfg.SSHTunnel.User
d.cfg.SSHTunnel.RemotePort = serverCfg.SSHTunnel.RemotePort
d.cfg.SSHTunnel.KeepaliveInterval = serverCfg.SSHTunnel.KeepaliveInterval

if tunnelChanged {
    if serverCfg.SSHTunnel.Enabled {
        log.Println("[config] SSH tunnel enabled - starting")
        d.tunnel.Stop()
        time.Sleep(500 * time.Millisecond)
        if err := d.tunnel.Start(); err != nil {
            log.Printf("[config] Failed to start tunnel: %v", err)
        }
    } else {
        log.Println("[config] SSH tunnel disabled - stopping")
        d.tunnel.Stop()
    }
}

Серверная конфигурация

1. Создание пользователя tunnel

# На сервере
sudo useradd -m -d /home/tunnel -s /bin/sh tunnel
sudo mkdir -p /home/tunnel/.ssh
sudo chmod 700 /home/tunnel/.ssh
sudo touch /home/tunnel/.ssh/authorized_keys
sudo chmod 600 /home/tunnel/.ssh/authorized_keys
sudo chown -R tunnel:tunnel /home/tunnel/.ssh

ВАЖНО: Используйте /bin/sh вместо /bin/false или /usr/sbin/nologin - иначе SSH сессия будет немедленно закрываться!

2. Настройка SSH сервера

Добавьте следующие настройки в /etc/ssh/sshd_config:

# Разрешить TCP forwarding для туннелей
AllowTcpForwarding yes

# Разрешить GatewayPorts для доступа извне (не только localhost)
GatewayPorts yes

# Разрешить туннели
PermitTunnel yes

Перезапустите SSH сервер:

sudo systemctl restart sshd

Объяснение:

  • AllowTcpForwarding yes - разрешает reverse SSH tunnels
  • GatewayPorts yes - разрешает bind на 0.0.0.0 (доступ извне), а не только на 127.0.0.1
  • PermitTunnel yes - разрешает туннелирование

3. Firewall правила

# Разрешить порты 50000-59999 для SSH Tunnel
sudo ufw allow 50000:59999/tcp comment "MyBeacon SSH Tunnel"

# Разрешить порты 60000-65535 для Dashboard Tunnel
sudo ufw allow 60000:65535/tcp comment "MyBeacon Dashboard Tunnel"

# Или для iptables
sudo iptables -A INPUT -p tcp --dport 50000:59999 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 60000:65535 -j ACCEPT

Серверное API

Database Schema

# Tunnel status (in-memory or Redis)
tunnel_status = {
    "device_id": str,              # "38:54:39:4b:1b:ac"
    "allocated_port": int,         # 12345
    "status": str,                 # "connected" | "disconnected"
    "connected_at": datetime,
    "last_heartbeat": datetime
}

# SSH sessions (in-memory with TTL or Redis)
ssh_sessions = {
    "uuid": str,                   # "550e8400-e29b-41d4-a716-446655440000"
    "device_id": str,
    "admin_user": str,
    "created_at": datetime,
    "expires_at": datetime,        # TTL: 1 hour
    "ttyd_port": int,              # 45678
    "ttyd_pid": int,
    "accessed": bool,              # True after first access
    "device_tunnel_port": int      # 12345 (from tunnel_status)
}

# Audit log (persistent database)
ssh_audit_log = {
    "id": int,
    "session_uuid": str,
    "device_id": str,
    "admin_user": str,
    "started_at": datetime,
    "ended_at": datetime,
    "duration_seconds": int,
    "commands_executed": list[str],  # Optional (requires ttyd logging)
    "client_ip": str
}

API Endpoints

POST /api/v1/registration

Добавить поле ssh_public_key в request и сохранить его в /home/tunnel/.ssh/authorized_keys с ограничениями.

POST /api/v1/tunnel-port

Устройство отправляет allocated port после подключения туннеля.

POST /admin/devices/{device_id}/ssh

Создаёт временную сессию с UUID, запускает ttyd, возвращает URL.

GET /admin/ssh/{session_uuid}

Отображает терминал в браузере (iframe с ttyd).

Lifecycle Management и Auto-cleanup

Отслеживание активности сессии

SSH туннель на устройстве остаётся активным постоянно (пока ssh_tunnel.enabled: true). Сервер управляет только веб-терминалом (ttyd).

Схема:

┌─────────────────────────────────────────────────────────────┐
│                   Browser (Admin)                           │
│                                                             │
│  /admin/ssh/{uuid}                                          │
│    │                                                        │
│    ├──> iframe: ttyd                                        │
│    │                                                        │
│    └──> WebSocket heartbeat (каждые 30 сек)               │
│          POST /admin/ssh/{uuid}/heartbeat                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────┐
│                      Server                                 │
│                                                             │
│  Session tracking:                                          │
│  - last_heartbeat: timestamp                                │
│  - ttyd_pid: process ID                                     │
│                                                             │
│  Background task (каждые 5 минут):                         │
│    1. Проверить last_heartbeat                             │
│    2. Если > 60 минут → kill ttyd                          │
│    3. Обновить audit log                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

События и действия

Событие Действие SSH Tunnel ttyd Audit Log
Открыта вкладка Запустить ttyd Остаётся Запущен started_at
Heartbeat получен Обновить last_heartbeat Остаётся Активен -
Закрыта вкладка - Остаётся Kill через 60 сек* ended_at
60 мин без heartbeat Kill ttyd Остаётся Убит ended_at, duration
Session UUID expired (1 час) Kill ttyd Остаётся Убит ended_at

* Grace period: После последнего heartbeat ждём 60 секунд перед убийством ttyd (пользователь может перезагрузить страницу)

Полное отключение SSH туннеля

Если нужно полностью отключить туннель на устройстве:

# Через server config
device_config = {
    "ssh_tunnel": {
        "enabled": False  # Устройство отключит туннель при следующем polling (≤30 сек)
    }
}

Устройство при получении ssh_tunnel.enabled: false:

  1. Закроет SSH процесс
  2. Отправит POST /api/v1/tunnel-port { "status": "disconnected" }
  3. Больше не будет переподключаться

API для heartbeat

@app.post("/admin/ssh/{session_uuid}/heartbeat")
async def session_heartbeat(session_uuid: str):
    """
    Browser sends heartbeat every 30 seconds
    """
    session = ssh_sessions.get(session_uuid)
    if not session:
        return JSONResponse({"error": "Session not found"}, status_code=404)

    # Update last activity
    session["last_heartbeat"] = datetime.now()

    return {"success": True}

Background cleanup task

async def cleanup_inactive_sessions():
    """
    Run every 5 minutes:
    - Kill ttyd processes with no heartbeat for 60 minutes
    - Remove expired sessions
    """
    while True:
        await asyncio.sleep(300)  # 5 minutes

        now = datetime.now()
        inactive_threshold = now - timedelta(minutes=60)
        grace_period = now - timedelta(seconds=60)

        for session_uuid, session in list(ssh_sessions.items()):
            # Check expiration (hard limit: 1 hour)
            if now > session["expires_at"]:
                log.info(f"Session expired: {session_uuid}")
                kill_ttyd(session["ttyd_pid"])
                update_audit_log(session_uuid, "expired")
                del ssh_sessions[session_uuid]
                continue

            # Check inactivity (60 minutes without heartbeat)
            last_hb = session.get("last_heartbeat")
            if last_hb and last_hb < inactive_threshold:
                log.info(f"Session inactive for 60 min: {session_uuid}")
                kill_ttyd(session["ttyd_pid"])
                update_audit_log(session_uuid, "inactive")
                del ssh_sessions[session_uuid]
                continue

            # Grace period: if tab closed, wait 60 seconds before killing
            # (allows page reload without killing session)
            if last_hb and last_hb < grace_period:
                # Check if ttyd process still alive (tab might be closed)
                if not is_process_alive(session["ttyd_pid"]):
                    log.info(f"ttyd process dead: {session_uuid}")
                    update_audit_log(session_uuid, "closed")
                    del ssh_sessions[session_uuid]

def kill_ttyd(pid: int):
    """Kill ttyd process gracefully"""
    try:
        os.kill(pid, signal.SIGTERM)
        log.info(f"Killed ttyd process {pid}")
    except ProcessLookupError:
        pass

def is_process_alive(pid: int) -> bool:
    """Check if process is running"""
    try:
        os.kill(pid, 0)  # Signal 0 = check existence
        return True
    except ProcessLookupError:
        return False

def update_audit_log(session_uuid: str, reason: str):
    """Update audit log with end time and reason"""
    for entry in ssh_audit_log:
        if entry["session_uuid"] == session_uuid and not entry.get("ended_at"):
            entry["ended_at"] = datetime.now()
            entry["duration_seconds"] = (entry["ended_at"] - entry["started_at"]).total_seconds()
            entry["end_reason"] = reason  # "expired" | "inactive" | "closed"
            break

Frontend (browser) heartbeat

// В iframe странице /admin/ssh/{uuid}
let heartbeatInterval;

function startHeartbeat(sessionUuid) {
  // Send heartbeat every 30 seconds
  heartbeatInterval = setInterval(async () => {
    try {
      await fetch(`/admin/ssh/${sessionUuid}/heartbeat`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${adminToken}`
        }
      });
      console.log('Heartbeat sent');
    } catch (err) {
      console.error('Heartbeat failed:', err);
    }
  }, 30000);  // 30 seconds
}

function stopHeartbeat() {
  if (heartbeatInterval) {
    clearInterval(heartbeatInterval);
  }
}

// Start on page load
window.addEventListener('load', () => {
  startHeartbeat(SESSION_UUID);
});

// Stop on page unload
window.addEventListener('beforeunload', () => {
  stopHeartbeat();
  // Note: можно попробовать отправить final heartbeat с флагом closed,
  // но это не гарантировано (браузер может не дождаться ответа)
});

Monitoring и метрики

# Статистика использования туннелей
@app.get("/admin/stats/ssh-sessions")
async def ssh_session_stats():
    """
    SSH session statistics
    """
    total_sessions = len(ssh_audit_log)
    active_sessions = len(ssh_sessions)

    # Calculate average duration
    durations = [
        log["duration_seconds"]
        for log in ssh_audit_log
        if log.get("duration_seconds")
    ]
    avg_duration = sum(durations) / len(durations) if durations else 0

    # Sessions by device
    sessions_by_device = {}
    for log in ssh_audit_log:
        device_id = log["device_id"]
        sessions_by_device[device_id] = sessions_by_device.get(device_id, 0) + 1

    return {
        "total_sessions": total_sessions,
        "active_sessions": active_sessions,
        "avg_duration_minutes": round(avg_duration / 60, 1),
        "sessions_by_device": sessions_by_device
    }

Security Checklist

  • Tunnel user has no shell (-s /bin/false)
  • authorized_keys with forced command restriction
  • Separate sshd on port 2222 with strict config
  • PermitTTY no - no terminal allocation
  • PermitOpen localhost:* - only localhost forwarding
  • no-agent-forwarding in authorized_keys
  • ED25519 keys (600 permissions on device)
  • Session UUIDs with 1-hour TTL
  • Audit logging (device_id, admin, timestamp)
  • Rate limiting on admin SSH endpoint
  • ttyd --once flag (single session per process)
  • Auto-cleanup of expired sessions

TODO

Устройство (Device)

  • Реализовать генерацию SSH ключа в client.go
  • Добавить поле ssh_public_key в RegistrationRequest
  • Реализовать SSHTunnel manager в ssh_tunnel.go
  • Интегрировать tunnel в main.go
  • Обработка изменения ssh_tunnel.enabled из server config

Сервер (Server)

  • Настройка отдельного sshd на порту 2222
  • Systemd unit для sshd-tunnel
  • Создание пользователя tunnel
  • Helper функции для управления authorized_keys
  • Серверный API: POST /api/v1/tunnel-port
  • Серверный API: POST /admin/devices/{id}/ssh
  • Серверный API: GET /admin/ssh/{uuid}
  • Серверный API: POST /admin/ssh/{uuid}/heartbeat
  • Серверный API: GET /admin/stats/ssh-sessions
  • Background task: cleanup_inactive_sessions()
  • Audit logging (database schema + insert/update)
  • ttyd process management (start/kill)

Frontend (Admin Dashboard)

  • Кнопка "Open SSH" в device details
  • Страница /admin/ssh/{uuid} с iframe ttyd
  • JavaScript heartbeat (каждые 30 сек)
  • Обработка закрытия вкладки (beforeunload)
  • Показ tunnel status (connected/disconnected)

Тестирование

  • Регистрация устройства с SSH ключом
  • Подключение туннеля к серверу
  • Создание SSH сессии через admin dashboard
  • Heartbeat от браузера
  • Auto-cleanup при закрытии вкладки (60 сек grace period)
  • Auto-cleanup при 60 мин неактивности
  • Expiration сессии (1 час hard limit)
  • Отключение туннеля через server config
  • Audit log записи
  • Переподключение туннеля при обрыве

Тестирование и использование

Проверка статуса туннелей на сервере

# Проверить что порты слушают
ss -tlnp | grep -E ':(50000|60000)'

# Пример вывода:
# LISTEN 0      128        0.0.0.0:50000      0.0.0.0:*
# LISTEN 0      128        0.0.0.0:60000      0.0.0.0:*

SSH Tunnel - доступ к терминалу

# Подключение к устройству через SSH tunnel
ssh -p 50000 root@server-ip

# Или через ProxyJump (если доступ через промежуточный сервер)
ssh -J user@server -p 50000 root@localhost

Dashboard Tunnel - доступ к веб-интерфейсу

# Через браузер
http://server-ip:60000/

# Или через curl
curl http://server-ip:60000/api/status

Пример ответа:

{
  "device_id": "38:54:39:4b:1b:ac",
  "registered": true,
  "mode": "cloud",
  "uptime_sec": 400,
  "network": {
    "eth0_ip": "192.168.5.244/24",
    "gateway": "192.168.5.1"
  },
  "scanners": {
    "ble_running": true,
    "wifi_running": true
  }
}

Логи туннелей на устройстве

# На устройстве просмотреть логи
ssh root@device-ip "tail -f /var/log/mybeacon.log | grep tunnel"

# Пример вывода:
# [ssh-tunnel] Started: 192.168.5.2:22 -> localhost:22 (remote_port=50000)
# [dashboard-tunnel] Started: 192.168.5.2:22 -> localhost:80 (remote_port=60000)

Управление туннелями через server API

# Включить SSH туннель
POST /api/v1/admin/update_device
{
  "device_id": "38:54:39:4b:1b:ac",
  "password": "12345678",
  "patch": {
    "ssh_tunnel.enabled": true
  }
}

# Включить Dashboard туннель
POST /api/v1/admin/update_device
{
  "device_id": "38:54:39:4b:1b:ac",
  "password": "12345678",
  "patch": {
    "dashboard_tunnel.enabled": true
  }
}

После получения новой конфигурации (≤30 секунд) устройство:

  1. Запустит соответствующий туннель
  2. Подключится к серверу
  3. Сервер выделит фиксированный порт из диапазона
  4. Туннель будет доступен по этому порту

Отладка проблем

# На сервере: проверить активные SSH сессии от tunnel user
sudo ps aux | grep 'sshd.*tunnel'

# На сервере: логи SSH
sudo journalctl -u sshd -f | grep tunnel

# На устройстве: проверить процессы туннелей
ps aux | grep 'ssh.*192.168'

# На устройстве: убить зависший туннель
killall ssh
# Демон автоматически перезапустит туннели через 5 секунд

Ссылки