internal/server/middleware_profiles.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/X4Applegate/caddyui/internal/models"
)
// v2.60.0 (issue #124): reusable middleware profiles.
//
// A profile bundles HTTP policy that many hosts share — security headers,
// forward auth, IP restrictions, extra headers — so it is configured once and a
// proxy host only picks it. The profile is applied when the config is built:
// the host row is never rewritten, so detaching a profile restores exactly what
// the host had. A value set on the host itself wins; empty host fields inherit
// the profile; the two header maps merge by header name.
// applyProfileToHost overlays prof onto p (a copy owned by the caller).
func applyProfileToHost(p *models.ProxyHost, prof *models.MiddlewareProfile) {
if prof == nil {
return
}
if prof.SecurityHeaders {
p.SecurityHeadersEnabled = true
}
inherit := func(host *string, profile string) {
if strings.TrimSpace(*host) == "" {
*host = profile
}
}
inherit(&p.XFrameOptions, prof.XFrameOptions)
inherit(&p.ReferrerPolicy, prof.ReferrerPolicy)
inherit(&p.PermissionsPolicy, prof.PermissionsPolicy)
inherit(&p.CSPHeader, prof.CSPHeader)
inherit(&p.AccessList, prof.AccessList)
inherit(&p.IPBlocklist, prof.IPBlocklist)
// Forward auth is one unit: a host with its own auth URL keeps its own
// method/headers/skip paths entirely; otherwise the profile supplies them all.
if strings.TrimSpace(p.ForwardAuthURL) == "" && strings.TrimSpace(prof.ForwardAuthURL) != "" {
p.ForwardAuthURL = prof.ForwardAuthURL
p.ForwardAuthMethod = prof.ForwardAuthMethod
p.ForwardAuthCopyHeaders = prof.ForwardAuthCopyHeaders
p.ForwardAuthHeadersPrefix = prof.ForwardAuthHeadersPrefix
p.ForwardAuthSkipPaths = prof.ForwardAuthSkipPaths
}
if prof.WAFMode == models.WAFModeDetect || prof.WAFMode == models.WAFModeBlock {
paranoia := prof.WAFParanoia
if paranoia < 1 {
paranoia = 1
}
p.WAF = &models.WAFSettings{Mode: prof.WAFMode, CRS: prof.WAFCRS, Paranoia: paranoia, Directives: prof.WAFDirectives}
}
p.CustomReqHeaders = mergeHeaderJSON(prof.CustomReqHeaders, p.CustomReqHeaders)
p.CustomRespHeaders = mergeHeaderJSON(prof.CustomRespHeaders, p.CustomRespHeaders)
}
// mergeHeaderJSON merges two {"Name":"value"} documents; entries in host win.
func mergeHeaderJSON(profile, host string) string {
pm, hm := map[string]string{}, map[string]string{}
if strings.TrimSpace(profile) != "" {
_ = json.Unmarshal([]byte(profile), &pm)
}
if strings.TrimSpace(host) != "" {
_ = json.Unmarshal([]byte(host), &hm)
}
if len(pm) == 0 {
return host
}
for k, v := range hm {
pm[k] = v
}
b, err := json.Marshal(pm)
if err != nil {
return host
}
return string(b)
}
// applyMiddlewareProfiles returns proxies with each host's profile applied. The
// input slice is not modified. With no attachments it returns the slice as is.
func (s *Server) applyMiddlewareProfiles(proxies []models.ProxyHost) []models.ProxyHost {
assoc, err := models.ProxyHostProfileMap(s.DB)
if err != nil || len(assoc) == 0 {
if err != nil {
log.Printf("middleware profiles: load attachments: %v", err)
}
return proxies
}
profiles, err := models.ListMiddlewareProfiles(s.DB)
if err != nil {
log.Printf("middleware profiles: load profiles: %v", err)
return proxies
}
byID := make(map[int64]*models.MiddlewareProfile, len(profiles))
for i := range profiles {
byID[profiles[i].ID] = &profiles[i]
}
out := make([]models.ProxyHost, len(proxies))
copy(out, proxies)
for i := range out {
if prof := byID[assoc[out[i].ID]]; prof != nil {
applyProfileToHost(&out[i], prof)
}
}
return out
}
var headerNameRe = regexp.MustCompile(`^[A-Za-z0-9!#$%&'*+.^_` + "`" + `|~-]+$`)
// validCIDRList accepts comma/newline/space separated CIDRs or single IPs.
func validCIDRList(raw string) (bad string) {
for _, f := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == ' ' || r == '\t' || r == '\r' }) {
if _, _, err := net.ParseCIDR(f); err == nil {
continue
}
if net.ParseIP(f) != nil {
continue
}
return f
}
return ""
}
func hasControlChars(v string) bool { return strings.ContainsAny(v, "\r\n\x00") }
// validateMiddlewareProfile returns a user-facing message, or "" when p is fine.
func validateMiddlewareProfile(p *models.MiddlewareProfile) string {
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" || len(p.Name) > 80 {
return "Name is required (up to 80 characters)."
}
for label, v := range map[string]string{
"X-Frame-Options": p.XFrameOptions, "Referrer-Policy": p.ReferrerPolicy,
"Permissions-Policy": p.PermissionsPolicy, "Content-Security-Policy": p.CSPHeader,
"Forward auth copy headers": p.ForwardAuthCopyHeaders, "Forward auth headers prefix": p.ForwardAuthHeadersPrefix,
"Forward auth skip paths": p.ForwardAuthSkipPaths,
} {
if hasControlChars(v) {
return label + " must be a single line."
}
}
if msg := validateForwardAuthURL(p.ForwardAuthURL); msg != "" {
return msg
}
switch strings.ToUpper(strings.TrimSpace(p.ForwardAuthMethod)) {
case "", "GET", "POST", "HEAD":
default:
return "Forward auth method must be GET, POST or HEAD."
}
if bad := validCIDRList(p.AccessList); bad != "" {
return fmt.Sprintf("Allowlist entry %q is not an IP address or CIDR range.", bad)
}
if bad := validCIDRList(p.IPBlocklist); bad != "" {
return fmt.Sprintf("Blocklist entry %q is not an IP address or CIDR range.", bad)
}
switch p.WAFMode {
case models.WAFModeOff, models.WAFModeDetect, models.WAFModeBlock:
default:
return "WAF mode must be Off, Detection only or Block."
}
if p.WAFMode != models.WAFModeOff {
if p.WAFParanoia < 1 || p.WAFParanoia > 4 {
return "WAF paranoia level must be between 1 and 4."
}
}
if len(p.WAFDirectives) > 8192 || strings.ContainsRune(p.WAFDirectives, 0) {
return "Custom WAF directives must be plain text up to 8 KB."
}
for label, raw := range map[string]string{"Request headers": p.CustomReqHeaders, "Response headers": p.CustomRespHeaders} {
if strings.TrimSpace(raw) == "" {
continue
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return label + ` must be a JSON object of header names to values, e.g. {"X-Env": "prod"}.`
}
for k, v := range m {
if !headerNameRe.MatchString(k) {
return fmt.Sprintf("%s: %q is not a valid header name.", label, k)
}
if hasControlChars(v) {
return fmt.Sprintf("%s: the value of %q must be a single line.", label, k)
}
}
}
return ""
}
func parseMiddlewareProfileForm(r *http.Request) *models.MiddlewareProfile {
_ = r.ParseForm()
t := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
p := &models.MiddlewareProfile{
Name: t("name"), Description: t("description"),
SecurityHeaders: r.FormValue("security_headers") == "on",
XFrameOptions: t("x_frame_options"), ReferrerPolicy: t("referrer_policy"),
PermissionsPolicy: t("permissions_policy"), CSPHeader: t("csp_header"),
ForwardAuthURL: t("forward_auth_url"), ForwardAuthMethod: strings.ToUpper(t("forward_auth_method")),
ForwardAuthCopyHeaders: t("forward_auth_copy_headers"), ForwardAuthHeadersPrefix: t("forward_auth_headers_prefix"),
ForwardAuthSkipPaths: t("forward_auth_skip_paths"),
AccessList: t("access_list"), IPBlocklist: t("ip_blocklist"),
CustomReqHeaders: t("custom_req_headers"), CustomRespHeaders: t("custom_resp_headers"),
WAFMode: t("waf_mode"), WAFCRS: r.FormValue("waf_crs") == "on", WAFDirectives: strings.TrimSpace(r.FormValue("waf_directives")),
}
p.WAFParanoia, _ = strconv.Atoi(t("waf_paranoia"))
if p.WAFMode == models.WAFModeOff {
p.WAFCRS, p.WAFParanoia, p.WAFDirectives = false, 0, ""
}
if p.ForwardAuthMethod == "GET" {
p.ForwardAuthMethod = ""
}
return p
}
// --- handlers (admin-only) ---
func (s *Server) listMiddlewareProfiles(w http.ResponseWriter, r *http.Request) {
rows, err := models.ListMiddlewareProfiles(s.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.render(w, r, "middleware_profiles.html", map[string]any{
"User": s.currentUser(r), "Rows": rows, "Section": "middleware",
"Flash": r.URL.Query().Get("flash"), "FlashError": r.URL.Query().Get("error"),
})
}
func (s *Server) renderMiddlewareProfileForm(w http.ResponseWriter, r *http.Request, p *models.MiddlewareProfile, errMsg string) {
s.render(w, r, "middleware_profile_form.html", map[string]any{
"User": s.currentUser(r), "Row": p, "Error": errMsg, "Section": "middleware",
})
}
func (s *Server) newMiddlewareProfile(w http.ResponseWriter, r *http.Request) {
s.renderMiddlewareProfileForm(w, r, &models.MiddlewareProfile{SecurityHeaders: true, WAFCRS: true, WAFParanoia: 1}, "")
}
func (s *Server) middlewareProfileFromURL(w http.ResponseWriter, r *http.Request) *models.MiddlewareProfile {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
p, err := models.GetMiddlewareProfile(s.DB, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil
}
if p == nil {
http.NotFound(w, r)
return nil
}
return p
}
func (s *Server) editMiddlewareProfile(w http.ResponseWriter, r *http.Request) {
if p := s.middlewareProfileFromURL(w, r); p != nil {
s.renderMiddlewareProfileForm(w, r, p, "")
}
}
func (s *Server) createMiddlewareProfile(w http.ResponseWriter, r *http.Request) {
p := parseMiddlewareProfileForm(r)
if msg := validateMiddlewareProfile(p); msg != "" {
s.renderMiddlewareProfileForm(w, r, p, msg)
return
}
if taken, err := models.MiddlewareProfileNameTaken(s.DB, p.Name, 0); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else if taken {
s.renderMiddlewareProfileForm(w, r, p, fmt.Sprintf("A profile named %q already exists.", p.Name))
return
}
if p.WAFMode != models.WAFModeOff {
if msg := s.wafPreflight([]int64{s.currentServerID(r)}); msg != "" {
s.renderMiddlewareProfileForm(w, r, p, msg)
return
}
}
id, err := models.CreateMiddlewareProfile(s.DB, p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "profile_create", fmt.Sprintf("profile:%d", id), p.Name, true)
http.Redirect(w, r, "/middleware-profiles?flash="+urlQueryEscape("Created profile "+p.Name+"."), http.StatusSeeOther)
}
func (s *Server) updateMiddlewareProfile(w http.ResponseWriter, r *http.Request) {
cur := s.middlewareProfileFromURL(w, r)
if cur == nil {
return
}
p := parseMiddlewareProfileForm(r)
p.ID = cur.ID
if msg := validateMiddlewareProfile(p); msg != "" {
s.renderMiddlewareProfileForm(w, r, p, msg)
return
}
if taken, err := models.MiddlewareProfileNameTaken(s.DB, p.Name, p.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else if taken {
s.renderMiddlewareProfileForm(w, r, p, fmt.Sprintf("Another profile is already named %q.", p.Name))
return
}
if p.WAFMode != models.WAFModeOff {
servers, _ := models.ServersUsingProfile(s.DB, p.ID)
if msg := s.wafPreflight(append(servers, s.currentServerID(r))); msg != "" {
s.renderMiddlewareProfileForm(w, r, p, msg)
return
}
}
if err := models.UpdateMiddlewareProfile(s.DB, p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "profile_update", fmt.Sprintf("profile:%d", p.ID), p.Name, true)
n := s.resyncProfileServers(p.ID)
msg := "Saved profile " + p.Name + "."
if n > 0 {
msg += fmt.Sprintf(" Re-synced %d server(s) that use it.", n)
}
http.Redirect(w, r, "/middleware-profiles?flash="+urlQueryEscape(msg), http.StatusSeeOther)
}
func (s *Server) deleteMiddlewareProfile(w http.ResponseWriter, r *http.Request) {
p := s.middlewareProfileFromURL(w, r)
if p == nil {
return
}
if err := models.DeleteMiddlewareProfile(s.DB, p.ID); err != nil {
if errors.Is(err, models.ErrProfileInUse) {
http.Redirect(w, r, "/middleware-profiles?error="+urlQueryEscape("Profile "+p.Name+" cannot be deleted: "+err.Error()+". Detach it from those hosts first."), http.StatusSeeOther)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "profile_delete", fmt.Sprintf("profile:%d", p.ID), p.Name, true)
http.Redirect(w, r, "/middleware-profiles?flash="+urlQueryEscape("Deleted profile "+p.Name+"."), http.StatusSeeOther)
}
// resyncProfileServers pushes every server that has a host on the profile, so
// an edit reaches Caddy without anyone re-saving each host. Returns how many.
func (s *Server) resyncProfileServers(profileID int64) int {
servers, err := models.ServersUsingProfile(s.DB, profileID)
if err != nil {
log.Printf("middleware profile %d: list servers: %v", profileID, err)
return 0
}
for _, sid := range servers {
s.trySyncCaddy(sid, false)
}
return len(servers)
}
// --- proxy host form integration ---
// hostProfileFromForm returns the profile picked on a host form, 0 for none or
// for an ID that does not exist.
func (s *Server) hostProfileFromForm(r *http.Request) int64 {
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("middleware_profile_id")), 10, 64)
if err != nil || id <= 0 {
return 0
}
if p, _ := models.GetMiddlewareProfile(s.DB, id); p == nil {
return 0
}
return id
}
// withProfileViewData adds the profile picker's data to a host form render.
func (s *Server) withProfileViewData(data map[string]any, selected int64) map[string]any {
profiles, _ := models.ListMiddlewareProfiles(s.DB)
data["Profiles"] = profiles
data["SelectedProfileID"] = selected
return data
}
// wafPreflight refuses a WAF that a Caddy cannot run. Without it the whole
// config for that server would be rejected ("unknown module: http.handlers.waf")
// and no host on it could be synced until the image is updated. servers whose
// Caddy cannot be asked are not blocked on.
func (s *Server) wafPreflight(serverIDs []int64) string {
var missing []string
seen := map[int64]bool{}
for _, id := range serverIDs {
if seen[id] {
continue
}
seen[id] = true
srv, err := models.GetCaddyServer(s.DB, id)
if err != nil || srv == nil || srv.Type == models.CaddyServerTypeExternal {
continue
}
if present, known := s.caddyForServer(id).HasWAFModule(); known && !present {
missing = append(missing, srv.Name)
}
}
if len(missing) == 0 {
return ""
}
return "The Caddy on " + strings.Join(missing, ", ") + " has no Coraza WAF module (http.handlers.waf). Update it to an image that includes it — applegater/caddyui-caddy v2.61.0 or newer — before turning the WAF on."
}
// profileWAFOn reports whether the profile with this ID has a WAF enabled.
func (s *Server) profileWAFOn(profileID int64) bool {
prof, _ := models.GetMiddlewareProfile(s.DB, profileID)
return prof != nil && (prof.WAFMode == models.WAFModeDetect || prof.WAFMode == models.WAFModeBlock)
}
// friendlyCaddyError adds the fix to the one Caddy rejection people will meet.
func friendlyCaddyError(msg string) string {
if strings.Contains(msg, "unknown module: http.handlers.waf") {
return msg + " — this Caddy has no Coraza WAF module; update to applegater/caddyui-caddy v2.61.0 or newer, or detach the WAF middleware profile from the hosts on this server."
}
return msg
}