// SPDX-License-Identifier: Apache-2.0 package server import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "sort" "strconv" "strings" "time" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/models" "github.com/go-chi/chi/v5" ) // isValidAdminURL returns true for URLs CaddyUI knows how to dial: // - http:// and https:// for standard TCP (optionally wrapped in TLS) // - unix:// for a Unix domain socket path (e.g. unix:///run/caddy-admin.sock) // // Empty strings and anything else (file://, ftp://, bare hostnames) are rejected. func isValidAdminURL(u string) bool { return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "unix://") } // SeedBootstrapServer inserts the first Caddy server using the admin URL the // process was launched with. No-op once any server row exists — idempotent so // restarts don't resurrect deleted rows. Optional username/password seed the // bootstrap server with HTTP Basic Auth credentials (from CADDY_ADMIN_USER / // CADDY_ADMIN_PASS env vars) for setups that gate port 2019 behind a reverse // proxy that enforces basic auth. func (s *Server) SeedBootstrapServer(adminURL, username, password string) error { n, err := models.CountCaddyServers(s.DB) if err != nil { return err } if n > 0 { return nil } _, err = models.CreateCaddyServer(s.DB, &models.CaddyServer{ Name: "Primary", AdminURL: adminURL, Type: models.CaddyServerTypeManaged, AdminUsername: username, AdminPassword: password, }) return err } // --- Handlers --- func (s *Server) listServersPage(w http.ResponseWriter, r *http.Request) { servers, err := models.ListCaddyServers(s.DB) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // v2.37.0: effective log-ingest target per managed node, plus a warning // when a remote node was handed a bare Docker service name it can't // resolve — the silent cause of "analytics is empty for that server". analyticsCfg := loadAnalyticsConfig(s.DB) ingestTargets := map[int64]string{} ingestWarnings := map[int64]string{} for _, sr := range servers { if sr.Type != models.CaddyServerTypeManaged { continue } target := sr.EffectiveIngestTarget(analyticsCfg.Target) ingestTargets[sr.ID] = target if warn := sr.IngestTargetWarning(target); warn != "" { ingestWarnings[sr.ID] = warn } } s.render(w, r, "servers.html", map[string]any{ "User": s.currentUser(r), "Servers": servers, "IngestTargets": ingestTargets, "IngestWarnings": ingestWarnings, "Section": "servers", "Flash": strings.TrimSpace(r.URL.Query().Get("flash")), "Error": strings.TrimSpace(r.URL.Query().Get("error")), }) } // syncServerFromCurrent performs a one-way merge of every CaddyUI-managed // route from the environment selected in the application shell into the target // fleet member. Target-only rows, target DNS records, and custom certificate // choices stay intact; repeated runs update the same paired rows. func (s *Server) syncServerFromCurrent(w http.ResponseWriter, r *http.Request) { targetServerID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil || targetServerID <= 0 { http.Error(w, "invalid target environment", http.StatusBadRequest) return } sourceServerID := s.currentServerID(r) source, target, err := s.validateFleetPair(sourceServerID, targetServerID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } summary, copyErr := s.syncFleetConfiguration(s.currentUserEmail(r), sourceServerID, targetServerID) applyErr := s.syncCaddy(targetServerID, summary.CertificatesCreated+summary.CertificatesUpdated > 0) if copyErr != nil || applyErr != nil { parts := []string{fmt.Sprintf("Configuration sync from %s to %s was incomplete (%s).", source.Name, target.Name, summary.String())} if copyErr != nil { parts = append(parts, copyErr.Error()) } if applyErr != nil { parts = append(parts, "Caddy apply failed: "+applyErr.Error()) } http.Redirect(w, r, "/servers?error="+url.QueryEscape(strings.Join(parts, " ")), http.StatusSeeOther) return } message := fmt.Sprintf("Synced configuration from %s to %s — %s. Target-only routes, DNS records, and custom certificate choices were preserved.", source.Name, target.Name, summary.String()) http.Redirect(w, r, "/servers?flash="+url.QueryEscape(message), http.StatusSeeOther) } func (s *Server) newServerPage(w http.ResponseWriter, r *http.Request) { s.render(w, r, "server_form.html", map[string]any{ "User": s.currentUser(r), "Target": &models.CaddyServer{Type: models.CaddyServerTypeManaged}, "Section": "servers", // v2.56.0 (issue #113): "Also copy to" for the layer4 Caddyfile field. // No server ID exists yet, so nothing needs to be excluded besides 0. "OtherServers": s.otherManagedServersExcept(0), }) } func (s *Server) createServer(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() c := &models.CaddyServer{ Name: strings.TrimSpace(r.FormValue("name")), AdminURL: strings.TrimSpace(r.FormValue("admin_url")), Type: r.FormValue("type"), Tags: strings.TrimSpace(r.FormValue("tags")), Version: strings.TrimSpace(r.FormValue("version")), AdminUsername: strings.TrimSpace(r.FormValue("admin_username")), AdminPassword: r.FormValue("admin_password"), IngestTarget: strings.TrimSpace(r.FormValue("ingest_target")), // v2.37.0 DataDir: strings.TrimSpace(r.FormValue("data_dir")), // v2.42.0 Layer4Caddyfile: strings.TrimSpace(r.FormValue("layer4_caddyfile")), // v2.56.0 (issue #113) // v2.57.0 (issue #120): persistent default deploy targets. Not // "deploy_to" — on this form that is the layer4 "Also copy to" picker. DefaultDeployTargets: s.parseDefaultDeployTargets(r, 0), PropagateDeletions: r.FormValue("propagate_deletions") != "", // v2.59.0 (issue #120) } renderErr := func(msg string) { s.render(w, r, "server_form.html", map[string]any{ "User": s.currentUser(r), "Target": c, "Section": "servers", "Error": msg, "OtherServers": s.otherManagedServersExcept(0), }) } if c.Name == "" { renderErr("Name is required") return } if c.AdminURL == "" { renderErr("Admin URL is required (e.g. http://10.0.0.2:2019 or unix:///run/caddy-admin.sock)") return } if !isValidAdminURL(c.AdminURL) { renderErr("Admin URL must start with http://, https://, or unix:///") return } // v2.56.0 (issue #113): adapt the pasted layer4 block against this // server's own admin API right now, so a typo or a Caddy build missing // the caddy-l4 module surfaces here instead of silently breaking every // sync of this server from now on. if c.Layer4Caddyfile != "" { if _, err := buildLayer4App(caddy.New(c.AdminURL, c.AdminUsername, c.AdminPassword), c.Layer4Caddyfile); err != nil { renderErr("Layer4 Caddyfile rejected by Caddy: " + err.Error()) return } } id, err := models.CreateCaddyServer(s.DB, c) if err != nil { renderErr(err.Error()) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "server_create", fmt.Sprintf("server:%d", id), c.Name, true) go func() { if err := s.ReconcileAnalyticsAccessLogs(); err != nil { log.Printf("server create: analytics log monitoring: %v", err) } if err := s.ReconcileCertificateLogs(); err != nil { log.Printf("server create: certificate log monitoring: %v", err) } }() // v2.56.0 (issue #113): copy the same layer4 Caddyfile text to any // selected fleet targets, same "Also deploy to" UX family as proxy // hosts/redirects/raw routes. if deployTo := parseDeployTo(r); len(deployTo) > 0 { s.crossDeployLayer4Caddyfile(s.currentUserEmail(r), id, c.Layer4Caddyfile, deployTo) } http.Redirect(w, r, "/servers", http.StatusSeeOther) } func (s *Server) editServerPage(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) c, err := models.GetCaddyServer(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } pending, _ := models.ListFleetPendingDeletions(s.DB, id) // v2.59.0 (issue #120) s.render(w, r, "server_form.html", map[string]any{ "User": s.currentUser(r), "Target": c, "Section": "servers", "OtherServers": s.otherManagedServersExcept(id), // v2.56.0 (issue #113) "PendingDeletions": pending, "Flash": r.URL.Query().Get("flash"), }) } func (s *Server) updateServer(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) existing, err := models.GetCaddyServer(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } previous := *existing _ = r.ParseForm() existing.Name = strings.TrimSpace(r.FormValue("name")) existing.AdminURL = strings.TrimSpace(r.FormValue("admin_url")) existing.Type = r.FormValue("type") existing.Tags = strings.TrimSpace(r.FormValue("tags")) existing.Version = strings.TrimSpace(r.FormValue("version")) existing.AdminUsername = strings.TrimSpace(r.FormValue("admin_username")) existing.IngestTarget = strings.TrimSpace(r.FormValue("ingest_target")) // v2.37.0 existing.DataDir = strings.TrimSpace(r.FormValue("data_dir")) // v2.42.0 existing.Layer4Caddyfile = strings.TrimSpace(r.FormValue("layer4_caddyfile")) // v2.56.0 (issue #113) existing.DefaultDeployTargets = s.parseDefaultDeployTargets(r, id) // v2.57.0 (issue #120) existing.PropagateDeletions = r.FormValue("propagate_deletions") != "" // v2.59.0 (issue #120) // Password: if the form submitted a blank value AND the user didn't explicitly // check the "clear password" box, keep the existing one. Protects against // masked-field UX where the password isn't re-typed on every edit. if newPw := r.FormValue("admin_password"); newPw != "" { existing.AdminPassword = newPw } else if r.FormValue("clear_admin_password") == "1" { existing.AdminPassword = "" } renderErr := func(msg string) { s.render(w, r, "server_form.html", map[string]any{ "User": s.currentUser(r), "Target": existing, "Section": "servers", "Error": msg, "OtherServers": s.otherManagedServersExcept(id), }) } if existing.Name == "" { renderErr("Name is required") return } if existing.AdminURL == "" || !isValidAdminURL(existing.AdminURL) { renderErr("Admin URL must start with http://, https://, or unix:///") return } if s.caddyLogHub != nil { if _, active := s.caddyLogHub.Capture(id); active { if err := s.disableRuntimeLogCapture(previous); err != nil { renderErr("Stop full server-log capture before changing this server: " + err.Error()) return } } } // v2.56.0 (issue #113): validate against this server's own (possibly // just-edited) admin URL/credentials before saving — see createServer. if existing.Layer4Caddyfile != "" { if _, err := buildLayer4App(caddy.New(existing.AdminURL, existing.AdminUsername, existing.AdminPassword), existing.Layer4Caddyfile); err != nil { renderErr("Layer4 Caddyfile rejected by Caddy: " + err.Error()) return } } // v2.57.0 (issue #120): two servers that each deploy to the other would // overwrite one another's copies on every save — refuse the loop outright // instead of leaving it to a warning in the form text. for _, targetID := range existing.DefaultDeployTargetIDs() { if t, err := models.GetCaddyServer(s.DB, targetID); err == nil && t.DefaultDeployTargetSet()[id] { renderErr(fmt.Sprintf("%s already deploys to this server automatically. Pointing two servers at each other would make each save overwrite the other — remove %s from %s's targets, or untick it here.", t.Name, existing.Name, t.Name)) return } } if err := models.UpdateCaddyServer(s.DB, existing); err != nil { renderErr(err.Error()) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "server_update", fmt.Sprintf("server:%d", id), existing.Name, true) go func() { if err := s.ReconcileAnalyticsAccessLogs(); err != nil { log.Printf("server update: analytics log monitoring: %v", err) } if err := s.ReconcileCertificateLogs(); err != nil { log.Printf("server update: certificate log monitoring: %v", err) } }() // v2.56.0 (issue #113): re-deploy the edited layer4 Caddyfile to any // selected targets, mirroring the raw-route/proxy-host edit flow. if deployTo := parseDeployTo(r); len(deployTo) > 0 { s.crossDeployLayer4Caddyfile(s.currentUserEmail(r), id, existing.Layer4Caddyfile, deployTo) } if msg := s.newDeployTargetsNotice(previous, *existing); msg != "" { http.Redirect(w, r, "/servers?flash="+url.QueryEscape(msg), http.StatusSeeOther) return } http.Redirect(w, r, "/servers", http.StatusSeeOther) } // newDeployTargetsNotice explains, when a save adds automatic deployment // targets, that what already exists on the source was not copied. Targets // receive what is saved from then on; existing resources need one Sync from // current. Without this, ticking a target looked like it had not worked // (issue #150). Empty when no target was added or nothing exists to copy. func (s *Server) newDeployTargetsNotice(before, after models.CaddyServer) string { had := before.DefaultDeployTargetSet() var added []string for _, id := range after.DefaultDeployTargetIDs() { if had[id] { continue } if t, err := models.GetCaddyServer(s.DB, id); err == nil && t != nil { added = append(added, t.Name) } } if len(added) == 0 { return "" } existing := 0 if rows, err := models.ListProxyHosts(s.DB, after.ID, 0, true, nil); err == nil { for _, p := range rows { if !p.NodeLocal { existing++ } } } if rows, err := models.ListRedirectionHosts(s.DB, after.ID, 0, true, nil); err == nil { existing += len(rows) } if rows, err := models.ListRawRoutes(s.DB, after.ID, 0, true, nil); err == nil { for _, rr := range rows { if !rr.NodeLocal { existing++ } } } if existing == 0 { return "" } targets := strings.Join(added, ", ") return fmt.Sprintf("Saved. From now on, every proxy host, redirection and advanced route saved on %s is also deployed to %s. "+ "The %d that already exist on %s were not copied: select %s as the current server, then use Sync from current on %s once.", after.Name, targets, existing, after.Name, after.Name, targets) } // parseDefaultDeployTargets reads the server form's "default_deploy_targets" // checkboxes (v2.57.0, issue #120) and returns them as the stored // comma-separated ID list. Only existing managed servers other than selfID // survive, so a forged or stale ID can neither point a server at itself nor at // a monitoring-only or deleted server. IDs are stored ascending so re-saving an // unchanged form never rewrites the column. func (s *Server) parseDefaultDeployTargets(r *http.Request, selfID int64) string { allowed := map[int64]bool{} for _, srv := range s.otherManagedServersExcept(selfID) { allowed[srv.ID] = true } var ids []int64 seen := map[int64]bool{} for _, v := range r.Form["default_deploy_targets"] { id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) if err != nil || !allowed[id] || seen[id] { continue } seen[id] = true ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return models.JoinServerIDs(ids) } // crossDeployLayer4Caddyfile copies the source server's pasted layer4 // Caddyfile text to each target server's own Layer4Caddyfile field (v2.56.0, // issue #113). Unlike proxy/redirect/raw-route cross-deploy there is no // separate resource row to create — this sets the field directly on each // target CaddyServer row and re-syncs it. Each target is validated against // its OWN admin API before being written: fleet members can run different // Caddy builds, and a target without caddy-l4 compiled in is skipped (with // the reason logged to its activity log) rather than silently broken on its // next scheduled sync. Best-effort per target, matching crossDeployProxyHost // and crossDeployRawRoute — one bad target must not abort the others. func (s *Server) crossDeployLayer4Caddyfile(actor string, sourceServerID int64, caddyfileSrc string, serverIDs []int64) { s.fleetDeployMu.Lock() defer s.fleetDeployMu.Unlock() for _, sid := range serverIDs { _, target, err := s.validateFleetPair(sourceServerID, sid) if err != nil { log.Printf("cross-deploy layer4 target %d: %v", sid, err) _ = models.LogActivity(s.DB, sourceServerID, actor, "layer4_cross_deploy", fmt.Sprintf("server:%d", sid), err.Error(), false) continue } caddyCl := caddy.New(target.AdminURL, target.AdminUsername, target.AdminPassword) if _, err := buildLayer4App(caddyCl, caddyfileSrc); err != nil { log.Printf("cross-deploy layer4 target %d rejected: %v", sid, err) _ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4", "rejected by Caddy: "+err.Error(), false) continue } target.Layer4Caddyfile = caddyfileSrc if err := models.UpdateCaddyServer(s.DB, target); err != nil { log.Printf("cross-deploy layer4 target %d save: %v", sid, err) _ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4", err.Error(), false) continue } _ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4", "updated layer4 Caddyfile", true) if err := s.syncCaddy(sid, false); err != nil { log.Printf("cross-deploy layer4 sync server %d: %v", sid, err) } } } func (s *Server) deleteServer(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) n, _ := models.CountCaddyServers(s.DB) if n <= 1 { http.Error(w, "can't delete the last server — add another first", http.StatusBadRequest) return } c, err := models.GetCaddyServer(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } if s.caddyLogHub != nil { if _, active := s.caddyLogHub.Capture(id); active { if err := s.disableRuntimeLogCapture(*c); err != nil { http.Error(w, "stop full server-log capture before deleting this server: "+err.Error(), http.StatusBadGateway) return } } } if err := models.DeleteCaddyServer(s.DB, id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "server_delete", fmt.Sprintf("server:%d", id), c.Name, true) http.Redirect(w, r, "/servers", http.StatusSeeOther) } // selectServer sets the caddyui_server cookie to the given server ID and // redirects back to wherever the user came from (or "/" if Referer is absent). func (s *Server) selectServer(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil || id <= 0 { http.Redirect(w, r, "/", http.StatusSeeOther) return } // Validate the server actually exists. if _, err := models.GetCaddyServer(s.DB, id); err != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(w, &http.Cookie{ Name: serverCookie, Value: strconv.FormatInt(id, 10), Path: "/", MaxAge: 60 * 60 * 24 * 365, // 1 year HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) ref := r.Header.Get("Referer") if ref == "" { ref = "/" } http.Redirect(w, r, ref, http.StatusSeeOther) } // viewServerConfig streams the live /config/ JSON from the selected Caddy server so // the operator can eyeball what's actually running on that box without SSH-ing. func (s *Server) viewServerConfig(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) c, err := models.GetCaddyServer(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } // Use a caddy.Client so the read respects auth + unix-socket transport. cc := caddy.New(c.AdminURL, c.AdminUsername, c.AdminPassword) _, raw, err := cc.FetchConfig() if err != nil { s.render(w, r, "server_config.html", map[string]any{ "User": s.currentUser(r), "Target": c, "Error": err.Error(), "Section": "servers", }) return } pretty := raw var v any if err := json.Unmarshal([]byte(raw), &v); err == nil { if b, err := json.MarshalIndent(v, "", " "); err == nil { pretty = string(b) } } s.render(w, r, "server_config.html", map[string]any{ "User": s.currentUser(r), "Target": c, "Config": pretty, "Status": 200, "Section": "servers", }) } // --- Health poller --- // StartHealthPoller launches a goroutine that pings every server's admin API on // an interval and records status + last-contact. Cheap — one GET per server per tick. // Runs until ctx cancels. Cadence is fixed at 30s; easy to env-ify later if needed. func (s *Server) StartHealthPoller(ctx context.Context) { go func() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() s.pollAllServers(ctx) for { select { case <-ctx.Done(): return case <-ticker.C: s.pollAllServers(ctx) } } }() } func (s *Server) pollAllServers(ctx context.Context) { servers, err := models.ListCaddyServers(s.DB) if err != nil { log.Printf("health poller: list servers: %v", err) return } for _, srv := range servers { s.pollOneServer(ctx, srv) } } func (s *Server) pollOneServer(ctx context.Context, srv models.CaddyServer) { // Build a caddy.Client per ping so we pick up any admin URL / credential // changes made via the UI since the last tick, and so the ping goes // through the same transport + auth path as real requests (unix sockets // and basic-auth-wrapped endpoints both work out of the box). client := caddy.New(srv.AdminURL, srv.AdminUsername, srv.AdminPassword) // 8s per ping is comfortable over WireGuard/Tailscale. Below that a // single dropped UDP packet during rekey can exceed the window and flap // the server. pingCtx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() code, err := client.Ping(pingCtx) failed := err != nil || code >= 500 s.healthMu.Lock() defer s.healthMu.Unlock() if failed { s.healthFailures[srv.ID]++ // Only flip to offline once we've missed healthFailThreshold pings // in a row. Before that, keep the existing status so the dashboard // doesn't flap on transient blips. Still update last-contact is // intentionally NOT set — we didn't actually contact the server. if s.healthFailures[srv.ID] >= healthFailThreshold { _ = models.SetCaddyServerStatus(s.DB, srv.ID, models.CaddyServerStatusOffline, nil) } return } // Success → reset the failure counter and mark online with a fresh // last-contact timestamp. s.healthFailures[srv.ID] = 0 now := time.Now() _ = models.SetCaddyServerStatus(s.DB, srv.ID, models.CaddyServerStatusOnline, &now) }