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)) s.mux.Handle("POST /web/api/cluster/apply-test-config", s.requireSession(s.handleClusterApplyTestConfig)) // 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 }