package httpapi import ( "context" "fmt" "html/template" "net/http" "strings" "time" "rmm-openwrt/server/internal/model" "rmm-openwrt/server/internal/store" ) const ( deviceAccessGrantTTL = 60 * time.Second deviceAccessSessionTTL = 2 * time.Hour ) type luciErrorPageData struct { Code string Title string Description string ActionLabel string ActionURL string ControlURL string RequestID string } var luciErrorPageTemplate = template.Must(template.New("luci-error").Parse(` {{.Title}} · OpenWrt RMM
R OpenWrt RMM
RMMоблакоLuCI
{{.Code}}

{{.Title}}

{{.Description}}

Настройки роутера не изменялись. Можно безопасно вернуться в RMM и проверить состояние агента или запустить диагностику.
{{if .RequestID}}
ID запроса: {{.RequestID}}
{{end}}
`)) func luciErrorCopy(status int) (title, description, action string) { switch status { case http.StatusUnauthorized: return "Срок доступа истёк", "Временная ссылка или сессия LuCI больше не действует. Создайте новый безопасный доступ из RMM.", "Вернуться в RMM" case http.StatusForbidden: return "Доступ отклонён", "У вашей учётной записи нет разрешения на этот запрос к LuCI.", "Вернуться в RMM" case http.StatusNotFound: return "Сессия больше не активна", "Удалённая сессия закрыта или роутер ещё не подтвердил её запуск.", "Вернуться в RMM" case http.StatusConflict: return "Туннель ещё запускается", "Роутер получил команду, но защищённый канал пока не готов. Обычно это занимает несколько секунд.", "Проверить ещё раз" case http.StatusTooManyRequests: return "Слишком много запросов", "Лимит временных ссылок достигнут. Подождите минуту и повторите попытку.", "Вернуться в RMM" case http.StatusBadGateway: return "LuCI недоступен", "Туннель работает, но веб-интерфейс роутера не отвечает.", "Повторить подключение" case http.StatusGatewayTimeout: return "LuCI не отвечает", "Роутер на связи, но веб-интерфейс не ответил вовремя.", "Повторить подключение" default: return "Не удалось открыть LuCI", "На сервере произошла ошибка при подготовке удалённого доступа.", "Вернуться в RMM" } } func (a *App) writeLuCIError(w http.ResponseWriter, r *http.Request, status int, message string) { if !strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") { writeError(w, status, message) return } title, description, actionLabel := luciErrorCopy(status) controlURL := "/" if a.publicURL != "" { controlURL = a.publicURL + "/" } actionURL := controlURL if status == http.StatusBadGateway || status == http.StatusGatewayTimeout || status == http.StatusConflict { actionURL = r.URL.Path if actionURL == "" { actionURL = "/" } } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Language", "ru") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(status) if err := luciErrorPageTemplate.Execute(w, luciErrorPageData{ Code: fmt.Sprintf("%d · %s", status, strings.ToUpper(http.StatusText(status))), Title: title, Description: description, ActionLabel: actionLabel, ActionURL: actionURL, ControlURL: controlURL, RequestID: requestID(r.Context()), }); err != nil { // Headers are already sent; keep the failure observable without exposing internals to the browser. logStructured(map[string]any{"event": "luci.error_page_failed", "request_id": requestID(r.Context()), "error": err.Error()}) } } 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 } principal, _ := principalFromContext(r.Context()) accessURL, expiresAt, err := a.createDeviceAccessGrant(r.Context(), principal.User.ID, principal.User.Username, device, session) if 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 } writeJSON(w, http.StatusCreated, map[string]any{"url": accessURL, "expires_at": expiresAt}) } func (a *App) createDeviceAccessGrant(ctx context.Context, userID, username string, device model.Device, session model.RemoteSession) (string, time.Time, error) { rawToken, err := randomToken(32) if err != nil { return "", time.Time{}, err } expiresAt := time.Now().UTC().Add(deviceAccessGrantTTL) if err := a.store.CreateDeviceAccessGrant(ctx, store.TokenHash(rawToken), userID, device.ID, session.ID, expiresAt); err != nil { return "", time.Time{}, err } domainName := device.DNSLabel + "." + a.deviceDomain accessURL := a.publicScheme + "://" + domainName + "/_rmm/access?token=" + rawToken _, _ = a.store.AddAuditEvent(ctx, username, "luci.access_grant", device.ID, "", mustJSON(map[string]any{ "remote_session_id": session.ID, "expires_at": expiresAt, "request_id": requestID(ctx), })) return accessURL, expiresAt, nil } 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 == "" { a.writeLuCIError(w, r, http.StatusUnauthorized, "LuCI access grant is required") return } route, found, err := a.store.AuthorizeDeviceAccessSession(r.Context(), store.TokenHash(cookie.Value), dnsLabel) if err != nil { a.writeLuCIError(w, r, http.StatusInternalServerError, "failed to authorize LuCI access") return } if !found { a.writeLuCIError(w, r, http.StatusUnauthorized, "LuCI access session has expired") return } if unsafeMethod(r.Method) && !sameOrigin(r) { a.writeLuCIError(w, r, 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 { a.writeLuCIError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } rawGrant := strings.TrimSpace(r.URL.Query().Get("token")) if rawGrant == "" { a.writeLuCIError(w, r, http.StatusBadRequest, "access token is required") return } rawSession, err := randomToken(32) if err != nil { a.writeLuCIError(w, r, 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 { a.writeLuCIError(w, r, http.StatusInternalServerError, "failed to consume LuCI access grant") return } if !found || route.DNSLabel != dnsLabel { a.writeLuCIError(w, r, 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) }