From 6583aecfde9a33361024abc5be35876b19cefb89 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 4 Jun 2026 19:34:19 +0300 Subject: [PATCH] Route dynamic LuCI requests through active session --- server/internal/httpapi/server.go | 53 ++++++++++++++++++++++++-- server/internal/httpapi/server_test.go | 29 ++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 487a9ea..0af3d44 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -76,6 +76,7 @@ type App struct { type contextKey string const requestIDContextKey contextKey = "request_id" +const luciRouteCookie = "rmm_luci_route" type enrollRequest struct { EnrollmentToken string `json:"enrollment_token"` @@ -181,6 +182,10 @@ func NewHandler(s Store, cfg Config) http.Handler { 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))) + for _, path := range []string{"/cgi-bin/", "/luci-static/", "/ubus/", "/ubus"} { + mux.Handle("GET "+path, a.operatorAuth(http.HandlerFunc(a.handleLuCIFallback))) + mux.Handle("POST "+path, a.operatorAuth(http.HandlerFunc(a.handleLuCIFallback))) + } mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) @@ -1055,6 +1060,37 @@ func (a *App) handleLuCIProxy(w http.ResponseWriter, r *http.Request) { return } deviceID, sessionID := parts[0], parts[1] + http.SetCookie(w, &http.Cookie{ + Name: luciRouteCookie, + Value: deviceID + "|" + sessionID, + Path: "/", + HttpOnly: true, + Secure: a.cookieSecure, + SameSite: http.SameSiteStrictMode, + MaxAge: 2 * 60 * 60, + }) + upstreamPath := "/" + if len(parts) == 3 && parts[2] != "" { + upstreamPath += parts[2] + } + a.proxyLuCI(w, r, deviceID, sessionID, upstreamPath) +} + +func (a *App) handleLuCIFallback(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(luciRouteCookie) + if err != nil { + writeError(w, http.StatusNotFound, "no active LuCI route selected") + return + } + deviceID, sessionID, ok := strings.Cut(cookie.Value, "|") + if !ok || deviceID == "" || sessionID == "" { + writeError(w, http.StatusNotFound, "invalid LuCI route") + return + } + a.proxyLuCI(w, r, deviceID, sessionID, r.URL.Path) +} + +func (a *App) proxyLuCI(w http.ResponseWriter, r *http.Request, deviceID, sessionID, upstreamPath string) { session, found, err := a.store.GetRemoteSession(r.Context(), deviceID, sessionID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load LuCI session") @@ -1080,15 +1116,24 @@ func (a *App) handleLuCIProxy(w http.ResponseWriter, r *http.Request) { 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.URL.Path = upstreamPath req.Host = upstream.Host req.Header.Del("Accept-Encoding") removeCookie(req, operatorSessionCookie) + removeCookie(req, luciRouteCookie) } proxy.ModifyResponse = func(resp *http.Response) error { + if resp.StatusCode >= 400 { + logStructured(map[string]any{ + "event": "luci.upstream_error", + "request_id": requestID(r.Context()), + "device_id": deviceID, + "session_id": sessionID, + "path": upstreamPath, + "status": resp.StatusCode, + "content_type": resp.Header.Get("Content-Type"), + }) + } 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") { diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index af563b6..b3a15e0 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -406,10 +406,15 @@ func TestAgentOperatorSmokeFlow(t *testing.T) { } func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { + var upstreamPath string 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") } + if strings.Contains(r.Header.Get("Cookie"), "rmm_luci_route=") { + t.Fatal("LuCI route cookie leaked to LuCI upstream") + } + upstreamPath = r.URL.Path w.Header().Set("Content-Type", "text/html") _, _ = io.WriteString(w, `LuCI`) })) @@ -457,6 +462,30 @@ func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { 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) } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + for _, path := range []string{prefix + "/", "/cgi-bin/luci/admin"} { + req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer operator-test") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("LuCI route %s returned %d", path, resp.StatusCode) + } + } + if upstreamPath != "/cgi-bin/luci/admin" { + t.Fatalf("LuCI fallback used upstream path %q", upstreamPath) + } } func TestServesStaticWebUI(t *testing.T) {