Add a target edit form with headers and timeout fields (closes #127) (#229)
Some checks failed
check / check (push) Failing after 2m59s

This commit was merged in pull request #229.
This commit is contained in:
2026-08-20 07:24:12 +02:00
parent c6a9884f86
commit aba02bc509
11 changed files with 1764 additions and 44 deletions

View File

@@ -0,0 +1,256 @@
package delivery
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
)
// MaxTargetTimeoutSeconds bounds a per-target request timeout.
// A delivery attempt holds a worker for its whole duration, so an
// unbounded timeout lets one misconfigured target stall the queue
// indefinitely. Five minutes is far beyond any healthy webhook
// receiver and still finite.
const MaxTargetTimeoutSeconds = 300
// Errors returned when a target's header or timeout form input
// cannot be turned into a configuration.
//
// None of these ever quotes a header VALUE. A target header value
// is routinely an authorization token, and these messages are shown
// to the user in an error page body.
var (
errHeaderLineMalformed = errors.New(
`each header line must be "Name: value"`,
)
errHeaderNameInvalid = errors.New(
"header name must be a valid HTTP token",
)
errHeaderValueInvalid = errors.New(
"header value must not contain control characters",
)
errHeaderDuplicate = errors.New(
"header given more than once",
)
errHeaderReserved = errors.New(
"header is set by the delivery engine and cannot be " +
"overridden",
)
errTimeoutInvalid = errors.New(
"timeout must be a whole number of seconds",
)
errTimeoutOutOfRange = errors.New(
"timeout is out of range",
)
)
// isReservedTargetHeader reports whether name (canonicalised) is a
// header a target configuration may not set, because the delivery
// path or net/http itself writes it regardless.
//
// These are rejected rather than accepted-and-ignored. Storing a
// header that provably never reaches the wire tells the operator
// their configuration took effect when it did not, which is the
// same failure mode as silently substituting a default for an
// invalid value.
func isReservedTargetHeader(name string) bool {
switch name {
case "Host", "Content-Length", "Transfer-Encoding", "Connection":
return true
case "User-Agent":
// applyRequestHeaders sets the User-Agent after it applies
// the configured headers, so a configured one would always
// be overwritten.
return true
default:
return false
}
}
// ParseTargetHeaders turns the target form's headers field — one
// "Name: value" pair per line, blank lines ignored — into the map
// stored in HTTPTargetConfig.Headers. Names are canonicalised, so a
// name repeated in a different case is still a duplicate rather than
// one pair silently overwriting the other.
//
// An input with no pairs yields an empty map, which omitempty drops
// from the stored config: a target configured with no headers keeps
// the same config JSON it had before this field existed.
func ParseTargetHeaders(raw string) (map[string]string, error) {
headers := make(map[string]string)
for i, line := range strings.Split(raw, "\n") {
lineNum := i + 1
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, value, err := parseHeaderLine(line)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
if _, dup := headers[name]; dup {
return nil, fmt.Errorf(
"line %d: %w: %q", lineNum,
errHeaderDuplicate, name,
)
}
headers[name] = value
}
return headers, nil
}
// parseHeaderLine splits and validates one "Name: value" line,
// returning the canonicalised name and the trimmed value.
func parseHeaderLine(line string) (string, string, error) {
rawName, value, found := strings.Cut(line, ":")
if !found {
return "", "", errHeaderLineMalformed
}
rawName = strings.TrimSpace(rawName)
if !validHeaderName(rawName) {
return "", "", fmt.Errorf(
"%w: %q", errHeaderNameInvalid, rawName,
)
}
name := http.CanonicalHeaderKey(rawName)
if isReservedTargetHeader(name) {
return "", "", fmt.Errorf(
"%w: %q", errHeaderReserved, name,
)
}
value = strings.TrimSpace(value)
if !validHeaderValue(value) {
return "", "", fmt.Errorf(
"%w: %q", errHeaderValueInvalid, name,
)
}
return name, value, nil
}
// validHeaderName reports whether name is a non-empty RFC 9110
// field name. Rejecting anything else here is what keeps a value
// containing CR or LF from being smuggled in as part of a name and
// injecting a second header into the outbound request.
func validHeaderName(name string) bool {
if name == "" {
return false
}
for i := range len(name) {
if !isTokenByte(name[i]) {
return false
}
}
return true
}
// isTokenByte reports whether c is a "tchar" per RFC 9110 5.6.2.
func isTokenByte(c byte) bool {
switch {
case c >= 'a' && c <= 'z',
c >= 'A' && c <= 'Z',
c >= '0' && c <= '9':
return true
}
return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0
}
// validHeaderValue reports whether value is a legal field value:
// no control characters, which is the other half of the header
// injection guard. An empty value is legal.
func validHeaderValue(value string) bool {
for i := range len(value) {
c := value[i]
if c < 0x20 || c == 0x7f {
return false
}
}
return true
}
// FormatTargetHeaders renders a stored header map back into the
// form's textarea representation, one "Name: value" per line.
//
// Names are sorted so that loading the edit form twice without
// saving produces identical text; Go map iteration order would
// otherwise reshuffle the field on every render.
func FormatTargetHeaders(headers map[string]string) string {
if len(headers) == 0 {
return ""
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var b strings.Builder
for _, name := range names {
b.WriteString(name)
b.WriteString(": ")
b.WriteString(headers[name])
b.WriteString("\n")
}
return b.String()
}
// ParseTargetTimeout interprets the target form's timeout field as
// a whole number of seconds. An empty field means "unset" and yields
// 0, which omitempty drops from the stored config and which the
// delivery path reads as "use the shared client's timeout".
//
// Anything else that is not a whole number in range is an error, not
// a silently substituted default: a target whose timeout was typed
// wrong must say so at the form rather than deliver on a timeout its
// operator did not choose.
func ParseTargetTimeout(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, errTimeoutInvalid
}
if v > MaxTargetTimeoutSeconds {
return 0, fmt.Errorf(
"%w: at most %d seconds",
errTimeoutOutOfRange, MaxTargetTimeoutSeconds,
)
}
return v, nil
}
// FormatTargetTimeout renders a stored timeout for the form field.
// An unset timeout renders as an empty field rather than "0", so the
// placeholder can describe the default the target actually uses.
func FormatTargetTimeout(timeout int) string {
if timeout <= 0 {
return ""
}
return strconv.Itoa(timeout)
}