# 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` ```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` ```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` В функции где происходит регистрация: ```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: ðIP, 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` ```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 ```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()`: ```go // 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 ```bash # На сервере 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`: ```bash # Разрешить TCP forwarding для туннелей AllowTcpForwarding yes # Разрешить GatewayPorts для доступа извне (не только localhost) GatewayPorts yes # Разрешить туннели PermitTunnel yes ``` Перезапустите SSH сервер: ```bash sudo systemctl restart sshd ``` **Объяснение:** - `AllowTcpForwarding yes` - разрешает reverse SSH tunnels - `GatewayPorts yes` - разрешает bind на 0.0.0.0 (доступ извне), а не только на 127.0.0.1 - `PermitTunnel yes` - разрешает туннелирование ### 3. Firewall правила ```bash # Разрешить порты 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 ```python # 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 туннеля Если нужно **полностью отключить** туннель на устройстве: ```python # Через 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 ```python @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 ```python 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 ```javascript // В 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 и метрики ```python # Статистика использования туннелей @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 записи - [ ] Переподключение туннеля при обрыве ## Тестирование и использование ### Проверка статуса туннелей на сервере ```bash # Проверить что порты слушают 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 - доступ к терминалу ```bash # Подключение к устройству через SSH tunnel ssh -p 50000 root@server-ip # Или через ProxyJump (если доступ через промежуточный сервер) ssh -J user@server -p 50000 root@localhost ``` ### Dashboard Tunnel - доступ к веб-интерфейсу ```bash # Через браузер http://server-ip:60000/ # Или через curl curl http://server-ip:60000/api/status ``` **Пример ответа:** ```json { "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 } } ``` ### Логи туннелей на устройстве ```bash # На устройстве просмотреть логи 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 ```python # Включить 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. Туннель будет доступен по этому порту ### Отладка проблем ```bash # На сервере: проверить активные 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 секунд ``` ## Ссылки - **ttyd**: https://github.com/tsl0922/ttyd - **OpenSSH Reverse Tunnel**: https://www.ssh.com/academy/ssh/tunneling-example - **ED25519**: https://ed25519.cr.yp.to/ - **golang.org/x/crypto/ssh**: https://pkg.go.dev/golang.org/x/crypto/ssh