Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
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.
201 строка
8.4 KiB
Go
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
|
|
}
|