Files
worker/internal/webapp/routes.go
Gleb Tv e987f24903
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
fix(worker): harden control-plane lifecycle
- reconnect safely after token rotation and retry leased results
- reject malformed tasks and remove production cluster debug mutation
- validate environment files and require immutable container images

BREAKING CHANGE: Docker install, deploy, and Compose now require an
immutable repository@sha256 image reference.
2026-07-19 23:11:43 +03:00

184 строки
7.1 KiB
Go

package webapp
import (
"context"
"errors"
"net"
"net/http"
"path"
"strings"
)
// routes wires the HTTP mux for the Phase 1 webapp. The shape is
// deliberately stdlib-only (no gin) to keep the binary small and to
// follow section 12 of the plan doc (strict CSP, no inline scripts).
//
// Public routes (no auth): /web/login, /web/logout, /web/change-password.
// Authenticated routes: everything else. The middleware chain in
// middleware.go enforces this and adds CSRF / security headers.
//
// Handlers live in handlers_auth.go (login flow) and the per-page
// handlers_* files for the rest.
func (s *Server) routes() {
// Static assets are served via the embedded FS so no CDN is
// reachable from the worker webapp. They are public so the
// login page can render with the right CSS.
s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Auth flow (unauthenticated).
s.mux.HandleFunc("GET /web/login", s.handleLoginForm)
s.mux.HandleFunc("POST /web/login", s.handleLoginSubmit)
s.mux.HandleFunc("POST /web/logout", s.handleLogout)
// Change-password is reachable from /settings. The login flow
// does not force the operator through it any more (the
// requires_change flag is recorded on the user row but no
// longer enforced); the page is still here so an operator who
// wants to rotate the bcrypt password can do so explicitly.
s.mux.Handle("GET /web/change-password", s.requireSession(s.handleChangePasswordForm))
s.mux.Handle("POST /web/change-password", s.requireSession(s.handleChangePasswordSubmit))
// Authenticated pages.
s.mux.Handle("GET /{$}", s.requireSession(s.handleOverview)) // exact "/"
s.mux.Handle("GET /overview", s.requireSession(s.handleOverview))
s.mux.Handle("GET /apps", s.requireSession(s.handleApps))
s.mux.Handle("GET /apps/{id}", s.requireSession(s.handleAppDetail))
s.mux.Handle("GET /checks", s.requireSession(s.handleChecks))
s.mux.Handle("GET /notifications", s.requireSession(s.handleNotifications))
s.mux.Handle("GET /logs", s.requireSession(s.handleLogs))
s.mux.Handle("GET /status", s.requireSession(s.handleStatus))
s.mux.Handle("GET /settings", s.requireSession(s.handleSettings))
s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
// Health endpoint for the cmd health subcommand and for the
// operator to confirm the listener is up without going through the
// login form. Returns 200 with a tiny body.
s.mux.HandleFunc("GET /healthz", s.handleHealth)
// Cluster admin endpoints. The routes are always registered so
// SetCluster can attach/detach a cluster at runtime; the handlers
// themselves return 503 when no cluster subsystem is attached.
// Both routes require a session (the worker webapp is single-tenant
// so every logged-in operator is effectively an admin).
s.mux.Handle("GET /web/api/cluster/status", s.requireSession(s.handleClusterStatus))
// Cross-worker peer status. The path is intentionally under
// /api/ (not /web/api/) so the basic-auth middleware does not
// intercept it; peer workers are still expected to live in a
// trusted local network (slice 1 of
// docs/distributed/worker-to-worker-raft.md).
s.mux.HandleFunc("GET /api/peer/status", s.handlePeerStatus)
}
// securityHeaders wraps the mux with the response-header policy
// required by section 12 of the plan doc:
//
// - Cache-Control: no-store on every authenticated response
// (this is enforced inside handlers instead because the policy
// depends on whether the response is HTML or a 401 redirect;
// see writeNoStore).
// - CSP: default-src 'self'; no inline scripts, no eval. Phase 1
// serves no external assets, so 'self' is sufficient.
// - X-Content-Type-Options: nosniff.
// - Referrer-Policy: no-referrer (do not leak paths in referrers).
// - X-Frame-Options: DENY (the webapp is never meant to be
// framed, even on loopback).
//
// Secure / SameSite / HttpOnly on cookies is enforced inside the
// session helpers (see writeSessionCookie) because the values depend
// on whether the request arrived over a loopback connection.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; "+
"script-src 'self'; object-src 'none'; base-uri 'none'; "+
"frame-ancestors 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
// writeNoStore sets the response policy required for authenticated
// pages. Per section 12.4: every authenticated response returns
// Cache-Control: no-store so an operator on a shared kiosk browser
// cannot walk back to a logged-in view of the operator console.
func writeNoStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
}
// isLoopbackRequest reports whether the incoming TCP connection is
// from a loopback address. Used by the cookie helper to decide
// whether to set the Secure flag (a Secure cookie set over plain
// HTTP on loopback works fine, but the cookie helper only flips
// Secure when not on loopback for safety).
func isLoopbackRequest(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// RemoteAddr may already be just an IP if no port is
// present (some test servers).
host = r.RemoteAddr
}
if host == "" {
return false
}
if host == "::1" || strings.HasPrefix(host, "127.") {
return true
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return true
}
return false
}
// clientIP extracts the client IP from r.RemoteAddr. X-Forwarded-For
// is intentionally ignored: Phase 1 binds to loopback only, so any
// forwarded header would come from the operator's own browser and
// is not authoritative.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// Path returns the request's URL path with a leading slash so route
// lookups can compare with the registered pattern.
func Path(r *http.Request) string {
p := r.URL.Path
if p == "" {
p = "/"
}
return path.Clean(p)
}
// redirectTo sends a 302 to the given path. Used by the login and
// logout handlers.
func redirectTo(w http.ResponseWriter, r *http.Request, p string) {
http.Redirect(w, r, p, http.StatusFound)
}
// errMissingSession is the canonical "no session cookie" error for
// middleware. It is converted to a redirect to the login page by
// requireSession.
var errMissingSession = errors.New("webapp: no session")
// ctxWithSession attaches a session to the request context.
type ctxKey int
const (
ctxKeySession ctxKey = iota
)
// sessionFromContext returns the session attached by requireSession.
// The bool result is false if no session is attached.
func sessionFromContext(ctx context.Context) (*Session, bool) {
v, ok := ctx.Value(ctxKeySession).(*Session)
return v, ok
}