feat: add Replace support for reverse proxy header ops

- Support substring replacement via Search field
- Support regex replacement via SearchRegexp field (precompiled at Provision)
- Support wildcard field name '*' to apply replacement to all headers
- Validate that Search and SearchRegexp are mutually exclusive
- Add 5 functional tests and 9 benchmark tests covering all operations

Benchmark results (no external allocs in hot paths):
  Add:              527 ns/op, 448 B/op,  5 allocs/op
  Set:              891 ns/op, 480 B/op,  7 allocs/op
  Delete(single):   476 ns/op,  48 B/op,  3 allocs/op
  Delete(wildcard): 1073 ns/op, 104 B/op,  7 allocs/op
  Replace(sub):     303 ns/op,  64 B/op,  2 allocs/op
  Replace(regex):  1503 ns/op, 224 B/op,  6 allocs/op
  Replace(wild):    731 ns/op,  80 B/op,  4 allocs/op
  Mixed:           1527 ns/op, 128 B/op,  7 allocs/op
This commit is contained in:
wjqserver 2026-04-19 11:28:08 +08:00
parent 06a6d42de1
commit 93f5edc6eb
2 changed files with 494 additions and 3 deletions

View file

@ -20,6 +20,7 @@ import (
"net/netip"
"net/textproto"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
@ -80,9 +81,17 @@ var (
)
type HeaderOps struct {
Add map[string][]string
Set map[string][]string
Delete []string
Add map[string][]string
Set map[string][]string
Delete []string
Replace map[string][]Replacement
}
type Replacement struct {
Search string
Replace string
SearchRegexp string
re *regexp.Regexp
}
type RespHeaderOps struct {
@ -146,6 +155,8 @@ func (ops *HeaderOps) applyToRequest(req *http.Request) {
req.Header.Del(fieldName)
}
}
ops.applyReplace(req.Header, replacer)
}
func (ops *RespHeaderOps) applyToResponse(hdr http.Header) {
@ -216,6 +227,71 @@ func (ops *HeaderOps) applyTo(hdr http.Header, repl *reverseProxyReplacer) {
hdr.Del(fieldName)
}
}
ops.applyReplace(hdr, repl)
}
func (ops *HeaderOps) applyReplace(hdr http.Header, repl *reverseProxyReplacer) {
if ops == nil || len(ops.Replace) == 0 {
return
}
for fieldName, replacements := range ops.Replace {
fieldName = http.CanonicalHeaderKey(repl.Replace(fieldName))
if fieldName == "*" {
for fn, vals := range hdr {
for i := range vals {
for _, r := range replacements {
hdr[fn][i] = r.apply(vals[i])
}
}
}
continue
}
vals, ok := hdr[fieldName]
if !ok {
continue
}
for i := range vals {
for _, r := range replacements {
hdr[fieldName][i] = r.apply(vals[i])
}
}
}
}
func (r *Replacement) apply(s string) string {
if r == nil || s == "" {
return s
}
if r.SearchRegexp != "" && r.re != nil {
return r.re.ReplaceAllString(s, r.Replace)
}
if r.Search != "" {
return strings.ReplaceAll(s, r.Search, r.Replace)
}
return s
}
func (ops *HeaderOps) Provision() error {
if ops == nil {
return nil
}
for fieldName, replacements := range ops.Replace {
for i, r := range replacements {
if r.SearchRegexp == "" {
continue
}
if r.Search != "" {
return fmt.Errorf("replacement %d for header field %q: cannot specify both Search and SearchRegexp", i, fieldName)
}
re, err := regexp.Compile(r.SearchRegexp)
if err != nil {
return fmt.Errorf("replacement %d for header field %q: %v", i, fieldName, err)
}
replacements[i].re = re
}
}
return nil
}
type reverseProxyReplacer struct {
@ -417,6 +493,19 @@ func newReverseProxyHandler(config ReverseProxyConfig) *reverseProxyHandler {
receivedBy: reverseProxyReceivedBy(config.Via),
}
if config.RequestHeaders != nil {
if err := config.RequestHeaders.Provision(); err != nil {
proxy.configError = err
return proxy
}
}
if config.ResponseHeaders != nil && config.ResponseHeaders.HeaderOps != nil {
if err := config.ResponseHeaders.HeaderOps.Provision(); err != nil {
proxy.configError = err
return proxy
}
}
upstreams, err := buildReverseProxyUpstreams(config)
if err != nil {
proxy.configError = err