Documentation
¶
Overview ¶
Package rewrite owns per-host HTTP header rewrite rules: the rule DTO, the ordered active rule set, and the request/response header mutators. It is a self-contained leaf (stdlib + the uuid generator, no Culvert coupling) extracted from the flat package main per ADR-0002.
Index ¶
- func FingerprintRules(rules []Rule) string
- func NewStableID() string
- type Rewriter
- func (rw *Rewriter) Add(rule Rule) Rule
- func (rw *Rewriter) ApplyRequest(host string, h http.Header)
- func (rw *Rewriter) ApplyResponse(host string, resp *http.Response)
- func (rw *Rewriter) List() []Rule
- func (rw *Rewriter) RemoveByID(id int) bool
- func (rw *Rewriter) RemoveByStableID(stableID string) bool
- func (rw *Rewriter) SetRules(rules []Rule) (backfilled int)
- func (rw *Rewriter) Snapshot() func()
- func (rw *Rewriter) StateSnapshot() (rules []Rule, revision string)
- type Rule
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func FingerprintRules ¶ added in v1.0.218
FingerprintRules is the canonical content fingerprint of an ORDERED rewrite rule set (§26): it covers stable identity, position, host, and every request/response header operation, with map keys sorted deterministically so map iteration order can never make the revision nondeterministic. The process-local integer ID is deliberately excluded — it changes across restarts without any semantic change.
func NewStableID ¶ added in v1.0.218
func NewStableID() string
NewStableID mints a durable rewrite-rule identity (server-owned; the interactive create path calls this so a client-supplied value is never trusted).
Types ¶
type Rewriter ¶
type Rewriter struct {
// contains filtered or unexported fields
}
Rewriter holds the ordered list of active rewrite rules and applies them.
── Why the request path reads a view instead of taking the lock ──────────────
ApplyRequest and ApplyResponse both run on the plain-HTTP forward path (proxy_http.go: prepareHTTPForward before the round trip, handleHTTP after it), so a proxied HTTP request paid TWO acquisitions of this one RWMutex. sync.RWMutex.RLock is an atomic read-modify-write on a single shared word, so that is not a constant cost but a throughput CEILING: every request in the process wrote the same cache line, and the contention grew with core count.
It landed hardest in the DEFAULT posture, where NO rewrite rules are configured and the lock guarded an empty slice. Measured on a 4-core Xeon (rewrite_bench_test.go, BenchmarkApplyRequest_NoRulesParallel, n=6 medians):
cores │ before │ after ──────┼─────────────┼────────── 1 │ 15.9 ns/op │ 1.7 ns/op 2 │ 94.6 ns/op │ 0.9 ns/op 4 │ 113.7 ns/op │ 0.5 ns/op
i.e. before, four cores delivered 0.56x the throughput of ONE core — adding cores SUBTRACTED throughput, for a feature that is switched off. This is the same finding, and the same remedy, as internal/threatfeed's lookup path and package main's IP filter: read an immutable view through an atomic.Pointer.
── The contract, and why it is a SECURITY contract ───────────────────────────
A published view's rules are NEVER mutated in place. Every mutator of rules or nextID must call publishLocked() before releasing mu.
Adding a mutator without that call is a silent SECURITY failure, not a performance one: rewrite rules set and strip headers (the package doc's own example installs Strict-Transport-Security and removes Server), so a rule added through an unpublishing mutator would be accepted, listed, persisted and audited while never actually being applied to a single request. It is pinned per mutator by TestRuleView_EveryMutatorRepublishes; the no-in-place-mutation half is enforced by the race detector via TestRuleView_ConcurrentReadersAndWriters.
mu and the fields it guards stay the AUTHORITATIVE write-side state, so SetRules/List/Add/RemoveByID/Snapshot keep their exact prior semantics — including List's pre-existing shallow copy, whose maps alias the stored rules. Only the read path changed.
func NewRewriter ¶
func NewRewriter() *Rewriter
NewRewriter returns a Rewriter with ID assignment starting at 1.
func (*Rewriter) Add ¶
Add appends a rule and returns it with the assigned identities. The interactive trust boundary (§22): any CALLER-supplied StableID is ignored — the server owns identity generation on create.
func (*Rewriter) ApplyRequest ¶
ApplyRequest mutates h in-place for every matching rule.
func (*Rewriter) ApplyResponse ¶
ApplyResponse mutates resp.Header in-place for every matching rule.
func (*Rewriter) RemoveByID ¶
RemoveByID deletes the rule with the given process-local integer ID (legacy-compat addressing). Returns false if not found.
func (*Rewriter) RemoveByStableID ¶ added in v1.0.218
RemoveByStableID deletes the rule with the given durable identity (the v2 management addressing). Returns false if not found.
func (*Rewriter) SetRules ¶
SetRules replaces the full rule set, PRESERVING LIST ORDER (order is evaluation semantics, §23) and every non-empty StableID verbatim (§21 — restart/rollback/snapshot must never re-identify a known rule). Legacy rules without a StableID are backfilled exactly once here; the returned count tells the caller whether a one-time migration happened so it can make the backfilled identities durable through the real persistence owner. Process-local integer IDs are reassigned as before (compatibility only).
DEFENSIVE dedupe: the validated doors (import / rollback / snapshot candidate checks) reject duplicate StableIDs before any apply reaches here; if a duplicate still arrives (hand-edited file that bypassed a door), the FIRST occurrence keeps the identity and later duplicates are regenerated — counted in the return so the caller persists and logs, never silently.
func (*Rewriter) Snapshot ¶
func (rw *Rewriter) Snapshot() func()
Snapshot captures the current rules and ID counter and returns a closure that restores them, under the mutex on both ends. Production code never calls this; it exists so package main's startup-slice test isolation helper can save and restore the package-global rewriter without reaching across the package boundary into the unexported fields (ADR-0002 extraction).
func (*Rewriter) StateSnapshot ¶ added in v1.0.218
StateSnapshot returns the ordered rules AND the content-derived revision that describes exactly them, from ONE lock hold (v2 coherent-read contract): rows from one state must never pair with another state's fence.
type Rule ¶
type Rule struct {
// ID is assigned automatically when the rule is added at runtime.
//
// COMPATIBILITY/PROCESS-LOCAL ONLY (2D-C §19): SetRules reassigns
// sequential IDs on every load, so this integer is NOT durable object
// identity — it exists for legacy clients that address rules by it within
// one process lifetime. Management identity is StableID.
ID int `json:"id"`
// StableID is the server-owned DURABLE rule identity (2D-C §20): a UUID
// assigned once — at interactive Add, or backfilled exactly once when a
// legacy persisted/imported rule without one first passes through
// SetRules — and preserved verbatim across restart, export/import,
// config-version rollback, and CP→DP snapshot sync (the persistence owner
// is AdminSettings, which snapshots rules WITH their StableIDs). The v2
// management surface addresses rules ONLY by this. Never derived from
// array position or rule contents: two identical rules are two different
// objects. yaml:"-" — YAML-authored config rules receive their identity at
// first load and it becomes durable through the settings owner.
StableID string `yaml:"-" json:"stableId,omitempty"`
// Host is an exact hostname or wildcard pattern (*.example.com).
// Empty string matches every request.
Host string `yaml:"host" json:"host"`
// Request header operations — applied before forwarding to upstream.
ReqSet map[string]string `yaml:"req_set" json:"req_set,omitempty"` // set / overwrite
ReqAdd map[string]string `yaml:"req_add" json:"req_add,omitempty"` // append
ReqRemove []string `yaml:"req_remove" json:"req_remove,omitempty"` // delete
// Response header operations — applied before returning to client.
RespSet map[string]string `yaml:"resp_set" json:"resp_set,omitempty"`
RespAdd map[string]string `yaml:"resp_add" json:"resp_add,omitempty"`
RespRemove []string `yaml:"resp_remove" json:"resp_remove,omitempty"`
}
Rule defines header mutations applied to requests and/or responses whose destination host matches the given pattern.
Example (config.yaml):
rewrite:
- host: "*.internal.corp"
req_set:
X-Forwarded-By: "Culvert"
resp_remove:
- Server
- host: "" # empty = match all hosts
resp_set:
Strict-Transport-Security: "max-age=31536000"