// SPDX-License-Identifier: Apache-2.0 package server import ( "fmt" "regexp" "strings" ) // v2.40.0: the proxy host's Advanced config box wraps its directives in a // site block and adapts them through Caddy; the resulting handlers run // before the host's own reverse_proxy. Anything that belongs *inside* // reverse_proxy — flush_interval, header_up, transport, lb_policy, health // checks — therefore had no home: typed bare it came back from Caddy as // "Caddyfile:2: unrecognized directive: flush_interval", and a reverse_proxy // block was rejected as a second terminal handler. Now a reverse_proxy block // with no upstream is accepted and its sub-directives are merged into the // host's generated handler (see caddy.MergeReverseProxyOverrides), and a // bare sub-directive gets an error that says where it goes. // reverseProxySubdirectives is every sub-directive of Caddy's reverse_proxy // directive. Seen at the top level of an Advanced config they are a sure // sign the user meant the reverse_proxy block. var reverseProxySubdirectives = []string{ "to", "dynamic", "lb_policy", "lb_retries", "lb_try_duration", "lb_try_interval", "lb_retry_match", "health_uri", "health_upstream", "health_port", "health_interval", "health_timeout", "health_status", "health_body", "health_method", "health_request_body", "health_follow_redirects", "health_headers", "health_passes", "health_fails", "fail_duration", "max_fails", "unhealthy_status", "unhealthy_latency", "unhealthy_request_count", "flush_interval", "request_buffers", "response_buffers", "stream_timeout", "stream_close_delay", "buffer_requests", "buffer_responses", "max_buffer_size", "verbose_logs", "trusted_proxies", "header_up", "header_down", "method", "rewrite", "transport", "replace_status", "handle_response", "copy_response", "copy_response_headers", } // reverseProxySubdirectiveError explains a bare reverse_proxy sub-directive // at the top level of an Advanced config, or returns "" when there is none. func reverseProxySubdirectiveError(src string) string { bad := scanTopLevelDirective(src, reverseProxySubdirectives) if bad == "" { return "" } hint := "" switch bad { case "flush_interval": hint = " This one also has a dedicated option: Streaming → Flush immediately / Flush interval." case "header_up", "header_down": hint = " Upstream request/response headers also have dedicated options under Headers." case "transport": hint = " Timeouts, TLS and HTTP version to the upstream also have dedicated options under Upstream." case "lb_policy", "lb_retries", "lb_try_duration", "lb_try_interval": hint = " Load balancing also has dedicated options under Upstreams." } return fmt.Sprintf("`%s` is a reverse_proxy sub-directive, not a site directive, so Caddy rejects it here. Wrap it in a reverse_proxy block and CaddyUI merges it into this host's own reverse_proxy:\n\nreverse_proxy {\n %s …\n}\n\nLeave the upstream address out — it comes from Forward host / port.%s", bad, bad, hint) } // extractReverseProxyOverrides removes the reverse_proxy handler that a // `reverse_proxy { … }` block in the Advanced config adapted to — walking // into the subroute Caddy wraps site directives in — and returns the // remaining handlers (which still run before the proxy) plus that handler's // fields, ready for caddy.MergeReverseProxyOverrides. It is an error for the // block to name an upstream or to sit behind a matcher: neither can be // folded into the host's single generated handler. func extractReverseProxyOverrides(handlers []any) ([]any, map[string]any, error) { var overrides map[string]any var walk func(hs []any, matched bool) ([]any, error) walk = func(hs []any, matched bool) ([]any, error) { out := make([]any, 0, len(hs)) for _, h := range hs { hm, ok := h.(map[string]any) if !ok { out = append(out, h) continue } switch hm["handler"] { case "reverse_proxy": if matched { return nil, fmt.Errorf("a reverse_proxy block behind a matcher can't be merged into this host's reverse_proxy — create an Advanced route for a matched proxy") } if overrides != nil { return nil, fmt.Errorf("Advanced config may contain only one reverse_proxy block") } if ups, _ := hm["upstreams"].([]any); len(ups) > 0 { return nil, fmt.Errorf("the reverse_proxy block in Advanced config must not name an upstream — the address comes from Forward host / port; keep only sub-directives such as flush_interval, header_up or transport") } overrides = map[string]any{} for k, v := range hm { if k != "handler" && k != "upstreams" { overrides[k] = v } } case "subroute": routes, _ := hm["routes"].([]any) kept := make([]any, 0, len(routes)) for _, r := range routes { rm, ok := r.(map[string]any) if !ok { kept = append(kept, r) continue } _, hasMatch := rm["match"] inner, _ := rm["handle"].([]any) newInner, err := walk(inner, matched || hasMatch) if err != nil { return nil, err } if len(inner) > 0 && len(newInner) == 0 { continue // the block was this route's only handler } copyRoute := make(map[string]any, len(rm)) for k, v := range rm { copyRoute[k] = v } copyRoute["handle"] = newInner kept = append(kept, copyRoute) } if len(routes) > 0 && len(kept) == 0 { continue } copyHandler := make(map[string]any, len(hm)) for k, v := range hm { copyHandler[k] = v } copyHandler["routes"] = kept out = append(out, copyHandler) default: out = append(out, hm) } } return out, nil } rest, err := walk(handlers, false) if err != nil { return nil, nil, err } return rest, overrides, nil } // describeOverrides lists the merged sub-directive keys for log lines. func describeOverrides(overrides map[string]any) string { if len(overrides) == 0 { return "" } keys := make([]string, 0, len(overrides)) for k := range overrides { keys = append(keys, k) } return strings.Join(keys, ", ") } // wrapBareReverseProxySubdirectives (v2.42.1) moves reverse_proxy // sub-directives typed at the top level of an Advanced config into a // `reverse_proxy { … }` block — merged into an existing one when there is // one — so `flush_interval -1` on its own simply works instead of being // explained back to the user. Multi-line sub-directives (a transport block) // move with their braces. Sources that need no change come back unchanged. func wrapBareReverseProxySubdirectives(src string) string { out, _ := wrapBareReverseProxySubdirectivesNamed(src) return out } func wrapBareReverseProxySubdirectivesNamed(src string) (string, []string) { isSub := map[string]bool{} for _, d := range reverseProxySubdirectives { isSub[d] = true } stripped := func(line string) string { t := strings.TrimSpace(line) if i := strings.Index(t, "#"); i >= 0 { t = strings.TrimSpace(t[:i]) } return t } braces := func(line string) int { d := 0 for _, ch := range stripped(line) { switch ch { case '{': d++ case '}': d-- } } return d } lines := strings.Split(src, "\n") var top, moved, names []string depth := 0 for i := 0; i < len(lines); { line := lines[i] t := stripped(line) first := "" if t != "" { first = strings.Fields(t)[0] } if depth == 0 && first != "" && isSub[first] { names = append(names, first) moved = append(moved, "\t"+strings.TrimSpace(line)) d := braces(line) i++ for d > 0 && i < len(lines) { moved = append(moved, "\t"+strings.TrimSpace(lines[i])) d += braces(lines[i]) i++ } continue } top = append(top, line) depth += braces(line) i++ } if len(moved) == 0 { return src, nil } // Merge into an existing top-level reverse_proxy block when there is one. depth = 0 for i, line := range top { t := stripped(line) if depth == 0 && t != "" && strings.Fields(t)[0] == "reverse_proxy" && strings.HasSuffix(t, "{") { d := braces(line) for j := i + 1; j < len(top); j++ { d += braces(top[j]) if d == 0 { merged := append([]string{}, top[:j]...) merged = append(merged, moved...) merged = append(merged, top[j:]...) return strings.TrimRight(strings.Join(merged, "\n"), "\n") + "\n", names } } } depth += braces(line) } body := strings.TrimRight(strings.Join(top, "\n"), "\n") block := "reverse_proxy {\n" + strings.Join(moved, "\n") + "\n}\n" if strings.TrimSpace(body) == "" { return block, names } return body + "\n" + block, names } // v2.42.2: users copy option names from Caddy's JSON (read_buffer_size) or // put transport options straight under reverse_proxy; Caddy's adapter // rejects both with "unrecognized subdirective". Both are unambiguous, so // they are repaired rather than reported: JSON-style names become their // Caddyfile spelling, and transport-only options found directly under // reverse_proxy move into its `transport http { … }` block. // transportAliases maps JSON field names people type into the Caddyfile // spelling of the same `transport http` option. var transportAliases = map[string]string{ "read_buffer_size": "read_buffer", "write_buffer_size": "write_buffer", "max_response_header_size": "max_response_header", } // transportSubdirectives are options that only exist inside `transport http`. var transportSubdirectives = []string{ "read_buffer", "write_buffer", "max_response_header", "proxy_protocol", "dial_timeout", "dial_fallback_delay", "response_header_timeout", "expect_continue_timeout", "resolvers", "tls", "tls_client_auth", "tls_insecure_skip_verify", "tls_timeout", "tls_trusted_ca_certs", "tls_trust_pool", "tls_server_name", "tls_renegotiation", "tls_except_ports", "tls_curves", "keepalive", "keepalive_interval", "keepalive_idle_conns", "keepalive_idle_conns_per_host", "versions", "compression", "max_conns_per_host", "forward_proxy_url", "network_proxy", "local_address", } // normalizeProxyAdvancedConfig is every repair applied to an Advanced config // before it is adapted or saved: bare reverse_proxy sub-directives wrapped // (v2.42.1), JSON-style transport names respelled, and transport options // under reverse_proxy moved into its transport block. func normalizeProxyAdvancedConfig(src string) string { return relocateTransportSubdirectives(respellTransportAliases(wrapBareReverseProxySubdirectives(src))) } func advancedFirstToken(line string) string { t := strings.TrimSpace(line) if i := strings.Index(t, "#"); i >= 0 { t = strings.TrimSpace(t[:i]) } if t == "" { return "" } return strings.Fields(t)[0] } func advancedBraceDelta(line string) int { t := strings.TrimSpace(line) if i := strings.Index(t, "#"); i >= 0 { t = t[:i] } d := 0 for _, ch := range t { switch ch { case '{': d++ case '}': d-- } } return d } // respellTransportAliases rewrites the first token of any line that uses a // JSON-style transport option name. func respellTransportAliases(src string) string { lines := strings.Split(src, "\n") changed := false for i, line := range lines { first := advancedFirstToken(line) alias, ok := transportAliases[first] if !ok { continue } indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] lines[i] = indent + alias + strings.TrimPrefix(strings.TrimLeft(line, " \t"), first) changed = true } if !changed { return src } return strings.Join(lines, "\n") } // relocateTransportSubdirectives moves transport-only options that sit // directly inside a top-level reverse_proxy block into that block's // `transport http { … }` (merged into an existing one). Anything else is // left exactly as typed. func relocateTransportSubdirectives(src string) string { isTransport := map[string]bool{} for _, d := range transportSubdirectives { isTransport[d] = true } lines := strings.Split(src, "\n") // Find the top-level reverse_proxy block. depth, start, end := 0, -1, -1 for i, line := range lines { first := advancedFirstToken(line) if depth == 0 && first == "reverse_proxy" && strings.HasSuffix(strings.TrimSpace(strings.SplitN(line, "#", 2)[0]), "{") { start = i d := advancedBraceDelta(line) for j := i + 1; j < len(lines); j++ { d += advancedBraceDelta(lines[j]) if d == 0 { end = j break } } break } depth += advancedBraceDelta(line) } if start < 0 || end < 0 { return src } // Walk the block body at relative depth 1: pull out transport options, // remember an existing transport block. var body, moved []string transportOpen, transportClose := -1, -1 // indexes into body rel := 0 for i := start + 1; i < end; { line := lines[i] first := advancedFirstToken(line) if rel == 0 && first == "transport" && transportOpen < 0 { transportOpen = len(body) body = append(body, line) d := advancedBraceDelta(line) i++ for d > 0 && i < end { body = append(body, lines[i]) d += advancedBraceDelta(lines[i]) i++ } transportClose = len(body) - 1 continue } if rel == 0 && isTransport[first] { moved = append(moved, "\t\t"+strings.TrimSpace(line)) d := advancedBraceDelta(line) i++ for d > 0 && i < end { moved = append(moved, "\t\t"+strings.TrimSpace(lines[i])) d += advancedBraceDelta(lines[i]) i++ } continue } body = append(body, line) rel += advancedBraceDelta(line) i++ } if len(moved) == 0 { return src } var newBody []string if transportOpen >= 0 && transportClose > transportOpen { newBody = append(newBody, body[:transportClose]...) newBody = append(newBody, moved...) newBody = append(newBody, body[transportClose:]...) } else { newBody = append(newBody, body...) newBody = append(newBody, "\ttransport http {") newBody = append(newBody, moved...) newBody = append(newBody, "\t}") } out := append([]string{}, lines[:start+1]...) out = append(out, newBody...) out = append(out, lines[end:]...) return strings.Join(out, "\n") } var unrecognizedSubdirective = regexp.MustCompile(`unrecognized subdirective ([A-Za-z0-9_]+)`) // friendlyAdvancedRejection turns Caddy's adapter error into advice when it // is about an unknown sub-directive — the one people hit when they mix JSON // names, Caddyfile names and transport options. func friendlyAdvancedRejection(err error) string { msg := err.Error() m := unrecognizedSubdirective.FindStringSubmatch(msg) if m == nil { return "Advanced config rejected by Caddy: " + msg } name := m[1] hint := fmt.Sprintf("`%s` is not an option Caddy knows there.", name) if alias, ok := transportAliases[name]; ok { hint += fmt.Sprintf(" In a Caddyfile it is spelled `%s`, inside `transport http { … }`.", alias) } hint += " Caddyfile names differ from the JSON ones (read_buffer_size → read_buffer, write_buffer_size → write_buffer, max_response_header_size → max_response_header); transport options belong inside `transport http { … }` within the reverse_proxy block; and most have a dedicated option in this form — Upstream → timeouts and buffers, Streaming → Flush immediately — which is the simplest place for them." return "Advanced config rejected by Caddy: " + hint + " Caddy said: " + msg }