Система поддерживает два независимых reverse туннеля для доступа к устройствам за NAT:
Оба туннеля управляются серверной конфигурацией и используют один 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 │
└─────────────────────────────────────────────────────────────────┘
tunnel на сервере может ТОЛЬКО форвардить порты, никаких shell командЕсли злоумышленник:
/opt/mybeacon/etc/tunnel_keyОн сможет:
Сервер не пострадает. Злоумышленник откроет только свой локальный 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
}
Файл: 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"` // Новое поле
}
Файл: 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: ð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)
}
Файл: 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
}
// В 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()
В 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()
}
}
# На сервере
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 сессия будет немедленно закрываться!
Добавьте следующие настройки в /etc/ssh/sshd_config:
# Разрешить TCP forwarding для туннелей
AllowTcpForwarding yes
# Разрешить GatewayPorts для доступа извне (не только localhost)
GatewayPorts yes
# Разрешить туннели
PermitTunnel yes
Перезапустите SSH сервер:
sudo systemctl restart sshd
Объяснение:
AllowTcpForwarding yes - разрешает reverse SSH tunnelsGatewayPorts yes - разрешает bind на 0.0.0.0 (доступ извне), а не только на 127.0.0.1PermitTunnel yes - разрешает туннелирование# Разрешить порты 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
# 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
}
Добавить поле ssh_public_key в request и сохранить его в /home/tunnel/.ssh/authorized_keys с ограничениями.
Устройство отправляет allocated port после подключения туннеля.
Создаёт временную сессию с UUID, запускает ttyd, возвращает URL.
Отображает терминал в браузере (iframe с ttyd).
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 (пользователь может перезагрузить страницу)
Если нужно полностью отключить туннель на устройстве:
# Через server config
device_config = {
"ssh_tunnel": {
"enabled": False # Устройство отключит туннель при следующем polling (≤30 сек)
}
}
Устройство при получении ssh_tunnel.enabled: false:
POST /api/v1/tunnel-port { "status": "disconnected" }@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}
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
// В 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,
// но это не гарантировано (браузер может не дождаться ответа)
});
# Статистика использования туннелей
@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
}
-s /bin/false)PermitTTY no - no terminal allocationPermitOpen localhost:* - only localhost forwardingno-agent-forwarding in authorized_keys--once flag (single session per process)client.gossh_public_key в RegistrationRequestSSHTunnel manager в ssh_tunnel.gomain.gossh_tunnel.enabled из server configtunnelPOST /api/v1/tunnel-portPOST /admin/devices/{id}/sshGET /admin/ssh/{uuid}POST /admin/ssh/{uuid}/heartbeatGET /admin/stats/ssh-sessionscleanup_inactive_sessions()/admin/ssh/{uuid} с iframe ttyd# Проверить что порты слушают
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 -p 50000 root@server-ip
# Или через ProxyJump (если доступ через промежуточный сервер)
ssh -J user@server -p 50000 root@localhost
# Через браузер
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)
# Включить 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 секунд) устройство:
# На сервере: проверить активные 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 секунд