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) {