Route dynamic LuCI requests through active session

This commit is contained in:
benya
2026-06-04 19:34:19 +03:00
parent 785e502b99
commit 6583aecfde
2 changed files with 78 additions and 4 deletions
+49 -4
View File
@@ -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") {
+29
View File
@@ -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, `<a href="/cgi-bin/luci/admin">LuCI</a><link href="/luci-static/test.css">`)
}))
@@ -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) {