Functional specification¶
This document is the contract for the implementation. Behavioural changes require a spec update in the same PR.
1. Goals¶
- A single static Go binary that echoes HTTP request properties to the client and to structured logs, for debugging intermediaries (proxies, LBs, ingresses, service meshes, TLS termination).
- Environment-variable compatibility with mendhak/http-https-echo wherever the equivalent feature exists, so it can be swapped in.
- First-class HTTP/2 (
h2+h2c) and flexible TLS issuance (static pair, local auto CA with per-SNI minting, Vault PKI with per-SNI issuance). - Optional forwarding: relay requests to a remote server and relay its responses back, capturing and dumping both legs of the exchange, so the daemon can be inserted between two components to observe their traffic.
- Optional verbose wire dump of every exchange in a
curl -v-style format.
Non-goals¶
- Not a production reverse proxy, API gateway, or mock-server DSL — forwarding is a diagnostic aid, not a traffic-serving feature.
- No certificate validation features (mTLS is echo-only).
- No TLS termination for other services.
2. Technology choices¶
| Concern | Choice |
|---|---|
| Language | Go ≥ 1.25, CGO_ENABLED=0 |
| Config | spf13/viper + spf13/pflag |
| Logging | uber-go/zap |
| HTTP/2 | stdlib net/http + golang.org/x/net/http2 (h2c handler) |
| Metrics | prometheus/client_golang |
| WebSocket | coder/websocket (pure Go, maintained) |
| Vault | plain net/http client against the Vault HTTP API (no SDK dependency) |
| JWT decode | manual base64url decode of header/payload (no verification, no dependency) |
| Forwarding | stdlib net/http client with a dedicated transport |
| CLI UX | plain pflag; no cobra subcommands needed (single daemon + --version) |
3. Package layout¶
cmd/https-echo-server/ main: wire config → logger → servers, signal handling
internal/version/ Version/Commit/Date vars injected via ldflags
internal/config/ viper loading, validation, defaults; Config struct
internal/logging/ zap construction from config
internal/server/ listener setup (HTTP + HTTPS), h2c wrapping, routing,
response shaping, CORS, request logging, WebSocket echo
internal/echo/ request capture → echo.Response struct → JSON render
internal/forward/ forwarding client: request relay, response capture
internal/verbose/ curl -v-style exchange printer
internal/tlsmgr/ tls.Config assembly, GetCertificate dispatch by mode
internal/tlsmgr/autoca/ CA create/load/persist, leaf minting, LRU cache
internal/tlsmgr/vaultpki/ Vault issue client, CA chain fetch, TTL-aware cache
internal/metrics/ Prometheus registry/collectors
internal/ everywhere — no public Go API commitment.
4. Configuration¶
- Precedence: flags > env > config file > defaults.
- Env names bind directly (no prefix), matching the tables in Configuration; flag names are the kebab-case versions.
--config <path>loads YAML/TOML/JSON; keys equal flag names.- Validation happens once at startup; invalid config → log at
error+ exit code2. TLS_MODE=staticrequiresHTTPS_CERT_FILE+HTTPS_KEY_FILE.TLS_MODE=vaultrequiresVAULT_ADDR,VAULT_TOKEN,VAULT_PKI_ROLE.- At least one of
HTTP_ENABLED/HTTPS_ENABLEDmust be true. FORWARD_URL, when set, must be an absolutehttp://orhttps://URL.--versionprintsversion commit dateand exits0.
5. Listeners & HTTP/2¶
- Plaintext listener on
BIND_ADDRESS:HTTP_PORT(default:8080): - HTTP/1.1 always.
HTTP2_H2C_ENABLED=true: wrap the handler withh2c.NewHandlerso both prior-knowledge HTTP/2 andUpgrade: h2cwork.- TLS listener on
BIND_ADDRESS:HTTPS_PORT(default:8443): HTTP2_ENABLED=true: ALPN offersh2, http/1.1; otherwisehttp/1.1.- Min TLS version 1.2.
- Port
0is allowed on either listener: an ephemeral port is chosen by the OS and the effective address is logged at startup (useful for embedding in tests). MAX_HEADER_SIZEmaps tohttp.Server.MaxHeaderByteson both.- Graceful shutdown: on SIGINT/SIGTERM, stop accepting, drain for
SHUTDOWN_TIMEOUT(default 10s), then exit0(or1on forced close). - Startup failures (port busy, bad cert) → error log + exit
1.
6. Echo semantics¶
6.1 Response object¶
Rendered as pretty-printed JSON (2-space indent), Content-Type:
application/json; charset=utf-8, status 200 unless shaped. Field-by-field:
| Field | Type | Source / notes |
|---|---|---|
path |
string | URL path without query |
query |
object | Decoded query params; repeated keys → array of strings |
method |
string | Request method |
protocol |
string | http or https (actual listener, not XFP) |
httpVersion |
string | 1.0, 1.1, 2.0 |
host |
string | Host header / :authority |
hostname |
string | host without port |
ip |
string | Client IP after trusted-proxy resolution |
ips |
array | Full X-Forwarded-For chain when behind trusted proxies |
headers |
object | Names in canonical HTTP case (see §6.6); repeated → array |
cookies |
object | Parsed Cookie header |
signedCookies |
object | Only with COOKIE_SECRET; cookies whose HMAC-SHA256 signature (s:<value>.<sig> format, mendhak/Express-compatible) verifies |
body |
string | Raw body, capped at 1 MiB by default (MAX_BODY_SIZE, bytes); truncation flagged in bodyTruncated: true |
json |
any | Parsed body when Content-Type is application/json and parsing succeeds |
jwt |
object | {header, payload} decoded (not verified) from JWT_HEADER |
clientCertificate |
object | Subject, issuer, serial, validity, SANs, SHA-256 fingerprint |
connection |
object | servername (SNI), alpn, tlsVersion, cipher — TLS listener only |
os |
object | {hostname} of the server machine/container |
env |
object | Process env, only with ECHO_INCLUDE_ENV_VARS=true |
Empty/inapplicable fields are omitted (omitempty semantics).
6.2 Trusted proxies¶
Loopback, link-local, and RFC1918 ranges are always trusted;
ADDITIONAL_TRUSTED_PROXIES (comma-separated IPs/CIDRs) extends the set.
ip is the first untrusted address walking X-Forwarded-For right-to-left.
When the peer is untrusted (or no X-Forwarded-For is present), the socket
peer address is used; when every chain entry is trusted, the leftmost entry
is used (Express/mendhak behaviour).
6.3 Response shaping¶
Header or query parameter, header wins on conflict:
x-set-response-status-code— int 100–599, else ignored + warn log.x-set-response-content-type— verbatim intoContent-Type.x-set-response-delay-ms— non-negative int, capped at 120000 (2 min); the delay must not block other requests (sleep in handler goroutine) and must abort early if the client disconnects. Aborted requests are logged and counted with status499(client closed request).response_body_only=true— query param only (not honoured as a header, matching mendhak): respond with the raw request body, capped atMAX_BODY_SIZElike the echoedbodyfield (status/content-type shaping still applies).- Shaping parameters are always visible in the echoed
headers/query. - Shaping is ignored in forwarding mode (§7): the remote response is authoritative.
Precedence for the body: ECHO_BACK_TO_CLIENT=false (empty) >
OVERRIDE_RESPONSE_BODY_FILE_PATH (file content, read per request) >
response_body_only (raw body) > full JSON echo.
6.4 Reserved paths¶
Handled before echo and before forwarding, in this order:
PROMETHEUS_METRICS_PATH(only ifPROMETHEUS_ENABLED) → metrics.TLS_CA_ENDPOINT(only inauto/vaultmode with the TLS listener enabled, non-empty) → CA PEM,Content-Type: application/x-pem-file. Served on both listeners; withHTTPS_ENABLED=falseno CA exists and the path echoes like any other.
6.5 WebSocket echo¶
If WS_ENABLED=true and the request is a WebSocket upgrade, upgrade instead
of echoing. First message from server: the JSON echo of the upgrade request.
Then every client message (text/binary) is echoed back unchanged, with no
message-size limit. Close is mirrored. Works on both listeners (ws://, wss://), any path. WebSocket
upgrades are handled locally even in forwarding mode (§7) — WebSocket traffic
is never forwarded.
6.6 Header case¶
Header names are always echoed (and dumped, logged, and forwarded) in their
canonical HTTP form — e.g. Content-Type, X-Arbitrary-Header — never
lower-cased. HTTP/2 transmits header names lower-cased on the wire (RFC 9113
§8.2); they are canonicalized to the same form, so output is identical across
HTTP versions. There is no configuration option for this behaviour.
7. Forwarding¶
Setting FORWARD_URL switches the daemon into forwarding mode: instead of
answering with the JSON echo, it relays each request to the remote server and
relays the remote response back to the original client — while capturing and
dumping both request and response. This lets the daemon sit between two
components and expose the exact messages they exchange.
7.1 Request relay¶
- Target URL: scheme, host, and base path of
FORWARD_URL, joined with the incoming request's path and query. - Method and body are relayed unchanged. The request body is fully buffered
(no size limit) so it can be dumped and relayed byte-for-byte; the echoed
bodyfield and verbose dumps still truncate atMAX_BODY_SIZE. - Headers are relayed in canonical case (§6.6), except hop-by-hop headers
(
Connectionand headers it names,Keep-Alive,Proxy-Authenticate,Proxy-Authorization,TE,Trailer,Transfer-Encoding,Upgrade). Hostis rewritten to the target's host;FORWARD_PRESERVE_HOST=truekeeps the client's originalHostinstead.- Standard proxy headers are added: the direct peer's IP is appended to the
(fully preserved)
X-Forwarded-Forchain;X-Forwarded-Protois set to the original scheme andX-Forwarded-Hostto the originalHost(both only if not already present). - Transport: HTTP/1.1 to
http://targets, ALPN (h2orhttp/1.1) tohttps://targets; system trust store, or none withFORWARD_SKIP_VERIFY=true. Transparent response decompression is disabled so bodies pass through byte-for-byte.
7.2 Response relay¶
- The remote response is fully read and dumped first — status, headers, and body go into the request log (§10) and the verbose output (§8) — then relayed to the original client unchanged: same status code, same headers (minus hop-by-hop), same body.
- Remote failure (connection error, TLS error, timeout after
FORWARD_TIMEOUT, default 30s) →502 Bad Gatewaywith a JSON error body{"error": <message>, "target": <url>}and an error log.
7.3 Interaction with other features¶
- The request is still captured and logged as a full echo object (§6.1).
- Reserved paths (§6.4) are served locally, never forwarded.
- WebSocket upgrades are handled locally (§6.5), never forwarded.
- Response shaping (§6.3) and echo body options (
ECHO_BACK_TO_CLIENT,OVERRIDE_RESPONSE_BODY_FILE_PATH,response_body_only) do not apply. - CORS handling (
CORS_ALLOW_*) does not apply; the remote's CORS headers pass through untouched.
8. Verbose exchange dump¶
VERBOSE=true prints every exchange to stdout in a curl -v-style trace,
independent of LOG_FORMAT/LOG_LEVEL (like TLS_CA_PRINT, the output is
meant to be read and copy-pasted by a human):
*— informational lines: request sequence number, peer address, listener protocol, HTTP version, TLS details, forwarding target, response status and duration.>— the incoming request: request line, one line per header, a bare>separator, then the request body (if any).>>/<<— in forwarding mode, the upstream leg: the relayed request and the remote response, in the same shape.<— the response sent to the original client: status line, headers, separator, body.
Rules:
- Each exchange is printed as one atomic block (concurrent requests do not interleave).
- Bodies are truncated at
MAX_BODY_SIZEwith a trailing* [body truncated]marker. - Header names appear in canonical case (§6.6); headers are sorted by name for deterministic output.
- Reserved paths (§6.4) are not dumped.
- WebSocket upgrades print the request leg and an informational
* WebSocket upgradeline instead of a response block.
9. TLS subsystem¶
internal/tlsmgr produces a *tls.Config; only GetCertificate differs per
mode.
9.1 Common¶
MinVersion: TLS1.2. ALPN per §5.MTLS_ENABLE=true→ClientAuth: tls.RequestClientCert(request, never verify).
9.2 static¶
tls.LoadX509KeyPair at startup; fatal on error; served for every SNI.
9.3 auto¶
- CA: load
TLS_CA_CERT_FILE/TLS_CA_KEY_FILEif both exist and parse; otherwise generate ECDSA P-256, CNhttps-echo-server Root CA,IsCA, validity 10 years, and persist (cert0644, key0600, directories created as needed). Persist failure → warn, continue in-memory. - Startup: print CA PEM to stdout if
TLS_CA_PRINT(independent of log format/level, so it is copy-pasteable). - Handshake: keyed by SNI (empty SNI → key
""): - cache hit and cert valid > 5 min → serve;
- miss → mint ECDSA P-256 leaf, validity
TLS_CERT_TTL,NotBefore5 min in the past; SANs: the SNI DNS name, or (no SNI)localhost, host hostname,127.0.0.1,::1. Wildcard SNI is used verbatim as SAN. - LRU cache,
TLS_CERT_CACHE_SIZEentries; minting is single-flight per key (concurrent handshakes for one SNI trigger one mint).
9.4 vault¶
- Issue:
POST /v1/{VAULT_PKI_MOUNT}/issue/{VAULT_PKI_ROLE}with{"common_name": <sni>, "ttl": VAULT_PKI_TTL}; no-SNI fallback uses the host's hostname as CN plus IP SANs as inauto. - Response
certificate+ca_chain+private_key→tls.Certificate(chain appended). - Cache: same LRU + single-flight; entry refreshed when ⅔ of the issued
cert's actual lifetime has elapsed; on refresh failure serve the stale cert
until its real
NotAfter, logging atwarn. - Vault client:
VAULT_ADDR,VAULT_TOKEN, optionalVAULT_NAMESPACE(headerX-Vault-Namespace),VAULT_CACERT,VAULT_SKIP_VERIFY; 5 s request timeout. Issuance failure with empty cache → handshake error (logged with SNI + Vault error). - Startup: fetch
GET /v1/{mount}/ca_chainfor printing//caendpoint; failure is a warning, not fatal. - Token renewal is out of scope (v1): provide a token with adequate TTL.
10. Logging¶
- zap;
LOG_FORMAT=json→ production encoder,console→ console encoder with colors;LOG_LEVELmaps to zap levels. - Per-request log (level
info): messagerequest, fields: the full echo object (§6.1) underrequest, plusstatus,durationMs. - In forwarding mode the same log line additionally carries a
forwardobject:url,status,headers,body(truncated atMAX_BODY_SIZE, withbodyTruncated: truewhen it was) of the remote response, orerrorwhen the relay failed. - Suppression:
DISABLE_REQUEST_LOGS=true, or path matches anyLOG_IGNORE_PATHregex (comma-separated list; invalid regex → fatal at startup with exit code2). - Reserved paths (metrics, CA) are logged at
debugonly.
11. Metrics¶
- Namespace
http_echo. Collectors: default Go/process +http_echo_requests_total{method,status[,path]}andhttp_echo_request_duration_seconds{...}as summary or histogram (PROMETHEUS_METRIC_TYPE). - Label sets controlled by
PROMETHEUS_WITH_METHOD/STATUS/PATH(path off by default — cardinality).
12. Exit codes & signals¶
| Code | Meaning |
|---|---|
0 |
Clean shutdown (signal, drained) or --version |
1 |
Runtime failure (listener bind, cert load, forced shutdown) |
2 |
Invalid configuration |
SIGINT/SIGTERM → graceful shutdown (§5). SIGHUP is ignored in v1 (no config reload).
13. Security considerations¶
- The daemon intentionally reflects everything, including secrets present in
headers (
Authorization, cookies). Never expose it publicly with sensitive traffic; docs must state this. - Forwarding relays traffic only to the operator-configured
FORWARD_URL— the target is never taken from the request. Dumped exchanges (logs, verbose output) contain both legs in full, including credentials; treat the output accordingly. ECHO_INCLUDE_ENV_VARScan leak container secrets — off by default.- Auto-CA private key file is written
0600; the docs warn against trusting the CA outside test machines. VAULT_TOKENis redacted from any log output and never echoed (env echo filtersVAULT_TOKEN,COOKIE_SECRETvalues, replaced by***).- Body size capped (
MAX_BODY_SIZE, default 1 MiB) to bound memory in echo mode; header size capped byMAX_HEADER_SIZE. In forwarding mode bodies are fully buffered by design (diagnostic tool); do not front high-volume production traffic with it. - Delay shaping capped at 2 min to limit trivial connection-exhaustion.
14. Testing requirements¶
- Unit tests: config precedence & validation, echo field derivation (trusted proxies, canonical header case, JWT, signed cookies), response shaping, forwarding (relay semantics, header rewriting, dump content, error mapping) against httptest backends, verbose formatting, auto-CA mint/reload/cache, Vault client against a mocked HTTP server.
- Integration tests (httptest + real listeners on
:0): h2/h2c negotiation, SNI → certificate CN assertions forautomode, mTLS echo, WebSocket echo, graceful shutdown. - Race detector on in CI; coverage uploaded as artifact.