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. 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/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/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", 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/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/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/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.go b/web/tls_config.go index 7245f741..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" @@ -48,6 +50,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 +76,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 +147,39 @@ 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"` +} + +// 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 { @@ -152,10 +197,81 @@ 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 +} + +// 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 { @@ -313,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() @@ -322,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 @@ -339,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) @@ -350,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..22318f5a 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" @@ -67,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`), } ) @@ -191,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) @@ -712,3 +740,128 @@ 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) + } + }) + } +} + +// 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) + } + }) + } + }) +} 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