internal/server/caddy_servers.go
Rama main · réplica de lectura del repositorio original.
// 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)
}