Files
worker/internal/webapp/routes.go
root ff0d2f088f
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
feat(worker): add Docker Compose discovery and management
Add an internal/compose package that discovers Compose projects via
`docker compose ls` + `docker ps` labels (grouped by
com.docker.compose.project/service) and enriches each container with
`docker inspect` ports/mounts and Traefik router labels. Management
runs `docker compose` in each project's working directory for
up/down/stop/restart/pull plus per-service variants and log tails.

Wire it into the webapp: a 60s ComposeRefresher (constructed in New,
started in Start, stopped in Close), a /compose list + detail + logs
HTML surface, and /web/api/compose/* JSON endpoints (list, detail,
logs, project/service lifecycle). Browser lifecycle POSTs are
session+CSRF protected; the /web/api/* variants accept HTTP basic auth.
WORKER_COMPOSE_ENABLED defaults on (false to disable).

Tests cover discovery parsing/grouping/traefik/summary, the action
allowlists, and the full handler surface (list/detail/logs HTML+API,
CSRF enforcement, disabled/unknown-action rejection, audit writes,
success+failure exec paths) via a stub Docker binary.
2026-07-29 21:31:52 +03:00

201 строка
8.4 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))
// Docker Compose discovery + management. The list/detail/logs pages
// are session-protected HTML; the lifecycle endpoints accept either a
// session (browser, CSRF-checked in the handler) or HTTP basic auth
// (scripting, on the /web/api/compose/* prefix). Compose is disabled
// per host via WORKER_COMPOSE_ENABLED=false; when off, the management
// handlers return 503 and the list page renders a banner.
s.mux.Handle("GET /compose", s.requireSession(s.handleComposeList))
s.mux.Handle("GET /compose/{project}", s.requireSession(s.handleComposeDetail))
s.mux.Handle("GET /compose/{project}/logs", s.requireSession(s.handleComposeLogsPage))
s.mux.Handle("POST /compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
s.mux.Handle("POST /compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
s.mux.Handle("GET /web/api/compose", s.requireSession(s.handleComposeAPIList))
s.mux.Handle("GET /web/api/compose/{project}", s.requireSession(s.handleComposeAPIDetail))
s.mux.Handle("GET /web/api/compose/{project}/logs", s.requireSession(s.handleComposeLogsAPI))
s.mux.Handle("POST /web/api/compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
s.mux.Handle("POST /web/api/compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
// 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
}