|
|
@@ -0,0 +1,943 @@
|
|
|
+# Reverse SSH Tunnel для удалённого доступа
|
|
|
+
|
|
|
+## Архитектура
|
|
|
+
|
|
|
+```
|
|
|
+┌─────────────────────────────────────────────────────────────────┐
|
|
|
+│ SERVER │
|
|
|
+│ │
|
|
|
+│ sshd (port 22) ───────────> main SSH (admin access) │
|
|
|
+│ │
|
|
|
+│ sshd_tunnel (port 2222) ──> tunnel-only SSH │
|
|
|
+│ │ │
|
|
|
+│ └──> /home/tunnel/.ssh/authorized_keys │
|
|
|
+│ (device pubkeys with restrictions) │
|
|
|
+│ │
|
|
|
+│ Admin Dashboard: │
|
|
|
+│ ┌────────────────────────────────────┐ │
|
|
|
+│ │ Device: 38:54:39:4b:1b:ac │ │
|
|
|
+│ │ [Open SSH] ──> Generate UUID │ │
|
|
|
+│ └────────────────────────────────────┘ │
|
|
|
+│ │ │
|
|
|
+│ └──> /admin/ssh/{uuid} │
|
|
|
+│ │ │
|
|
|
+│ └──> ttyd on localhost:random_port │
|
|
|
+│ ssh -p {allocated_port} root@localhost │
|
|
|
+│ │
|
|
|
+│ Tunnel Status API: │
|
|
|
+│ ┌────────────────────────────────────┐ │
|
|
|
+│ │ POST /api/v1/tunnel-port │ │
|
|
|
+│ │ { device_id, port: 12345 } │ │
|
|
|
+│ └────────────────────────────────────┘ │
|
|
|
+│ │
|
|
|
+│ Audit Log: │
|
|
|
+│ - device_id │
|
|
|
+│ - admin_user │
|
|
|
+│ - session_uuid │
|
|
|
+│ - timestamp │
|
|
|
+│ - commands (optional) │
|
|
|
+└─────────────────────────────────────────────────────────────────┘
|
|
|
+
|
|
|
+┌─────────────────────────────────────────────────────────────────┐
|
|
|
+│ 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): │
|
|
|
+│ ssh -N -R 0:localhost:22 \ │
|
|
|
+│ -p 2222 \ │
|
|
|
+│ -o ServerAliveInterval=30 \ │
|
|
|
+│ -o ServerAliveCountMax=3 \ │
|
|
|
+│ -o ExitOnForwardFailure=yes \ │
|
|
|
+│ -i /opt/mybeacon/etc/tunnel_key \ │
|
|
|
+│ tunnel@server.com │
|
|
|
+│ │
|
|
|
+│ Parse allocated port from stderr → │
|
|
|
+│ POST /api/v1/tunnel-port │
|
|
|
+│ { "device_id": "...", "port": 12345 } │
|
|
|
+│ │
|
|
|
+│ Auto-reconnect on disconnect (ReconnectDelay: 5s) │
|
|
|
+└─────────────────────────────────────────────────────────────────┘
|
|
|
+```
|
|
|
+
|
|
|
+## Безопасность
|
|
|
+
|
|
|
+### Принципы
|
|
|
+
|
|
|
+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/false 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
|
|
|
+```
|
|
|
+
|
|
|
+### 2. Конфиг отдельного sshd
|
|
|
+
|
|
|
+**Файл:** `/etc/ssh/sshd_tunnel_config`
|
|
|
+
|
|
|
+```sshd_config
|
|
|
+# Tunnel-only SSH daemon for MyBeacon devices
|
|
|
+Port 2222
|
|
|
+Protocol 2
|
|
|
+
|
|
|
+# Listening
|
|
|
+ListenAddress 0.0.0.0
|
|
|
+
|
|
|
+# Host keys (use existing)
|
|
|
+HostKey /etc/ssh/ssh_host_ed25519_key
|
|
|
+HostKey /etc/ssh/ssh_host_rsa_key
|
|
|
+
|
|
|
+# Logging
|
|
|
+SyslogFacility AUTH
|
|
|
+LogLevel INFO
|
|
|
+
|
|
|
+# Authentication
|
|
|
+PubkeyAuthentication yes
|
|
|
+AuthorizedKeysFile /home/tunnel/.ssh/authorized_keys
|
|
|
+PasswordAuthentication no
|
|
|
+ChallengeResponseAuthentication no
|
|
|
+KerberosAuthentication no
|
|
|
+GSSAPIAuthentication no
|
|
|
+PermitRootLogin no
|
|
|
+
|
|
|
+# Restrictions for tunnel user
|
|
|
+Match User tunnel
|
|
|
+ # ONLY port forwarding, nothing else
|
|
|
+ AllowTcpForwarding remote
|
|
|
+ GatewayPorts no
|
|
|
+ PermitOpen localhost:*
|
|
|
+
|
|
|
+ # Disable everything else
|
|
|
+ X11Forwarding no
|
|
|
+ AllowAgentForwarding no
|
|
|
+ PermitTTY no
|
|
|
+ PermitUserRC no
|
|
|
+
|
|
|
+ # Force command (ignored with -N, but extra safety)
|
|
|
+ ForceCommand /bin/echo "Port forwarding only"
|
|
|
+
|
|
|
+ # Limits
|
|
|
+ MaxSessions 10
|
|
|
+ ClientAliveInterval 30
|
|
|
+ ClientAliveCountMax 3
|
|
|
+
|
|
|
+ # Security
|
|
|
+ PermitEmptyPasswords no
|
|
|
+ MaxAuthTries 3
|
|
|
+```
|
|
|
+
|
|
|
+### 3. Systemd unit для tunnel sshd
|
|
|
+
|
|
|
+**Файл:** `/etc/systemd/system/sshd-tunnel.service`
|
|
|
+
|
|
|
+```ini
|
|
|
+[Unit]
|
|
|
+Description=OpenSSH Tunnel Server for MyBeacon Devices
|
|
|
+After=network.target auditd.service
|
|
|
+ConditionPathExists=!/etc/ssh/sshd_tunnel_not_to_be_run
|
|
|
+
|
|
|
+[Service]
|
|
|
+Type=notify
|
|
|
+EnvironmentFile=-/etc/default/ssh
|
|
|
+ExecStartPre=/usr/sbin/sshd -t -f /etc/ssh/sshd_tunnel_config
|
|
|
+ExecStart=/usr/sbin/sshd -D -f /etc/ssh/sshd_tunnel_config
|
|
|
+ExecReload=/bin/kill -HUP $MAINPID
|
|
|
+KillMode=process
|
|
|
+Restart=on-failure
|
|
|
+RestartSec=42s
|
|
|
+
|
|
|
+[Install]
|
|
|
+WantedBy=multi-user.target
|
|
|
+```
|
|
|
+
|
|
|
+Запуск:
|
|
|
+
|
|
|
+```bash
|
|
|
+sudo systemctl daemon-reload
|
|
|
+sudo systemctl enable sshd-tunnel
|
|
|
+sudo systemctl start sshd-tunnel
|
|
|
+sudo systemctl status sshd-tunnel
|
|
|
+```
|
|
|
+
|
|
|
+### 4. Firewall правила
|
|
|
+
|
|
|
+```bash
|
|
|
+# Разрешить порт 2222 для tunnel SSH
|
|
|
+sudo ufw allow 2222/tcp comment "MyBeacon SSH Tunnel"
|
|
|
+
|
|
|
+# Или для iptables
|
|
|
+sudo iptables -A INPUT -p tcp --dport 2222 -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 записи
|
|
|
+- [ ] Переподключение туннеля при обрыве
|
|
|
+
|
|
|
+## Ссылки
|
|
|
+
|
|
|
+- **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
|