// SPDX-License-Identifier: Apache-2.0 package server import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/X4Applegate/caddyui/internal/dns" "github.com/X4Applegate/caddyui/internal/models" ) // Proxy-host form parsing. // // v2.30.0: moved verbatim out of server.go. parseProxyHostForm is ~660 lines // on its own — the single largest function in the codebase — and server.go // was over 16,600 lines. Nothing here changed except the file it lives in. // clampAtoi parses a form value as an int and constrains it to [min, max]. // Blank, unparseable, or non-positive input yields zeroValue, which callers // use as their "unset — fall back to the built-in default" sentinel. A value // that parses but falls outside the range is pulled to the nearest bound // rather than rejected, so a typo in one numeric field can't fail the whole // form save. // // v2.28.0 (issue #39). func clampAtoi(raw string, zeroValue, min, max int) int { v, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || v <= 0 { return zeroValue } if v < min { return min } if v > max { return max } return v } func parseProxyHostForm(r *http.Request) (*models.ProxyHost, error) { _ = r.ParseForm() port, err := strconv.Atoi(r.FormValue("forward_port")) if err != nil { return nil, err } certID, _ := strconv.ParseInt(r.FormValue("certificate_id"), 10, 64) // DNS picker is now a two-field combo: dns_provider selects which // provider (or "" for none) and dns_zone_id is the provider-native // zone identifier. For human display the zone_name is also captured — // the form stashes it in a hidden input whenever the picker changes. provider := strings.ToLower(strings.TrimSpace(r.FormValue("dns_provider"))) profileID := strings.TrimSpace(r.FormValue("dns_profile_id")) zoneID := "" zoneName := "" if provider != "" { if _, ok := dns.Lookup(provider); !ok { provider = "" // unknown ID → treat as "no DNS" } else { zoneID = strings.TrimSpace(r.FormValue("dns_zone_id")) zoneName = strings.TrimSpace(r.FormValue("dns_zone_name")) if zoneID == "" { // Picker never captured a zone; treat the whole thing as // unset so we don't try to create a record with no target. provider = "" zoneName = "" } if zoneName == "" { // PB/DO/GD/NC use ID == Name; fall back transparently. zoneName = zoneID } } } // v2.9.0: validate TLS min version — only accept known values. tlsMinVersion := strings.TrimSpace(r.FormValue("tls_min_version")) switch tlsMinVersion { case "", "1.0", "1.1", "1.2", "1.3": // valid default: tlsMinVersion = "" } // v2.64.0 (discussion #137): client certificate authentication (mTLS). // An unknown mode turns the feature off rather than reaching Caddy and // having the whole config rejected. clientAuthMode := strings.TrimSpace(r.FormValue("client_auth_mode")) if !models.ValidClientAuthMode(clientAuthMode) { clientAuthMode = "" } clientAuthCAFiles := strings.TrimSpace(r.FormValue("client_auth_ca_files")) if clientAuthMode == "" { // Keep the stored pair consistent: no mode, no trust pool. clientAuthCAFiles = "" } if models.ClientAuthModeVerifies(clientAuthMode) && clientAuthCAFiles == "" { // Caddy would accept this and then reject every client, because a // verifying mode with no trust pool trusts nothing. Fail the save // with an explanation instead of silently locking the host. return nil, fmt.Errorf("client certificate authentication: mode %q verifies the client certificate, so it needs at least one trusted CA certificate file — add one, or use \"request\" / \"require\" to ask for a certificate without verifying its issuer", clientAuthMode) } // Parse custom request headers: parallel arrays header_req_key[] + header_req_val[] reqKeys := r.Form["header_req_key"] reqVals := r.Form["header_req_val"] reqMap := map[string]string{} for i, k := range reqKeys { k = strings.TrimSpace(k) if k == "" { continue } v := "" if i < len(reqVals) { v = strings.TrimSpace(reqVals[i]) } reqMap[k] = v } customReqHeaders := "{}" if len(reqMap) > 0 { if b, err := json.Marshal(reqMap); err == nil { customReqHeaders = string(b) } } // Parse custom response headers: parallel arrays header_resp_key[] + header_resp_val[] respKeys := r.Form["header_resp_key"] respVals := r.Form["header_resp_val"] respMap := map[string]string{} for i, k := range respKeys { k = strings.TrimSpace(k) if k == "" { continue } v := "" if i < len(respVals) { v = strings.TrimSpace(respVals[i]) } respMap[k] = v } customRespHeaders := "{}" if len(respMap) > 0 { if b, err := json.Marshal(respMap); err == nil { customRespHeaders = string(b) } } // v2.9.2: URL rewrite rules — submitted as parallel arrays: // rewrite_type[], rewrite_from[], rewrite_to[] rewriteTypes := r.Form["rewrite_type[]"] rewriteFroms := r.Form["rewrite_from[]"] rewriteTos := r.Form["rewrite_to[]"] var urlRewrites string { type rule struct { Type string `json:"type"` From string `json:"from"` To string `json:"to"` } var rules []rule for i := range rewriteTypes { from := "" to := "" if i < len(rewriteFroms) { from = strings.TrimSpace(rewriteFroms[i]) } if i < len(rewriteTos) { to = strings.TrimSpace(rewriteTos[i]) } t := strings.TrimSpace(rewriteTypes[i]) if t == "" || from == "" { continue } rules = append(rules, rule{Type: t, From: from, To: to}) } if len(rules) == 0 { urlRewrites = "[]" } else { b, _ := json.Marshal(rules) urlRewrites = string(b) } } var maxBodyMB int if v := strings.TrimSpace(r.FormValue("max_request_body_mb")); v != "" && v != "0" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { maxBodyMB = n } } var upstreamTimeoutSec int if v := strings.TrimSpace(r.FormValue("upstream_timeout_sec")); v != "" && v != "0" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { upstreamTimeoutSec = n } } var healthCheckIntervalSec int = 30 if v := strings.TrimSpace(r.FormValue("health_check_interval_sec")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { healthCheckIntervalSec = n } } var keepaliveConns int if v := strings.TrimSpace(r.FormValue("keepalive_conns")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { keepaliveConns = n } } ph := &models.ProxyHost{ Domains: strings.TrimSpace(r.FormValue("domains")), ForwardScheme: r.FormValue("forward_scheme"), ForwardHost: strings.TrimSpace(r.FormValue("forward_host")), ForwardPort: port, WebsocketSupport: r.FormValue("websocket_support") == "on", BlockCommonExploits: r.FormValue("block_common_exploits") == "on", SSLEnabled: r.FormValue("ssl_enabled") == "on", SSLForced: r.FormValue("ssl_forced") == "on", HTTP2Support: r.FormValue("http2_support") == "on", AdvancedConfig: normalizeProxyAdvancedConfig(r.FormValue("advanced_config")), // v2.42.1 / v2.42.2 Enabled: r.FormValue("enabled") == "on", CertificateID: certID, AccessList: strings.TrimSpace(r.FormValue("access_list")), DNSProvider: provider, DNSZoneID: zoneID, DNSZoneName: zoneName, DNSProfileID: profileID, DNSSkipRecord: provider != "" && r.FormValue("dns_create_record") != "on", CompressionEnabled: r.FormValue("compression_enabled") == "on", SecurityHeadersEnabled: r.FormValue("security_headers_enabled") == "on", MaintenanceMode: r.FormValue("maintenance_mode") == "on", MaintenanceMsg: strings.TrimSpace(r.FormValue("maintenance_msg")), MaintenanceStatusCode: func() int { if sc, err := strconv.Atoi(r.FormValue("maintenance_status_code")); err == nil && sc > 0 { return sc } return 503 }(), StickySessions: r.FormValue("sticky_sessions") == "on", TLSMinVersion: tlsMinVersion, ClientAuthMode: clientAuthMode, ClientAuthCAFiles: clientAuthCAFiles, CustomReqHeaders: customReqHeaders, CustomRespHeaders: customRespHeaders, URLRewrites: urlRewrites, MaxRequestBodyMB: maxBodyMB, UpstreamTimeoutSec: upstreamTimeoutSec, CORSEnabled: r.FormValue("cors_enabled") == "on", CORSOrigins: strings.TrimSpace(r.FormValue("cors_origins")), HealthCheckURI: strings.TrimSpace(r.FormValue("health_check_uri")), HealthCheckIntervalSec: healthCheckIntervalSec, HealthCheckMethod: func() string { m := r.FormValue("health_check_method") if m == "" { return "GET" } return m }(), KeepaliveConns: keepaliveConns, Tags: strings.TrimSpace(r.FormValue("tags")), Notes: r.FormValue("notes"), DisableAccessLog: r.FormValue("disable_access_log") == "on", AddRequestID: r.FormValue("add_request_id") == "on", StripRespHeaders: strings.TrimSpace(r.FormValue("strip_resp_headers")), BlockedAgents: strings.TrimSpace(r.FormValue("blocked_agents")), ResponseCacheControl: strings.TrimSpace(r.FormValue("response_cache_control")), UpstreamSNI: strings.TrimSpace(r.FormValue("upstream_sni")), HSTSPreload: r.FormValue("hsts_preload") == "on", MaxConnsPerHost: func() int { n, _ := strconv.Atoi(r.FormValue("max_conns_per_host")) return n }(), } hcTimeout, _ := strconv.Atoi(r.FormValue("health_check_timeout_sec")) ph.HealthCheckTimeoutSec = hcTimeout retries, _ := strconv.Atoi(r.FormValue("upstream_retries")) ph.UpstreamRetries = retries ph.ForceHTTP1 = r.FormValue("force_http1") == "on" ph.BasicAuthRealm = strings.TrimSpace(r.FormValue("basicauth_realm")) if ph.BasicAuthRealm == "" { ph.BasicAuthRealm = "Restricted" } ph.ErrorPageHTML = r.FormValue("error_page_html") ph.MaintenanceWindowStart = strings.TrimSpace(r.FormValue("maintenance_window_start")) ph.MaintenanceWindowEnd = strings.TrimSpace(r.FormValue("maintenance_window_end")) ph.MaintenanceWindowDays = strings.Join(r.Form["maintenance_window_days"], ",") ph.IPBlocklist = strings.TrimSpace(r.FormValue("ip_blocklist")) ph.LBPolicy = r.FormValue("lb_policy") ph.ProxyProtocol = r.FormValue("proxy_protocol") ph.RobotsTxt = r.FormValue("robots_txt") ph.PassiveFailDurationSec, _ = strconv.Atoi(r.FormValue("passive_fail_duration_sec")) ph.PassiveMaxFails, _ = strconv.Atoi(r.FormValue("passive_max_fails")) ph.HSTSMaxAgeSec, _ = strconv.Atoi(r.FormValue("hsts_max_age_sec")) ph.CSPHeader = strings.TrimSpace(r.FormValue("csp_header")) ph.H2CEnabled = r.FormValue("h2c_enabled") == "on" if hch := strings.TrimSpace(r.FormValue("health_check_headers")); hch != "" { ph.HealthCheckHeaders = hch } else { ph.HealthCheckHeaders = "{}" } ph.FlushImmediate = r.FormValue("flush_immediate") == "on" ph.BufferResponses = r.FormValue("buffer_responses") == "on" ph.TrustedProxies = strings.TrimSpace(r.FormValue("trusted_proxies")) ph.UpstreamHostOverride = strings.TrimSpace(r.FormValue("upstream_host_override")) ph.ReadTimeoutSec, _ = strconv.Atoi(r.FormValue("read_timeout_sec")) ph.DenyDotfiles = r.FormValue("deny_dotfiles") == "on" ph.RequestBuffersKB, _ = strconv.Atoi(r.FormValue("request_buffers_kb")) ph.CORSAllowCredentials = r.FormValue("cors_allow_credentials") == "on" ph.CORSExposeHeaders = strings.TrimSpace(r.FormValue("cors_expose_headers")) ph.SSLVerifyUpstream = r.FormValue("ssl_verify_upstream") == "on" ph.DialTimeoutSec, _ = strconv.Atoi(r.FormValue("dial_timeout_sec")) ph.APIKeyHeader = strings.TrimSpace(r.FormValue("api_key_header")) ph.APIKeyValue = strings.TrimSpace(r.FormValue("api_key_value")) ph.BlockEmptyUserAgent = r.FormValue("block_empty_user_agent") == "on" ph.ErrorRedirectURL = strings.TrimSpace(r.FormValue("error_redirect_url")) ph.PermissionsPolicy = strings.TrimSpace(r.FormValue("permissions_policy")) ph.XFrameOptions = strings.TrimSpace(r.FormValue("x_frame_options")) ph.ReferrerPolicy = strings.TrimSpace(r.FormValue("referrer_policy")) ph.HSTSIncludeSubdomains = r.FormValue("hsts_include_subdomains") == "on" ph.CSPReportOnly = strings.TrimSpace(r.FormValue("csp_report_only")) ph.KeepaliveIdleTimeoutSec, _ = strconv.Atoi(r.FormValue("keepalive_idle_timeout_sec")) ph.HealthCheckExpectStatus, _ = strconv.Atoi(r.FormValue("health_check_expect_status")) ph.HealthCheckExpectBody = strings.TrimSpace(r.FormValue("health_check_expect_body")) ph.HealthCheckFollowRedirects = r.FormValue("health_check_follow_redirects") == "on" ph.PathMatcher = strings.TrimSpace(r.FormValue("path_matcher")) ph.StripPathPrefix = r.FormValue("strip_path_prefix") == "on" ph.StickyCookieName = strings.TrimSpace(r.FormValue("sticky_cookie_name")) ph.LBTryDurationSec, _ = strconv.Atoi(r.FormValue("lb_try_duration_sec")) ph.LBTryIntervalMS, _ = strconv.Atoi(r.FormValue("lb_try_interval_ms")) ph.CompressionMinSizeKB, _ = strconv.Atoi(r.FormValue("compression_min_size_kb")) ph.ForwardClientIP = r.FormValue("forward_client_ip") == "on" ph.CORSMaxAgeSec, _ = strconv.Atoi(r.FormValue("cors_max_age_sec")) ph.CORSAllowMethods = strings.TrimSpace(r.FormValue("cors_allow_methods")) ph.CORSAllowHeaders = strings.TrimSpace(r.FormValue("cors_allow_headers")) ph.RetryStatusCodes = strings.TrimSpace(r.FormValue("retry_status_codes")) ph.WriteTimeoutSec, _ = strconv.Atoi(r.FormValue("write_timeout_sec")) ph.UpstreamTLSMinVersion = r.FormValue("upstream_tls_min_version") ph.ForwardProxyURL = strings.TrimSpace(r.FormValue("forward_proxy_url")) ph.BlockedMethods = strings.TrimSpace(r.FormValue("blocked_methods")) ph.ForwardAuthURL = strings.TrimSpace(r.FormValue("forward_auth_url")) ph.ForwardAuthCopyHeaders = strings.TrimSpace(r.FormValue("forward_auth_copy_headers")) ph.StripQueryString = r.FormValue("strip_query_string") == "on" ph.DeleteQueryParams = strings.TrimSpace(r.FormValue("delete_query_params")) ph.RequestBodyReadTimeoutSec, _ = strconv.Atoi(r.FormValue("request_body_read_timeout_sec")) ph.ResponseHeaderTimeoutSec, _ = strconv.Atoi(r.FormValue("response_header_timeout_sec")) ph.MaxConnDurationSec, _ = strconv.Atoi(r.FormValue("max_conn_duration_sec")) ph.DecompressResponse = r.FormValue("decompress_response") == "on" ph.Color = strings.TrimSpace(r.FormValue("color")) ph.WWWRedirect = r.FormValue("www_redirect") // "" | "to_www" | "to_bare" ph.StripReqHeaders = strings.TrimSpace(r.FormValue("strip_req_headers")) ph.UpstreamPathPrefix = strings.TrimSpace(r.FormValue("upstream_path_prefix")) ph.CompressionLevel, _ = strconv.Atoi(r.FormValue("compression_level")) ph.CompressionPreferGzip = r.FormValue("compression_prefer_gzip") == "on" ph.SortOrder, _ = strconv.Atoi(r.FormValue("sort_order")) ph.AllowedMethods = strings.TrimSpace(r.FormValue("allowed_methods")) ph.UpstreamMaxRespHeaderKB, _ = strconv.Atoi(r.FormValue("upstream_max_resp_header_kb")) ph.HealthCheckPort, _ = strconv.Atoi(r.FormValue("health_check_port")) ph.RequestIDHeaderName = strings.TrimSpace(r.FormValue("request_id_header_name")) ph.LBCookiePath = strings.TrimSpace(r.FormValue("lb_cookie_path")) ph.PassiveUnhealthyLatencyMS, _ = strconv.Atoi(r.FormValue("passive_unhealthy_latency_ms")) ph.TLSHandshakeTimeoutSec, _ = strconv.Atoi(r.FormValue("tls_handshake_timeout_sec")) ph.ExpectContinueTimeoutSec, _ = strconv.Atoi(r.FormValue("expect_continue_timeout_sec")) ph.ResponseBuffersKB, _ = strconv.Atoi(r.FormValue("response_buffers_kb")) ph.UpstreamMaxIdleConns, _ = strconv.Atoi(r.FormValue("upstream_max_idle_conns")) ph.UpstreamKeepAliveProbeIntervalSec, _ = strconv.Atoi(r.FormValue("upstream_keep_alive_probe_sec")) ph.ForwardAuthMethod = strings.TrimSpace(r.FormValue("forward_auth_method")) ph.GRPCWebEnabled = r.FormValue("grpc_web_enabled") == "on" ph.ForwardAuthHeadersPrefix = strings.TrimSpace(r.FormValue("forward_auth_headers_prefix")) ph.HealthCheckMaxSizeKB, _ = strconv.Atoi(r.FormValue("health_check_max_size_kb")) ph.StripPathSuffix = strings.TrimSpace(r.FormValue("strip_path_suffix")) ph.AddReqQueryParams = strings.TrimSpace(r.FormValue("add_req_query_params")) ph.ErrorPageCodes = strings.TrimSpace(r.FormValue("error_page_codes")) ph.UpstreamTLSCAPEMFile = strings.TrimSpace(r.FormValue("upstream_tls_ca_pem_file")) ph.KeepaliveDisabled = r.FormValue("keepalive_disabled") == "on" ph.TrailingSlashRedirect = r.FormValue("trailing_slash_redirect") ph.DialFallbackDelayMS, _ = strconv.Atoi(r.FormValue("dial_fallback_delay_ms")) ph.UpstreamNetwork = strings.TrimSpace(r.FormValue("upstream_network")) ph.DNSResolver = strings.TrimSpace(r.FormValue("dns_resolver")) ph.PathMatcherType = r.FormValue("path_matcher_type") ph.CORSAllowPrivateNetwork = r.FormValue("cors_allow_private_network") == "on" ph.RobotsTxtDisallowAll = r.FormValue("robots_txt_disallow_all") == "on" ph.MaintenanceRetryAfterSec, _ = strconv.Atoi(r.FormValue("maintenance_retry_after_sec")) ph.UpstreamResolveTimeoutSec, _ = strconv.Atoi(r.FormValue("upstream_resolve_timeout_sec")) ph.UpstreamReadBufferSizeKB, _ = strconv.Atoi(r.FormValue("upstream_read_buffer_size_kb")) ph.UpstreamWriteBufferSizeKB, _ = strconv.Atoi(r.FormValue("upstream_write_buffer_size_kb")) ph.ReqHeaderReplace = strings.TrimSpace(r.FormValue("req_header_replace")) ph.RespHeaderReplace = strings.TrimSpace(r.FormValue("resp_header_replace")) ph.UpstreamHTTPVersions = strings.TrimSpace(r.FormValue("upstream_http_versions")) ph.HealthCheckBody = strings.TrimSpace(r.FormValue("health_check_body")) ph.AddCanonicalLinkHeader = r.FormValue("add_canonical_link_header") == "on" ph.HTTPBasicAuthUpstream = strings.TrimSpace(r.FormValue("http_basic_auth_upstream")) ph.BlockUARegexp = strings.TrimSpace(r.FormValue("block_ua_regexp")) ph.SecurityTxtBody = strings.TrimSpace(r.FormValue("security_txt_body")) ph.ServerHeaderValue = strings.TrimSpace(r.FormValue("server_header_value")) ph.XRobotsTag = strings.TrimSpace(r.FormValue("x_robots_tag")) ph.AddForwardedHeader = r.FormValue("add_forwarded_header") == "on" ph.LBCookieSecret = strings.TrimSpace(r.FormValue("lb_cookie_secret")) ph.PassiveUnhealthyStatusCodes = strings.TrimSpace(r.FormValue("passive_unhealthy_status_codes")) ph.HealthCheckContentType = strings.TrimSpace(r.FormValue("health_check_content_type")) ph.UpstreamTLSClientCertFile = strings.TrimSpace(r.FormValue("upstream_tls_client_cert_file")) ph.UpstreamTLSClientKeyFile = strings.TrimSpace(r.FormValue("upstream_tls_client_key_file")) ph.BlockPrivateIPs = r.FormValue("block_private_ips") == "on" ph.EnableBrotli = r.FormValue("enable_brotli") == "on" ph.VaryHeader = strings.TrimSpace(r.FormValue("vary_header")) ph.StripETag = r.FormValue("strip_etag") == "on" ph.HTTP2PushPaths = strings.TrimSpace(r.FormValue("http2_push_paths")) ph.DenyContentTypes = strings.TrimSpace(r.FormValue("deny_content_types")) ph.UpstreamLocalAddr = strings.TrimSpace(r.FormValue("upstream_local_addr")) ph.UpstreamTLSRenegotiation = r.FormValue("upstream_tls_renegotiation") ph.UpstreamTLSCurves = strings.TrimSpace(r.FormValue("upstream_tls_curves")) ph.UpstreamTLSMaxVersion = r.FormValue("upstream_tls_max_version") ph.UpstreamTLSPins = strings.TrimSpace(r.FormValue("upstream_tls_pins")) ph.LBHeaderField = strings.TrimSpace(r.FormValue("lb_header_field")) ph.MaintenanceCustomHeaders = strings.TrimSpace(r.FormValue("maintenance_custom_headers")) ph.DenyExtensions = strings.TrimSpace(r.FormValue("deny_extensions")) ph.InjectRequestTimestamp = r.FormValue("inject_request_timestamp") == "on" ph.AddRespCookies = strings.TrimSpace(r.FormValue("add_resp_cookies")) ph.StripAcceptEncoding = r.FormValue("strip_accept_encoding") == "on" ph.AddUpstreamTimingHeader = r.FormValue("add_upstream_timing_header") == "on" ph.StripServerHeader = r.FormValue("strip_server_header") == "on" ph.BlockRefererRegexp = strings.TrimSpace(r.FormValue("block_referer_regexp")) ph.AddContentTypeNosniff = r.FormValue("add_content_type_nosniff") == "on" ph.StripAuthorizationHeader = r.FormValue("strip_authorization_header") == "on" ph.RealIPFromHeader = strings.TrimSpace(r.FormValue("real_ip_from_header")) ph.HealthCheckHostOverride = strings.TrimSpace(r.FormValue("health_check_host_override")) ph.AddXForwardedPort = r.FormValue("add_x_forwarded_port") == "on" ph.LBRetryOn = strings.TrimSpace(r.FormValue("lb_retry_on")) if v := strings.TrimSpace(r.FormValue("max_buffer_size_kb")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.MaxBufferSizeKB = n } } if v := strings.TrimSpace(r.FormValue("upstream_keepalive_probes")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.UpstreamKeepaliveProbes = n } } if v := strings.TrimSpace(r.FormValue("upstream_flush_interval_ms")); v != "" { if n, err := strconv.Atoi(v); err == nil { ph.UpstreamFlushIntervalMS = n } } ph.AddXForwardedHost = r.FormValue("add_x_forwarded_host") == "on" ph.MaintenanceAllowedIPs = strings.TrimSpace(r.FormValue("maintenance_allowed_ips")) ph.UpstreamTLSCipherSuites = strings.TrimSpace(r.FormValue("upstream_tls_cipher_suites")) ph.AddCacheControlNoStore = r.FormValue("add_cache_control_no_store") == "on" ph.DenyRefererEmpty = r.FormValue("deny_referer_empty") == "on" ph.LBCookieHTTPOnly = r.FormValue("lb_cookie_httponly") == "on" ph.LBCookieSecure = r.FormValue("lb_cookie_secure") == "on" ph.LBCookieSameSite = r.FormValue("lb_cookie_same_site") ph.UpstreamTLSEarlyData = r.FormValue("upstream_tls_early_data") == "on" ph.AddViaHeader = r.FormValue("add_via_header") == "on" ph.ReqHeaderRename = strings.TrimSpace(r.FormValue("req_header_rename")) ph.AddExpectCTHeader = r.FormValue("add_expect_ct_header") == "on" ph.ForceUpstreamEncoding = strings.TrimSpace(r.FormValue("force_upstream_encoding")) if v := strings.TrimSpace(r.FormValue("passive_unhealthy_count")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.PassiveUnhealthyCount = n } } ph.StripXPoweredBy = r.FormValue("strip_x_powered_by") == "on" ph.AddTimingAllowOrigin = strings.TrimSpace(r.FormValue("add_timing_allow_origin")) if v := strings.TrimSpace(r.FormValue("lb_cookie_max_age_sec")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.LBCookieMaxAgeSec = n } } ph.CrossOriginOpenerPolicy = strings.TrimSpace(r.FormValue("cross_origin_opener_policy")) ph.CrossOriginResourcePolicy = strings.TrimSpace(r.FormValue("cross_origin_resource_policy")) ph.CrossOriginEmbedderPolicy = strings.TrimSpace(r.FormValue("cross_origin_embedder_policy")) ph.DenyRequestContentType = strings.TrimSpace(r.FormValue("deny_request_content_type")) ph.CompressionExcludeRegexp = strings.TrimSpace(r.FormValue("compression_exclude_regexp")) ph.AddCacheControlPublic = r.FormValue("add_cache_control_public") == "on" // v2.9.140: add_x_request_start — inject X-Request-Start: t= for APM timing. ph.AddXRequestStart = r.FormValue("add_x_request_start") == "on" // v2.9.141: maintenance_window_timezone — IANA timezone for the scheduled maintenance window. ph.MaintenanceWindowTimezone = strings.TrimSpace(r.FormValue("maintenance_window_timezone")) // v2.9.142: lb_random_choose_count — "choose" count for the random_choice lb policy. if v := strings.TrimSpace(r.FormValue("lb_random_choose_count")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.LBRandomChooseCount = n } } // v2.9.143: add_x_forwarded_scheme — inject X-Forwarded-Scheme with the client-facing scheme. ph.AddXForwardedScheme = r.FormValue("add_x_forwarded_scheme") == "on" // v2.9.144: response_cache_ttl_sec — set Cache-Control: max-age=N (0 = disabled). if v := strings.TrimSpace(r.FormValue("response_cache_ttl_sec")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.ResponseCacheTTLSec = n } } // v2.9.145: add_link_preload — Link response header for HTTP/2 preload hints. ph.AddLinkPreload = strings.TrimSpace(r.FormValue("add_link_preload")) // v2.9.146: deny_path_regexp — block requests whose path matches this regex (403). ph.DenyPathRegexp = strings.TrimSpace(r.FormValue("deny_path_regexp")) // v2.9.147: add_request_id_to_response — echo request trace ID in response header. ph.AddRequestIDToResponse = r.FormValue("add_request_id_to_response") == "on" // v2.9.148: health_check_tls_server_name — TLS SNI override for health check connections. ph.HealthCheckTLSServerName = strings.TrimSpace(r.FormValue("health_check_tls_server_name")) // v2.9.149: add_x_real_ip — inject X-Real-IP with the direct client IP. ph.AddXRealIP = r.FormValue("add_x_real_ip") == "on" // v2.9.150: strip_incoming_x_forwarded_for — delete incoming X-Forwarded-For. ph.StripIncomingXForwardedFor = r.FormValue("strip_incoming_x_forwarded_for") == "on" // v2.9.151: health_check_tls_insecure_skip_verify — skip TLS cert verification for health check probes. ph.HealthCheckTLSInsecureSkipVerify = r.FormValue("health_check_tls_insecure_skip_verify") == "on" // v2.9.152: add_cors_vary_header — add Vary: Origin response header for CDN caching of CORS responses. ph.AddCORSVaryHeader = r.FormValue("add_cors_vary_header") == "on" // v2.9.153: upstream_tls_alpn — ALPN protocol list for upstream TLS connections. ph.UpstreamTLSALPN = strings.TrimSpace(r.FormValue("upstream_tls_alpn")) // v2.9.154: add_x_powered_by — custom X-Powered-By response header value. ph.AddXPoweredBy = strings.TrimSpace(r.FormValue("add_x_powered_by")) // v2.9.155: block_query_params — comma-separated query param names to block (403). ph.BlockQueryParams = strings.TrimSpace(r.FormValue("block_query_params")) // v2.9.156: add_document_policy — Document-Policy response header value. ph.AddDocumentPolicy = strings.TrimSpace(r.FormValue("add_document_policy")) // v2.9.157: maintenance_redirect_url — redirect to this URL during maintenance. ph.MaintenanceRedirectURL = strings.TrimSpace(r.FormValue("maintenance_redirect_url")) // v2.9.158: upstream_keepalive_max_lifetime_sec — max keepalive connection lifetime. if v := strings.TrimSpace(r.FormValue("upstream_keepalive_max_lifetime_sec")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { ph.UpstreamKeepaliveMaxLifetimeSec = n } } // v2.9.159: add_origin_header — inject Origin request header. ph.AddOriginHeader = strings.TrimSpace(r.FormValue("add_origin_header")) // v2.9.160: upstream_tls_ca_pem_inline — inline PEM CA certificate for upstream TLS. ph.UpstreamTLSCAPEMInline = strings.TrimSpace(r.FormValue("upstream_tls_ca_pem_inline")) // v2.9.161: add_server_timing_header — inject Server-Timing with upstream duration. ph.AddServerTimingHeader = r.FormValue("add_server_timing_header") == "on" // v2.9.162: add_clear_site_data — Clear-Site-Data response header value. ph.AddClearSiteData = strings.TrimSpace(r.FormValue("add_clear_site_data")) // v2.9.163: add_x_dns_prefetch_control — set X-DNS-Prefetch-Control: off. ph.AddXDNSPrefetchControl = r.FormValue("add_x_dns_prefetch_control") == "on" // v2.9.164: add_accept_ranges — signal byte-range support with Accept-Ranges: bytes. ph.AddAcceptRanges = r.FormValue("add_accept_ranges") == "on" // v2.9.165: add_content_disposition — set a custom Content-Disposition response header. ph.AddContentDisposition = strings.TrimSpace(r.FormValue("add_content_disposition")) // v2.9.166: upstream_tls_server_name_from_host — use Host header value as upstream TLS SNI. ph.UpstreamTLSServerNameFromHost = r.FormValue("upstream_tls_server_name_from_host") == "on" // v2.9.167: add_x_permitted_cross_domain_policies — X-Permitted-Cross-Domain-Policies response header value. ph.AddXPermittedCrossDomainPolicies = strings.TrimSpace(r.FormValue("add_x_permitted_cross_domain_policies")) // strip_response_headers (v2.9.168) duplicated strip_resp_headers above — // same semantics, a second column, and only strip_resp_headers was // understood by the Caddyfile exporter. The duplicate input is gone from the // form and strip_resp_headers is canonical; the global-strip setting and the // security-bundle precedence now read the canonical field. // // The legacy form value is still ACCEPTED for one release so an old // bookmarked form or an API client posting the v2.9.168 name does not // silently lose data; it is merged into the canonical field instead of being // stored separately. The legacy column is no longer written. // // TODO(v2.70): drop this fallback and the StripResponseHeaders field, and // drop the strip_response_headers column once no supported upgrade path // still carries an unmigrated value. if legacy := strings.TrimSpace(r.FormValue("strip_response_headers")); legacy != "" { ph.StripRespHeaders = models.MergeStripResponseHeaders(ph.StripRespHeaders, legacy) } ph.StripResponseHeaders = "" // v2.9.169: add_report_to — Report-To response header value (JSON endpoint group config). ph.AddReportTo = strings.TrimSpace(r.FormValue("add_report_to")) // v2.9.170: add_nel_header — NEL response header JSON config for Network Error Logging. ph.AddNELHeader = strings.TrimSpace(r.FormValue("add_nel_header")) // v2.9.171: block_http_methods — comma-separated HTTP methods to reject with 405. ph.BlockHTTPMethods = strings.TrimSpace(r.FormValue("block_http_methods")) // v2.9.172: add_service_worker_allowed — Service-Worker-Allowed response header value. ph.AddServiceWorkerAllowed = strings.TrimSpace(r.FormValue("add_service_worker_allowed")) // v2.9.173: add_accept_ch — Accept-CH response header to declare accepted client hints. ph.AddAcceptCH = strings.TrimSpace(r.FormValue("add_accept_ch")) // v2.9.174: add_alt_svc — Alt-Svc response header for alternate service advertisement. ph.AddAltSvc = strings.TrimSpace(r.FormValue("add_alt_svc")) // v2.9.175: add_content_language — Content-Language response header value. ph.AddContentLanguage = strings.TrimSpace(r.FormValue("add_content_language")) // v2.9.176: add_critical_ch — Critical-CH response header (marks client hints required before rendering). ph.AddCriticalCH = strings.TrimSpace(r.FormValue("add_critical_ch")) // v2.9.177: add_x_download_options — set X-Download-Options: noopen (IE file open prevention). ph.AddXDownloadOptions = r.FormValue("add_x_download_options") == "on" // v2.9.178: deny_user_agent_regexp — block requests whose User-Agent matches this regexp with 403. ph.DenyUserAgentRegexp = strings.TrimSpace(r.FormValue("deny_user_agent_regexp")) // v2.9.179: add_pragma_no_cache — set Pragma: no-cache response header. ph.AddPragmaNoCache = r.FormValue("add_pragma_no_cache") == "on" // v2.9.180: health_check_user_agent — custom User-Agent for active health check probes. ph.HealthCheckUserAgent = strings.TrimSpace(r.FormValue("health_check_user_agent")) // v2.9.181: add_x_request_path — inject X-Request-Path request header on upstream calls. ph.AddXRequestPath = r.FormValue("add_x_request_path") == "on" // v2.9.182: add_x_clacks_overhead — X-Clacks-Overhead response header value. ph.AddXClacksOverhead = strings.TrimSpace(r.FormValue("add_x_clacks_overhead")) // v2.9.183: add_x_ua_compatible — X-UA-Compatible response header value. ph.AddXUACompatible = strings.TrimSpace(r.FormValue("add_x_ua_compatible")) // v2.9.184: forward_auth_skip_paths — comma-separated path prefixes that bypass forward_auth. ph.ForwardAuthSkipPaths = strings.TrimSpace(r.FormValue("forward_auth_skip_paths")) // v2.9.185: add_age_zero — set Age: 0 response header to signal a fresh response. ph.AddAgeZero = r.FormValue("add_age_zero") == "on" // v2.9.186: add_surrogate_control — Surrogate-Control response header value (CDN-only cache directive). ph.AddSurrogateControl = strings.TrimSpace(r.FormValue("add_surrogate_control")) // v2.9.187: add_warning_header — Warning response header value. ph.AddWarningHeader = strings.TrimSpace(r.FormValue("add_warning_header")) // v2.9.188: add_x_request_method — forward X-Request-Method header (echoes HTTP method) to upstream. ph.AddXRequestMethod = r.FormValue("add_x_request_method") == "on" // v2.9.189: add_x_request_query — forward X-Request-Query header (echoes query string) to upstream. ph.AddXRequestQuery = r.FormValue("add_x_request_query") == "on" // v2.9.190: add_x_forwarded_user — static X-Forwarded-User request header value. ph.AddXForwardedUser = strings.TrimSpace(r.FormValue("add_x_forwarded_user")) // v2.9.191: add_x_real_scheme — forward X-Real-Scheme request header to upstream. ph.AddXRealScheme = r.FormValue("add_x_real_scheme") == "on" // v2.9.192: add_origin_agent_cluster — set Origin-Agent-Cluster: ?1 response header. ph.AddOriginAgentCluster = r.FormValue("add_origin_agent_cluster") == "on" // v2.9.193: add_x_forwarded_groups — static X-Forwarded-Groups request header value. ph.AddXForwardedGroups = strings.TrimSpace(r.FormValue("add_x_forwarded_groups")) // v2.9.194: add_x_forwarded_email — static X-Forwarded-Email request header value. ph.AddXForwardedEmail = strings.TrimSpace(r.FormValue("add_x_forwarded_email")) // v2.9.195: add_x_forwarded_roles — static X-Forwarded-Roles request header value. ph.AddXForwardedRoles = strings.TrimSpace(r.FormValue("add_x_forwarded_roles")) // v2.9.196: block_query_param_regexp — block requests whose query string matches this regexp with 403. ph.BlockQueryParamRegexp = strings.TrimSpace(r.FormValue("block_query_param_regexp")) // v2.9.197: add_x_request_referer — forward X-Request-Referer header to upstream. ph.AddXRequestReferer = r.FormValue("add_x_request_referer") == "on" // v2.9.198: add_x_request_origin — forward X-Request-Origin header to upstream. ph.AddXRequestOrigin = r.FormValue("add_x_request_origin") == "on" // v2.9.199: add_x_forwarded_uri — forward X-Forwarded-URI header to upstream. ph.AddXForwardedURI = r.FormValue("add_x_forwarded_uri") == "on" // v2.9.200: add_x_no_archive — set X-No-Archive: yes response header. ph.AddXNoArchive = r.FormValue("add_x_no_archive") == "on" // v2.9.201: add_x_request_hostname — forward X-Request-Hostname header to upstream. ph.AddXRequestHostname = r.FormValue("add_x_request_hostname") == "on" // v2.9.202: add_x_xss_protection_disabled — set X-XSS-Protection: 0 response header. ph.AddXXSSProtectionDisabled = r.FormValue("add_x_xss_protection_disabled") == "on" // v2.9.212: add_x_request_remote_port — forward X-Request-Remote-Port header to upstream. ph.AddXRequestRemotePort = r.FormValue("add_x_request_remote_port") == "on" // v2.9.213: add_x_request_protocol — forward X-Request-Protocol header (HTTP version) to upstream. ph.AddXRequestProtocol = r.FormValue("add_x_request_protocol") == "on" // v2.9.214: add_save_data_vary — append Save-Data to Vary response header. ph.AddSaveDataVary = r.FormValue("add_save_data_vary") == "on" // v2.9.217: add_x_environment — static X-Environment request header value. ph.AddXEnvironment = strings.TrimSpace(r.FormValue("add_x_environment")) // v2.9.218: add_x_trace_id — forward X-Trace-ID header (Caddy UUID per request) to upstream. ph.AddXTraceID = r.FormValue("add_x_trace_id") == "on" // v2.9.219: health_check_query_params — query string appended to active health check URL. ph.HealthCheckQueryParams = strings.TrimSpace(r.FormValue("health_check_query_params")) // v2.9.220: add_x_session_id — forward X-Session-ID header to upstream. ph.AddXSessionID = r.FormValue("add_x_session_id") == "on" // v2.9.221: add_x_response_trace_id — set X-Response-Trace-ID response header. ph.AddXResponseTraceID = r.FormValue("add_x_response_trace_id") == "on" // v2.9.222: add_x_request_local_addr — forward X-Local-Addr header to upstream. ph.AddXRequestLocalAddr = r.FormValue("add_x_request_local_addr") == "on" // v2.9.223: add_x_request_local_port — forward X-Local-Port header to upstream. ph.AddXRequestLocalPort = r.FormValue("add_x_request_local_port") == "on" // v2.9.224: add_x_request_path_info — forward X-PathInfo header to upstream. ph.AddXRequestPathInfo = r.FormValue("add_x_request_path_info") == "on" // v2.9.234: add_x_authenticated_user — static X-Authenticated-User request header. ph.AddXAuthenticatedUser = strings.TrimSpace(r.FormValue("add_x_authenticated_user")) // v2.9.235: block_path_extensions — comma-separated extensions to 403. ph.BlockPathExtensions = strings.TrimSpace(r.FormValue("block_path_extensions")) // v2.9.236: add_link_modulepreload — Link rel=modulepreload value. ph.AddLinkModulePreload = strings.TrimSpace(r.FormValue("add_link_modulepreload")) // v2.9.237: add_x_remote_user — static X-Remote-User request header. ph.AddXRemoteUser = strings.TrimSpace(r.FormValue("add_x_remote_user")) // v2.9.238: add_x_forwarded_path — forward X-Forwarded-Path header. ph.AddXForwardedPath = r.FormValue("add_x_forwarded_path") == "on" // v2.9.239: add_x_geo_country_code — static X-Geo-Country header. ph.AddXGeoCountryCode = strings.TrimSpace(r.FormValue("add_x_geo_country_code")) // v2.9.240: add_x_request_priority — X-Request-Priority response header (RFC 9218). ph.AddXRequestPriority = strings.TrimSpace(r.FormValue("add_x_request_priority")) // v2.9.241: health_check_basic_auth — "user:pass" credentials for health check probes. ph.HealthCheckBasicAuth = strings.TrimSpace(r.FormValue("health_check_basic_auth")) // v2.9.242: add_x_real_ssl_protocol — forward TLS version header. ph.AddXRealSSLProtocol = r.FormValue("add_x_real_ssl_protocol") == "on" // v2.9.243: add_x_real_ssl_cipher — forward negotiated cipher header. ph.AddXRealSSLCipher = r.FormValue("add_x_real_ssl_cipher") == "on" // v2.9.244: add_x_cache_status — static X-Cache-Status response header. ph.AddXCacheStatus = strings.TrimSpace(r.FormValue("add_x_cache_status")) // v2.9.245: deny_referer_regexp — block by Referer regexp with 403. ph.DenyRefererRegexp = strings.TrimSpace(r.FormValue("deny_referer_regexp")) // v2.9.246: add_x_request_user_agent — echo UA to upstream (debug). ph.AddXRequestUserAgent = r.FormValue("add_x_request_user_agent") == "on" // v2.9.247: add_reporting_endpoints — Reporting-Endpoints response header (RFC 8942). ph.AddReportingEndpoints = strings.TrimSpace(r.FormValue("add_reporting_endpoints")) // v2.9.248: add_x_request_byte_count — forward Content-Length as X-Request-Byte-Count. ph.AddXRequestByteCount = r.FormValue("add_x_request_byte_count") == "on" // v2.9.249: add_x_request_received_at — forward server-side timestamp. ph.AddXRequestReceivedAt = r.FormValue("add_x_request_received_at") == "on" // issue #167: strip_request_headers (v2.9.250) duplicated strip_req_headers // above — same semantics, different column, and only strip_req_headers was // understood by the Caddyfile exporter. The duplicate input is gone from the // form and strip_req_headers is canonical. // // The legacy form value is still ACCEPTED for one release so an old // bookmarked form or an API client posting the v2.9.250 name does not // silently lose data; it is merged into the canonical field instead of // being stored separately. The legacy column is no longer written. // // TODO(v2.70): drop this fallback and the StripRequestHeaders field, and // drop the strip_request_headers column once no supported upgrade path // still carries an unmigrated value. if legacy := strings.TrimSpace(r.FormValue("strip_request_headers")); legacy != "" { ph.StripReqHeaders = models.MergeStripRequestHeaders(ph.StripReqHeaders, legacy) } ph.StripRequestHeaders = "" // v2.9.251: add_x_forwarded_method — forward HTTP method header. ph.AddXForwardedMethod = r.FormValue("add_x_forwarded_method") == "on" // v2.9.252: add_x_request_original_host — preserve original Host header. ph.AddXRequestOriginalHost = r.FormValue("add_x_request_original_host") == "on" // v2.9.253: add_x_request_dnt — forward DNT header. ph.AddXRequestDNT = r.FormValue("add_x_request_dnt") == "on" // v2.9.254: add_x_geo_region — static X-Geo-Region request header. ph.AddXGeoRegion = strings.TrimSpace(r.FormValue("add_x_geo_region")) // v2.9.255: add_x_request_secure — X-Request-Secure header based on TLS state. ph.AddXRequestSecure = r.FormValue("add_x_request_secure") == "on" // v2.9.256: add_x_request_query_count — debug header for query parameters. ph.AddXRequestQueryCount = r.FormValue("add_x_request_query_count") == "on" // v2.9.257: add_x_request_id_header_response — echo trace UUID to response header. ph.AddXRequestIDHeaderResponse = r.FormValue("add_x_request_id_header_response") == "on" // v2.9.258: force_canonical_host — canonical host for SEO-style consolidation. ph.ForceCanonicalHost = strings.TrimSpace(r.FormValue("force_canonical_host")) // v2.9.259: add_x_robots_noindex_quick — X-Robots-Tag: noindex, nofollow. ph.AddXRobotsNoindexQuick = r.FormValue("add_x_robots_noindex_quick") == "on" // v2.9.260: block_bot_user_agents — built-in bot blocklist. ph.BlockBotUserAgents = r.FormValue("block_bot_user_agents") == "on" // v2.9.261: block_admin_paths — 404 common admin paths. ph.BlockAdminPaths = r.FormValue("block_admin_paths") == "on" // v2.9.262: add_link_dns_prefetch — Link rel=dns-prefetch header. ph.AddLinkDNSPrefetch = strings.TrimSpace(r.FormValue("add_link_dns_prefetch")) // v2.9.263: add_link_preconnect — Link rel=preconnect header. ph.AddLinkPreconnect = strings.TrimSpace(r.FormValue("add_link_preconnect")) // v2.9.264: add_x_csp_disabled — strip Content-Security-Policy from response. ph.AddXCSPDisabled = r.FormValue("add_x_csp_disabled") == "on" // v2.9.265: add_x_request_method_override — honor X-HTTP-Method-Override. ph.AddXRequestMethodOverride = r.FormValue("add_x_request_method_override") == "on" // v2.12.52: disable upstream compression toggle. ph.DisableUpstreamCompression = r.FormValue("disable_upstream_compression") == "on" // v2.28.0 (issue #39): CaddyUI's own monitoring controls. The override // fields are parsed regardless of mode so switching custom → auto → custom // doesn't discard what the user typed; the mode alone decides whether they // take effect (see models.ProxyHost.MonitorSettings). ph.MonitorMode = models.NormalizeMonitorMode(r.FormValue("monitor_mode")) ph.MonitorPath = strings.TrimSpace(r.FormValue("monitor_path")) // v2.36.0 (issue #59): any method in models.MonitorMethods; blank or // unrecognised input is stored as GET so the row is canonical on disk. ph.MonitorMethod = models.NormalizeMonitorMethod(r.FormValue("monitor_method")) ph.MonitorExpectStatus = clampAtoi(r.FormValue("monitor_expect_status"), 0, 100, 599) // Floor the interval at the poller's own tick: a smaller value can't be // honoured (the poller only wakes every appHealthInterval) and storing it // would promise a cadence the UI can't deliver. ph.MonitorIntervalSec = clampAtoi(r.FormValue("monitor_interval_sec"), 0, 60, 86400) ph.MonitorTimeoutSec = clampAtoi(r.FormValue("monitor_timeout_sec"), 0, 1, 120) // v2.33.0: node-local — never sync this host to another Caddy. ph.NodeLocal = r.FormValue("node_local") == "on" // v2.46.0 (discussion #91): issue this host's certificate from Caddy's // internal CA instead of ACME. Only meaningful with Auto TLS (no custom // certificate selected); buildInternalTLSAutomationPolicies enforces that. ph.InternalTLS = r.FormValue("internal_tls") == "on" // v2.51.0 (issue #102): per-host rate limiting (caddy-ratelimit). ph.RateLimitEnabled = r.FormValue("rate_limit_enabled") == "on" ph.RateLimitEvents = clampAtoi(r.FormValue("rate_limit_events"), 0, 1, 1000000) ph.RateLimitWindowSec = clampAtoi(r.FormValue("rate_limit_window_sec"), 0, 1, 86400) // If enabled with the fields left blank, apply the form's placeholder // defaults (100 requests / 60s) so a ticked box is never a silent no-op. if ph.RateLimitEnabled { if ph.RateLimitEvents == 0 { ph.RateLimitEvents = 100 } if ph.RateLimitWindowSec == 0 { ph.RateLimitWindowSec = 60 } } // v2.9.266: proxy_redirect_rules — JSON array of path-based redirects // fired before the reverse_proxy. Same shape as redirection_hosts. ph.ProxyRedirectRules = func() string { v := strings.TrimSpace(r.FormValue("proxy_redirect_rules")) if v == "" || v == "[]" { return "" } var probe []models.RedirectRule if err := json.Unmarshal([]byte(v), &probe); err != nil { return "" } return v }() // v2.9.267: additional_upstream_rules — JSON array of path-based upstream // overrides. Probe-parse to drop garbage that UpstreamRuleList would // silently ignore on read. ph.AdditionalUpstreamRules = func() string { v := strings.TrimSpace(r.FormValue("additional_upstream_rules")) if v == "" || v == "[]" { return "" } var probe []models.UpstreamRule if err := json.Unmarshal([]byte(v), &probe); err != nil { return "" } return v }() // v2.38.0: expectations_json — post-apply checks; stored canonicalised. ph.Expectations = models.NormalizeHostExpectationsJSON(r.FormValue("expectations_json")) return ph, nil }