Files
rmm-openwrt/server/internal/httpapi/luci_access.go

159 lines
5.3 KiB
Go

package httpapi
import (
"net/http"
"strings"
"time"
"rmm-openwrt/server/internal/store"
)
const (
deviceAccessGrantTTL = 60 * time.Second
deviceAccessSessionTTL = 2 * time.Hour
)
func (a *App) handleCreateDeviceAccess(w http.ResponseWriter, r *http.Request) {
if a.deviceDomain == "" {
writeError(w, http.StatusConflict, "device domain is not configured")
return
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 6 {
writeError(w, http.StatusNotFound, "not found")
return
}
deviceID, remoteSessionID := parts[2], parts[4]
device, found, err := a.store.GetDevice(r.Context(), deviceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load device")
return
}
if !found || device.DNSLabel == "" {
writeError(w, http.StatusNotFound, "device DNS name is unavailable")
return
}
session, found, err := a.store.GetRemoteSession(r.Context(), deviceID, remoteSessionID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load remote session")
return
}
if !found || session.Status != "active" || session.LuCIPort <= 0 || !session.ExpiresAt.After(time.Now().UTC()) {
writeError(w, http.StatusConflict, "LuCI remote session is not active")
return
}
rawToken, err := randomToken(32)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create LuCI access grant")
return
}
principal, _ := principalFromContext(r.Context())
expiresAt := time.Now().UTC().Add(deviceAccessGrantTTL)
if err := a.store.CreateDeviceAccessGrant(r.Context(), store.TokenHash(rawToken), principal.User.ID, deviceID, remoteSessionID, expiresAt); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "limit") {
writeError(w, http.StatusTooManyRequests, "active LuCI access grant limit reached")
return
}
writeError(w, http.StatusInternalServerError, "failed to create LuCI access grant")
return
}
domainName := device.DNSLabel + "." + a.deviceDomain
accessURL := a.publicScheme + "://" + domainName + "/_rmm/access?token=" + rawToken
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "luci.access_grant", deviceID, "", mustJSON(map[string]any{
"remote_session_id": remoteSessionID,
"expires_at": expiresAt,
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusCreated, map[string]any{"url": accessURL, "expires_at": expiresAt})
}
func (a *App) routeByHost(control http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if label, ok := a.deviceLabelFromHost(r.Host); ok {
a.handleDeviceHost(w, r, label)
return
}
control.ServeHTTP(w, r)
})
}
func (a *App) deviceLabelFromHost(host string) (string, bool) {
if a.deviceDomain == "" {
return "", false
}
host = strings.ToLower(hostWithoutPort(host))
suffix := "." + a.deviceDomain
if !strings.HasSuffix(host, suffix) {
return "", false
}
label := strings.TrimSuffix(host, suffix)
return label, dnsLabelPattern.MatchString(label)
}
func (a *App) handleDeviceHost(w http.ResponseWriter, r *http.Request, dnsLabel string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.URL.Path == "/_rmm/access" {
a.consumeDeviceAccess(w, r, dnsLabel)
return
}
cookie, err := r.Cookie(deviceAccessCookie)
if err != nil || cookie.Value == "" {
writeError(w, http.StatusUnauthorized, "LuCI access grant is required")
return
}
route, found, err := a.store.AuthorizeDeviceAccessSession(r.Context(), store.TokenHash(cookie.Value), dnsLabel)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to authorize LuCI access")
return
}
if !found {
writeError(w, http.StatusUnauthorized, "LuCI access session has expired")
return
}
if unsafeMethod(r.Method) && !sameOrigin(r) {
writeError(w, http.StatusForbidden, "cross-origin request rejected")
return
}
a.proxyLuCIWithPrefix(w, r, route.DeviceID, route.RemoteSessionID, r.URL.Path, "")
}
func (a *App) consumeDeviceAccess(w http.ResponseWriter, r *http.Request, dnsLabel string) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
rawGrant := strings.TrimSpace(r.URL.Query().Get("token"))
if rawGrant == "" {
writeError(w, http.StatusBadRequest, "access token is required")
return
}
rawSession, err := randomToken(32)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create LuCI access session")
return
}
route, found, err := a.store.ConsumeDeviceAccessGrant(r.Context(), store.TokenHash(rawGrant), store.TokenHash(rawSession), dnsLabel, time.Now().UTC().Add(deviceAccessSessionTTL))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to consume LuCI access grant")
return
}
if !found || route.DNSLabel != dnsLabel {
writeError(w, http.StatusUnauthorized, "invalid or expired LuCI access grant")
return
}
http.SetCookie(w, &http.Cookie{
Name: deviceAccessCookie,
Value: rawSession,
Path: "/",
HttpOnly: true,
Secure: a.cookieSecure,
SameSite: http.SameSiteStrictMode,
Expires: route.ExpiresAt,
MaxAge: int(time.Until(route.ExpiresAt).Seconds()),
})
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Location", "/cgi-bin/luci/")
w.WriteHeader(http.StatusSeeOther)
}