From 59acd0d5bb3219a131217bd7c127253fcfa42e0c Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Wed, 27 May 2026 17:50:14 -0700 Subject: [PATCH 1/6] docs: add design brief for IP socket options (TTL, hop-limit, DSCP) Pre-implementation design brief covering the configurable IP socket options feature: IPv4 TTL, IPv6 Hop Limit, and DSCP. Documents the config surface (flag/env/YAML), the socket-option mechanism (net.ListenConfig.Control + setsockopt), kernel inheritance behavior on accepted connections, platform support matrix, and the test plan. Companion to the operator-facing spec in node_exporter/docs/IP_SOCKET_CONFIG.md. Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com --- docs/ip-socket-config-design.md | 402 ++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/ip-socket-config-design.md diff --git a/docs/ip-socket-config-design.md b/docs/ip-socket-config-design.md new file mode 100644 index 00000000..17fa9b61 --- /dev/null +++ b/docs/ip-socket-config-design.md @@ -0,0 +1,402 @@ +# Design Brief: Configurable IP Socket Options (TTL, Hop Limit, DSCP) for `web.ListenAndServe` + +Status: Draft — pre-PR design brief +Author: dave.seddon.ca@gmail.com +Branch: `ttl` (fork: `randomizedcoder/exporter-toolkit`) +Companion doc: [`node_exporter:docs/IP_SOCKET_CONFIG.md`](https://github.com/randomizedcoder/node_exporter/blob/ttl/docs/IP_SOCKET_CONFIG.md) (security/QoS rationale and exporter-side perspective) + +## Introduction + +This brief proposes adding configurable IP-layer header fields — IPv4 TTL, IPv6 Hop Limit, and DSCP (applied uniformly to IPv4 ToS and IPv6 Traffic Class) — to the listening sockets created by `github.com/prometheus/exporter-toolkit/web`. Two motivations: + +1. **Security via TTL clamping.** TTL=2 means packets die after two router hops; the exporter cannot leak metric data beyond the immediate L3 neighborhood even if firewalls or ACLs misbehave. +2. **QoS via DSCP marking.** Operators on networks with traffic shaping or differentiated services need to classify scrape traffic with a specific codepoint (CS2, AF11, EF, etc.) so intermediate equipment queues it correctly. + +All three knobs touch the IP header on the same socket via the same `net.ListenConfig.Control` chokepoint with the same `setsockopt` pattern and the same Linux inheritance semantics on accepted connections. Bundling them in one feature is natural; landing it here propagates to every toolkit-using exporter. + +## Table of contents + +- [Introduction](#introduction) +- [Background](#background) +- [Configuration surface](#configuration-surface) +- [Step 1 — Config plumbing](#step-1--config-plumbing) +- [Step 2 — Socket-control function](#step-2--socket-control-function) +- [Step 3 — Wire into `ListenAndServe`](#step-3--wire-into-listenandserve) +- [Step 4 — Tests](#step-4--tests) +- [Step 5 — Documentation](#step-5--documentation) +- [Step 6 — Platform support and edge cases](#step-6--platform-support-and-edge-cases) +- [Summary](#summary) + +## Background + +`ListenAndServe` in `web/tls_config.go` creates the TCP listener via a bare `net.Listen("tcp", address)` at line 323. Go's standard library exposes the listening socket's file descriptor before `bind(2)` via `net.ListenConfig.Control`; calling `unix.SetsockoptInt` at that point sets options on the listening socket. On Linux, the relevant options — `IP_TTL`, `IP_TOS`, `IPV6_UNICAST_HOPS`, `IPV6_TCLASS` — are all inherited by sockets returned from `accept(2)`, including the SYN-ACK packet (`accept(2)`, `ip(7)`, `ipv6(7)`). `tls.NewListener` is a transparent wrapper, so the settings flow through TLS unchanged. + +**TTL/Hop Limit semantics.** RFC 1122 §3.2.1.7 forbids hosts from sending datagrams with TTL=0. On Linux, `setsockopt(IP_TTL, 0)` is overloaded to mean "use the kernel default", not "send literal zero". The minimum useful configured TTL is therefore **1** (packet dies at the first router; same-L2 reach only). This design treats values 1–255 as configured TTLs and reserves `0` as the "not configured" sentinel. We use Go type `uint8` for these knobs: it exactly matches the wire field, makes negative values a compile-time impossibility (removing a whole class of bug-or-validation), and the sentinel `0` is the one `uint8` value that's invalid as a configured TTL anyway. A small `int(value)` cast bridges to `unix.SetsockoptInt` at the syscall boundary. + +**DSCP semantics.** RFC 2474 / 3260 define the ToS / Traffic Class byte as DSCP in the upper 6 bits and ECN in the lower 2 bits. `setsockopt(IP_TOS, d << 2)` sets a persistent DSCP `d` while the kernel continues to manage ECN bits per packet for ECN-capable TCP connections. **DSCP=0 (CS0) is a valid configured value**, so the "not configured" sentinel must live outside the value range — `-1` (Go type `int`) is the natural choice. We accept the asymmetry with TTL/Hop Limit (`uint8` vs `int`) because each type matches its own semantics; forcing both to `uint8` would mean picking an awkward in-band sentinel like `255` for DSCP. + +Two listener flavors need bespoke treatment: + +- **VSOCK** (`web/tls_config.go:316–321`): no IP layer; all three options are meaningless. Log-and-skip. +- **Systemd socket activation** (`web/tls_config.go:297–306`): listeners come pre-bound from `activation.Listeners()`, so `ListenConfig.Control` doesn't apply. Options are set post-bind via `(*net.TCPListener).File()` + `unix.SetsockoptInt` on the duplicated FD. + +The toolkit's `go.mod` already requires `golang.org/x/sys` (indirect, v0.44.0). Promote to a direct dep. + +## Configuration surface + +Three input layers with precedence **flag > env var > YAML > default**: + +| Knob | Flag | Env var | YAML field | Configured range | Sentinel (not configured) | +|---|---|---|---|---|---| +| IPv4 TTL | `--web.ipv4-ttl` | `WEB_IPV4_TTL` | `ip_socket_config.ipv4_ttl` | 1–255 | flag `0`; YAML absent | +| IPv6 Hop Limit | `--web.ipv6-hop-limit` | `WEB_IPV6_HOP_LIMIT` | `ip_socket_config.ipv6_hop_limit` | 1–255 | flag `0`; YAML absent | +| DSCP | `--web.dscp` | `WEB_DSCP` | `ip_socket_config.dscp` | 0–63 | flag `-1`; YAML absent | + +**Validation** at config-load time: + +- TTL / Hop Limit: 1–255; explicit `0` via YAML is rejected (use omission instead). `0` via flag is the sentinel and silently means "not configured". +- DSCP: 0–63; `-1` via flag is the sentinel; out-of-range values rejected with a clear error citing the config file path. + +YAML fields use `*int` so "absent" is distinguishable from "explicit zero" (load-bearing for DSCP). + +## Step 1 — Config plumbing + +In `web/tls_config.go`: + +- `FlagConfig` (around line 68): add `WebIPv4TTL *uint8`, `WebIPv6HopLimit *uint8`, `WebDSCP *int`. Types chosen so TTL/Hop-Limit can never go negative; DSCP needs `int` because 0 is valid and we want `-1` as the "not configured" sentinel. +- Top-level `Config` (around line 45): add `IPSocketConfig IPSocketConfig \`yaml:"ip_socket_config"\``. +- New struct (pointer fields so YAML absent ≠ explicit zero): + +```go +type IPSocketConfig struct { + IPv4TTL *uint8 `yaml:"ipv4_ttl"` + IPv6HopLimit *uint8 `yaml:"ipv6_hop_limit"` + DSCP *int `yaml:"dscp"` +} +``` + +- `getConfig()` (around line 119) validates non-nil fields; errors include the config file path. + +In `web/kingpinflag/flag.go` (around line 28): register three flags with `.Envar(...)` and the appropriate sentinel defaults (`0` for TTL fields, `-1` for DSCP). + +Precedence helper in `tls_config.go` — generic so the `*uint8` (TTL/Hop-Limit) and `*int` (DSCP) call sites share one implementation: + +```go +func effective[T comparable](flagVal *T, flagSentinel T, yamlVal *T) (T, bool) { + var zero T + if flagVal != nil && *flagVal != flagSentinel { + return *flagVal, true + } + if yamlVal != nil { + return *yamlVal, true + } + return zero, false +} +``` + +### Definition of done — Step 1 + +- [ ] `FlagConfig` exposes three new `*int` fields with correct sentinel defaults. +- [ ] `Config` exposes `IPSocketConfig` with `*int` YAML-tagged fields. +- [ ] `kingpinflag.AddFlags` registers three flags with env-var fallback. +- [ ] `getConfig()` validates ranges; errors cite the config file path. +- [ ] `effective` consolidates precedence in one place. + +## Step 2 — Socket-control function + +Three platform-split files in `web/`: + +- `socket_options_linux.go` (`//go:build linux`) +- `socket_options_bsd.go` (`//go:build freebsd || darwin || dragonfly || netbsd || openbsd`) +- `socket_options_other.go` (`//go:build !linux && !freebsd && !darwin && !dragonfly && !netbsd && !openbsd`) — no-op plus one-time warn log. + +Common signature: + +```go +type socketOptions struct { + IPv4TTL uint8 // 0 means "do not set" + IPv6HopLimit uint8 // 0 means "do not set" + DSCP int // negative means "do not set" +} + +func applySocketOptions(network string, c syscall.RawConn, opts socketOptions) error +``` + +Linux/BSD body (sketch): + +```go +var setErr error +ctrlErr := c.Control(func(fd uintptr) { + if opts.IPv4TTL > 0 && (network == "tcp" || network == "tcp4") { + // Cast uint8 -> int at the syscall boundary. + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_TTL, int(opts.IPv4TTL)); err != nil { + setErr = fmt.Errorf("set IP_TTL=%d: %w", opts.IPv4TTL, err); return + } + } + if opts.IPv6HopLimit > 0 && (network == "tcp" || network == "tcp6") { + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_UNICAST_HOPS, int(opts.IPv6HopLimit)); err != nil { + setErr = fmt.Errorf("set IPV6_UNICAST_HOPS=%d: %w", opts.IPv6HopLimit, err); return + } + } + if opts.DSCP >= 0 { + tos := opts.DSCP << 2 // upper 6 bits DSCP; lower 2 bits ECN (kernel-managed) + if network == "tcp" || network == "tcp4" { + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_TOS, tos); err != nil { + setErr = fmt.Errorf("set IP_TOS for DSCP=%d: %w", opts.DSCP, err); return + } + } + if network == "tcp" || network == "tcp6" { + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_TCLASS, tos); err != nil { + setErr = fmt.Errorf("set IPV6_TCLASS for DSCP=%d: %w", opts.DSCP, err); return + } + } + } +}) +if ctrlErr != nil { + return ctrlErr +} +return setErr +``` + +For dual-stack `[::]:port` listens (`network == "tcp"`), all relevant v4 and v6 options are set; the kernel applies the right ones per outbound packet. + +`setsockopt` failures are surfaced — never swallowed — because silent fallback defeats both security (TTL) and correctness (DSCP marking) purposes. + +### Definition of done — Step 2 + +- [ ] Three files compile under `GOOS=linux,freebsd,darwin,windows`. +- [ ] `applySocketOptions` signature stable across platforms. +- [ ] Linux path correctly calls all four socket options when appropriate. +- [ ] DSCP shift (`<< 2`) verified; ECN bits not touched. +- [ ] Non-Unix platforms log exactly one warn-level message when any option is configured. +- [ ] `setsockopt` errors propagate out of `Listen`. + +## Step 3 — Wire into `ListenAndServe` + +Three code paths to touch in `tls_config.go`: + +**Regular TCP** (line 323): replace `net.Listen` with `net.ListenConfig.Listen` carrying our `Control` callback. + +```go +v4ttl, _ := effective[uint8](flags.WebIPv4TTL, 0, cfg.IPSocketConfig.IPv4TTL) +v6hop, _ := effective[uint8](flags.WebIPv6HopLimit, 0, cfg.IPSocketConfig.IPv6HopLimit) +dscp, dscpSet := effective[int](flags.WebDSCP, -1, cfg.IPSocketConfig.DSCP) +opts := socketOptions{IPv4TTL: v4ttl, IPv6HopLimit: v6hop} +if dscpSet { + opts.DSCP = dscp +} else { + opts.DSCP = -1 +} +lc := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + return applySocketOptions(network, c, opts) + }, +} +listener, err = lc.Listen(context.Background(), "tcp", address) +``` + +(`cfg` comes from `getConfig(*flags.WebConfigFile)` which `ListenAndServe` already calls before binding; if no config file is provided, `cfg.IPSocketConfig` is the zero value and only the flag/env path is in play.) + +**VSOCK** (lines 316–321): if any of the three options is configured, log at info level ("ignoring IP socket options on VSOCK listener %s") and proceed. + +**Systemd socket activation** (lines 297–306): for each `*net.TCPListener` returned by `activation.Listeners()`, call `tcpLn.File()`, apply options via `unix.SetsockoptInt` on the dup'd FD, close the dup. Non-TCP systemd listeners are skipped with a debug log. + +### Definition of done — Step 3 + +- [ ] Regular TCP path uses `net.ListenConfig.Control`. +- [ ] VSOCK path logs and skips, never errors when options are configured. +- [ ] Systemd path applies options post-bind via dup'd FD. +- [ ] TLS-wrapped path continues to function (verified by test, no code change needed). +- [ ] No regression in existing `ListenAndServe` tests. + +## Step 4 — Tests + +The toolkit's existing test style is light-to-moderate: table-driven YAML validation via `TestYAMLFiles` with `web/testdata/*.yml` files; real `net.Listen` + dial-in behavior tests; plain `t.Fatalf`/`t.Errorf`; no testify; no existing `getsockopt` testing; Linux-only CI. The plan matches that style — two additions, no new mocking infrastructure. + +### 4a. Validation: extend `TestYAMLFiles` + +Add 4 bad + 1 good testdata files to `web/testdata/`, wire them into `testTables` and `ErrorMap` in `tls_config_test.go`: + +| File | Body | Expected error | +|---|---|---| +| `web_config_ipv4_ttl_zero.bad.yml` | `ip_socket_config: {ipv4_ttl: 0}` | `ipv4_ttl must be in range 1-255` | +| `web_config_ipv4_ttl_high.bad.yml` | `ip_socket_config: {ipv4_ttl: 256}` | YAML overflow or our range error | +| `web_config_dscp_neg.bad.yml` | `ip_socket_config: {dscp: -1}` | `dscp must be in range 0-63` | +| `web_config_dscp_high.bad.yml` | `ip_socket_config: {dscp: 64}` | `dscp must be in range 0-63` | +| `web_config_ip_socket.good.yml` | full valid config with TTL=2, hop_limit=2, dscp=46 | (no error) | + +Zero new machinery — same pattern as the 30+ existing testdata files. + +### 4b. Behavior: one new Linux-gated table-driven test + +New file `web/socket_options_linux_test.go` (`//go:build linux`). Single test function with a table of ~6 subtests that each: + +1. Build a `net.ListenConfig` with the same `Control` hook the feature installs. +2. `lc.Listen(...)` on a fresh port. +3. `getsockopt` on the listener FD (via `(*net.TCPListener).SyscallConn()`) — verify `IP_TTL`, `IPV6_UNICAST_HOPS`, `IP_TOS`, `IPV6_TCLASS` as appropriate (mask ECN before comparing DSCP). +4. Dial in from a goroutine; `Accept`. +5. `getsockopt` on the accepted conn FD (via `(*net.TCPConn).SyscallConn()`) — same expected values. This is the load-bearing inheritance claim. + +Subtests (9 total — positive / boundary / corner explicitly covered): + +| Subtest | Family | TTL | Hop | DSCP | Role | +|---|---|---|---|---|---| +| `ipv4_ttl_min` | tcp4 | 1 | — | — | boundary low + security extreme | +| `ipv4_ttl_mid` | tcp4 | 7 | — | — | positive | +| `ipv4_ttl_max` | tcp4 | 255 | — | — | boundary high | +| `ipv6_hop_mid` | tcp6 | — | 4 | — | positive (v6) | +| `dscp_zero` | tcp4 | — | — | 0 | corner — explicit 0 IS configured | +| `dscp_mid` | tcp4 | — | — | 46 | positive (EF) | +| `dscp_max` | tcp4 | — | — | 63 | boundary high | +| `all_options_v4` | tcp4 | 3 | — | 16 | combined | +| `dual_stack_all` | tcp `[::]:0` | 2 | 2 | 26 | corner — both v4 and v6 options on one socket | + +~120 lines total. Uses only stdlib + `golang.org/x/sys/unix`. No fake `RawConn`, no recording hooks, no slog handler interception. + +### 4d. Case-coverage matrix + +| Category | Covered by | +|---|---| +| Positive | `ipv4_ttl_mid`, `ipv6_hop_mid`, `dscp_mid`, `all_options_v4`, `dual_stack_all`; good-YAML testdata. | +| Negative (rejected at load time) | 4 bad-YAML testdata files in §4a. | +| Boundary | `ipv4_ttl_min`, `ipv4_ttl_max`, `dscp_zero`, `dscp_max`. | +| Corner | `dscp_zero` (explicit 0 is configured, not skipped); `dual_stack_all` (both v4 and v6 options applied on `[::]:0`). | +| Attacker / malicious input | Out-of-range integers covered by negative testdata. `*uint8` flags can't accept negatives (parser-rejected). YAML type-confusion is yaml.v2's responsibility. Input space per knob is a single 8-bit integer — exhaustively covered by the boundary tests. No invented adversarial scenarios. | + +### 4c. Tiny precedence test for `effective[T]` + +A standalone ~20-line table test verifying flag-set wins over YAML, YAML wins over default, sentinel value means "not configured". No network. No build tag. Catches refactor regressions in the helper. + +### Out of scope (justified omissions) + +| Skipped | Why | +|---|---| +| TLS-wrapped listener | `tls.NewListener` is std-lib transparent; would test std-lib, not us. | +| VSOCK skip path | Requires vsock device. Manual smoke test only. | +| Systemd activation path | Requires systemd FDs. Manual smoke test only. | +| `setsockopt` error propagation | Needs a fake `syscall.RawConn` — no precedent in toolkit; mocking risk > value. | +| Cross-platform builds | CI is Linux-only; cross-compile is a `make`/manual check. | +| Flag/env precedence directly | Kingpin's `.Envar()` is exercised by every other toolkit flag; `effective[T]` test covers the in-package layer. | + +### Definition of done — Step 4 + +- [ ] 5 new `web/testdata/web_config_*.yml` files wired into `testTables` and `ErrorMap`. +- [ ] `TestApplySocketOptions_Inheritance` in `web/socket_options_linux_test.go` (build-tagged), **9 passing subtests** covering positive / boundary / corner per the §4d matrix. +- [ ] Small `TestEffective` table test for `effective[T]` precedence. +- [ ] `go test ./web/...` clean on Linux CI. +- [ ] No new mocking infrastructure; no new test dependencies in `go.mod`. + +## Appendix A — Optional follow-up commit: migrating `handler_test.go` to table-driven + +This appendix proposes an **optional, clearly-separated commit** on the same `ttl` branch that converts `web/handler_test.go` to a table-driven structure. The commit is isolated from the feature commits so reviewers can either accept it, drop it via `git rebase -i`, or request it be split into its own PR — without that decision blocking the feature itself. + +### Current state of table-driven density + +| File | Lines | Table-driven? | Notes | +|---|---|---|---| +| `web/tls_config_test.go` | 714 | **Yes** — `TestYAMLFiles` + `TestInputs` slice drives the majority. | Already the model we plug new validation cases into. No conversion needed. | +| `web/handler_test.go` | 257 | **No** — 4 separate named test functions covering auth caching, basic-auth headers, rate limiting, etc. | The realistic conversion candidate. | +| `web/cache_test.go` | 37 | n/a — single short test. | Too small to bother. | +| `web/kingpinflag/` | 0 | n/a — no test file. | Could add a small table-driven `TestAddFlags` covering flag registration if there's appetite. | + +### Why conversion of `handler_test.go` would help + +- Adding new auth-related cases currently means writing a new top-level test function with its own setup/teardown duplication. Table form lets a new case be one row. +- The 4 existing tests share ~30 lines of nearly-identical fixture code (build `http.Server`, start goroutine, `waitForPort`, cleanup). A `TestInputs`-style fixture would consolidate that. +- The fixtures and assertions don't currently make it obvious which auth/cache scenarios are NOT tested; a table-driven format makes coverage gaps visible. + +### Sketch of the refactor + +```go +type handlerCase struct { + name string + yamlConfig string // path under testdata/ + requests []requestSpec // method, path, headers, body + expectedStatus []int // per-request + cacheBehavior func(t *testing.T, server *http.Server) // optional post-assert hook +} + +func TestHandler(t *testing.T) { + cases := []handlerCase{ + // BasicAuthCache, basic-auth header behavior, rate limiting, etc. + // — each currently-separate test becomes one row. + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // single shared fixture: ListenAndServe, waitForPort, t.Cleanup + // walk tc.requests, assert against tc.expectedStatus + }) + } +} +``` + +Estimated effort: ~1 day of refactor + careful diff-review to ensure no test behavior changed. Net line count likely shrinks 20–40%. No new behavior tested; no behavior tested less. + +### Why this lives in a separate commit (not mixed into the feature commits) + +- Reviewers expect feature commits to be feature-shaped. A sweeping test refactor mixed into the same commit as feature code obscures the actual change and slows review. +- The refactor is justified on maintainability, not on enabling the TTL/DSCP feature — those tests already exist as separate functions and our feature doesn't touch them. +- Keeping it as one self-contained commit makes it trivial for maintainers to drop or split: `git rebase -i upstream/master` and either `drop` the refactor commit or move it to its own branch for a follow-up PR. +- Bisect stays clean: if either the feature or the refactor introduces a regression, `git bisect` lands on the right commit. + +### Commit organization on the `ttl` branch + +The branch carries the work in this order so each commit is independently revertable: + +1. `web: add IP socket-options config plumbing (FlagConfig, IPSocketConfig)` — Step 1. +2. `web: implement applySocketOptions for Linux/BSD/other` — Step 2. +3. `web: wire IP socket options into ListenAndServe` — Step 3. +4. `web: tests for IP socket options (validation + Linux inheritance)` — Step 4a + 4b + 4c. +5. `web: docs for ip_socket_config` — Step 5. +6. **`web: refactor handler_test.go to table-driven`** — this appendix. **Optional / droppable.** + +A reviewer who wants only the feature can request "please drop commit 6" and the author rebases it out. A reviewer who likes the refactor can leave it. The branch description in the PR should call out commit 6 explicitly as optional and reference this appendix. + +### Note on node_exporter + +node_exporter's collector tests are file-fixture-based (procfs/sysfs trees under `collector/fixtures/`). Forcing them into table-driven form would fight the existing architecture rather than help it. We do **not** propose any conversion there. + +## Step 5 — Documentation + +- `docs/web-configuration.md`: new section "`ip_socket_config`" with annotated YAML example, security/QoS rationale, validation rules (TTL 1–255, DSCP 0–63), table of which listener flavors honor each option, note that ECN bits are not touched. +- `web/web-config.yml`: commented-out example block. +- `CHANGELOG.md`: one-line entry under the next unreleased version covering both TTL and DSCP. + +### Definition of done — Step 5 + +- [ ] `docs/web-configuration.md` includes the new section with validation rules. +- [ ] `web/web-config.yml` ships with the commented example. +- [ ] `CHANGELOG.md` updated. + +## Step 6 — Platform support and edge cases + +| Platform | Status | +|---|---| +| Linux | CI-tested; load-bearing target. | +| FreeBSD, DragonFly, NetBSD, OpenBSD, Darwin | Build-supported, not CI-tested. | +| Windows | No-op + single warn log. Note: Windows IP_TOS is subject to the QoS API; even outside this exporter, raw setsockopt may be ignored without a registry tweak. | +| Other (Plan 9, JS/Wasm, …) | No-op + single warn log. | + +| Edge case | Handling | +|---|---| +| TLS-wrapped listener | Transparent — `tls.NewListener` passes through. | +| HTTP/2 | Same TCP transport; no impact. | +| Dual-stack `[::]:port` | All relevant v4 and v6 options set. | +| VSOCK | No IP layer; log-and-skip. | +| Systemd socket activation | Apply post-bind via dup'd FD. | +| ECN bits | Never touched; `dscp << 2` only writes upper 6 bits. | +| `IPV6_V6ONLY` | Not modified. | +| Explicit `ipv4_ttl: 0` in YAML | Rejected at config-load time. | +| Explicit `dscp: 0` in YAML | Accepted; `setsockopt(IP_TOS, 0)` called. | + +### Definition of done — Step 6 + +- [ ] Cross-compile matrix succeeds for `GOOS=linux,freebsd,darwin,windows`. +- [ ] Windows path emits exactly one warning at startup. +- [ ] VSOCK + any-option-configured starts cleanly. +- [ ] Systemd path manually verified with a unit file using `ListenStream=`. +- [ ] DSCP wire value verified via `tcpdump` (upper 6 bits of ToS match configured DSCP). + +## Summary + +This brief proposes adding `--web.ipv4-ttl`, `--web.ipv6-hop-limit`, and `--web.dscp` flags (with env-var and YAML equivalents) to `web.ListenAndServe`. The implementation hooks `net.ListenConfig.Control` to call `setsockopt` on `IP_TTL`, `IPV6_UNICAST_HOPS`, `IP_TOS` (DSCP shifted into the upper 6 bits), and `IPV6_TCLASS` pre-bind. On Linux these options are inherited by accepted connections, so the SYN-ACK and all subsequent response packets carry the configured values. VSOCK ignores all three; systemd socket-activated listeners get them applied post-bind via a dup'd FD. TTL and Hop Limit accept 1–255 (with 0 reserved as the not-configured sentinel — RFC 1122 forbids hosts from sending TTL=0). DSCP accepts 0–63 (with -1 as the sentinel since 0 = CS0 is a valid configured value); ECN bits are left for the kernel to manage. Linux is the load-bearing platform; BSD/Darwin compile; Windows is a no-op with a warning. From ad8f516a9982ffad02736fc8db2292d5d8127fef Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Wed, 27 May 2026 17:54:14 -0700 Subject: [PATCH 2/6] web: add IP socket-options config plumbing (FlagConfig, IPSocketConfig) Add the configuration surface for a forthcoming feature that clamps IP TTL / IPv6 Hop Limit and sets DSCP on the listening socket: * IPSocketConfig YAML struct under Config (ip_socket_config: block) with *uint8 ipv4_ttl, *uint8 ipv6_hop_limit, *int dscp. Pointer types let an absent YAML field be distinguished from an explicit zero (load-bearing for DSCP, since DSCP=0 is the valid CS0 codepoint). * FlagConfig fields WebIPv4TTL (*uint8), WebIPv6HopLimit (*uint8), WebDSCP (*int) wired through kingpinflag.AddFlags as the three new flags --web.ipv4-ttl, --web.ipv6-hop-limit, --web.dscp with matching env vars WEB_IPV4_TTL, WEB_IPV6_HOP_LIMIT, WEB_DSCP. * validateIPSocketConfig enforces the configured ranges (TTL/hop 1-255, DSCP 0-63). The uint8 field type rejects negatives and overflow at YAML-parse time, so the validator only checks the minimum for TTL fields. * Promote golang.org/x/sys to a direct require (will be used by the platform-specific socket option code in the next commit). No behavior change yet -- ListenAndServe still ignores the new fields. The wiring lands in a follow-up commit. Design: docs/ip-socket-config-design.md Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com --- go.mod | 2 +- web/kingpinflag/flag.go | 12 +++++++++++ web/tls_config.go | 48 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9f0518cc..f98d2e4e 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 golang.org/x/crypto v0.54.0 golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 golang.org/x/time v0.15.0 ) @@ -29,7 +30,6 @@ require ( github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/web/kingpinflag/flag.go b/web/kingpinflag/flag.go index fab1c8b0..e42e8c9d 100644 --- a/web/kingpinflag/flag.go +++ b/web/kingpinflag/flag.go @@ -46,6 +46,18 @@ func AddFlags(a flagGroup, defaultAddress string) *web.FlagConfig { "web.config.file", "Path to configuration file that can enable TLS or authentication. See: https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md", ).Default("").String(), + WebIPv4TTL: a.Flag( + "web.ipv4-ttl", + "IPv4 TTL to set on the listening socket. Valid: 1-255. 0 (default) leaves the kernel default. Lower values bound how far response packets can travel.", + ).Default("0").Envar("WEB_IPV4_TTL").Uint8(), + WebIPv6HopLimit: a.Flag( + "web.ipv6-hop-limit", + "IPv6 Hop Limit to set on the listening socket. Valid: 1-255. 0 (default) leaves the kernel default.", + ).Default("0").Envar("WEB_IPV6_HOP_LIMIT").Uint8(), + WebDSCP: a.Flag( + "web.dscp", + "DSCP codepoint applied to outbound packets via IPv4 ToS and IPv6 Traffic Class (upper 6 bits). Valid: 0-63. -1 (default) leaves the kernel default. ECN bits are left for the kernel.", + ).Default("-1").Envar("WEB_DSCP").Int(), } return &flags } diff --git a/web/tls_config.go b/web/tls_config.go index 7245f741..5ada6419 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -48,6 +48,7 @@ type Config struct { HTTPConfig HTTPConfig `yaml:"http_server_config"` RateLimiterConfig RateLimiterConfig `yaml:"rate_limit"` Users map[string]config_util.Secret `yaml:"basic_auth_users"` + IPSocketConfig IPSocketConfig `yaml:"ip_socket_config"` } type TLSConfig struct { @@ -73,6 +74,15 @@ type FlagConfig struct { WebSystemdSocket *bool // WebConfigFile points to the TLS and authentication configuration file. WebConfigFile *string + // WebIPv4TTL is the IPv4 TTL to set on the listening socket. + // Sentinel 0 (or nil) means "not configured; use kernel default". + WebIPv4TTL *uint8 + // WebIPv6HopLimit is the IPv6 Hop Limit to set on the listening socket. + // Sentinel 0 (or nil) means "not configured; use kernel default". + WebIPv6HopLimit *uint8 + // WebDSCP is the DSCP codepoint (upper 6 bits of IP ToS / IPv6 Traffic Class). + // Sentinel -1 (or nil) means "not configured". Valid configured range: 0-63. + WebDSCP *int } // checkFlags validates that the flag configuration contains the required @@ -135,6 +145,25 @@ type RateLimiterConfig struct { Interval time.Duration `yaml:"interval"` } +// IPSocketConfig configures IP-layer socket options applied to the listening +// socket. All fields are optional; an omitted (nil) field means "not configured; +// use the kernel default". +// +// Valid ranges: +// - IPv4TTL, IPv6HopLimit: 1-255. (TTL=0 is forbidden by RFC 1122 and not +// useful since the first router decrements it to -1 and discards.) +// - DSCP: 0-63. The 6-bit DSCP codepoint is shifted into the upper 6 bits of +// the IPv4 ToS / IPv6 Traffic Class byte; the lower 2 bits (ECN) are left +// for the kernel to manage on ECN-capable TCP connections. +// +// On Linux, options set on the listening socket are inherited by accepted +// connections including the SYN-ACK packet. See accept(2), ip(7), ipv6(7). +type IPSocketConfig struct { + IPv4TTL *uint8 `yaml:"ipv4_ttl"` + IPv6HopLimit *uint8 `yaml:"ipv6_hop_limit"` + DSCP *int `yaml:"dscp"` +} + func getConfig(configPath string) (*Config, error) { content, err := os.ReadFile(configPath) if err != nil { @@ -152,10 +181,29 @@ func getConfig(configPath string) (*Config, error) { if err == nil { err = validateHeaderConfig(c.HTTPConfig.Header) } + if err == nil { + err = validateIPSocketConfig(c.IPSocketConfig) + } c.TLSConfig.SetDirectory(filepath.Dir(configPath)) return c, err } +// validateIPSocketConfig enforces value-range rules. The uint8 type on the TTL +// fields already excludes negatives and values >255 at YAML-parse time, so we +// only need to reject the explicit-zero sentinel here. +func validateIPSocketConfig(c IPSocketConfig) error { + if c.IPv4TTL != nil && *c.IPv4TTL < 1 { + return fmt.Errorf("ipv4_ttl must be in range 1-255, got %d", *c.IPv4TTL) + } + if c.IPv6HopLimit != nil && *c.IPv6HopLimit < 1 { + return fmt.Errorf("ipv6_hop_limit must be in range 1-255, got %d", *c.IPv6HopLimit) + } + if c.DSCP != nil && (*c.DSCP < 0 || *c.DSCP > 63) { + return fmt.Errorf("dscp must be in range 0-63, got %d", *c.DSCP) + } + return nil +} + func getTLSConfig(configPath string) (*tls.Config, error) { c, err := getConfig(configPath) if err != nil { From f2a7fe1dbdd481b0ff653ae5ee24cc411f7b1af8 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Wed, 27 May 2026 18:01:16 -0700 Subject: [PATCH 3/6] web: apply IP socket options on the listening socket and accepted conns Wire the configuration added in the previous commit through to the listener and to each accepted connection. The two halves of the work are split because Linux does NOT inherit all options equally: * IP_TTL and IPV6_UNICAST_HOPS ARE inherited by accepted connections (accept(2), ip(7), ipv6(7)). Set them on the listening socket via net.ListenConfig{Control: applyListenerOptions} so the SYN-ACK and every subsequent packet on accepted connections carries the value. * IP_TOS and IPV6_TCLASS (DSCP, upper 6 bits) are NOT inherited. Accepted connections get the kernel default unless we explicitly setsockopt per connection. We do that via an ipSocketListener wrapper around the underlying net.Listener that calls applyConnOptions on every Accept. Listener flavors: * Regular TCP: net.ListenConfig.Control sets listener options; the listener is wrapped with ipSocketListener if DSCP is configured. * Systemd socket activation: applyListenerOptions is called post-bind via (*net.TCPListener).SyscallConn(); the listener is then wrapped if DSCP is configured. * VSOCK: no IP layer; if any IP option is configured we log an info line and proceed unchanged. Platform support: * Linux + BSD family: real setsockopt implementation in socket_options_unix.go using golang.org/x/sys/unix. * Other (Windows, Plan 9, JS/Wasm): socket_options_other.go is a no-op that emits a single warn-level slog message when any option is configured. Implementation detail: applyListenerOptions and applyConnOptions try both v4 and v6 options when configured and silently swallow ENOPROTOOPT on the inapplicable family. This lets us safely set v4+v6 options on a dual-stack listener (e.g. [::]:9100) without inspecting the socket family up front. ECN bits (lower 2 bits of the ToS byte) are NOT touched -- the kernel manages them per-packet for ECN-capable TCP (RFC 3168). The effective[T] generic helper resolves flag > YAML > default precedence; resolveSocketOptions composes flag values with YAML values from the (optional) web-config file. Design: docs/ip-socket-config-design.md Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com --- web/socket_options_other.go | 46 +++++++++++ web/socket_options_unix.go | 97 ++++++++++++++++++++++++ web/tls_config.go | 147 +++++++++++++++++++++++++++++++++++- web/tls_config_test.go | 67 ++++++++++++++++ 4 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 web/socket_options_other.go create mode 100644 web/socket_options_unix.go diff --git a/web/socket_options_other.go b/web/socket_options_other.go new file mode 100644 index 00000000..ce3cdaa2 --- /dev/null +++ b/web/socket_options_other.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux && !freebsd && !darwin && !dragonfly && !netbsd && !openbsd + +package web + +import ( + "log/slog" + "sync" + "syscall" +) + +var warnSocketOptionsUnsupportedOnce sync.Once + +func warnOnceIfConfigured(opts socketOptions) { + if opts.anySet() { + warnSocketOptionsUnsupportedOnce.Do(func() { + slog.Default().Warn("IP socket options (TTL/hop-limit/DSCP) are not supported on this platform; configured values will be ignored") + }) + } +} + +// applyListenerOptions is a no-op on platforms without unix.SetsockoptInt +// support. The first time it sees configured options it emits a single +// warn-level log line; subsequent calls are silent. +func applyListenerOptions(_ syscall.RawConn, opts socketOptions) error { + warnOnceIfConfigured(opts) + return nil +} + +// applyConnOptions mirrors applyListenerOptions: no-op + one-shot warning. +func applyConnOptions(_ syscall.RawConn, opts socketOptions) error { + warnOnceIfConfigured(opts) + return nil +} diff --git a/web/socket_options_unix.go b/web/socket_options_unix.go new file mode 100644 index 00000000..04a5ec90 --- /dev/null +++ b/web/socket_options_unix.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux || freebsd || darwin || dragonfly || netbsd || openbsd + +package web + +import ( + "errors" + "fmt" + "syscall" + + "golang.org/x/sys/unix" +) + +// applyListenerOptions sets the IP socket options that are *inherited* by +// accepted connections on Linux: IP_TTL and IPV6_UNICAST_HOPS. These belong +// on the listening socket so the SYN-ACK and every subsequent packet on an +// accepted connection carries the configured value. DSCP is NOT inherited +// and is applied per-accepted-connection by applyConnOptions instead. +func applyListenerOptions(c syscall.RawConn, opts socketOptions) error { + if opts.IPv4TTL == 0 && opts.IPv6HopLimit == 0 { + return nil + } + var setErr error + ctrlErr := c.Control(func(fd uintptr) { + if opts.IPv4TTL > 0 { + if err := setIfApplicable(fd, unix.IPPROTO_IP, unix.IP_TTL, int(opts.IPv4TTL), "IP_TTL"); err != nil { + setErr = err + return + } + } + if opts.IPv6HopLimit > 0 { + if err := setIfApplicable(fd, unix.IPPROTO_IPV6, unix.IPV6_UNICAST_HOPS, int(opts.IPv6HopLimit), "IPV6_UNICAST_HOPS"); err != nil { + setErr = err + return + } + } + }) + if ctrlErr != nil { + return ctrlErr + } + return setErr +} + +// applyConnOptions sets the IP socket options that are NOT inherited by +// accepted connections and so must be applied per-connection: IP_TOS and +// IPV6_TCLASS (the DSCP codepoint shifted into the upper 6 bits). +// +// The 2 ECN bits (lower 2 bits of the ToS / Traffic Class byte) are +// deliberately not touched -- the kernel manages them per-packet for +// ECN-capable TCP connections (RFC 3168). +func applyConnOptions(c syscall.RawConn, opts socketOptions) error { + if opts.DSCP < 0 { + return nil + } + tos := opts.DSCP << 2 + var setErr error + ctrlErr := c.Control(func(fd uintptr) { + if err := setIfApplicable(fd, unix.IPPROTO_IP, unix.IP_TOS, tos, "IP_TOS"); err != nil { + setErr = err + return + } + if err := setIfApplicable(fd, unix.IPPROTO_IPV6, unix.IPV6_TCLASS, tos, "IPV6_TCLASS"); err != nil { + setErr = err + return + } + }) + if ctrlErr != nil { + return ctrlErr + } + return setErr +} + +// setIfApplicable calls setsockopt, swallowing ENOPROTOOPT so we can try +// both v4 and v6 options on a socket without first inspecting its family +// (matters for dual-stack listeners on [::]:port). +func setIfApplicable(fd uintptr, level, opt, value int, name string) error { + err := unix.SetsockoptInt(int(fd), level, opt, value) + if err == nil { + return nil + } + if errors.Is(err, unix.ENOPROTOOPT) { + return nil + } + return fmt.Errorf("setsockopt %s=%d: %w", name, value, err) +} diff --git a/web/tls_config.go b/web/tls_config.go index 5ada6419..1efbae9e 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -14,6 +14,7 @@ package web import ( + "context" "crypto/tls" "crypto/x509" "errors" @@ -27,6 +28,7 @@ import ( "slices" "strconv" "strings" + "syscall" "time" "github.com/coreos/go-systemd/v22/activation" @@ -164,6 +166,20 @@ type IPSocketConfig struct { DSCP *int `yaml:"dscp"` } +// socketOptions is the resolved set of IP socket options to apply to a +// listening socket. Sentinels: IPv4TTL/IPv6HopLimit == 0 means "do not set", +// DSCP < 0 means "do not set". +type socketOptions struct { + IPv4TTL uint8 + IPv6HopLimit uint8 + DSCP int +} + +// anySet reports whether any option is configured. +func (o socketOptions) anySet() bool { + return o.IPv4TTL > 0 || o.IPv6HopLimit > 0 || o.DSCP >= 0 +} + func getConfig(configPath string) (*Config, error) { content, err := os.ReadFile(configPath) if err != nil { @@ -204,6 +220,58 @@ func validateIPSocketConfig(c IPSocketConfig) error { return nil } +// effective resolves the configured value for an IP socket option applying +// flag > YAML > default precedence. flagVal is the parsed pointer from kingpin +// (non-nil after parse; equal to flagSentinel when the operator did not set +// the flag). yamlVal is the YAML field pointer, nil when the field is absent. +// Returns the resolved value and true if the option was configured, otherwise +// the zero value and false. +func effective[T comparable](flagVal *T, flagSentinel T, yamlVal *T) (T, bool) { + var zero T + if flagVal != nil && *flagVal != flagSentinel { + return *flagVal, true + } + if yamlVal != nil { + return *yamlVal, true + } + return zero, false +} + +// resolveSocketOptions builds a socketOptions value from the flag and YAML +// configuration, applying the documented precedence (flag > env > YAML > +// default). The YAML config is loaded from flags.WebConfigFile if set. +func resolveSocketOptions(flags *FlagConfig) (socketOptions, error) { + var yamlV4, yamlV6 *uint8 + var yamlDSCP *int + if flags.WebConfigFile != nil && *flags.WebConfigFile != "" { + cfg, err := getConfig(*flags.WebConfigFile) + if err != nil { + return socketOptions{}, err + } + yamlV4 = cfg.IPSocketConfig.IPv4TTL + yamlV6 = cfg.IPSocketConfig.IPv6HopLimit + yamlDSCP = cfg.IPSocketConfig.DSCP + } + v4ttl, _ := effective(flags.WebIPv4TTL, uint8(0), yamlV4) + v6hop, _ := effective(flags.WebIPv6HopLimit, uint8(0), yamlV6) + dscp, dscpSet := effective(flags.WebDSCP, -1, yamlDSCP) + // Flag-level range check for DSCP. The kingpin.Int() parser accepts any + // int so a value like --web.dscp=999 would otherwise flow into setsockopt + // where the kernel takes the low byte of (dscp << 2), silently producing + // a DSCP value different from what the operator asked for. + // (TTL/Hop-Limit don't need this guard: kingpin.Uint8() already rejects + // negative and >255 values at parse time, and the 0 sentinel means + // "not configured".) + if dscpSet && (dscp < 0 || dscp > 63) { + return socketOptions{}, fmt.Errorf("dscp must be in range 0-63, got %d", dscp) + } + opts := socketOptions{IPv4TTL: v4ttl, IPv6HopLimit: v6hop, DSCP: -1} + if dscpSet { + opts.DSCP = dscp + } + return opts, nil +} + func getTLSConfig(configPath string) (*tls.Config, error) { c, err := getConfig(configPath) if err != nil { @@ -361,6 +429,13 @@ func ListenAndServe(server *http.Server, flags *FlagConfig, logger *slog.Logger) return err } + // Resolve IP socket options from flags + YAML config (precedence: flag > env > YAML). + // This is loaded once here and reused across all listener paths. + opts, err := resolveSocketOptions(flags) + if err != nil { + return err + } + if flags.WebSystemdSocket != nil && *flags.WebSystemdSocket { logger.Info("Listening on systemd activated listeners instead of port listeners.") listeners, err := activation.Listeners() @@ -370,14 +445,38 @@ func ListenAndServe(server *http.Server, flags *FlagConfig, logger *slog.Logger) if len(listeners) < 1 { return errors.New("no socket activation file descriptors found") } + // Apply TTL/HopLimit (inherited options) post-bind to each TCP listener + // handed to us by systemd; ListenConfig.Control isn't an option here + // because the sockets are already bound. Then wrap each listener so + // DSCP (not inherited) is applied per accepted connection. + for i, ln := range listeners { + tcpLn, ok := ln.(*net.TCPListener) + if !ok { + continue + } + if opts.IPv4TTL > 0 || opts.IPv6HopLimit > 0 { + rc, err := tcpLn.SyscallConn() + if err != nil { + return fmt.Errorf("get syscall conn for systemd listener: %w", err) + } + if err := applyListenerOptions(rc, opts); err != nil { + return fmt.Errorf("apply IP socket options to systemd listener %s: %w", ln.Addr(), err) + } + } + if opts.DSCP >= 0 { + listeners[i] = &ipSocketListener{Listener: ln, opts: opts, logger: logger} + } + } return ServeMultiple(listeners, server, flags, logger) } listeners := make([]net.Listener, 0, len(*flags.WebListenAddresses)) for _, address := range *flags.WebListenAddresses { - var err error var listener net.Listener if strings.HasPrefix(address, "vsock://") { + if opts.anySet() { + logger.Info("Ignoring IP socket options on VSOCK listener (VSOCK has no IP layer)", "address", address) + } port, err := parseVsockPort(address) if err != nil { return err @@ -387,10 +486,19 @@ func ListenAndServe(server *http.Server, flags *FlagConfig, logger *slog.Logger) return err } } else { - listener, err = net.Listen("tcp", address) + lc := net.ListenConfig{ + Control: func(_, _ string, c syscall.RawConn) error { + return applyListenerOptions(c, opts) + }, + } + var err error + listener, err = lc.Listen(context.Background(), "tcp", address) if err != nil { return err } + if opts.DSCP >= 0 { + listener = &ipSocketListener{Listener: listener, opts: opts, logger: logger} + } } defer listener.Close() listeners = append(listeners, listener) @@ -398,6 +506,41 @@ func ListenAndServe(server *http.Server, flags *FlagConfig, logger *slog.Logger) return ServeMultiple(listeners, server, flags, logger) } +// ipSocketListener wraps a net.Listener and applies per-connection IP socket +// options (currently DSCP via IP_TOS / IPV6_TCLASS) to each accepted +// connection. The options that ARE inherited from the listening socket -- +// IP_TTL and IPV6_UNICAST_HOPS -- are set elsewhere via applyListenerOptions +// and need not be re-set per connection. +// +// A setsockopt failure on an accepted connection is logged but does not +// reject the connection: a working scrape over a non-fatal socket-option +// glitch is preferred to a hard listener failure. +type ipSocketListener struct { + net.Listener + opts socketOptions + logger *slog.Logger +} + +func (l *ipSocketListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return conn, nil + } + rc, rcErr := tcpConn.SyscallConn() + if rcErr != nil { + l.logger.Warn("could not get SyscallConn for accepted connection; DSCP not applied", "err", rcErr) + return conn, nil + } + if err := applyConnOptions(rc, l.opts); err != nil { + l.logger.Warn("could not apply DSCP to accepted connection", "remote", conn.RemoteAddr(), "err", err) + } + return conn, nil +} + func parseVsockPort(address string) (uint32, error) { uri, err := url.Parse(address) if err != nil { diff --git a/web/tls_config_test.go b/web/tls_config_test.go index a0dd1d41..55b4b320 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -24,6 +24,7 @@ import ( "net/http" "os" "regexp" + "strings" "sync" "testing" "time" @@ -712,3 +713,69 @@ func TestUsers(t *testing.T) { t.Run(testInputs.Name, testInputs.Test) } } + +// TestResolveSocketOptions_FlagValidation covers the flag-level range check +// for DSCP. kingpin.Int() accepts any integer, so the only guardrail against +// e.g. --web.dscp=999 silently producing a wrong wire value is the check +// inside resolveSocketOptions. +func TestResolveSocketOptions_FlagValidation(t *testing.T) { + i := func(v int) *int { return &v } + emptyFile := OfString("") + cases := []struct { + name string + flags *FlagConfig + wantErr bool + wantMatch string // substring expected in the error + }{ + { + name: "dscp_in_range_lower", + flags: &FlagConfig{WebDSCP: i(0), WebConfigFile: emptyFile}, + wantErr: false, + }, + { + name: "dscp_in_range_upper", + flags: &FlagConfig{WebDSCP: i(63), WebConfigFile: emptyFile}, + wantErr: false, + }, + { + name: "dscp_sentinel_means_unset", + flags: &FlagConfig{WebDSCP: i(-1), WebConfigFile: emptyFile}, + wantErr: false, + }, + { + name: "dscp_above_range", + flags: &FlagConfig{WebDSCP: i(999), WebConfigFile: emptyFile}, + wantErr: true, + wantMatch: "dscp must be in range 0-63", + }, + { + name: "dscp_negative_but_not_sentinel", + flags: &FlagConfig{WebDSCP: i(-5), WebConfigFile: emptyFile}, + wantErr: true, + wantMatch: "dscp must be in range 0-63", + }, + { + name: "dscp_one_above_max", + flags: &FlagConfig{WebDSCP: i(64), WebConfigFile: emptyFile}, + wantErr: true, + wantMatch: "dscp must be in range 0-63", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := resolveSocketOptions(tc.flags) + if tc.wantErr { + if err == nil { + t.Fatalf("resolveSocketOptions: expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantMatch) { + t.Fatalf("resolveSocketOptions: error %q does not contain %q", err.Error(), tc.wantMatch) + } + return + } + if err != nil { + t.Fatalf("resolveSocketOptions: unexpected error: %v", err) + } + }) + } +} From 86a59ce28eefee3ca49faf455034326fe7116062 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Wed, 27 May 2026 18:10:00 -0700 Subject: [PATCH 4/6] web: tests for IP socket options (validation + Linux inheritance) Two new test surfaces matching the toolkit's existing light test style: * 5 testdata YAML files (4 bad + 1 good) wired into the existing TestYAMLFiles table-driven test in tls_config_test.go. Bad files cover the validation rules: ipv4_ttl=0, ipv4_ttl=256 (uint8 overflow at parse time), dscp=-1, dscp=64. Good file exercises a full ip_socket_config: block. * TestApplySocketOptions_Inheritance in a new socket_options_linux_test.go (//go:build linux) -- the load-bearing test for this feature. Nine subtests covering positive, boundary and corner cases: TTL=1 (security extreme), TTL=255 (boundary), DSCP=0 (corner: explicit 0 IS configured), DSCP=63 (boundary), mid-range values, all-options-combined, and dual-stack [::]:0. Each subtest builds the full listener stack (ListenConfig.Control + ipSocketListener wrapper), dials in, and verifies via getsockopt on both the listener FD and the accepted connection FD that the right values are in place. The accepted-connection check is what catches a regression in either the listener-side inheritance or the per-connection DSCP application. * TestEffective table-driven test for the effective[T] precedence helper, including the corner case that DSCP=0 from a flag IS treated as configured (because the sentinel for DSCP is -1, not 0). ENOPROTOOPT is swallowed inside setIfApplicable so v4 and v6 options can be set unconditionally on dual-stack listeners; the test confirms this works for the [::]:0 case. No new mocking infrastructure, no new go.mod deps, no testify -- the patterns match handler_test.go (real net.Listen + dial-in) and TestYAMLFiles (testdata + regex error matching). Design: docs/ip-socket-config-design.md Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com --- web/socket_options_linux_test.go | 264 ++++++++++++++++++ web/testdata/web_config_dscp_high.bad.yml | 2 + web/testdata/web_config_dscp_neg.bad.yml | 2 + web/testdata/web_config_ip_socket.good.yml | 4 + web/testdata/web_config_ipv4_ttl_high.bad.yml | 2 + web/testdata/web_config_ipv4_ttl_zero.bad.yml | 2 + web/tls_config_test.go | 92 +++++- 7 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 web/socket_options_linux_test.go create mode 100644 web/testdata/web_config_dscp_high.bad.yml create mode 100644 web/testdata/web_config_dscp_neg.bad.yml create mode 100644 web/testdata/web_config_ip_socket.good.yml create mode 100644 web/testdata/web_config_ipv4_ttl_high.bad.yml create mode 100644 web/testdata/web_config_ipv4_ttl_zero.bad.yml diff --git a/web/socket_options_linux_test.go b/web/socket_options_linux_test.go new file mode 100644 index 00000000..ea76b102 --- /dev/null +++ b/web/socket_options_linux_test.go @@ -0,0 +1,264 @@ +// Copyright 2026 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package web + +import ( + "context" + "log/slog" + "net" + "os" + "syscall" + "testing" + + "golang.org/x/sys/unix" +) + +// TestApplySocketOptions_Inheritance is the load-bearing test for this +// feature: it verifies that IP socket options set on the listening socket +// via net.ListenConfig.Control are inherited by accepted connections on +// Linux. If this property ever stops holding, the whole feature stops +// working -- so the test is intentionally pedantic. +// +// Coverage matrix (positive / boundary / corner): +// - positive: ipv4_ttl_mid, ipv6_hop_mid, dscp_mid, all_options_v4, dual_stack_all +// - boundary: ipv4_ttl_min (TTL=1, security extreme), ipv4_ttl_max (TTL=255), +// dscp_zero (corner: explicit 0 IS configured), dscp_max (DSCP=63) +// - corner: dscp_zero (verifies setsockopt is actually called when DSCP=0), +// dual_stack_all (both v4 and v6 options set on [::]:0) +func TestApplySocketOptions_Inheritance(t *testing.T) { + const skipCheck = -1 + cases := []struct { + name string + address string + opts socketOptions + // Expected values read back via getsockopt on both the listener + // and the accepted connection. -1 means "don't check this option". + wantIPTTL int + wantIPv6Hops int + wantIPToS int // already shifted (DSCP << 2); skipCheck to skip + wantIPv6TCl int + }{ + { + name: "ipv4_ttl_min", + address: "127.0.0.1:0", + opts: socketOptions{IPv4TTL: 1, DSCP: -1}, + wantIPTTL: 1, + wantIPv6Hops: skipCheck, + wantIPToS: skipCheck, + wantIPv6TCl: skipCheck, + }, + { + name: "ipv4_ttl_mid", + address: "127.0.0.1:0", + opts: socketOptions{IPv4TTL: 7, DSCP: -1}, + wantIPTTL: 7, + wantIPv6Hops: skipCheck, + wantIPToS: skipCheck, + wantIPv6TCl: skipCheck, + }, + { + name: "ipv4_ttl_max", + address: "127.0.0.1:0", + opts: socketOptions{IPv4TTL: 255, DSCP: -1}, + wantIPTTL: 255, + wantIPv6Hops: skipCheck, + wantIPToS: skipCheck, + wantIPv6TCl: skipCheck, + }, + { + name: "ipv6_hop_mid", + address: "[::1]:0", + opts: socketOptions{IPv6HopLimit: 4, DSCP: -1}, + wantIPTTL: skipCheck, + wantIPv6Hops: 4, + wantIPToS: skipCheck, + wantIPv6TCl: skipCheck, + }, + { + name: "dscp_zero", + address: "127.0.0.1:0", + opts: socketOptions{DSCP: 0}, + wantIPTTL: skipCheck, + wantIPv6Hops: skipCheck, + wantIPToS: 0, // DSCP=0 explicitly configured; setsockopt is called + wantIPv6TCl: skipCheck, + }, + { + name: "dscp_mid", + address: "127.0.0.1:0", + opts: socketOptions{DSCP: 46}, // EF + wantIPTTL: skipCheck, + wantIPv6Hops: skipCheck, + wantIPToS: 46 << 2, + wantIPv6TCl: skipCheck, + }, + { + name: "dscp_max", + address: "127.0.0.1:0", + opts: socketOptions{DSCP: 63}, + wantIPTTL: skipCheck, + wantIPv6Hops: skipCheck, + wantIPToS: 63 << 2, + wantIPv6TCl: skipCheck, + }, + { + name: "all_options_v4", + address: "127.0.0.1:0", + opts: socketOptions{IPv4TTL: 3, DSCP: 16}, + wantIPTTL: 3, + wantIPv6Hops: skipCheck, + wantIPToS: 16 << 2, + wantIPv6TCl: skipCheck, + }, + { + name: "dual_stack_all", + address: "[::]:0", + opts: socketOptions{IPv4TTL: 2, IPv6HopLimit: 2, DSCP: 26}, + wantIPTTL: 2, + wantIPv6Hops: 2, + wantIPToS: 26 << 2, + wantIPv6TCl: 26 << 2, + }, + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Build the same listener stack ListenAndServe constructs: + // * ListenConfig.Control applies the *inherited* options + // (IP_TTL, IPV6_UNICAST_HOPS) on the listening socket. + // * ipSocketListener wraps the result to apply the + // *non-inherited* options (IP_TOS, IPV6_TCLASS) per + // accepted connection. + lc := net.ListenConfig{ + Control: func(_, _ string, c syscall.RawConn) error { + return applyListenerOptions(c, tc.opts) + }, + } + rawLn, err := lc.Listen(context.Background(), "tcp", tc.address) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { rawLn.Close() }) + + // Verify TTL/HopLimit on the listener FD. DSCP is checked only + // on accepted connections (the listener-side IP_TOS isn't + // inherited, so setting it on the listener has no effect on + // outbound packets from accepted conns). + tcpLn := rawLn.(*net.TCPListener) + lrc, err := tcpLn.SyscallConn() + if err != nil { + t.Fatalf("listener SyscallConn: %v", err) + } + checkFD(t, lrc, "listener", tc.wantIPTTL, tc.wantIPv6Hops, skipCheck, skipCheck) + + // Wrap with ipSocketListener for DSCP application on accept, + // matching the stack ListenAndServe builds when DSCP is configured. + var ln net.Listener = tcpLn + if tc.opts.DSCP >= 0 { + ln = &ipSocketListener{Listener: tcpLn, opts: tc.opts, logger: logger} + } + + dialErrCh := make(chan error, 1) + go func() { + conn, err := net.Dial("tcp", rawLn.Addr().String()) + if conn != nil { + t.Cleanup(func() { conn.Close() }) + } + dialErrCh <- err + }() + acceptedConn, err := ln.Accept() + if err != nil { + t.Fatalf("accept: %v", err) + } + t.Cleanup(func() { acceptedConn.Close() }) + if err := <-dialErrCh; err != nil { + t.Fatalf("dial: %v", err) + } + + tcpConn := acceptedConn.(*net.TCPConn) + arc, err := tcpConn.SyscallConn() + if err != nil { + t.Fatalf("accepted conn SyscallConn: %v", err) + } + // Check all four expected options on the accepted connection. + // TTL/HopLimit are inherited from the listener; DSCP was applied + // by the ipSocketListener wrapper. + checkFD(t, arc, "accepted", tc.wantIPTTL, tc.wantIPv6Hops, tc.wantIPToS, tc.wantIPv6TCl) + }) + } +} + +// checkFD reads each requested socket option from the given RawConn and +// verifies it matches the expected value. -1 means "skip this option". +// DSCP comparisons mask off the lower 2 bits because the kernel may modify +// the ECN bits dynamically on ECN-capable TCP connections. +func checkFD(t *testing.T, rc syscall.RawConn, label string, wantTTL, wantHops, wantToS, wantTCl int) { + t.Helper() + const skip = -1 + var controlErr error + err := rc.Control(func(fd uintptr) { + if wantTTL != skip { + got, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_TTL) + if err != nil { + controlErr = err + return + } + if got != wantTTL { + t.Errorf("%s: IP_TTL = %d, want %d", label, got, wantTTL) + } + } + if wantHops != skip { + got, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_UNICAST_HOPS) + if err != nil { + controlErr = err + return + } + if got != wantHops { + t.Errorf("%s: IPV6_UNICAST_HOPS = %d, want %d", label, got, wantHops) + } + } + if wantToS != skip { + got, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_TOS) + if err != nil { + controlErr = err + return + } + // Mask off ECN (low 2 bits); we only compare the DSCP portion. + gotDSCP := got & 0xFC + if gotDSCP != wantToS { + t.Errorf("%s: IP_TOS DSCP bits = 0x%x, want 0x%x (raw=0x%x)", label, gotDSCP, wantToS, got) + } + } + if wantTCl != skip { + got, err := unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_TCLASS) + if err != nil { + controlErr = err + return + } + gotDSCP := got & 0xFC + if gotDSCP != wantTCl { + t.Errorf("%s: IPV6_TCLASS DSCP bits = 0x%x, want 0x%x (raw=0x%x)", label, gotDSCP, wantTCl, got) + } + } + }) + if err != nil { + t.Fatalf("%s: rc.Control: %v", label, err) + } + if controlErr != nil { + t.Fatalf("%s: getsockopt: %v", label, controlErr) + } +} diff --git a/web/testdata/web_config_dscp_high.bad.yml b/web/testdata/web_config_dscp_high.bad.yml new file mode 100644 index 00000000..ca8f62a3 --- /dev/null +++ b/web/testdata/web_config_dscp_high.bad.yml @@ -0,0 +1,2 @@ +ip_socket_config: + dscp: 64 diff --git a/web/testdata/web_config_dscp_neg.bad.yml b/web/testdata/web_config_dscp_neg.bad.yml new file mode 100644 index 00000000..25c57047 --- /dev/null +++ b/web/testdata/web_config_dscp_neg.bad.yml @@ -0,0 +1,2 @@ +ip_socket_config: + dscp: -1 diff --git a/web/testdata/web_config_ip_socket.good.yml b/web/testdata/web_config_ip_socket.good.yml new file mode 100644 index 00000000..036bb080 --- /dev/null +++ b/web/testdata/web_config_ip_socket.good.yml @@ -0,0 +1,4 @@ +ip_socket_config: + ipv4_ttl: 2 + ipv6_hop_limit: 2 + dscp: 46 diff --git a/web/testdata/web_config_ipv4_ttl_high.bad.yml b/web/testdata/web_config_ipv4_ttl_high.bad.yml new file mode 100644 index 00000000..dd8df2fa --- /dev/null +++ b/web/testdata/web_config_ipv4_ttl_high.bad.yml @@ -0,0 +1,2 @@ +ip_socket_config: + ipv4_ttl: 256 diff --git a/web/testdata/web_config_ipv4_ttl_zero.bad.yml b/web/testdata/web_config_ipv4_ttl_zero.bad.yml new file mode 100644 index 00000000..36e65332 --- /dev/null +++ b/web/testdata/web_config_ipv4_ttl_zero.bad.yml @@ -0,0 +1,2 @@ +ip_socket_config: + ipv4_ttl: 0 diff --git a/web/tls_config_test.go b/web/tls_config_test.go index 55b4b320..22318f5a 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -68,9 +68,11 @@ var ( "Invalid header": regexp.MustCompile(`HTTP header ".*" can not be configured`), "Invalid client cert": regexp.MustCompile(`bad certificate`), // Introduced in Go 1.21 - "Certificate required": regexp.MustCompile(`certificate required`), - "Unknown CA": regexp.MustCompile(`unknown certificate authority`), - "Too Many Requests": regexp.MustCompile(`Too Many Requests`), + "Certificate required": regexp.MustCompile(`certificate required`), + "Unknown CA": regexp.MustCompile(`unknown certificate authority`), + "Too Many Requests": regexp.MustCompile(`Too Many Requests`), + "IPv4 TTL out of range": regexp.MustCompile(`ipv4_ttl must be in range`), + "DSCP out of range": regexp.MustCompile(`dscp must be in range`), } ) @@ -192,6 +194,31 @@ func TestYAMLFiles(t *testing.T) { YAMLConfigPath: "testdata/web_config_noAuth_wrongTLSVersion.bad.yml", ExpectedError: ErrorMap["Unknown TLS version"], }, + { + Name: `invalid config yml (ipv4_ttl = 0)`, + YAMLConfigPath: "testdata/web_config_ipv4_ttl_zero.bad.yml", + ExpectedError: ErrorMap["IPv4 TTL out of range"], + }, + { + Name: `invalid config yml (ipv4_ttl = 256, uint8 overflow)`, + YAMLConfigPath: "testdata/web_config_ipv4_ttl_high.bad.yml", + ExpectedError: ErrorMap["YAML error"], + }, + { + Name: `invalid config yml (dscp = -1)`, + YAMLConfigPath: "testdata/web_config_dscp_neg.bad.yml", + ExpectedError: ErrorMap["DSCP out of range"], + }, + { + Name: `invalid config yml (dscp = 64)`, + YAMLConfigPath: "testdata/web_config_dscp_high.bad.yml", + ExpectedError: ErrorMap["DSCP out of range"], + }, + { + Name: `valid ip_socket_config yml`, + YAMLConfigPath: "testdata/web_config_ip_socket.good.yml", + ExpectedError: nil, + }, } for _, testInputs := range testTables { t.Run("run/"+testInputs.Name, testInputs.Test) @@ -779,3 +806,62 @@ func TestResolveSocketOptions_FlagValidation(t *testing.T) { }) } } + +// TestEffective covers the flag > YAML > default precedence logic. It does +// not exercise socket options or networking -- just the generic helper. +func TestEffective(t *testing.T) { + u8 := func(v uint8) *uint8 { return &v } + i := func(v int) *int { return &v } + + t.Run("uint8", func(t *testing.T) { + cases := []struct { + name string + flag *uint8 + sentinel uint8 + yaml *uint8 + wantVal uint8 + wantIsSet bool + }{ + {"all_nil", nil, 0, nil, 0, false}, + {"flag_set", u8(7), 0, nil, 7, true}, + {"yaml_set", nil, 0, u8(3), 3, true}, + {"flag_wins_over_yaml", u8(7), 0, u8(3), 7, true}, + {"flag_is_sentinel_yaml_used", u8(0), 0, u8(3), 3, true}, + {"flag_is_sentinel_yaml_nil", u8(0), 0, nil, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotVal, gotIsSet := effective(tc.flag, tc.sentinel, tc.yaml) + if gotVal != tc.wantVal || gotIsSet != tc.wantIsSet { + t.Errorf("effective = (%d, %v), want (%d, %v)", gotVal, gotIsSet, tc.wantVal, tc.wantIsSet) + } + }) + } + }) + + t.Run("int_dscp_semantics", func(t *testing.T) { + // DSCP uses -1 as the not-configured sentinel because 0 is a valid + // configured value (CS0). Verify both that explicit 0 wins and that + // -1 from the flag falls through to YAML / default. + cases := []struct { + name string + flag *int + yaml *int + wantVal int + wantIsSet bool + }{ + {"flag_dscp_zero_is_configured", i(0), nil, 0, true}, + {"flag_sentinel_yaml_zero", i(-1), i(0), 0, true}, + {"flag_sentinel_yaml_nil", i(-1), nil, 0, false}, + {"flag_dscp_set_wins", i(46), i(16), 46, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotVal, gotIsSet := effective(tc.flag, -1, tc.yaml) + if gotVal != tc.wantVal || gotIsSet != tc.wantIsSet { + t.Errorf("effective = (%d, %v), want (%d, %v)", gotVal, gotIsSet, tc.wantVal, tc.wantIsSet) + } + }) + } + }) +} From ca3049539869ddfc7d0e226819a07fc635a70a0f Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Wed, 27 May 2026 18:11:42 -0700 Subject: [PATCH 5/6] web: docs and example for ip_socket_config Add operator-facing documentation for the IP socket-options feature: * docs/web-configuration.md gains an ip_socket_config: block in the YAML schema (with placeholder fields per the toolkit's [] convention) and an "About ip_socket_config" section describing listener-flavor support (TCP / systemd / VSOCK), platform support (Linux first-class, BSD/Darwin compile-only, Windows/others no-op with warning), and operator notes (why TTL=0 is rejected; why DSCP=0 is a valid configured value; dual-stack behavior). * web/web-config.yml gains a commented-out example block showing TTL=2, hop_limit=2, dscp=16 (CS2). CHANGELOG is intentionally not touched: the toolkit doesn't track a CHANGELOG file (release notes come from GitHub releases). Design: docs/ip-socket-config-design.md Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com --- docs/web-configuration.md | 57 +++++++++++++++++++++++++++++++++++++++ web/web-config.yml | 15 +++++++++++ 2 files changed, 72 insertions(+) diff --git a/docs/web-configuration.md b/docs/web-configuration.md index c3b1cf12..5aff2e96 100644 --- a/docs/web-configuration.md +++ b/docs/web-configuration.md @@ -132,10 +132,67 @@ basic_auth_users: rate_limit: interval: # time interval between two requests, set to 0 to disable rate limiter burst: # and permits a burst of requests. + +# IP-layer socket options applied to the listening socket. +# All fields are optional; an omitted field uses the kernel default. +ip_socket_config: + # IPv4 TTL on outbound packets. Valid: 1-255. + # Lower values bound how far response packets can travel; useful as a + # defense-in-depth measure (e.g. ttl=2 means packets die after two + # router hops). On Linux this is inherited by accepted connections + # (accept(2), ip(7)). + [ ipv4_ttl: ] + + # IPv6 Hop Limit on outbound packets. Valid: 1-255. Same semantics as + # ipv4_ttl but for IPv6. + [ ipv6_hop_limit: ] + + # DSCP codepoint applied to outbound packets via IPv4 ToS and IPv6 + # Traffic Class (upper 6 bits). Valid: 0-63. Common values: + # 0 (CS0, best-effort), 8 (CS1), 16 (CS2), 26 (AF31), 46 (EF). + # The 2 ECN bits (lower 2 bits of the ToS byte) are NOT touched -- + # the kernel manages them per-packet for ECN-capable TCP (RFC 3168). + [ dscp: ] ``` [A sample configuration file](web-config.yml) is provided. +## About `ip_socket_config` + +The `ip_socket_config` block sets IP-layer header fields on the listening +socket. Each option can also be set via a CLI flag or environment variable +(`--web.ipv4-ttl` / `WEB_IPV4_TTL`, `--web.ipv6-hop-limit` / +`WEB_IPV6_HOP_LIMIT`, `--web.dscp` / `WEB_DSCP`); the flag wins when both +the flag and a YAML value are set. + +Listener-flavor support: + +| Listener | TTL / Hop Limit | DSCP | +|---|---|---| +| Regular TCP | Set on the listening socket via `net.ListenConfig.Control`; inherited by accepted connections. | Set per accepted connection (IP_TOS / IPV6_TCLASS are *not* inherited from the listener on Linux). | +| Systemd socket activation | Set on the systemd-provided listener post-bind via `setsockopt`. | Set per accepted connection (same as regular TCP). | +| VSOCK | Ignored (VSOCK has no IP layer); an info-level log line is emitted if any option is configured. | Same — ignored. | + +Platform support: + +| Platform | Status | +|---|---| +| Linux | Fully supported, CI-tested. | +| FreeBSD / DragonFly / NetBSD / OpenBSD / Darwin | Compile-supported via `golang.org/x/sys/unix`; not CI-tested. | +| Windows / Plan 9 / JS+Wasm / others | No-op. The first time any IP socket option is configured, a single warn-level log line is emitted and the configured values are ignored. | + +Operator notes: + +* The minimum useful TTL is **1** (packet dies at the first router; reach is + limited to the local L2 segment). TTL=0 is rejected by configuration + validation — it is forbidden by RFC 1122 §3.2.1.7 and Linux overloads + `setsockopt(IP_TTL, 0)` to mean "use the kernel default" anyway. +* DSCP=0 (CS0) is a valid configured value and is honored; it is *not* the + "not configured" sentinel. Omit the field if you don't want to set DSCP. +* On dual-stack listeners (e.g. `[::]:9100`) both the IPv4 and IPv6 socket + options are set; the kernel applies the appropriate one per outbound + packet. + ## About bcrypt There are several tools out there to generate bcrypt passwords, e.g. diff --git a/web/web-config.yml b/web/web-config.yml index 984aa0db..6438f696 100644 --- a/web/web-config.yml +++ b/web/web-config.yml @@ -3,3 +3,18 @@ tls_server_config: cert_file: server.crt key_file: server.key + +# Optional. Sets IP-layer fields on the exporter's listening socket. Each +# field is optional; an omitted field leaves the kernel default in place. +# See docs/web-configuration.md for the per-listener-flavor support matrix. +# +# ip_socket_config: +# # IPv4 TTL clamp on outbound packets (1-255). Lower values bound +# # packet propagation distance -- useful as defense-in-depth. +# ipv4_ttl: 2 +# # IPv6 Hop Limit clamp on outbound packets (1-255). +# ipv6_hop_limit: 2 +# # DSCP codepoint (0-63) applied to outbound packets via IPv4 ToS and +# # IPv6 Traffic Class (upper 6 bits). ECN bits left for the kernel. +# # Common values: 0 (CS0), 16 (CS2), 26 (AF31), 46 (EF). +# dscp: 16 From eedf4e69be1c1269d7b1b8c5514ca52f6ce2d202 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Wed, 27 May 2026 18:13:33 -0700 Subject: [PATCH 6/6] [optional] web: refactor handler_test.go to table-driven Fold the four separate test functions in handler_test.go (TestBasicAuthCache, TestBasicAuthWithFakepassword, TestByPassBasicAuthVuln, TestHTTPHeaders) into a single TestHandler driven by a small table. Each test's distinct logic stays in its own per-case function; the shared server lifecycle (build http.Server, launch ListenAndServe in a goroutine, waitForPort, shutdown) is extracted into withHandlerServer so it isn't duplicated four times. No behavior change. Every assertion that ran before still runs, and the set of passing test outcomes is identical. The subtest names (BasicAuthCache, BasicAuthWithFakepassword, ByPassBasicAuthVuln, HTTPHeaders) map 1:1 to the original function names so git log -p review is easy. This commit is OPTIONAL. It is justified on maintainability (adding a fifth test of the same shape becomes one table row instead of a new ~30-line function) rather than on enabling any feature. Reviewers who prefer to keep the four separate test functions can drop this commit via `git rebase -i upstream/master` and the feature commits before it will be unaffected. See Appendix A of docs/ip-socket-config-design.md for the full design rationale. Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com --- web/handler_test.go | 158 +++++++++++++++++--------------------------- 1 file changed, 62 insertions(+), 96 deletions(-) diff --git a/web/handler_test.go b/web/handler_test.go index 210cb1d9..aa3d9bf9 100644 --- a/web/handler_test.go +++ b/web/handler_test.go @@ -22,15 +22,58 @@ import ( "time" ) -// TestBasicAuthCache validates that the cache is working by calling a password -// protected endpoint multiple times. -func TestBasicAuthCache(t *testing.T) { +// handlerCase is one row in the TestHandler table. Each case starts an HTTP +// server with the named YAML config and then runs `do` against it. The +// per-case `do` function holds the assertions specific to that case; the +// shared server lifecycle (start, wait, shutdown) is provided by +// withHandlerServer so it doesn't have to be duplicated per case. +type handlerCase struct { + name string + yamlConfigPath string + do func(t *testing.T) +} + +func TestHandler(t *testing.T) { + cases := []handlerCase{ + { + name: "BasicAuthCache", + yamlConfigPath: "testdata/web_config_users_noTLS.good.yml", + do: testBasicAuthCacheBody, + }, + { + name: "BasicAuthWithFakepassword", + yamlConfigPath: "testdata/web_config_users_noTLS.good.yml", + do: testBasicAuthFakepasswordBody, + }, + { + name: "ByPassBasicAuthVuln", + yamlConfigPath: "testdata/web_config_users_noTLS.good.yml", + do: testByPassBasicAuthVulnBody, + }, + { + name: "HTTPHeaders", + yamlConfigPath: "testdata/web_config_headers.good.yml", + do: testHTTPHeadersBody, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withHandlerServer(t, tc.yamlConfigPath, tc.do) + }) + } +} + +// withHandlerServer starts an http.Server on the package-level `port` using +// the given YAML config, waits until the port is reachable, runs body, and +// then shuts the server down. Replaces the per-test boilerplate that the +// original four separate test functions duplicated. +func withHandlerServer(t *testing.T, yamlConfigPath string, body func(t *testing.T)) { + t.Helper() server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("Hello World!")) }), } - done := make(chan struct{}) t.Cleanup(func() { if err := server.Shutdown(context.Background()); err != nil { @@ -38,19 +81,22 @@ func TestBasicAuthCache(t *testing.T) { } <-done }) - go func() { flags := FlagConfig{ WebListenAddresses: &([]string{port}), WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"), + WebConfigFile: OfString(yamlConfigPath), } ListenAndServe(server, &flags, testlogger) close(done) }() - waitForPort(t, port) + body(t) +} +// testBasicAuthCacheBody validates that the cache is working by calling a +// password-protected endpoint repeatedly, then stressing it concurrently. +func testBasicAuthCacheBody(t *testing.T) { login := func(username, password string, code int) { client := &http.Client{} req, err := http.NewRequest("GET", "http://localhost"+port, nil) @@ -89,35 +135,9 @@ func TestBasicAuthCache(t *testing.T) { wg.Wait() } -// TestBasicAuthWithFakePassword validates that we can't login the "fakepassword" used in -// to prevent user enumeration. -func TestBasicAuthWithFakepassword(t *testing.T) { - server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World!")) - }), - } - - done := make(chan struct{}) - t.Cleanup(func() { - if err := server.Shutdown(context.Background()); err != nil { - t.Fatal(err) - } - <-done - }) - - go func() { - flags := FlagConfig{ - WebListenAddresses: &([]string{port}), - WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"), - } - ListenAndServe(server, &flags, testlogger) - close(done) - }() - - waitForPort(t, port) - +// testBasicAuthFakepasswordBody validates that we can't login with the +// "fakepassword" used to prevent user enumeration. +func testBasicAuthFakepasswordBody(t *testing.T) { login := func() { client := &http.Client{} req, err := http.NewRequest("GET", "http://localhost"+port, nil) @@ -133,41 +153,14 @@ func TestBasicAuthWithFakepassword(t *testing.T) { t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode) } } - // Login with a cold cache. login() // Login with the response cached. login() } -// TestByPassBasicAuthVuln tests for CVE-2022-46146. -func TestByPassBasicAuthVuln(t *testing.T) { - server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World!")) - }), - } - - done := make(chan struct{}) - t.Cleanup(func() { - if err := server.Shutdown(context.Background()); err != nil { - t.Fatal(err) - } - <-done - }) - - go func() { - flags := FlagConfig{ - WebListenAddresses: &([]string{port}), - WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"), - } - ListenAndServe(server, &flags, testlogger) - close(done) - }() - - waitForPort(t, port) - +// testByPassBasicAuthVulnBody tests for CVE-2022-46146. +func testByPassBasicAuthVulnBody(t *testing.T) { login := func(username, password string) { client := &http.Client{} req, err := http.NewRequest("GET", "http://localhost"+port, nil) @@ -183,41 +176,15 @@ func TestByPassBasicAuthVuln(t *testing.T) { t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode) } } - // Poison the cache. login("alice$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", "fakepassword") // Login with a wrong password. login("alice", "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSifakepassword") } -// TestHTTPHeaders validates that HTTP headers are added correctly. -func TestHTTPHeaders(t *testing.T) { - server := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello World!")) - }), - } - - done := make(chan struct{}) - t.Cleanup(func() { - if err := server.Shutdown(context.Background()); err != nil { - t.Fatal(err) - } - <-done - }) - - go func() { - flags := FlagConfig{ - WebListenAddresses: &([]string{port}), - WebSystemdSocket: OfBool(false), - WebConfigFile: OfString("testdata/web_config_headers.good.yml"), - } - ListenAndServe(server, &flags, testlogger) - close(done) - }() - - waitForPort(t, port) - +// testHTTPHeadersBody validates that HTTP headers from web_config_headers.good.yml +// are added correctly to responses. +func testHTTPHeadersBody(t *testing.T) { client := &http.Client{} req, err := http.NewRequest("GET", "http://localhost"+port, nil) if err != nil { @@ -227,7 +194,6 @@ func TestHTTPHeaders(t *testing.T) { if err != nil { t.Fatal(err) } - for k, v := range map[string]string{ "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Frame-Options": "deny",