Add authenticated LuCI remote access

This commit is contained in:
benya
2026-06-04 17:25:50 +03:00
parent b722d09edf
commit 785e502b99
13 changed files with 275 additions and 21 deletions
+7 -3
View File
@@ -551,6 +551,8 @@ remote_ssh_reverse_output() {
server_host="$(command_arg_string "$args" "server_host")" server_host="$(command_arg_string "$args" "server_host")"
server_port="$(command_arg_string "$args" "server_port")" server_port="$(command_arg_string "$args" "server_port")"
remote_port="$(command_arg_string "$args" "remote_port")" remote_port="$(command_arg_string "$args" "remote_port")"
luci_port="$(command_arg_string "$args" "luci_port")"
luci_local_port="$(command_arg_string "$args" "luci_local_port")"
local_host="$(command_arg_string "$args" "local_host")" local_host="$(command_arg_string "$args" "local_host")"
local_port="$(command_arg_string "$args" "local_port")" local_port="$(command_arg_string "$args" "local_port")"
server_user="$(command_arg_string "$args" "server_user")" server_user="$(command_arg_string "$args" "server_user")"
@@ -559,6 +561,7 @@ remote_ssh_reverse_output() {
[ -n "$server_port" ] || server_port="22" [ -n "$server_port" ] || server_port="22"
[ -n "$local_host" ] || local_host="127.0.0.1" [ -n "$local_host" ] || local_host="127.0.0.1"
[ -n "$local_port" ] || local_port="22" [ -n "$local_port" ] || local_port="22"
[ -n "$luci_local_port" ] || luci_local_port="80"
[ -n "$server_user" ] || server_user="rmm-tunnel" [ -n "$server_user" ] || server_user="rmm-tunnel"
[ -n "$duration_seconds" ] || duration_seconds="900" [ -n "$duration_seconds" ] || duration_seconds="900"
@@ -566,7 +569,7 @@ remote_ssh_reverse_output() {
printf 'remote tunnel host or user is invalid\n' printf 'remote tunnel host or user is invalid\n'
return 2 return 2
fi fi
if ! safe_port "$server_port" || ! safe_port "$remote_port" || ! safe_port "$local_port" || ! safe_port "$duration_seconds"; then if ! safe_port "$server_port" || ! safe_port "$remote_port" || ! safe_port "$luci_port" || ! safe_port "$local_port" || ! safe_port "$luci_local_port" || ! safe_port "$duration_seconds"; then
printf 'remote tunnel port or duration is invalid\n' printf 'remote tunnel port or duration is invalid\n'
return 2 return 2
fi fi
@@ -582,10 +585,10 @@ remote_ssh_reverse_output() {
fi fi
if command -v ssh >/dev/null 2>&1; then if command -v ssh >/dev/null 2>&1; then
# shellcheck disable=SC2086 # shellcheck disable=SC2086
ssh $identity_args -N -o StrictHostKeyChecking=accept-new -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=2 -R "$remote_port:$local_host:$local_port" -p "$server_port" "$server_user@$server_host" >> "$log_file" 2>&1 & ssh $identity_args -N -o StrictHostKeyChecking=accept-new -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=2 -R "$remote_port:$local_host:$local_port" -R "$luci_port:127.0.0.1:$luci_local_port" -p "$server_port" "$server_user@$server_host" >> "$log_file" 2>&1 &
elif command -v dbclient >/dev/null 2>&1; then elif command -v dbclient >/dev/null 2>&1; then
# shellcheck disable=SC2086 # shellcheck disable=SC2086
dbclient $identity_args -N -y -R "$remote_port:$local_host:$local_port" -p "$server_port" "$server_user@$server_host" >> "$log_file" 2>&1 & dbclient $identity_args -N -y -R "$remote_port:$local_host:$local_port" -R "$luci_port:127.0.0.1:$luci_local_port" -p "$server_port" "$server_user@$server_host" >> "$log_file" 2>&1 &
else else
printf 'remote ssh reverse requires ssh or dbclient on router\n' printf 'remote ssh reverse requires ssh or dbclient on router\n'
return 2 return 2
@@ -603,6 +606,7 @@ remote_ssh_reverse_output() {
printf 'remote ssh reverse started\n' printf 'remote ssh reverse started\n'
printf 'session=%s pid=%s\n' "$session_id" "$pid" printf 'session=%s pid=%s\n' "$session_id" "$pid"
printf 'operator endpoint: %s:%s -> %s:%s\n' "$server_host" "$remote_port" "$local_host" "$local_port" printf 'operator endpoint: %s:%s -> %s:%s\n' "$server_host" "$remote_port" "$local_host" "$local_port"
printf 'LuCI proxy endpoint: %s:%s -> 127.0.0.1:%s\n' "$server_host" "$luci_port" "$luci_local_port"
printf 'log=%s\n' "$log_file" printf 'log=%s\n' "$log_file"
return 0 return 0
} }
+1
View File
@@ -13,6 +13,7 @@ services:
RMM_OPERATOR_PASSWORD: "${RMM_OPERATOR_PASSWORD:-dev-operator-password}" RMM_OPERATOR_PASSWORD: "${RMM_OPERATOR_PASSWORD:-dev-operator-password}"
RMM_SESSION_SECRET: "${RMM_SESSION_SECRET:-dev-session-secret-change-me}" RMM_SESSION_SECRET: "${RMM_SESSION_SECRET:-dev-session-secret-change-me}"
RMM_COOKIE_SECURE: "${RMM_COOKIE_SECURE:-false}" RMM_COOKIE_SECURE: "${RMM_COOKIE_SECURE:-false}"
RMM_TUNNEL_HTTP_HOST: tunnel-ssh
ports: ports:
- "${RMM_HTTP_BIND_IP:-0.0.0.0}:${RMM_HTTP_PORT:-18080}:8080" - "${RMM_HTTP_BIND_IP:-0.0.0.0}:${RMM_HTTP_PORT:-18080}:8080"
volumes: volumes:
+9 -2
View File
@@ -463,6 +463,7 @@ Request:
"server_port": 22, "server_port": 22,
"remote_port": 22022, "remote_port": 22022,
"local_port": 22, "local_port": 22,
"luci_scheme": "https",
"duration_seconds": 900 "duration_seconds": 900
} }
``` ```
@@ -478,6 +479,8 @@ Response:
"server_host": "10.10.10.2", "server_host": "10.10.10.2",
"server_port": 22, "server_port": 22,
"remote_port": 22022, "remote_port": 22022,
"luci_port": 22122,
"luci_scheme": "https",
"local_host": "127.0.0.1", "local_host": "127.0.0.1",
"local_port": 22, "local_port": 22,
"command_id": "cmd_...", "command_id": "cmd_...",
@@ -493,14 +496,18 @@ GET /api/devices/{device_id}/remote-sessions?limit=25
Authorization: Bearer dev-operator-token Authorization: Bearer dev-operator-token
``` ```
Close a session record: Close a session and queue tunnel termination on the agent:
```http ```http
POST /api/devices/{device_id}/remote-sessions/{session_id}/close POST /api/devices/{device_id}/remote-sessions/{session_id}/close
Authorization: Bearer dev-operator-token Authorization: Bearer dev-operator-token
``` ```
The MVP close action updates server state and audit history. Active SSH processes stop by duration timeout on the agent side. Open LuCI for an active session through the authenticated RMM proxy:
```http
GET /luci/{device_id}/{session_id}/
```
## Create Bulk Command ## Create Bulk Command
+1
View File
@@ -10,6 +10,7 @@ Published ports:
- `18080/tcp`: RMM API and web UI. - `18080/tcp`: RMM API and web UI.
- `2222/tcp`: router-to-server SSH tunnel connection. - `2222/tcp`: router-to-server SSH tunnel connection.
- `22000-22099/tcp`: temporary operator endpoints created by remote sessions. - `22000-22099/tcp`: temporary operator endpoints created by remote sessions.
- `22100-22199/tcp`: internal LuCI proxy forwards; these are not published by Compose.
## 1. Configure Secrets ## 1. Configure Secrets
+6 -3
View File
@@ -5,6 +5,7 @@ Remote access is now implemented as an MVP reverse SSH session flow. The server
## Current Capabilities ## Current Capabilities
- Temporary SSH access through an outbound reverse tunnel. - Temporary SSH access through an outbound reverse tunnel.
- LuCI access through an authenticated RMM HTTP proxy over the same tunnel.
- Session list and close action in the web UI. - Session list and close action in the web UI.
- Session audit with device, command id, endpoint, and expiration. - Session audit with device, command id, endpoint, and expiration.
- Automatic session expiration in the server store. - Automatic session expiration in the server store.
@@ -15,8 +16,8 @@ Remote access is now implemented as an MVP reverse SSH session flow. The server
1. Operator opens a remote SSH session for a device. 1. Operator opens a remote SSH session for a device.
2. Server creates a `remote_sessions` record with expiration. 2. Server creates a `remote_sessions` record with expiration.
3. Server queues `remote_ssh_reverse` for the router. 3. Server queues `remote_ssh_reverse` for the router.
4. Agent runs `ssh` or `dbclient` and requests `-R remote_port:127.0.0.1:22`. 4. Agent runs `ssh` or `dbclient` and requests reverse forwards for router SSH and LuCI.
5. Operator connects to the server endpoint shown by the UI. 5. Operator connects to SSH or opens LuCI through the authenticated RMM proxy.
6. Session expires automatically on the router or is closed by an operator command. 6. Session expires automatically on the router or is closed by an operator command.
## Docker/Linux Requirement ## Docker/Linux Requirement
@@ -28,7 +29,9 @@ The reverse tunnel needs an SSH server reachable by the router. The default comm
- remote router port: `22` - remote router port: `22`
- duration: `15 minutes` - duration: `15 minutes`
The included Compose stack exposes the SSH endpoint on port `2222` and remote-forwarded ports `22000-22099`. See [docker-compose.md](docker-compose.md) for deployment and router key installation. The included Compose stack exposes the SSH endpoint on port `2222` and operator SSH ports `22000-22099`. Internal LuCI ports `22100-22199` stay inside the Compose network. See [docker-compose.md](docker-compose.md) for deployment and router key installation.
Select HTTP or HTTPS when opening the session to match the router's LuCI listener. HTTPS supports router-local self-signed certificates inside the tunnel.
## Safety Rules ## Safety Rules
+1
View File
@@ -29,6 +29,7 @@ func main() {
OperatorPassword: env("RMM_OPERATOR_PASSWORD", "dev-operator-password"), OperatorPassword: env("RMM_OPERATOR_PASSWORD", "dev-operator-password"),
SessionSecret: env("RMM_SESSION_SECRET", "dev-session-secret-change-me"), SessionSecret: env("RMM_SESSION_SECRET", "dev-session-secret-change-me"),
CookieSecure: envBool("RMM_COOKIE_SECURE", false), CookieSecure: envBool("RMM_COOKIE_SECURE", false),
TunnelHTTPHost: env("RMM_TUNNEL_HTTP_HOST", "tunnel-ssh"),
StaticDir: env("RMM_WEB_DIR", "web"), StaticDir: env("RMM_WEB_DIR", "web"),
}) })
+148 -1
View File
@@ -1,14 +1,19 @@
package httpapi package httpapi
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/tls"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"net/http/httputil"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -53,6 +58,7 @@ type Config struct {
OperatorPassword string OperatorPassword string
SessionSecret string SessionSecret string
CookieSecure bool CookieSecure bool
TunnelHTTPHost string
StaticDir string StaticDir string
} }
@@ -64,6 +70,7 @@ type App struct {
operatorPassword string operatorPassword string
sessionSecret []byte sessionSecret []byte
cookieSecure bool cookieSecure bool
tunnelHTTPHost string
} }
type contextKey string type contextKey string
@@ -103,6 +110,7 @@ type remoteSessionRequest struct {
ServerPort int `json:"server_port"` ServerPort int `json:"server_port"`
RemotePort int `json:"remote_port"` RemotePort int `json:"remote_port"`
LocalPort int `json:"local_port"` LocalPort int `json:"local_port"`
LuCIScheme string `json:"luci_scheme"`
} }
type fleetRequest struct { type fleetRequest struct {
@@ -151,6 +159,10 @@ func NewHandler(s Store, cfg Config) http.Handler {
operatorPassword: cfg.OperatorPassword, operatorPassword: cfg.OperatorPassword,
sessionSecret: []byte(cfg.SessionSecret), sessionSecret: []byte(cfg.SessionSecret),
cookieSecure: cfg.CookieSecure, cookieSecure: cfg.CookieSecure,
tunnelHTTPHost: strings.TrimSpace(cfg.TunnelHTTPHost),
}
if a.tunnelHTTPHost == "" {
a.tunnelHTTPHost = "tunnel-ssh"
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -167,6 +179,8 @@ func NewHandler(s Store, cfg Config) http.Handler {
mux.Handle("POST /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("POST /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree)))
mux.Handle("PATCH /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("PATCH /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree)))
mux.Handle("GET /api/audit-events", a.operatorAuth(http.HandlerFunc(a.handleListAuditEvents))) mux.Handle("GET /api/audit-events", a.operatorAuth(http.HandlerFunc(a.handleListAuditEvents)))
mux.Handle("GET /luci/", a.operatorAuth(http.HandlerFunc(a.handleLuCIProxy)))
mux.Handle("POST /luci/", a.operatorAuth(http.HandlerFunc(a.handleLuCIProxy)))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}) })
@@ -949,7 +963,16 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request)
if remotePort <= 0 { if remotePort <= 0 {
remotePort = randomRemotePort() remotePort = randomRemotePort()
} }
if !validTCPPort(serverPort) || !validTCPPort(localPort) || !validTCPPort(remotePort) { luciPort := randomLuCIPort()
luciScheme := strings.ToLower(strings.TrimSpace(req.LuCIScheme))
if luciScheme == "" {
luciScheme = "http"
}
if luciScheme != "http" && luciScheme != "https" {
writeError(w, http.StatusBadRequest, "luci_scheme must be http or https")
return
}
if !validTCPPort(serverPort) || !validTCPPort(localPort) || !validTCPPort(remotePort) || !validTCPPort(luciPort) {
writeError(w, http.StatusBadRequest, "ports must be between 1 and 65535") writeError(w, http.StatusBadRequest, "ports must be between 1 and 65535")
return return
} }
@@ -962,6 +985,8 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request)
ServerHost: serverHost, ServerHost: serverHost,
ServerPort: serverPort, ServerPort: serverPort,
RemotePort: remotePort, RemotePort: remotePort,
LuCIPort: luciPort,
LuCIScheme: luciScheme,
LocalHost: "127.0.0.1", LocalHost: "127.0.0.1",
LocalPort: localPort, LocalPort: localPort,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
@@ -975,11 +1000,17 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request)
return return
} }
luciLocalPort := "80"
if session.LuCIScheme == "https" {
luciLocalPort = "443"
}
args := mustJSON(map[string]any{ args := mustJSON(map[string]any{
"session_id": session.ID, "session_id": session.ID,
"server_host": session.ServerHost, "server_host": session.ServerHost,
"server_port": strconv.Itoa(session.ServerPort), "server_port": strconv.Itoa(session.ServerPort),
"remote_port": strconv.Itoa(session.RemotePort), "remote_port": strconv.Itoa(session.RemotePort),
"luci_port": strconv.Itoa(session.LuCIPort),
"luci_local_port": luciLocalPort,
"local_host": session.LocalHost, "local_host": session.LocalHost,
"local_port": strconv.Itoa(session.LocalPort), "local_port": strconv.Itoa(session.LocalPort),
"server_user": "rmm-tunnel", "server_user": "rmm-tunnel",
@@ -1007,6 +1038,8 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request)
"server_host": session.ServerHost, "server_host": session.ServerHost,
"server_port": session.ServerPort, "server_port": session.ServerPort,
"remote_port": session.RemotePort, "remote_port": session.RemotePort,
"luci_port": session.LuCIPort,
"luci_scheme": session.LuCIScheme,
"local_port": session.LocalPort, "local_port": session.LocalPort,
"expires_at": session.ExpiresAt, "expires_at": session.ExpiresAt,
"request_id": requestID(r.Context()), "request_id": requestID(r.Context()),
@@ -1014,6 +1047,110 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, session) writeJSON(w, http.StatusCreated, session)
} }
func (a *App) handleLuCIProxy(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/luci/")
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
writeError(w, http.StatusNotFound, "LuCI session not found")
return
}
deviceID, sessionID := parts[0], parts[1]
session, found, err := a.store.GetRemoteSession(r.Context(), deviceID, sessionID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load LuCI session")
return
}
if !found || session.Status != "active" || session.LuCIPort <= 0 {
writeError(w, http.StatusNotFound, "LuCI session is not active")
return
}
prefix := "/luci/" + deviceID + "/" + sessionID
scheme := session.LuCIScheme
if scheme == "" {
scheme = "http"
}
upstream, _ := url.Parse(scheme + "://" + a.tunnelHTTPHost + ":" + strconv.Itoa(session.LuCIPort))
proxy := httputil.NewSingleHostReverseProxy(upstream)
if scheme == "https" {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // LuCI commonly uses a router-local self-signed certificate.
proxy.Transport = transport
}
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.URL.Path = "/"
if len(parts) == 3 && parts[2] != "" {
req.URL.Path += parts[2]
}
req.Host = upstream.Host
req.Header.Del("Accept-Encoding")
removeCookie(req, operatorSessionCookie)
}
proxy.ModifyResponse = func(resp *http.Response) error {
rewriteLuCIHeaders(resp.Header, prefix)
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "text/html") && !strings.Contains(contentType, "javascript") && !strings.Contains(contentType, "text/css") {
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
_ = resp.Body.Close()
body = rewriteLuCIBody(body, prefix)
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
logStructured(map[string]any{
"event": "luci.proxy_failed",
"request_id": requestID(r.Context()),
"device_id": deviceID,
"session_id": sessionID,
"error": err.Error(),
})
writeError(w, http.StatusBadGateway, "LuCI is unreachable through this session")
}
proxy.ServeHTTP(w, r)
}
func removeCookie(r *http.Request, name string) {
cookies := make([]string, 0)
for _, cookie := range r.Cookies() {
if cookie.Name != name {
cookies = append(cookies, cookie.Name+"="+cookie.Value)
}
}
if len(cookies) == 0 {
r.Header.Del("Cookie")
return
}
r.Header.Set("Cookie", strings.Join(cookies, "; "))
}
func rewriteLuCIHeaders(header http.Header, prefix string) {
if location := header.Get("Location"); strings.HasPrefix(location, "/") {
header.Set("Location", prefix+location)
}
for index, cookie := range header.Values("Set-Cookie") {
cookie = strings.ReplaceAll(cookie, "Path=/;", "Path="+prefix+"/;")
cookie = strings.ReplaceAll(cookie, "Path=/ ", "Path="+prefix+"/ ")
header["Set-Cookie"][index] = cookie
}
}
func rewriteLuCIBody(body []byte, prefix string) []byte {
for _, marker := range []string{`="/`, `'/`, `url(/`} {
replacement := marker[:len(marker)-1] + prefix + "/"
body = bytes.ReplaceAll(body, []byte(marker), []byte(replacement))
}
return body
}
func (a *App) handleCloseRemoteSession(w http.ResponseWriter, r *http.Request) { func (a *App) handleCloseRemoteSession(w http.ResponseWriter, r *http.Request) {
deviceID, sessionID, ok := remoteSessionCloseIDsFromPath(r.URL.Path) deviceID, sessionID, ok := remoteSessionCloseIDsFromPath(r.URL.Path)
if !ok { if !ok {
@@ -1032,6 +1169,7 @@ func (a *App) handleCloseRemoteSession(w http.ResponseWriter, r *http.Request) {
closeCommand, commandFound, err := a.store.CreateCommand(r.Context(), deviceID, "remote_ssh_close", mustJSON(map[string]string{ closeCommand, commandFound, err := a.store.CreateCommand(r.Context(), deviceID, "remote_ssh_close", mustJSON(map[string]string{
"session_id": session.ID, "session_id": session.ID,
"remote_port": strconv.Itoa(session.RemotePort), "remote_port": strconv.Itoa(session.RemotePort),
"luci_port": strconv.Itoa(session.LuCIPort),
})) }))
if err != nil || !commandFound { if err != nil || !commandFound {
writeError(w, http.StatusInternalServerError, "failed to queue remote session close") writeError(w, http.StatusInternalServerError, "failed to queue remote session close")
@@ -1264,6 +1402,15 @@ func randomRemotePort() int {
return 22000 + (value % 100) return 22000 + (value % 100)
} }
func randomLuCIPort() int {
var b [2]byte
if _, err := rand.Read(b[:]); err != nil {
return 22122
}
value := int(b[0])<<8 | int(b[1])
return 22100 + (value % 100)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
defer r.Body.Close() defer r.Body.Close()
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
+67 -4
View File
@@ -8,12 +8,15 @@ import (
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"rmm-openwrt/server/internal/httpapi" "rmm-openwrt/server/internal/httpapi"
"rmm-openwrt/server/internal/model"
"rmm-openwrt/server/internal/store" "rmm-openwrt/server/internal/store"
) )
@@ -263,9 +266,10 @@ func TestAgentOperatorSmokeFlow(t *testing.T) {
} }
var uciCommand struct { var uciCommand struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Args json.RawMessage `json:"args"`
} }
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{
"type": "uci_show", "type": "uci_show",
@@ -305,6 +309,7 @@ func TestAgentOperatorSmokeFlow(t *testing.T) {
ServerHost string `json:"server_host"` ServerHost string `json:"server_host"`
ServerPort int `json:"server_port"` ServerPort int `json:"server_port"`
RemotePort int `json:"remote_port"` RemotePort int `json:"remote_port"`
LuCIPort int `json:"luci_port"`
LocalPort int `json:"local_port"` LocalPort int `json:"local_port"`
} }
requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", map[string]any{ requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/remote-sessions", "operator-test", map[string]any{
@@ -313,9 +318,10 @@ func TestAgentOperatorSmokeFlow(t *testing.T) {
"server_port": 22, "server_port": 22,
"remote_port": 22022, "remote_port": 22022,
"local_port": 22, "local_port": 22,
"luci_scheme": "https",
"duration_seconds": 900, "duration_seconds": 900,
}, http.StatusCreated, &remoteSession) }, http.StatusCreated, &remoteSession)
if remoteSession.ID == "" || remoteSession.Status != "queued" || remoteSession.CommandID == "" || remoteSession.RemotePort != 22022 { if remoteSession.ID == "" || remoteSession.Status != "queued" || remoteSession.CommandID == "" || remoteSession.RemotePort != 22022 || remoteSession.LuCIPort < 22100 || remoteSession.LuCIPort > 22199 {
t.Fatalf("unexpected remote session: %#v", remoteSession) t.Fatalf("unexpected remote session: %#v", remoteSession)
} }
var remoteSessions struct { var remoteSessions struct {
@@ -333,6 +339,9 @@ func TestAgentOperatorSmokeFlow(t *testing.T) {
if uciCommand.Type != "remote_ssh_reverse" || uciCommand.Status != "queued" { if uciCommand.Type != "remote_ssh_reverse" || uciCommand.Status != "queued" {
t.Fatalf("unexpected remote command: %#v", uciCommand) t.Fatalf("unexpected remote command: %#v", uciCommand)
} }
if !strings.Contains(string(uciCommand.Args), `"luci_local_port":"443"`) || !strings.Contains(string(uciCommand.Args), `"luci_port":"`) {
t.Fatalf("remote command does not include LuCI HTTPS forward: %s", uciCommand.Args)
}
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+remoteSession.CommandID+"/result", enrolled.DeviceToken, map[string]any{ requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+remoteSession.CommandID+"/result", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID, "device_id": enrolled.DeviceID,
"status": "completed", "status": "completed",
@@ -396,6 +405,60 @@ func TestAgentOperatorSmokeFlow(t *testing.T) {
} }
} }
func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("Cookie"), "rmm_operator_session=") {
t.Fatal("operator session cookie leaked to LuCI upstream")
}
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, `<a href="/cgi-bin/luci/admin">LuCI</a><link href="/luci-static/test.css">`)
}))
defer upstream.Close()
upstreamURL, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
_, portText, _ := strings.Cut(upstreamURL.Host, ":")
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "luci.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
enrolled, err := st.EnrollDevice(context.Background(), "router", "OpenWrt")
if err != nil {
t.Fatal(err)
}
session, _, err := st.CreateRemoteSession(context.Background(), model.RemoteSession{
DeviceID: enrolled.DeviceID,
Target: "ssh",
Status: "active",
ServerHost: "tunnel",
ServerPort: 2222,
RemotePort: 22000,
LuCIPort: port,
})
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
OperatorToken: "operator-test",
TunnelHTTPHost: "127.0.0.1",
}))
defer srv.Close()
body := requestText(t, http.MethodGet, srv.URL+"/luci/"+enrolled.DeviceID+"/"+session.ID+"/", "operator-test", nil, http.StatusOK)
prefix := "/luci/" + enrolled.DeviceID + "/" + session.ID
if !strings.Contains(body, `href="`+prefix+`/cgi-bin/luci/admin"`) || !strings.Contains(body, `href="`+prefix+`/luci-static/test.css"`) {
t.Fatalf("LuCI paths were not rewritten: %s", body)
}
}
func TestServesStaticWebUI(t *testing.T) { func TestServesStaticWebUI(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><title>RMM UI</title>"), 0644); err != nil { if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><title>RMM UI</title>"), 0644); err != nil {
+2
View File
@@ -80,6 +80,8 @@ type RemoteSession struct {
ServerHost string `json:"server_host,omitempty"` ServerHost string `json:"server_host,omitempty"`
ServerPort int `json:"server_port,omitempty"` ServerPort int `json:"server_port,omitempty"`
RemotePort int `json:"remote_port,omitempty"` RemotePort int `json:"remote_port,omitempty"`
LuCIPort int `json:"luci_port,omitempty"`
LuCIScheme string `json:"luci_scheme,omitempty"`
LocalHost string `json:"local_host,omitempty"` LocalHost string `json:"local_host,omitempty"`
LocalPort int `json:"local_port,omitempty"` LocalPort int `json:"local_port,omitempty"`
CommandID string `json:"command_id,omitempty"` CommandID string `json:"command_id,omitempty"`
+14 -5
View File
@@ -158,6 +158,8 @@ CREATE TABLE IF NOT EXISTS remote_sessions (
server_host TEXT NOT NULL DEFAULT '', server_host TEXT NOT NULL DEFAULT '',
server_port INTEGER NOT NULL DEFAULT 22, server_port INTEGER NOT NULL DEFAULT 22,
remote_port INTEGER NOT NULL DEFAULT 0, remote_port INTEGER NOT NULL DEFAULT 0,
luci_port INTEGER NOT NULL DEFAULT 0,
luci_scheme TEXT NOT NULL DEFAULT 'http',
local_host TEXT NOT NULL DEFAULT '127.0.0.1', local_host TEXT NOT NULL DEFAULT '127.0.0.1',
local_port INTEGER NOT NULL DEFAULT 22, local_port INTEGER NOT NULL DEFAULT 22,
command_id TEXT NOT NULL DEFAULT '', command_id TEXT NOT NULL DEFAULT '',
@@ -198,6 +200,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_events_device_created_at ON audit_events(de
`ALTER TABLE remote_sessions ADD COLUMN server_host TEXT NOT NULL DEFAULT ''`, `ALTER TABLE remote_sessions ADD COLUMN server_host TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE remote_sessions ADD COLUMN server_port INTEGER NOT NULL DEFAULT 22`, `ALTER TABLE remote_sessions ADD COLUMN server_port INTEGER NOT NULL DEFAULT 22`,
`ALTER TABLE remote_sessions ADD COLUMN remote_port INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE remote_sessions ADD COLUMN remote_port INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE remote_sessions ADD COLUMN luci_port INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE remote_sessions ADD COLUMN luci_scheme TEXT NOT NULL DEFAULT 'http'`,
`ALTER TABLE remote_sessions ADD COLUMN local_host TEXT NOT NULL DEFAULT '127.0.0.1'`, `ALTER TABLE remote_sessions ADD COLUMN local_host TEXT NOT NULL DEFAULT '127.0.0.1'`,
`ALTER TABLE remote_sessions ADD COLUMN local_port INTEGER NOT NULL DEFAULT 22`, `ALTER TABLE remote_sessions ADD COLUMN local_port INTEGER NOT NULL DEFAULT 22`,
`ALTER TABLE remote_sessions ADD COLUMN command_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE remote_sessions ADD COLUMN command_id TEXT NOT NULL DEFAULT ''`,
@@ -832,6 +836,9 @@ func (s *Store) CreateRemoteSession(ctx context.Context, session model.RemoteSes
if session.ServerPort <= 0 { if session.ServerPort <= 0 {
session.ServerPort = 22 session.ServerPort = 22
} }
if session.LuCIScheme == "" {
session.LuCIScheme = "http"
}
if session.CreatedAt.IsZero() { if session.CreatedAt.IsZero() {
session.CreatedAt = parseTime(now) session.CreatedAt = parseTime(now)
} }
@@ -840,9 +847,9 @@ func (s *Store) CreateRemoteSession(ctx context.Context, session model.RemoteSes
} }
_, err = s.db.ExecContext(ctx, ` _, err = s.db.ExecContext(ctx, `
INSERT INTO remote_sessions (id, device_id, target, status, server_host, server_port, remote_port, local_host, local_port, command_id, created_at, expires_at, updated_at) INSERT INTO remote_sessions (id, device_id, target, status, server_host, server_port, remote_port, luci_port, luci_scheme, local_host, local_port, command_id, created_at, expires_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, session.ID, session.DeviceID, session.Target, session.Status, session.ServerHost, session.ServerPort, session.RemotePort, session.LocalHost, session.LocalPort, session.CommandID, session.CreatedAt.Format(time.RFC3339Nano), session.ExpiresAt.Format(time.RFC3339Nano), now) `, session.ID, session.DeviceID, session.Target, session.Status, session.ServerHost, session.ServerPort, session.RemotePort, session.LuCIPort, session.LuCIScheme, session.LocalHost, session.LocalPort, session.CommandID, session.CreatedAt.Format(time.RFC3339Nano), session.ExpiresAt.Format(time.RFC3339Nano), now)
if err != nil { if err != nil {
return model.RemoteSession{}, false, err return model.RemoteSession{}, false, err
} }
@@ -865,7 +872,7 @@ func (s *Store) ListRemoteSessions(ctx context.Context, deviceID string, opts Re
limit = 25 limit = 25
} }
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT id, device_id, target, status, server_host, server_port, remote_port, local_host, local_port, command_id, created_at, expires_at, started_at, closed_at SELECT id, device_id, target, status, server_host, server_port, remote_port, luci_port, luci_scheme, local_host, local_port, command_id, created_at, expires_at, started_at, closed_at
FROM remote_sessions FROM remote_sessions
WHERE device_id = ? WHERE device_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -891,7 +898,7 @@ func (s *Store) GetRemoteSession(ctx context.Context, deviceID, sessionID string
return model.RemoteSession{}, false, err return model.RemoteSession{}, false, err
} }
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT id, device_id, target, status, server_host, server_port, remote_port, local_host, local_port, command_id, created_at, expires_at, started_at, closed_at SELECT id, device_id, target, status, server_host, server_port, remote_port, luci_port, luci_scheme, local_host, local_port, command_id, created_at, expires_at, started_at, closed_at
FROM remote_sessions FROM remote_sessions
WHERE device_id = ? AND id = ? WHERE device_id = ? AND id = ?
`, deviceID, sessionID) `, deviceID, sessionID)
@@ -1172,6 +1179,8 @@ func scanRemoteSession(s scanner) (model.RemoteSession, error) {
&session.ServerHost, &session.ServerHost,
&session.ServerPort, &session.ServerPort,
&session.RemotePort, &session.RemotePort,
&session.LuCIPort,
&session.LuCIScheme,
&session.LocalHost, &session.LocalHost,
&session.LocalPort, &session.LocalPort,
&session.CommandID, &session.CommandID,
+8
View File
@@ -66,6 +66,7 @@ const els = {
remoteServerPort: document.querySelector("#remoteServerPort"), remoteServerPort: document.querySelector("#remoteServerPort"),
remotePort: document.querySelector("#remotePort"), remotePort: document.querySelector("#remotePort"),
remoteLocalPort: document.querySelector("#remoteLocalPort"), remoteLocalPort: document.querySelector("#remoteLocalPort"),
remoteLuCIScheme: document.querySelector("#remoteLuCIScheme"),
remoteDuration: document.querySelector("#remoteDuration"), remoteDuration: document.querySelector("#remoteDuration"),
createRemoteSessionBtn: document.querySelector("#createRemoteSessionBtn"), createRemoteSessionBtn: document.querySelector("#createRemoteSessionBtn"),
reloadRemoteSessionsBtn: document.querySelector("#reloadRemoteSessionsBtn"), reloadRemoteSessionsBtn: document.querySelector("#reloadRemoteSessionsBtn"),
@@ -667,6 +668,7 @@ function renderRemoteSessions(sessions) {
const canClose = ["requested", "queued", "active"].includes(session.status); const canClose = ["requested", "queued", "active"].includes(session.status);
const endpoint = `${session.server_host || "-"}:${session.remote_port || "-"}`; const endpoint = `${session.server_host || "-"}:${session.remote_port || "-"}`;
const connectCommand = session.remote_port ? `ssh -p ${session.remote_port} root@${session.server_host || "server"}` : "-"; const connectCommand = session.remote_port ? `ssh -p ${session.remote_port} root@${session.server_host || "server"}` : "-";
const canOpenLuCI = session.status === "active" && session.luci_port;
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "row remote-session-row"; row.className = "row remote-session-row";
row.innerHTML = ` row.innerHTML = `
@@ -678,10 +680,14 @@ function renderRemoteSessions(sessions) {
<span>expires ${escapeHtml(formatShortDate(session.expires_at))}</span> <span>expires ${escapeHtml(formatShortDate(session.expires_at))}</span>
<code>${escapeHtml(connectCommand)}</code> <code>${escapeHtml(connectCommand)}</code>
<div class="row-actions"> <div class="row-actions">
<button type="button" data-action="luci" ${canOpenLuCI ? "" : "disabled"}>Open LuCI</button>
<button type="button" data-action="commands">Commands</button> <button type="button" data-action="commands">Commands</button>
<button type="button" data-action="close" ${canClose ? "" : "disabled"}>Close</button> <button type="button" data-action="close" ${canClose ? "" : "disabled"}>Close</button>
</div> </div>
`; `;
row.querySelector('[data-action="luci"]').addEventListener("click", () => {
window.open(`/luci/${encodeURIComponent(session.device_id)}/${encodeURIComponent(session.id)}/`, "_blank", "noopener");
});
row.querySelector('[data-action="commands"]').addEventListener("click", scrollToCommands); row.querySelector('[data-action="commands"]').addEventListener("click", scrollToCommands);
row.querySelector('[data-action="close"]').addEventListener("click", () => closeRemoteSession(session.id)); row.querySelector('[data-action="close"]').addEventListener("click", () => closeRemoteSession(session.id));
els.remoteSessionList.appendChild(row); els.remoteSessionList.appendChild(row);
@@ -796,6 +802,7 @@ async function createRemoteSession() {
const serverPort = Number(els.remoteServerPort.value || 2222); const serverPort = Number(els.remoteServerPort.value || 2222);
const remotePort = Number(els.remotePort.value || 0); const remotePort = Number(els.remotePort.value || 0);
const localPort = Number(els.remoteLocalPort.value || 22); const localPort = Number(els.remoteLocalPort.value || 22);
const luciScheme = els.remoteLuCIScheme.value || "http";
const durationSeconds = Number(els.remoteDuration.value || 900); const durationSeconds = Number(els.remoteDuration.value || 900);
if (!serverHost) { if (!serverHost) {
setStatus("Tunnel server is required"); setStatus("Tunnel server is required");
@@ -811,6 +818,7 @@ async function createRemoteSession() {
server_port: serverPort, server_port: serverPort,
remote_port: remotePort, remote_port: remotePort,
local_port: localPort, local_port: localPort,
luci_scheme: luciScheme,
duration_seconds: durationSeconds, duration_seconds: durationSeconds,
}), }),
}); });
+9 -2
View File
@@ -328,6 +328,13 @@
Router SSH port Router SSH port
<input id="remoteLocalPort" type="number" min="1" max="65535" value="22"> <input id="remoteLocalPort" type="number" min="1" max="65535" value="22">
</label> </label>
<label>
LuCI protocol
<select id="remoteLuCIScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</label>
<label> <label>
Duration Duration
<select id="remoteDuration"> <select id="remoteDuration">
@@ -337,10 +344,10 @@
<option value="7200">2 hours</option> <option value="7200">2 hours</option>
</select> </select>
</label> </label>
<button id="createRemoteSessionBtn" type="button">Open SSH access</button> <button id="createRemoteSessionBtn" type="button">Open remote access</button>
</div> </div>
<div class="remote-hint"> <div class="remote-hint">
Docker/Linux: tunnel endpoint must accept SSH for user <strong>rmm-tunnel</strong> and allow reverse ports. Active sessions provide SSH and an authenticated Open LuCI button. Choose the protocol used by this router.
</div> </div>
<div id="remoteSessionList" class="table-list remote-session-list"></div> <div id="remoteSessionList" class="table-list remote-session-list"></div>
</section> </section>
+2 -1
View File
@@ -251,7 +251,7 @@ select {
.remote-form { .remote-form {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, 0.6fr)) auto; grid-template-columns: minmax(180px, 1.2fr) repeat(5, minmax(110px, 0.6fr)) auto;
gap: 10px; gap: 10px;
align-items: end; align-items: end;
} }
@@ -780,6 +780,7 @@ select {
.command-form, .command-form,
.fleet-form, .fleet-form,
.package-grid, .package-grid,
.remote-form,
.health-summary, .health-summary,
.diagnostic-grid, .diagnostic-grid,
.uci-grid, .uci-grid,