d787d4ff95
DNS names are case-insensitive, so a backend named back in a different case escaped redaction and reached the client. - Fold ASCII case when replacing a backend's URL, host and hostname - Pin the redaction vectors, the same-status rule and the bare-GET premise behind treating 406 and 415 as replayable
173 lines
5.8 KiB
Go
173 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// upstreamBodyLimit caps how much of a backend's error body is kept for replay.
|
|
const upstreamBodyLimit = 8 << 10
|
|
|
|
// redactedBackend stands in for anything naming a backend in a replayed body.
|
|
const redactedBackend = "<backend>"
|
|
|
|
// upstreamError is a non-2xx reply from a backend kept whole — status, content
|
|
// type and body — so a rejection the backend explained can be replayed to the
|
|
// client instead of collapsed into a gateway error that throws the explanation
|
|
// away.
|
|
type upstreamError struct {
|
|
status int
|
|
contentType string
|
|
body []byte
|
|
}
|
|
|
|
func newUpstreamError(status int, contentType string, body []byte) *upstreamError {
|
|
if len(body) > upstreamBodyLimit {
|
|
body = body[:upstreamBodyLimit]
|
|
}
|
|
return &upstreamError{status: status, contentType: contentType, body: body}
|
|
}
|
|
|
|
func (e *upstreamError) Error() string {
|
|
return fmt.Sprintf("HTTP %d: %s", e.status, strings.TrimSpace(string(e.body)))
|
|
}
|
|
|
|
// clientShaped reports whether a status blames the request rather than the
|
|
// backend or pdbmux itself. openvoxdb answers every bad query with 400 —
|
|
// src/puppetlabs/puppetdb/middleware.clj:98-116 and http.clj:115-129 — and its
|
|
// other 4xx say something a client of pdbmux neither caused nor can fix: 403 is
|
|
// pdbmux's own certificate being refused (middleware.clj:44-58); 404 is either
|
|
// an absent object or an unknown path (http.clj:238-242,
|
|
// middleware.clj:381-398), and which objects a backend holds is the one thing
|
|
// backends are meant to disagree about; 408 and 429 report a backend's timing
|
|
// and capacity. A backend that times out mid-query answers 200 with a truncated
|
|
// body rather than any 4xx (query_eng.clj:463-483), so no timeout reaches here.
|
|
//
|
|
// Every 4xx left over blames the request and so is safe to replay, though none
|
|
// is reachable while the fan-out forwards no client header: 406 needs an Accept
|
|
// the query app refuses (http/server.clj:72; must-accept-type in
|
|
// http.clj:136-145 is unwired), and 415 a Content-Encoding on a POST to
|
|
// /commands (middleware.clj:165-178), which pdbmux never proxies.
|
|
func clientShaped(status int) bool {
|
|
switch status {
|
|
case http.StatusForbidden, http.StatusNotFound,
|
|
http.StatusRequestTimeout, http.StatusTooManyRequests:
|
|
return false
|
|
}
|
|
return status >= 400 && status < 500
|
|
}
|
|
|
|
// unanimousClientError returns the rejection to replay when every backend in a
|
|
// fan-out refused the same query with the same client-shaped status, and nil
|
|
// otherwise. Every backend is asked the same question, so unanimity is what
|
|
// distinguishes a bad query from a sick estate: a transport failure, a 5xx, or
|
|
// two backends disagreeing on the status all leave at least one backend whose
|
|
// answer is evidence about the backend rather than about the request.
|
|
//
|
|
// The reply returned is the first in configured order, so a client retrying a
|
|
// rejected query is told the same thing every time.
|
|
func unanimousClientError(results []backendResult) *upstreamError {
|
|
var first *upstreamError
|
|
for _, res := range results {
|
|
var ue *upstreamError
|
|
if !errors.As(res.err, &ue) || !clientShaped(ue.status) {
|
|
return nil
|
|
}
|
|
if first == nil {
|
|
first = ue
|
|
} else if ue.status != first.status {
|
|
return nil
|
|
}
|
|
}
|
|
return first
|
|
}
|
|
|
|
// writeUpstreamError answers a fan-out that produced no records. A unanimous
|
|
// client-shaped rejection is replayed with the backend's own status and
|
|
// explanation; anything else is reported as a gateway failure.
|
|
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error) {
|
|
var ue *upstreamError
|
|
if !errors.As(err, &ue) {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
body := s.redactBackends(ue.body)
|
|
if len(strings.TrimSpace(string(body))) == 0 {
|
|
setContentType(w, "text/plain; charset=utf-8")
|
|
w.WriteHeader(ue.status)
|
|
_, _ = fmt.Fprintf(w, "upstream rejected the query: %s\n", http.StatusText(ue.status))
|
|
return
|
|
}
|
|
setContentType(w, ue.contentType)
|
|
w.WriteHeader(ue.status)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// redactBackends strips anything naming a configured backend out of text bound
|
|
// for a client. Hiding the estate behind one endpoint is the point of pdbmux, so
|
|
// a backend's own words may reach the client but its address may not.
|
|
func (s *Server) redactBackends(body []byte) []byte {
|
|
out := string(body)
|
|
for _, b := range s.cfg.Backends {
|
|
for _, term := range backendTerms(b) {
|
|
out = replaceFold(out, term, redactedBackend)
|
|
}
|
|
}
|
|
return []byte(out)
|
|
}
|
|
|
|
// replaceFold is strings.ReplaceAll ignoring ASCII case, because DNS names are
|
|
// case-insensitive and a backend shouted back in capitals is still named.
|
|
func replaceFold(s, old, repl string) string {
|
|
if old == "" {
|
|
return s
|
|
}
|
|
hay, needle := foldASCII(s), foldASCII(old)
|
|
var out strings.Builder
|
|
for {
|
|
i := strings.Index(hay, needle)
|
|
if i < 0 {
|
|
out.WriteString(s)
|
|
return out.String()
|
|
}
|
|
out.WriteString(s[:i])
|
|
out.WriteString(repl)
|
|
s, hay = s[i+len(needle):], hay[i+len(needle):]
|
|
}
|
|
}
|
|
|
|
// foldASCII lowercases ASCII only, so byte offsets into the result also index
|
|
// the input — which Unicode-aware folding does not guarantee.
|
|
func foldASCII(s string) string {
|
|
b := []byte(s)
|
|
for i, c := range b {
|
|
if c >= 'A' && c <= 'Z' {
|
|
b[i] = c + 'a' - 'A'
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// backendTerms is what identifies a backend in text: its URL, that URL's host
|
|
// and the bare hostname, longest first so replacing one cannot leave a fragment
|
|
// of another behind. The configured name is deliberately absent — no backend
|
|
// knows it, and names are short enough to match ordinary words.
|
|
func backendTerms(b Backend) []string {
|
|
terms := []string{}
|
|
if b.URL != "" {
|
|
terms = append(terms, strings.TrimRight(b.URL, "/"))
|
|
}
|
|
u, err := url.Parse(b.URL)
|
|
if err != nil || u.Host == "" {
|
|
return terms
|
|
}
|
|
terms = append(terms, u.Host)
|
|
if hn := u.Hostname(); hn != u.Host {
|
|
terms = append(terms, hn)
|
|
}
|
|
return terms
|
|
}
|