internal/server/preflight.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/X4Applegate/caddyui/internal/models"
"github.com/go-chi/chi/v5"
)
// v2.42.1 (issue #74): a change that Caddy will not load used to be saved
// anyway. The form said "saved", the sync failed in the log, the live config
// stayed as it was — and every later sync for that server failed the same
// way until someone noticed. The reported case was a file-path certificate
// whose files exist on the host but not inside the Caddy container.
//
// Two layers now: proxy hosts, redirections and certificates are validated
// against Caddy before they are saved, the way Advanced routes already were;
// and when a sync fails for any reason after a save, the failure is kept per
// server and shown on every page until the next successful sync.
// serverResources loads every resource of a server for a preview build.
// ok is false when a query failed — callers then skip validation rather than
// block a save on an unrelated error.
func (s *Server) serverResources(serverID int64) (proxies []models.ProxyHost, redirs []models.RedirectionHost, raws []models.RawRoute, certs []models.Certificate, ok bool) {
var err error
if proxies, err = models.ListProxyHosts(s.DB, serverID, 0, true, nil); err != nil {
return nil, nil, nil, nil, false
}
if redirs, err = models.ListRedirectionHosts(s.DB, serverID, 0, true, nil); err != nil {
return nil, nil, nil, nil, false
}
if raws, err = models.ListRawRoutes(s.DB, serverID, 0, true, nil); err != nil {
return nil, nil, nil, nil, false
}
if certs, err = models.ListCertificates(s.DB, serverID); err != nil {
return nil, nil, nil, nil, false
}
return proxies, redirs, raws, certs, true
}
// previewProxyHostValidate simulates the sync with p saved (replacing the
// row with the same ID, or added) and asks Caddy to validate the result.
// Returns a message only when Caddy would reject it.
func (s *Server) previewProxyHostValidate(serverID int64, p *models.ProxyHost) string {
proxies, redirs, raws, certs, ok := s.serverResources(serverID)
if !ok {
return ""
}
replaced := false
for i := range proxies {
if p.ID != 0 && proxies[i].ID == p.ID {
proxies[i] = *p
replaced = true
break
}
}
if !replaced {
proxies = append(proxies, *p)
}
return friendlyCaddyRejection(s.validateProposedConfig(serverID, proxies, redirs, raws, certs), certs)
}
// previewRedirectValidate is previewProxyHostValidate for a redirection.
func (s *Server) previewRedirectValidate(serverID int64, rh *models.RedirectionHost) string {
proxies, redirs, raws, certs, ok := s.serverResources(serverID)
if !ok {
return ""
}
replaced := false
for i := range redirs {
if rh.ID != 0 && redirs[i].ID == rh.ID {
redirs[i] = *rh
replaced = true
break
}
}
if !replaced {
redirs = append(redirs, *rh)
}
return friendlyCaddyRejection(s.validateProposedConfig(serverID, proxies, redirs, raws, certs), certs)
}
// previewCertificateValidate is the same for a certificate: a file-path
// certificate whose files Caddy cannot open fails right here, before the
// row exists, instead of breaking every later sync.
func (s *Server) previewCertificateValidate(serverID int64, c *models.Certificate) string {
proxies, redirs, raws, certs, ok := s.serverResources(serverID)
if !ok {
return ""
}
replaced := false
for i := range certs {
if c.ID != 0 && certs[i].ID == c.ID {
certs[i] = *c
replaced = true
break
}
}
if !replaced {
certs = append(certs, *c)
}
return friendlyCaddyRejection(s.validateProposedConfig(serverID, proxies, redirs, raws, certs), certs)
}
// friendlyCaddyRejection turns Caddy's validation error into something a
// person can act on. The message from validateProposedConfig starts with
// "Caddy rejected the proposed config: "; the common case — a certificate
// file that exists on the host but not inside the Caddy container — gets a
// specific explanation.
func friendlyCaddyRejection(msg string, certs []models.Certificate) string {
if msg == "" {
return ""
}
lower := strings.ToLower(msg)
if strings.Contains(lower, "no such file or directory") || strings.Contains(lower, "loading certificates") {
for _, c := range certs {
if c.Source != models.CertSourcePath {
continue
}
for _, p := range []string{c.CertPath, c.KeyPath} {
if p != "" && strings.Contains(msg, p) {
return fmt.Sprintf("Caddy cannot open %s for certificate %q. File paths are read by Caddy inside the Caddy container, so the file must exist there — mount the directory into the Caddy container (for example `-v /etc/letsencrypt:/etc/letsencrypt:ro`) and use that path, or paste the PEM instead. Caddy said: %s", p, c.Name, strings.TrimPrefix(msg, "Caddy rejected the proposed config: "))
}
}
}
}
return strings.Replace(msg, "Caddy rejected the proposed config: ", "Caddy rejected this change, so it was not saved: ", 1)
}
// --- failed-sync banner --------------------------------------------------
const syncErrorKeyPrefix = "sync_error_server_"
type syncError struct {
ServerID int64 `json:"server_id"`
ServerName string `json:"server_name"`
Error string `json:"error"`
At time.Time `json:"at"`
}
func syncErrorKey(serverID int64) string {
return syncErrorKeyPrefix + strconv.FormatInt(serverID, 10)
}
func (s *Server) setSyncError(serverID int64, serverName string, err error) {
raw, marshalErr := json.Marshal(syncError{ServerID: serverID, ServerName: serverName, Error: err.Error(), At: time.Now().UTC()})
if marshalErr != nil {
return
}
if setErr := models.SetSetting(s.DB, syncErrorKey(serverID), string(raw)); setErr != nil {
log.Printf("record sync error for server %d: %v", serverID, setErr)
}
}
func (s *Server) clearSyncError(serverID int64) {
_ = models.DeleteSetting(s.DB, syncErrorKey(serverID))
}
func (s *Server) syncErrorFor(serverID int64) *syncError {
raw, err := models.GetSetting(s.DB, syncErrorKey(serverID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var e syncError
if json.Unmarshal([]byte(raw), &e) != nil {
return nil
}
return &e
}
// activeSyncErrors lists servers whose last sync failed, for the layout
// banner. Servers under a post-apply hold are left out — the hold banner
// already covers them.
func (s *Server) activeSyncErrors(servers []models.CaddyServer) []syncError {
var out []syncError
for _, sr := range servers {
if s.syncHoldFor(sr.ID) != nil {
continue
}
if e := s.syncErrorFor(sr.ID); e != nil {
if e.ServerName == "" {
e.ServerName = sr.Name
}
out = append(out, *e)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ServerID < out[j].ServerID })
return out
}
// clearSyncErrorHandler: POST /servers/{id}/sync-error/clear — Dismiss on
// the banner. The next sync records a fresh result either way.
func (s *Server) clearSyncErrorHandler(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
s.clearSyncError(id)
redirectBack(w, r)
}
// renderCertificateFormError re-renders the certificate form with what the
// user typed and an error, for both the create and the edit flow.
func (s *Server) renderCertificateFormError(w http.ResponseWriter, r *http.Request, c *models.Certificate, errMsg string) {
data := map[string]any{
"User": s.currentUser(r),
"Cert": c,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Error": errMsg,
"Section": "certs",
}
s.addCertificateExportViewData(data, c.ID, *c)
s.render(w, r, "certificate_form.html", s.applyDNSViewData(s.currentServerID(r), data))
}
// certificateForCaddyfile loads the certificate a host references so the
// Caddyfile view can show a real `tls` line for file-path certificates
// (v2.42.1, issue #74 mentioned the view being "almost empty"). nil when
// there is none or it cannot be loaded.
func (s *Server) certificateForCaddyfile(certificateID int64) *models.Certificate {
if certificateID == 0 {
return nil
}
c, err := models.GetCertificate(s.DB, certificateID)
if err != nil {
return nil
}
return c
}