package webapp import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "rocketgit.ru/rsmon/worker/internal/compose" ) // Compose management endpoints. The browser UI uses session+CSRF POSTs // under /compose; scripted callers use HTTP basic auth under // /web/api/compose (the /web/api/* prefix is the only one the auth // middleware accepts basic credentials on). Read endpoints (list, // detail, logs) are GET; lifecycle endpoints (up/down/stop/restart/pull) // are POST. Every mutation is audited via the shared webapp audit log. // handleComposeList renders the project list page. func (s *Server) handleComposeList(w http.ResponseWriter, r *http.Request) { writeNoStore(w) sess, _ := sessionFromContext(r.Context()) data := composeListPageData{ basePageData: s.newBasePage(r, "Compose projects", sess), Snapshot: s.compose.Snapshot(), LastAt: s.compose.LastRefreshAt(), Enabled: s.compose.Enabled(), } if err := s.templates.Execute(w, "compose.html", data); err != nil { s.deps.Logger.Printf("render compose: %v", err) http.Error(w, "template error", http.StatusInternalServerError) } } // handleComposeDetail renders one project with its services, containers, // mounts, and Traefik routes, plus the management action buttons. func (s *Server) handleComposeDetail(w http.ResponseWriter, r *http.Request) { writeNoStore(w) sess, _ := sessionFromContext(r.Context()) snap := s.compose.Snapshot() p := snap.FindProject(r.PathValue("project")) if p == nil { http.NotFound(w, r) return } data := composeDetailPageData{ basePageData: s.newBasePage(r, "Compose: "+p.Name, sess), Project: p, LastAt: s.compose.LastRefreshAt(), ActionOK: r.URL.Query().Get("ok") == "1", ActionMessage: r.URL.Query().Get("msg"), } if err := s.templates.Execute(w, "compose_detail.html", data); err != nil { s.deps.Logger.Printf("render compose detail: %v", err) http.Error(w, "template error", http.StatusInternalServerError) } } // handleComposeLogsPage renders the recent log tail for a project in a //
block so an operator can read it from the browser without curl.
func (s *Server) handleComposeLogsPage(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
project := r.PathValue("project")
res, status := s.composeLogsResult(r.Context(), project)
if status != http.StatusOK {
http.Error(w, res.Output, status)
return
}
data := composeLogsPageData{
basePageData: s.newBasePage(r, "Compose logs: "+project, sess),
Project: project,
Output: res.Output,
}
if err := s.templates.Execute(w, "compose_logs.html", data); err != nil {
s.deps.Logger.Printf("render compose logs: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeProjectAction runs a lifecycle action against a project
// from the HTML form (session+CSRF) or the API (basic auth). The
// response is a redirect for the browser path and JSON for the API path.
func (s *Server) handleComposeProjectAction(w http.ResponseWriter, r *http.Request) {
if !s.compose.Enabled() {
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
return
}
if !validProjectPostAction(r.PathValue("action")) {
http.NotFound(w, r)
return
}
if !s.composeMutationAuthorized(w, r) {
return
}
project := r.PathValue("project")
action := r.PathValue("action")
refs, ok := s.compose.Refs(project)
if !ok {
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
return
}
res, _ := compose.ManageProject(r.Context(), refs, compose.Action(action))
s.auditCompose(r, action, project, "")
s.respondComposeAction(w, r, project, res)
}
// handleComposeServiceAction runs a lifecycle action against a single
// service inside a project.
func (s *Server) handleComposeServiceAction(w http.ResponseWriter, r *http.Request) {
if !s.compose.Enabled() {
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
return
}
if _, ok := compose.ValidServiceAction(r.PathValue("action")); !ok {
http.NotFound(w, r)
return
}
if !s.composeMutationAuthorized(w, r) {
return
}
project := r.PathValue("project")
service := r.PathValue("service")
action := r.PathValue("action")
refs, ok := s.compose.Refs(project)
if !ok {
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
return
}
res, _ := compose.ManageService(r.Context(), refs, service, compose.Action(action))
s.auditCompose(r, action, project, service)
s.respondComposeAction(w, r, project, res)
}
// handleComposeAPIList returns the full discovery snapshot as JSON. The
// shape matches the /compose HTML page (a Snapshot: projects + errors).
func (s *Server) handleComposeAPIList(w http.ResponseWriter, r *http.Request) {
writeComposeJSON(w, s.compose.Snapshot())
}
// handleComposeAPIDetail returns one project summary as JSON.
func (s *Server) handleComposeAPIDetail(w http.ResponseWriter, r *http.Request) {
p := s.compose.Snapshot().FindProject(r.PathValue("project"))
if p == nil {
http.Error(w, "compose project not found", http.StatusNotFound)
return
}
writeComposeJSON(w, p)
}
// handleComposeLogsAPI returns the recent log tail for a project as JSON.
func (s *Server) handleComposeLogsAPI(w http.ResponseWriter, r *http.Request) {
res, status := s.composeLogsResult(r.Context(), r.PathValue("project"))
if status != http.StatusOK {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(res)
return
}
writeComposeJSON(w, res)
}
// composeLogsResult resolves the log tail for a project and the HTTP
// status the caller should write. A 404 means the project (or its
// working directory) is unknown; 502 means Docker refused the call.
func (s *Server) composeLogsResult(ctx context.Context, project string) (compose.ManagementResult, int) {
refs, ok := s.compose.Refs(project)
if !ok {
return compose.ManagementResult{OK: false, Action: "logs", Output: "compose project working directory is unknown"}, http.StatusNotFound
}
res, err := compose.Logs(ctx, refs, 300)
if err != nil && res.Output == "" {
return res, http.StatusBadGateway
}
return res, http.StatusOK
}
// composeMutationAuthorized enforces CSRF for browser (session) callers
// while letting basic-auth API callers through without a token (the
// middleware already validated their credentials). Returns true when the
// request may proceed; writes a 403 and returns false otherwise.
func (s *Server) composeMutationAuthorized(w http.ResponseWriter, r *http.Request) bool {
sess, hasSess := sessionFromContext(r.Context())
if !hasSess {
return true // basic-auth API path; middleware authenticated it
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return false
}
return true
}
// validProjectPostAction reports whether name is a POST-able project
// action. It reuses compose.ValidProjectAction but excludes "logs",
// which is served by a dedicated GET route.
func validProjectPostAction(name string) bool {
if name == string(compose.ActionLogs) {
return false
}
_, ok := compose.ValidProjectAction(name)
return ok
}
// respondComposeAction sends the action result to the right consumer:
// JSON for /web/api/* (scripting), a redirect with a flash banner for
// the browser path. The action's combined output is URL-encoded into the
// banner so the operator sees what Compose printed without a separate
// request; very large output is truncated to keep the URL bounded.
func (s *Server) respondComposeAction(w http.ResponseWriter, r *http.Request, project string, res compose.ManagementResult) {
if strings.HasPrefix(r.URL.Path, "/web/api/") {
writeComposeJSON(w, res)
return
}
msg := strings.TrimSpace(res.Output)
if len(msg) > 500 {
msg = msg[:500] + "…"
}
if !res.OK && msg == "" {
msg = res.Action + " failed"
}
loc := fmt.Sprintf("/compose/%s?ok=%t&msg=%s", project, res.OK, url.QueryEscape(msg))
http.Redirect(w, r, loc, http.StatusSeeOther)
}
// writeComposeJSON serializes a value as no-store JSON. Used by every
// /web/api/compose endpoint so scripted callers get one consistent shape.
func writeComposeJSON(w http.ResponseWriter, v interface{}) {
writeNoStore(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}
// auditCompose records a management action in the worker audit log. The
// target encodes project and service so the audit trail reconstructs the
// exact scope (e.g. "compose:rsmon/web"); failures are still audited so
// an operator can trace a stopped service back to the action.
func (s *Server) auditCompose(r *http.Request, action, project, service string) {
target := "compose:" + project
if service != "" {
target += "/" + service
}
authMode := auditAuthModeBasic
if _, hasSess := sessionFromContext(r.Context()); hasSess {
authMode = auditAuthModeLocal
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: authMode,
IP: clientIP(r),
UA: r.UserAgent(),
Action: "compose_" + action,
Target: target,
})
}
type composeListPageData struct {
basePageData
Snapshot compose.Snapshot
LastAt time.Time
Enabled bool
}
type composeDetailPageData struct {
basePageData
Project *compose.ProjectSummary
LastAt time.Time
ActionOK bool
ActionMessage string
}
type composeLogsPageData struct {
basePageData
Project string
Output string
}