diff --git a/agent/openwrt/rmm-agent.sh b/agent/openwrt/rmm-agent.sh index ab1b7dc..458fb8e 100644 --- a/agent/openwrt/rmm-agent.sh +++ b/agent/openwrt/rmm-agent.sh @@ -551,6 +551,8 @@ remote_ssh_reverse_output() { server_host="$(command_arg_string "$args" "server_host")" server_port="$(command_arg_string "$args" "server_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_port="$(command_arg_string "$args" "local_port")" server_user="$(command_arg_string "$args" "server_user")" @@ -559,6 +561,7 @@ remote_ssh_reverse_output() { [ -n "$server_port" ] || server_port="22" [ -n "$local_host" ] || local_host="127.0.0.1" [ -n "$local_port" ] || local_port="22" + [ -n "$luci_local_port" ] || luci_local_port="80" [ -n "$server_user" ] || server_user="rmm-tunnel" [ -n "$duration_seconds" ] || duration_seconds="900" @@ -566,7 +569,7 @@ remote_ssh_reverse_output() { printf 'remote tunnel host or user is invalid\n' return 2 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' return 2 fi @@ -582,10 +585,10 @@ remote_ssh_reverse_output() { fi if command -v ssh >/dev/null 2>&1; then # 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 # 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 printf 'remote ssh reverse requires ssh or dbclient on router\n' return 2 @@ -603,6 +606,7 @@ remote_ssh_reverse_output() { printf 'remote ssh reverse started\n' 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 'LuCI proxy endpoint: %s:%s -> 127.0.0.1:%s\n' "$server_host" "$luci_port" "$luci_local_port" printf 'log=%s\n' "$log_file" return 0 } diff --git a/compose.yaml b/compose.yaml index 7c435bf..9f33815 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,7 @@ services: RMM_OPERATOR_PASSWORD: "${RMM_OPERATOR_PASSWORD:-dev-operator-password}" RMM_SESSION_SECRET: "${RMM_SESSION_SECRET:-dev-session-secret-change-me}" RMM_COOKIE_SECURE: "${RMM_COOKIE_SECURE:-false}" + RMM_TUNNEL_HTTP_HOST: tunnel-ssh ports: - "${RMM_HTTP_BIND_IP:-0.0.0.0}:${RMM_HTTP_PORT:-18080}:8080" volumes: diff --git a/docs/api.md b/docs/api.md index b46ab9c..ab4c1f1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -463,6 +463,7 @@ Request: "server_port": 22, "remote_port": 22022, "local_port": 22, + "luci_scheme": "https", "duration_seconds": 900 } ``` @@ -478,6 +479,8 @@ Response: "server_host": "10.10.10.2", "server_port": 22, "remote_port": 22022, + "luci_port": 22122, + "luci_scheme": "https", "local_host": "127.0.0.1", "local_port": 22, "command_id": "cmd_...", @@ -493,14 +496,18 @@ GET /api/devices/{device_id}/remote-sessions?limit=25 Authorization: Bearer dev-operator-token ``` -Close a session record: +Close a session and queue tunnel termination on the agent: ```http POST /api/devices/{device_id}/remote-sessions/{session_id}/close 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 diff --git a/docs/docker-compose.md b/docs/docker-compose.md index e75fe13..fc1ba31 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -10,6 +10,7 @@ Published ports: - `18080/tcp`: RMM API and web UI. - `2222/tcp`: router-to-server SSH tunnel connection. - `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 diff --git a/docs/remote-access.md b/docs/remote-access.md index a2b1d79..f6bca33 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -5,6 +5,7 @@ Remote access is now implemented as an MVP reverse SSH session flow. The server ## Current Capabilities - 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 audit with device, command id, endpoint, and expiration. - 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. 2. Server creates a `remote_sessions` record with expiration. 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`. -5. Operator connects to the server endpoint shown by the UI. +4. Agent runs `ssh` or `dbclient` and requests reverse forwards for router SSH and LuCI. +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. ## 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` - 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 diff --git a/server/cmd/rmm-server/main.go b/server/cmd/rmm-server/main.go index 8908173..6991a23 100644 --- a/server/cmd/rmm-server/main.go +++ b/server/cmd/rmm-server/main.go @@ -29,6 +29,7 @@ func main() { OperatorPassword: env("RMM_OPERATOR_PASSWORD", "dev-operator-password"), SessionSecret: env("RMM_SESSION_SECRET", "dev-session-secret-change-me"), CookieSecure: envBool("RMM_COOKIE_SECURE", false), + TunnelHTTPHost: env("RMM_TUNNEL_HTTP_HOST", "tunnel-ssh"), StaticDir: env("RMM_WEB_DIR", "web"), }) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 5638666..487a9ea 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -1,14 +1,19 @@ package httpapi import ( + "bytes" "context" "crypto/rand" + "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" + "io" "log" "net/http" + "net/http/httputil" + "net/url" "os" "path/filepath" "strconv" @@ -53,6 +58,7 @@ type Config struct { OperatorPassword string SessionSecret string CookieSecure bool + TunnelHTTPHost string StaticDir string } @@ -64,6 +70,7 @@ type App struct { operatorPassword string sessionSecret []byte cookieSecure bool + tunnelHTTPHost string } type contextKey string @@ -103,6 +110,7 @@ type remoteSessionRequest struct { ServerPort int `json:"server_port"` RemotePort int `json:"remote_port"` LocalPort int `json:"local_port"` + LuCIScheme string `json:"luci_scheme"` } type fleetRequest struct { @@ -151,6 +159,10 @@ func NewHandler(s Store, cfg Config) http.Handler { operatorPassword: cfg.OperatorPassword, sessionSecret: []byte(cfg.SessionSecret), cookieSecure: cfg.CookieSecure, + tunnelHTTPHost: strings.TrimSpace(cfg.TunnelHTTPHost), + } + if a.tunnelHTTPHost == "" { + a.tunnelHTTPHost = "tunnel-ssh" } 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("PATCH /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) 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) { 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 { 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") return } @@ -962,6 +985,8 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request) ServerHost: serverHost, ServerPort: serverPort, RemotePort: remotePort, + LuCIPort: luciPort, + LuCIScheme: luciScheme, LocalHost: "127.0.0.1", LocalPort: localPort, ExpiresAt: expiresAt, @@ -975,11 +1000,17 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request) return } + luciLocalPort := "80" + if session.LuCIScheme == "https" { + luciLocalPort = "443" + } args := mustJSON(map[string]any{ "session_id": session.ID, "server_host": session.ServerHost, "server_port": strconv.Itoa(session.ServerPort), "remote_port": strconv.Itoa(session.RemotePort), + "luci_port": strconv.Itoa(session.LuCIPort), + "luci_local_port": luciLocalPort, "local_host": session.LocalHost, "local_port": strconv.Itoa(session.LocalPort), "server_user": "rmm-tunnel", @@ -1007,6 +1038,8 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request) "server_host": session.ServerHost, "server_port": session.ServerPort, "remote_port": session.RemotePort, + "luci_port": session.LuCIPort, + "luci_scheme": session.LuCIScheme, "local_port": session.LocalPort, "expires_at": session.ExpiresAt, "request_id": requestID(r.Context()), @@ -1014,6 +1047,110 @@ func (a *App) handleCreateRemoteSession(w http.ResponseWriter, r *http.Request) 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) { deviceID, sessionID, ok := remoteSessionCloseIDsFromPath(r.URL.Path) 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{ "session_id": session.ID, "remote_port": strconv.Itoa(session.RemotePort), + "luci_port": strconv.Itoa(session.LuCIPort), })) if err != nil || !commandFound { writeError(w, http.StatusInternalServerError, "failed to queue remote session close") @@ -1264,6 +1402,15 @@ func randomRemotePort() int { 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 { defer r.Body.Close() dec := json.NewDecoder(r.Body) diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index 2a44f62..af563b6 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -8,12 +8,15 @@ import ( "net/http" "net/http/cookiejar" "net/http/httptest" + "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "rmm-openwrt/server/internal/httpapi" + "rmm-openwrt/server/internal/model" "rmm-openwrt/server/internal/store" ) @@ -263,9 +266,10 @@ func TestAgentOperatorSmokeFlow(t *testing.T) { } var uciCommand struct { - ID string `json:"id"` - Type string `json:"type"` - Status string `json:"status"` + ID string `json:"id"` + Type string `json:"type"` + 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{ "type": "uci_show", @@ -305,6 +309,7 @@ func TestAgentOperatorSmokeFlow(t *testing.T) { ServerHost string `json:"server_host"` ServerPort int `json:"server_port"` RemotePort int `json:"remote_port"` + LuCIPort int `json:"luci_port"` LocalPort int `json:"local_port"` } 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, "remote_port": 22022, "local_port": 22, + "luci_scheme": "https", "duration_seconds": 900, }, 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) } var remoteSessions struct { @@ -333,6 +339,9 @@ func TestAgentOperatorSmokeFlow(t *testing.T) { if uciCommand.Type != "remote_ssh_reverse" || uciCommand.Status != "queued" { 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{ "device_id": enrolled.DeviceID, "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, `LuCI`) + })) + 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) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("RMM UI"), 0644); err != nil { diff --git a/server/internal/model/model.go b/server/internal/model/model.go index f8b5230..8c7eb67 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -80,6 +80,8 @@ type RemoteSession struct { ServerHost string `json:"server_host,omitempty"` ServerPort int `json:"server_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"` LocalPort int `json:"local_port,omitempty"` CommandID string `json:"command_id,omitempty"` diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index 20caa7f..61b7232 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -158,6 +158,8 @@ CREATE TABLE IF NOT EXISTS remote_sessions ( server_host TEXT NOT NULL DEFAULT '', server_port INTEGER NOT NULL DEFAULT 22, 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_port INTEGER NOT NULL DEFAULT 22, 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_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 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_port INTEGER NOT NULL DEFAULT 22`, `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 { session.ServerPort = 22 } + if session.LuCIScheme == "" { + session.LuCIScheme = "http" + } if session.CreatedAt.IsZero() { session.CreatedAt = parseTime(now) } @@ -840,9 +847,9 @@ func (s *Store) CreateRemoteSession(ctx context.Context, session model.RemoteSes } _, 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) -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) +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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, 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 { return model.RemoteSession{}, false, err } @@ -865,7 +872,7 @@ func (s *Store) ListRemoteSessions(ctx context.Context, deviceID string, opts Re limit = 25 } 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 WHERE device_id = ? ORDER BY created_at DESC @@ -891,7 +898,7 @@ func (s *Store) GetRemoteSession(ctx context.Context, deviceID, sessionID string return model.RemoteSession{}, false, err } 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 WHERE device_id = ? AND id = ? `, deviceID, sessionID) @@ -1172,6 +1179,8 @@ func scanRemoteSession(s scanner) (model.RemoteSession, error) { &session.ServerHost, &session.ServerPort, &session.RemotePort, + &session.LuCIPort, + &session.LuCIScheme, &session.LocalHost, &session.LocalPort, &session.CommandID, diff --git a/web/app.js b/web/app.js index 46b3180..072f6e6 100644 --- a/web/app.js +++ b/web/app.js @@ -66,6 +66,7 @@ const els = { remoteServerPort: document.querySelector("#remoteServerPort"), remotePort: document.querySelector("#remotePort"), remoteLocalPort: document.querySelector("#remoteLocalPort"), + remoteLuCIScheme: document.querySelector("#remoteLuCIScheme"), remoteDuration: document.querySelector("#remoteDuration"), createRemoteSessionBtn: document.querySelector("#createRemoteSessionBtn"), reloadRemoteSessionsBtn: document.querySelector("#reloadRemoteSessionsBtn"), @@ -667,6 +668,7 @@ function renderRemoteSessions(sessions) { const canClose = ["requested", "queued", "active"].includes(session.status); const endpoint = `${session.server_host || "-"}:${session.remote_port || "-"}`; 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"); row.className = "row remote-session-row"; row.innerHTML = ` @@ -678,10 +680,14 @@ function renderRemoteSessions(sessions) { expires ${escapeHtml(formatShortDate(session.expires_at))} ${escapeHtml(connectCommand)}
+
`; + 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="close"]').addEventListener("click", () => closeRemoteSession(session.id)); els.remoteSessionList.appendChild(row); @@ -796,6 +802,7 @@ async function createRemoteSession() { const serverPort = Number(els.remoteServerPort.value || 2222); const remotePort = Number(els.remotePort.value || 0); const localPort = Number(els.remoteLocalPort.value || 22); + const luciScheme = els.remoteLuCIScheme.value || "http"; const durationSeconds = Number(els.remoteDuration.value || 900); if (!serverHost) { setStatus("Tunnel server is required"); @@ -811,6 +818,7 @@ async function createRemoteSession() { server_port: serverPort, remote_port: remotePort, local_port: localPort, + luci_scheme: luciScheme, duration_seconds: durationSeconds, }), }); diff --git a/web/index.html b/web/index.html index b1ade5b..a7bd645 100644 --- a/web/index.html +++ b/web/index.html @@ -328,6 +328,13 @@ Router SSH port + - +
- Docker/Linux: tunnel endpoint must accept SSH for user rmm-tunnel and allow reverse ports. + Active sessions provide SSH and an authenticated Open LuCI button. Choose the protocol used by this router.
diff --git a/web/styles.css b/web/styles.css index ad8e9b8..bb5b5c7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -251,7 +251,7 @@ select { .remote-form { 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; align-items: end; } @@ -780,6 +780,7 @@ select { .command-form, .fleet-form, .package-grid, + .remote-form, .health-summary, .diagnostic-grid, .uci-grid,