Add authenticated LuCI remote access
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user