From 1c62c0ca70f181ba8cd83c03a962c08fa9302f04 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 16 Sep 2026 02:13:32 +0300 Subject: [PATCH 1/4] chore(decdn_node,charts): sync config-schema surface to decdn main @ d3bc7da7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-sync the role's and chart's node.toml key surface to the current `main` of decdn/decdn (was 0b94efe4, 32 commits back). Every config section is `#[serde(deny_unknown_fields)]` with no serde aliases, so a stale key is a startup crash-loop. Field delta (verified against crates/common/src/config/types.rs): - REMOVE payment.delivery_floor and blockchain.rate_bounds_poll_interval_sec. Upstream dropped the seller-side rate-floor clamp and the rate-bounds watcher: the node no longer bounds its own sell rate — a rate below the on-chain delivery floor still sells and settles, with only its vote-weight byte credit clamped at redemption. - observability.otlp_endpoint: OTLP span export is now always compiled in (the `otlp` cargo feature is gone), and the endpoint must be exactly `http://host:port` — https://, a missing port, a path/query/fragment and userinfo are all rejected at config resolution. No other types.rs change; the 421614 deployment manifest is unchanged since d306cc5c, so host_vars addresses and the manual install method stand. Touched: templates/node.toml.j2 (drop both emit blocks), defaults/main.yml, tasks/main.yml (drop both from the numeric asserts; tighten the otlp_endpoint shape assert to http://host:port, case-insensitive scheme), README.md, host_vars/decdn-node-1 economics comment, galaxy/CHANGELOG.md, the chart's ci/ci-values.yaml, and the molecule oracle: gen-schema-keys.py header bumped to d3bc7da7, regenerated schema-keys.txt (155 paths), schema+default converge and the default verify's delivery_floor check. Verified: make lint, lint-ansible, lint-helm (incl. DECDN_CLI=… so all three ci-values files pass a real `decdn config validate` at d3bc7da7), security, and all six molecule scenarios. Co-Authored-By: Claude Opus 5 (1M context) --- ansible/galaxy/CHANGELOG.md | 13 ++++++++++ .../inventory/host_vars/decdn-node-1/main.yml | 6 ++--- ansible/molecule/default/converge.yml | 1 - ansible/molecule/default/verify.yml | 2 -- ansible/molecule/schema/converge.yml | 2 -- .../molecule/schema/files/gen-schema-keys.py | 2 +- ansible/molecule/schema/files/schema-keys.txt | 4 +-- ansible/roles/decdn_node/README.md | 17 +++++++------ ansible/roles/decdn_node/defaults/main.yml | 16 +++++------- ansible/roles/decdn_node/tasks/main.yml | 25 +++++++++++-------- .../roles/decdn_node/templates/node.toml.j2 | 6 ----- charts/decdn-node/ci/ci-values.yaml | 2 -- 12 files changed, 48 insertions(+), 48 deletions(-) diff --git a/ansible/galaxy/CHANGELOG.md b/ansible/galaxy/CHANGELOG.md index 9a8a90f..cd521f1 100644 --- a/ansible/galaxy/CHANGELOG.md +++ b/ansible/galaxy/CHANGELOG.md @@ -6,6 +6,19 @@ collection adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Removed + +- `decdn_delivery_floor` and `decdn_rate_bounds_poll_interval_sec`: upstream + (decdn/decdn @ d3bc7da7) dropped `payment.delivery_floor` and + `blockchain.rate_bounds_poll_interval_sec` with the seller-side rate-floor clamp, + so emitting either is a startup failure. Setting them now has no effect. + +### Changed + +- `decdn_otlp_endpoint` must be `http://host:port`, matching upstream: `https://`, + a missing port, a path/query/fragment and userinfo are rejected at deploy time. + OTLP export is always compiled in; no `--features otlp` build is needed. + ## [0.1.0] — unreleased Initial packaging of the public deCDN node roles as a distributable collection. diff --git a/ansible/inventory/host_vars/decdn-node-1/main.yml b/ansible/inventory/host_vars/decdn-node-1/main.yml index 4558c86..128b448 100644 --- a/ansible/inventory/host_vars/decdn-node-1/main.yml +++ b/ansible/inventory/host_vars/decdn-node-1/main.yml @@ -77,7 +77,7 @@ decdn_region: "US" # MUST-EDIT — ISO 3166-1 alpha-2 of the node's p # decdn_relay_urls: [] # optional iroh relays for NAT traversal ([] => n0 defaults) # --- Economics ---------------------------------------------------------------- -# Seller-side quote. The on-chain PaymentPool rate bounds are authoritative at -# runtime — the daemon overwrites payment.delivery_floor from getRateBounds() at -# startup — so this is the node's own asking price, not a clamp. +# Seller-side quote: the node's own asking price. The daemon never clamps it — a +# rate below the on-chain delivery floor still sells and settles, with only its +# vote-weight byte credit clamped at redemption. decdn_rate_per_mb: 10 # USDC base units (6 decimals); 10 = $0.00001/MB diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index e19b691..124fcb5 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -48,7 +48,6 @@ decdn_gc_interval_sec: 0 decdn_credit_max: 2097152 decdn_relay_foreign_namespaces: false - decdn_delivery_floor: 0 decdn_redeem_threshold_micro_usdc: 50000000 # A float knob: `| float` must render a bare TOML float, and verify.yml's type # guards have to accept float (not just int) for these. Covers the one rendering diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index fc6641a..f9f3bdd 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -191,8 +191,6 @@ ("network.relay_urls", net.get("relay_urls") == ["https://relay1.example.invalid:443", "https://relay2.example.invalid:443"]), - ("payment.delivery_floor (explicit 0 must emit)", - _is_int(pay.get("delivery_floor")) and pay.get("delivery_floor") == 0), ("blockchain.redeem_threshold_micro_usdc", _is_int(bc.get("redeem_threshold_micro_usdc")) and bc.get("redeem_threshold_micro_usdc") == 50000000), diff --git a/ansible/molecule/schema/converge.yml b/ansible/molecule/schema/converge.yml index f946194..e5b3c6e 100644 --- a/ansible/molecule/schema/converge.yml +++ b/ansible/molecule/schema/converge.yml @@ -50,7 +50,6 @@ decdn_chain_staleness_grace_sec: 900 decdn_rpc_watchdog_interval_sec: 15 decdn_event_poll_interval_ms: 5000 - decdn_rate_bounds_poll_interval_sec: 1800 decdn_fee_shares_poll_interval_sec: 1800 decdn_redeem_threshold_micro_usdc: 2000000 decdn_redeem_max_vouchers_per_tx: 250 @@ -62,7 +61,6 @@ # --- [payment] --- decdn_rate_per_mb: 12 - decdn_delivery_floor: 0 decdn_credit_max: 33554432 decdn_credit_ramp_divisor: 4 decdn_frame_target_bytes: 262144 diff --git a/ansible/molecule/schema/files/gen-schema-keys.py b/ansible/molecule/schema/files/gen-schema-keys.py index a4ab364..9aef2c3 100755 --- a/ansible/molecule/schema/files/gen-schema-keys.py +++ b/ansible/molecule/schema/files/gen-schema-keys.py @@ -135,7 +135,7 @@ def main(): "# a leaf-name inventory cannot tell the two apart. See gen-schema-keys.py for\n" "# the struct -> path mapping and molecule/schema/README.md for why this exists.\n" "#\n" - f"# Synced from decdn/decdn @ 0b94efe4 (crate version 0.0.0): {len(paths)} paths." + f"# Synced from decdn/decdn @ d3bc7da7 (crate version 0.0.0): {len(paths)} paths." ) for path in sorted(paths): print(path) diff --git a/ansible/molecule/schema/files/schema-keys.txt b/ansible/molecule/schema/files/schema-keys.txt index dd791ea..ee84184 100644 --- a/ansible/molecule/schema/files/schema-keys.txt +++ b/ansible/molecule/schema/files/schema-keys.txt @@ -9,7 +9,7 @@ # a leaf-name inventory cannot tell the two apart. See gen-schema-keys.py for # the struct -> path mapping and molecule/schema/README.md for why this exists. # -# Synced from decdn/decdn @ 0b94efe4 (crate version 0.0.0): 157 paths. +# Synced from decdn/decdn @ d3bc7da7 (crate version 0.0.0): 155 paths. blockchain.buyer_max_approve blockchain.buyer_working_deposit_micro_usdc blockchain.capacity_bond_address @@ -28,7 +28,6 @@ blockchain.payment_pool_address blockchain.pool_floor_signer_live_windows blockchain.pool_min_remaining_deposit_micro_usdc blockchain.publisher_registry_address -blockchain.rate_bounds_poll_interval_sec blockchain.redeem_interval_secs blockchain.redeem_max_vouchers_per_tx blockchain.redeem_threshold_micro_usdc @@ -148,7 +147,6 @@ observability.metrics_port observability.otlp_endpoint payment.credit_max payment.credit_ramp_divisor -payment.delivery_floor payment.frame_target_bytes payment.rate_per_mb payment.voucher_commit_interval_ms diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index da9620c..95cb2d9 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -12,7 +12,7 @@ machine. This is the repo's deployment (`playbooks/site.yml`). > download. Build the two binaries from a checkout until that changes. **Schema tracking.** This role renders `node.toml` against the config schema of -`decdn/decdn` main @ `0b94efe4` (crate version 0.0.0 — unreleased). Upstream marks every config +`decdn/decdn` main @ `d3bc7da7` (crate version 0.0.0 — unreleased). Upstream marks every config section `#[serde(deny_unknown_fields)]` and defines **no** serde aliases, so a key this role emits that your binary does not know is a startup crash-loop, not a warning. The role runs `decdn config validate` against the installed binary after @@ -184,12 +184,12 @@ only to override; an explicit `0`/`false` **is** emitted (`0` is meaningful — fails loud at deploy time, and `decdn config validate` catches anything the role's own asserts miss. See `defaults/main.yml` for every knob's upstream default, unit and range. -- **Payment / credit** — `decdn_rate_per_mb`, `decdn_delivery_floor`, +- **Payment / credit** — `decdn_rate_per_mb`, `decdn_credit_max` (bare-integer bytes), `decdn_credit_ramp_divisor`, `decdn_frame_target_bytes` (`1..=1048576`), `decdn_voucher_commit_interval_ms`. - Note `delivery_floor` is a **pre-chain seed only**: the daemon overwrites it from - `PaymentPool.getRateBounds()` at startup, so the on-chain value is authoritative. - There is no companion ceiling knob any more. + The node never clamps its own `rate_per_mb`: a rate below the on-chain delivery + floor still sells and settles, with only its vote-weight byte credit clamped at + redemption. There is no `delivery_floor` / `delivery_ceiling` knob any more. - **Settlement / payment pool** — `decdn_redeem_threshold_micro_usdc`, `decdn_redeem_max_vouchers_per_tx`, `decdn_redeem_interval_secs`, `decdn_buyer_working_deposit_micro_usdc`, `decdn_buyer_max_approve` (bool), @@ -230,7 +230,7 @@ asserts miss. See `defaults/main.yml` for every knob's upstream default, unit an `decdn_event_poll_interval_ms` (`>= 250`), `decdn_content_blacklist_poll_interval_sec` (`>= 1`), `decdn_chain_staleness_grace_sec` (seconds the node may go without a successful chain read before it stops serving — ADR 011; `> 0`, daemon default - `1800`), `decdn_rate_bounds_poll_interval_sec`, `decdn_fee_shares_poll_interval_sec`, + `1800`), `decdn_fee_shares_poll_interval_sec`, and the `decdn_origin_directory_*` cache knobs. - **Network** — `decdn_relay_urls` (list; the singular `relay_url` config key no longer exists). Operator-run address discovery (#818) via @@ -251,8 +251,9 @@ asserts miss. See `defaults/main.yml` for every knob's upstream default, unit an - **Receipts + local denylist** — `decdn_receipts_max_file_bytes`, `decdn_receipts_retained_files`, and `decdn_content_denied_hashes` / `decdn_content_denied_origins` (node-local, independent of the on-chain blacklist). -- **Observability** — `decdn_otlp_endpoint` (OTLP span export; needs the node built - `--features otlp`). +- **Observability** — `decdn_otlp_endpoint` (OTLP/gRPC span export, always compiled in; + must be `http://host:port`, e.g. `http://localhost:4317` — no `https://`, path or + userinfo). ## Secrets diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 215a229..104c256 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -100,13 +100,10 @@ decdn_discovery_dns_origin: "" decdn_discovery_peers: {} # --- Economics ---------------------------------------------------------------- +# The node's own asking price. The daemon never clamps it: a rate below the on-chain +# delivery floor still sells and settles, with only its vote-weight byte credit +# clamped at redemption. (`delivery_floor` / `delivery_ceiling` were removed upstream.) decdn_rate_per_mb: 10 # USDC base units (6 decimals) -# PRE-CHAIN SEED ONLY: the daemon reads PaymentPool.getRateBounds() at startup and -# overwrites this before it serves anything, then tracks RateBoundsUpdated. It only -# governs the window before that read completes (a failed read refuses startup), so -# the on-chain value is authoritative. There is no companion ceiling knob any more — -# `delivery_ceiling` was removed and DECDN_DELIVERY_CEILING is a retired env var. -decdn_delivery_floor: "" # daemon dflt 0 # Per-stream credit window (ADR 003 credit slow-start). credit_max is a bare # integer of bytes on the wire; ramp_divisor 0 opens the full ceiling immediately. decdn_credit_max: "" # daemon dflt 67108864 (64 MiB) @@ -141,7 +138,6 @@ decdn_content_blacklist_poll_interval_sec: "" # daemon dflt 600; >= 1 — bla # (ADR 011 §Serving while chain-stale). Always evaluated (no on/off flag) and only # consulted when content_blacklist_address is set; set it large to opt out. decdn_chain_staleness_grace_sec: "" # daemon dflt 1800 (30 min); must be > 0 -decdn_rate_bounds_poll_interval_sec: "" # daemon dflt 3600; > 0 — PaymentPool.getRateBounds refresh decdn_fee_shares_poll_interval_sec: "" # daemon dflt 3600; > 0 — FeeRouter share refresh # --- CLI-only [blockchain] keys ---------------------------------------------- @@ -167,8 +163,8 @@ decdn_disk_headroom_mb: "" decdn_max_blob_size_mb: 1024 # 1 GB # Buyer-side ABSOLUTE per-MB rate ceiling for paid pulls (#1375) — what this node # will accept a provider to quote on a cache-miss pull, on top of the always-applied -# probe-relative bound. Distinct from payment.delivery_floor, which raises this -# node's own SELLER quote. "" / 0 => unlimited. +# probe-relative bound. Buyer-side only — nothing bounds this node's own SELLER +# quote. "" / 0 => unlimited. decdn_max_rate_per_mb: "" # Cache tuning ("" / [] => omit + use the daemon default; an explicit 0 IS emitted — # 0 is meaningful here, e.g. gc_interval_sec = 0 disables the sweep and @@ -274,7 +270,7 @@ decdn_metrics_port: 9090 decdn_metrics_bind: "127.0.0.1" # keep loopback — Prometheus scrape is a follow-up decdn_admin_port: 9191 # Optional ("" => omit + use the daemon default). -decdn_otlp_endpoint: "" # OTLP span export; http(s)://; needs the node built --features otlp +decdn_otlp_endpoint: "" # OTLP/gRPC span export; http://host:port only (e.g. http://localhost:4317) # --- Abuse limits, load shedding, receipts, local denylist -------------------- # [security] and [load_shed] and [content] are HOT-RELOADABLE: change them and run diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 26648dd..22253ba 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -644,14 +644,14 @@ # a null stays None (caught by the `in [..., none]` guard) instead of becoming the # string "None", which would fail the shape regex. loop: >- - {{ [decdn_delivery_floor, decdn_credit_max, decdn_credit_ramp_divisor, + {{ [decdn_credit_max, decdn_credit_ramp_divisor, decdn_frame_target_bytes, decdn_voucher_commit_interval_ms, decdn_redeem_threshold_micro_usdc, decdn_redeem_max_vouchers_per_tx, decdn_redeem_interval_secs, decdn_buyer_working_deposit_micro_usdc, decdn_pool_min_remaining_deposit_micro_usdc, decdn_pool_floor_signer_live_windows, decdn_chain_staleness_grace_sec, decdn_rpc_watchdog_interval_sec, decdn_event_poll_interval_ms, decdn_content_blacklist_poll_interval_sec, - decdn_rate_bounds_poll_interval_sec, decdn_fee_shares_poll_interval_sec, + decdn_fee_shares_poll_interval_sec, decdn_origin_directory_positive_ttl_sec, decdn_origin_directory_negative_ttl_sec, decdn_origin_directory_cache_capacity, decdn_max_blob_size_mb, decdn_disk_headroom_mb, decdn_max_rate_per_mb, decdn_max_probe_holds, @@ -790,12 +790,12 @@ decdn_chain_staleness_grace_sec (>= 1); and each of decdn_redeem_threshold_micro_usdc, decdn_redeem_max_vouchers_per_tx, decdn_redeem_interval_secs, decdn_buyer_working_deposit_micro_usdc, - decdn_rate_bounds_poll_interval_sec, decdn_fee_shares_poll_interval_sec, + decdn_fee_shares_poll_interval_sec, decdn_voucher_commit_interval_ms must be >= 1 when set ("{{ item }}" is not). loop: >- {{ [decdn_redeem_threshold_micro_usdc, decdn_redeem_max_vouchers_per_tx, decdn_redeem_interval_secs, decdn_buyer_working_deposit_micro_usdc, - decdn_rate_bounds_poll_interval_sec, decdn_fee_shares_poll_interval_sec, + decdn_fee_shares_poll_interval_sec, decdn_voucher_commit_interval_ms] }} # Bounded ranges. Each is a closed interval the daemon range-checks at load, so an @@ -978,19 +978,24 @@ # user_agent + otlp_endpoint interpolate into TOML basic strings ("..."). An # unescaped '"', backslash or newline breaks the rendered node.toml (a daemon # crash-loop — a trailing '\' escapes the closing quote), and the daemon additionally -# requires otlp_endpoint to be an http(s):// URL. Neither is emitted through | int, so -# validate their shape here (guarded so "" / null pass). +# requires otlp_endpoint to be exactly http://host:port (the gRPC exporter has no TLS, +# so https:// is rejected, as are a missing port, a path/query/fragment and userinfo). +# Neither is emitted through | int, so validate their shape here (guarded so "" / null pass). - name: Validate optional string knobs (user_agent, otlp_endpoint) ansible.builtin.assert: that: # No '"', CR, LF or backslash (would break the TOML string / are rejected upstream). - decdn_cache_user_agent in ["", none] or (decdn_cache_user_agent is match('^[^\"\\r\\n\\\\]+$')) - # http(s):// URL, and no quote/whitespace/backslash (same TOML-injection guard). - - decdn_otlp_endpoint in ["", none] or (decdn_otlp_endpoint is match('^https?://[^\"\\s\\\\]+$')) + # http://host:port (optional trailing '/'), no userinfo/path/query/fragment, and no + # quote/whitespace/backslash (same TOML-injection guard). + - >- + decdn_otlp_endpoint in ["", none] + or (decdn_otlp_endpoint is match('^http://[^\"\\s\\\\/@?#]+:[0-9]+/?$', ignorecase=true)) fail_msg: >- decdn_cache_user_agent must contain no double-quote, backslash or newline, and - decdn_otlp_endpoint must be an http(s):// URL with no quote, whitespace or - backslash (both are interpolated verbatim into node.toml). Leave either "" to omit. + decdn_otlp_endpoint must be http://host:port (e.g. http://localhost:4317) with no + https, userinfo, path, query, fragment, quote, whitespace or backslash (both are + interpolated verbatim into node.toml). Leave either "" to omit. # The REQUIRED scalars render raw (no `not in ["", none]` guard), so unlike every # optional knob they reach node.toml unchecked. `decdn_cache_size_mb: "20 GB"` diff --git a/ansible/roles/decdn_node/templates/node.toml.j2 b/ansible/roles/decdn_node/templates/node.toml.j2 index c981265..b6dbf33 100644 --- a/ansible/roles/decdn_node/templates/node.toml.j2 +++ b/ansible/roles/decdn_node/templates/node.toml.j2 @@ -82,9 +82,6 @@ rpc_watchdog_interval_sec = {{ decdn_rpc_watchdog_interval_sec | int }} {% if decdn_event_poll_interval_ms not in ["", none] %} event_poll_interval_ms = {{ decdn_event_poll_interval_ms | int }} {% endif %} -{% if decdn_rate_bounds_poll_interval_sec not in ["", none] %} -rate_bounds_poll_interval_sec = {{ decdn_rate_bounds_poll_interval_sec | int }} -{% endif %} {% if decdn_fee_shares_poll_interval_sec not in ["", none] %} fee_shares_poll_interval_sec = {{ decdn_fee_shares_poll_interval_sec | int }} {% endif %} @@ -115,9 +112,6 @@ usdc_address = "{{ decdn_usdc_address }}" [payment] rate_per_mb = {{ decdn_rate_per_mb }} -{% if decdn_delivery_floor not in ["", none] %} -delivery_floor = {{ decdn_delivery_floor | int }} -{% endif %} {% if decdn_credit_max not in ["", none] %} credit_max = {{ decdn_credit_max | int }} {% endif %} diff --git a/charts/decdn-node/ci/ci-values.yaml b/charts/decdn-node/ci/ci-values.yaml index b11b0f6..c1ccc74 100644 --- a/charts/decdn-node/ci/ci-values.yaml +++ b/charts/decdn-node/ci/ci-values.yaml @@ -56,7 +56,6 @@ config: chain_staleness_grace_sec: 900 rpc_watchdog_interval_sec: 15 event_poll_interval_ms: 5000 - rate_bounds_poll_interval_sec: 1800 fee_shares_poll_interval_sec: 1800 redeem_threshold_micro_usdc: 2000000 redeem_max_vouchers_per_tx: 250 @@ -67,7 +66,6 @@ config: pool_floor_signer_live_windows: 4 payment: rate_per_mb: 12 - delivery_floor: 0 credit_max: 33554432 credit_ramp_divisor: 4 frame_target_bytes: 262144 From 1491c930a726b57f23e97d8736b25a00a49a9e3b Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 16 Sep 2026 02:39:22 +0300 Subject: [PATCH 2/4] fix(decdn_node): close two otlp_endpoint grammar holes, cover them in molecule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #49. The endpoint guard admitted two values upstream rejects: - `http://host:bad:4317` — the host class allowed `:`, so a multi-colon authority passed. Only a bracketed IPv6 literal may carry a colon; upstream's `url::Url::parse` reads `bad:4317` as the port and refuses. The host is now an alternation: bracketed IPv6, or a colon-free host. - `http://host:4317\n` — Python's `$` also matches BEFORE a final newline, so a trailing-newline value passed the very guard that exists to stop a newline breaking the rendered TOML basic string. Anchored with `\Z`. The second hole was also in the pre-existing `user_agent` guard next to it (same assert, same TOML-injection hazard), so both anchors move to `\Z`. Coverage — the PR description's templar table was not a repository test: - molecule/validation gains one case per rejection class (`otlp-https`, `otlp-noport`, `otlp-path`, `otlp-userinfo`, `otlp-colon`, `otlp-newline`), each recorded only when the role aborts at its own `Validate optional` assert, and all six added to `_decdn_expected` so a loosened pattern fails the suite. (`block` takes no `loop`, hence one case apiece.) - molecule/default covers the accept half: `HTTP://[::1]:4317` — bracketed IPv6 plus an uppercase scheme, which upstream compares case-insensitively — must survive validation and render verbatim, asserted in the exact-value table. Also cite the governing ADRs for the rate-floor behaviour this PR described in operator-facing files (AGENTS.md: this repo is not a source of truth for protocol claims). ADR 003 §Rate-floor enforcement and ADR 005 §Rate bounds are authoritative; ADR 019 §Step 3.3 is the operator-facing rate choice. Touches the role README, defaults, host_vars comment and the collection CHANGELOG. Verified: make lint, make lint-ansible, and all six molecule scenarios; the validation scenario's exact-set tally now includes the six new tags. Co-Authored-By: Claude Opus 5 (1M context) --- ansible/galaxy/CHANGELOG.md | 7 +- .../inventory/host_vars/decdn-node-1/main.yml | 6 +- ansible/molecule/default/converge.yml | 5 + ansible/molecule/default/verify.yml | 3 + ansible/molecule/validation/converge.yml | 94 ++++++++++++++++++- ansible/roles/decdn_node/README.md | 8 +- ansible/roles/decdn_node/defaults/main.yml | 9 +- ansible/roles/decdn_node/tasks/main.yml | 12 ++- 8 files changed, 127 insertions(+), 17 deletions(-) diff --git a/ansible/galaxy/CHANGELOG.md b/ansible/galaxy/CHANGELOG.md index cd521f1..b1ba955 100644 --- a/ansible/galaxy/CHANGELOG.md +++ b/ansible/galaxy/CHANGELOG.md @@ -9,9 +9,10 @@ collection adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Removed - `decdn_delivery_floor` and `decdn_rate_bounds_poll_interval_sec`: upstream - (decdn/decdn @ d3bc7da7) dropped `payment.delivery_floor` and - `blockchain.rate_bounds_poll_interval_sec` with the seller-side rate-floor clamp, - so emitting either is a startup failure. Setting them now has no effect. + (decdn/decdn @ d3bc7da7) removed `payment.delivery_floor` and + `blockchain.rate_bounds_poll_interval_sec`, so emitting either is a startup + failure. The node no longer reads the on-chain rate bounds at all; the floor's + role is defined by ADR 003 §Rate-floor enforcement and ADR 005 §Rate bounds. ### Changed diff --git a/ansible/inventory/host_vars/decdn-node-1/main.yml b/ansible/inventory/host_vars/decdn-node-1/main.yml index 128b448..12e4dc1 100644 --- a/ansible/inventory/host_vars/decdn-node-1/main.yml +++ b/ansible/inventory/host_vars/decdn-node-1/main.yml @@ -77,7 +77,7 @@ decdn_region: "US" # MUST-EDIT — ISO 3166-1 alpha-2 of the node's p # decdn_relay_urls: [] # optional iroh relays for NAT traversal ([] => n0 defaults) # --- Economics ---------------------------------------------------------------- -# Seller-side quote: the node's own asking price. The daemon never clamps it — a -# rate below the on-chain delivery floor still sells and settles, with only its -# vote-weight byte credit clamped at redemption. +# Seller-side quote: this node's asking price, advertised verbatim and never clamped +# to the delivery floor by the daemon (ADR 003 §Rate-floor enforcement, ADR 019 +# §Step 3.3 — the ADRs, not this file, govern that behaviour). decdn_rate_per_mb: 10 # USDC base units (6 decimals); 10 = $0.00001/MB diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index 124fcb5..52ef2f0 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -45,6 +45,11 @@ # "2MiB"/"4.0"), a bool (rendered lowercase via `| lower`), and a string list. # verify.yml asserts each renders with the exact value AND type. Keep in sync. decdn_node_to_node_pull_through_enabled: true + # The ACCEPT half of the otlp_endpoint grammar (the validation scenario owns the + # rejects): a bracketed IPv6 literal — the one host form allowed to carry colons — + # and an uppercase scheme, which upstream compares case-insensitively. Both must + # survive validation and render verbatim into the TOML basic string. + decdn_otlp_endpoint: "HTTP://[::1]:4317" decdn_gc_interval_sec: 0 decdn_credit_max: 2097152 decdn_relay_foreign_namespaces: false diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index f9f3bdd..750b419 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -145,6 +145,9 @@ ("identity", "region"): "US", ("observability", "metrics_bind"): "127.0.0.1", ("observability", "metrics_port"): 9090, + # Bracketed IPv6 + uppercase scheme, rendered verbatim (accept half of + # the otlp grammar; molecule/validation owns the reject matrix). + ("observability", "otlp_endpoint"): "HTTP://[::1]:4317", ("blockchain", "chain_id"): 421614, ("blockchain", "payment_pool_address"): "0x1111111111111111111111111111111111111111", ("blockchain", "capacity_bond_address"): "0x2222222222222222222222222222222222222222", diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index 081eb3f..8aeced0 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -254,6 +254,95 @@ decdn_rejected: "{{ decdn_rejected + ['string'] }}" when: ansible_failed_task.name is match('^Validate optional') + # --- Cases: otlp_endpoint grammar -------------------------------------------- + # Upstream resolves observability.otlp_endpoint as http://host:port ONLY (the + # OTLP gRPC exporter has no TLS), so each class below is a daemon start-up + # refusal the role has to catch first. One case per rejection class: without + # them a later loosening of the pattern would re-admit an invalid endpoint with + # the whole suite still green. `_bad` is the tag suffix, not a knob name. + # (`block` takes no `loop`, so each class is its own case — same shape as above.) + - name: "Case otlp-https — TLS scheme the gRPC exporter cannot speak" + block: + - name: Run decdn_node with an https otlp_endpoint + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "https://collector:4317" + rescue: + - name: Record otlp-https rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-https'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + - name: "Case otlp-noport — no collector port" + block: + - name: Run decdn_node with a portless otlp_endpoint + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "http://collector" + rescue: + - name: Record otlp-noport rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-noport'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + - name: "Case otlp-path — an OTLP/HTTP URL (port 4318, /v1/traces) is the wrong protocol" + block: + - name: Run decdn_node with a path-bearing otlp_endpoint + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "http://collector:4318/v1/traces" + rescue: + - name: Record otlp-path rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-path'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + - name: "Case otlp-userinfo — credentials carried in the URL" + block: + - name: Run decdn_node with userinfo in otlp_endpoint + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "http://user:pass@collector:4317" + rescue: + - name: Record otlp-userinfo rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-userinfo'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # Only a bracketed IPv6 literal may carry a colon in the host. Unbracketed, + # url::Url::parse upstream reads "bad:4317" as the port and refuses. + - name: "Case otlp-colon — unbracketed extra colon in the host" + block: + - name: Run decdn_node with a multi-colon otlp_endpoint host + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "http://collector:bad:4317" + rescue: + - name: Record otlp-colon rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-colon'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # A trailing newline would break the rendered TOML basic string. Python's `$` + # matches before a final newline, so this case is what pins the `\Z` anchor. + - name: "Case otlp-newline — trailing newline" + block: + - name: Run decdn_node with a trailing newline in otlp_endpoint + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "http://collector:4317\n" + rescue: + - name: Record otlp-newline rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-newline'] }}" + when: ansible_failed_task.name is match('^Validate optional') + # --- Case: no RPC endpoint anywhere (#39) ------------------------------------- # decdn_rpc_url may now be empty — but ONLY when the operator has provisioned # /etc/decdn/decdn.env on the host. Nothing has staged that file yet at this @@ -411,5 +500,6 @@ vars: _decdn_expected: ["cross-field", "binary-format", "range", "bounded-range", "shape", "bool", - "enum", "dict", "float", "list", "string", "env-gate", "env-extra", - "env-content", "config-gate"] + "enum", "dict", "float", "list", "string", "otlp-https", "otlp-noport", + "otlp-path", "otlp-userinfo", "otlp-colon", "otlp-newline", "env-gate", + "env-extra", "env-content", "config-gate"] diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 95cb2d9..5406825 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -187,9 +187,11 @@ asserts miss. See `defaults/main.yml` for every knob's upstream default, unit an - **Payment / credit** — `decdn_rate_per_mb`, `decdn_credit_max` (bare-integer bytes), `decdn_credit_ramp_divisor`, `decdn_frame_target_bytes` (`1..=1048576`), `decdn_voucher_commit_interval_ms`. - The node never clamps its own `rate_per_mb`: a rate below the on-chain delivery - floor still sells and settles, with only its vote-weight byte credit clamped at - redemption. There is no `delivery_floor` / `delivery_ceiling` knob any more. + The node advertises `rate_per_mb` verbatim and never clamps it to the delivery + floor, which is a redemption-time credit clamp rather than a quote gate — see + ADR 003 §Rate-floor enforcement and ADR 005 §Rate bounds for the authoritative + behaviour, and ADR 019 §Step 3.3 for the operator-facing rate choice. There is no + `delivery_floor` / `delivery_ceiling` knob any more. - **Settlement / payment pool** — `decdn_redeem_threshold_micro_usdc`, `decdn_redeem_max_vouchers_per_tx`, `decdn_redeem_interval_secs`, `decdn_buyer_working_deposit_micro_usdc`, `decdn_buyer_max_approve` (bool), diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 104c256..d8c2bd8 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -100,9 +100,12 @@ decdn_discovery_dns_origin: "" decdn_discovery_peers: {} # --- Economics ---------------------------------------------------------------- -# The node's own asking price. The daemon never clamps it: a rate below the on-chain -# delivery floor still sells and settles, with only its vote-weight byte credit -# clamped at redemption. (`delivery_floor` / `delivery_ceiling` were removed upstream.) +# The node's own asking price, advertised verbatim (ADR 005 §Rate bounds). The daemon +# never clamps it: the delivery floor is a redemption-time credit clamp, not a quote +# gate (ADR 003 §Rate-floor enforcement). Setting this at or above the live +# deliveryFloor is a revenue choice — full vote-weight credit — not a protocol +# requirement (ADR 019 §Step 3.3). (`delivery_floor` / `delivery_ceiling` were removed +# upstream; see that ADR text for the authoritative behaviour.) decdn_rate_per_mb: 10 # USDC base units (6 decimals) # Per-stream credit window (ADR 003 credit slow-start). credit_max is a bare # integer of bytes on the wire; ramp_divisor 0 opens the full ceiling immediately. diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 22253ba..a29fe66 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -985,12 +985,18 @@ ansible.builtin.assert: that: # No '"', CR, LF or backslash (would break the TOML string / are rejected upstream). - - decdn_cache_user_agent in ["", none] or (decdn_cache_user_agent is match('^[^\"\\r\\n\\\\]+$')) + # `\Z`, not `$`: Python's `$` also matches BEFORE a final newline, so `$` would + # let a trailing-newline value through the very guard that exists to block it. + - decdn_cache_user_agent in ["", none] or (decdn_cache_user_agent is match('^[^\"\\r\\n\\\\]+\\Z')) # http://host:port (optional trailing '/'), no userinfo/path/query/fragment, and no - # quote/whitespace/backslash (same TOML-injection guard). + # quote/whitespace/backslash (same TOML-injection guard). The host alternation is + # what bounds the colon: only a bracketed IPv6 literal may contain one, so + # `http://host:bad:4317` (which upstream's url::Url::parse rejects as a bad port) + # cannot slip through a permissive host class. - >- decdn_otlp_endpoint in ["", none] - or (decdn_otlp_endpoint is match('^http://[^\"\\s\\\\/@?#]+:[0-9]+/?$', ignorecase=true)) + or (decdn_otlp_endpoint is match('^http://(\\[[0-9A-Fa-f:.]+\\]|[^\"\\s\\\\/@?#:]+):[0-9]+/?\\Z', + ignorecase=true)) fail_msg: >- decdn_cache_user_agent must contain no double-quote, backslash or newline, and decdn_otlp_endpoint must be http://host:port (e.g. http://localhost:4317) with no From 889bf7cfe763c21344e343df51903b3d94aea9f4 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 16 Sep 2026 03:35:37 +0300 Subject: [PATCH 3/4] fix(decdn_node): correct a wrong rate claim, bound the otlp port, close test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent review follow-up on #49. Every item below was verified against the real d3bc7da7 binary (`decdn config validate`) before changing anything. Wrong protocol claim (the one that mattered): - defaults/main.yml said "nothing bounds this node's own SELLER quote". False — MAX_RATE_PER_MB = 1000 bounds it at the wire layer, `decdn config validate --rate-per-mb 1001` refuses, and ADR 005 §Rate bounds / ADR 019 §Step 3.3 (cited four lines above it) say so. The role's own range assert for that knob allows up to u64::MAX, so the comment was the only thing an operator reads first. otlp_endpoint port is now bounded 1..=65535: - `http://c:0` and `http://c:70000` passed the guard; upstream refuses both. An out-of-range port renders VALID TOML, so without this only the daemon caught it — and the `decdn config validate` gate is skipped under `make check`, which is exactly where the role's asserts are the whole contract. Every other port knob in the role is already checked at 1..=65535, so this is a consistency fix. Leading zeros stay legal (upstream accepts `:04317`). Test gaps, each proven by mutating the guard and watching the suite stay green: - otlp-userinfo used `user:pass@collector`, which the COLON rule rejects — so the '@' exclusion had no coverage at all. Now `user@collector`: drop '@' from the host class and a credential-bearing endpoint would render into the 0640 node.toml. - The `\Z` anchor on user_agent had no case (the existing quote case passes under `$` too). Added `string-newline`, and `otlp-port` for the new bound. - The seven string-knob rescues matched `^Validate optional`, a TEN-task family, so a regression failing that assert unconditionally could record every tag and "prove" broken guards. They now match the exact task name. Honesty fixes, no behaviour change: - The guard is a deliberate SUPERSET of upstream's parser (it still admits `[:::]` and non-idna hosts, which the config-validate gate catches); the comment now says so instead of implying parity. A differential run of 771 candidates against the real binary found 0 cases where the role rejects what upstream accepts. - fail_msg said "no path" while a bare trailing "/" is accepted; CHANGELOG claimed the node "no longer reads the on-chain rate bounds at all" — narrowed to the ADR's own wording, since ADR 019's Phase 4 table still lists the floor as loaded at startup (upstream doc lag, reported separately). - Dropped a stale `_bad` sentence, disambiguated a bare "that ADR", and refreshed the schema key-floor comment (render is 128, so the slack is 3, not ~10). Verified: make lint, make lint-ansible, all six molecule scenarios, and DECDN_CLI=… make lint-helm. Co-Authored-By: Claude Opus 5 (1M context) --- ansible/galaxy/CHANGELOG.md | 5 +- ansible/molecule/schema/verify.yml | 4 +- ansible/molecule/validation/converge.yml | 65 ++++++++++++++++++---- ansible/roles/decdn_node/defaults/main.yml | 8 ++- ansible/roles/decdn_node/tasks/main.yml | 19 ++++++- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/ansible/galaxy/CHANGELOG.md b/ansible/galaxy/CHANGELOG.md index b1ba955..679bf1e 100644 --- a/ansible/galaxy/CHANGELOG.md +++ b/ansible/galaxy/CHANGELOG.md @@ -11,8 +11,9 @@ collection adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - `decdn_delivery_floor` and `decdn_rate_bounds_poll_interval_sec`: upstream (decdn/decdn @ d3bc7da7) removed `payment.delivery_floor` and `blockchain.rate_bounds_poll_interval_sec`, so emitting either is a startup - failure. The node no longer reads the on-chain rate bounds at all; the floor's - role is defined by ADR 003 §Rate-floor enforcement and ADR 005 §Rate bounds. + failure. The floor is redemption-time contract state that the node reads for no + wire decision (ADR 003 §Rate-floor enforcement, ADR 005 §Rate bounds), so there + is nothing left for either knob to tune. ### Changed diff --git a/ansible/molecule/schema/verify.yml b/ansible/molecule/schema/verify.yml index fc77dbd..6e7cf0c 100644 --- a/ansible/molecule/schema/verify.yml +++ b/ansible/molecule/schema/verify.yml @@ -131,7 +131,9 @@ exit 1 fi # Key floor stays a count: knobs are added upstream routinely, so an exact - # number would need editing on every sync. 125 is ~10 below today's render. + # number would need editing on every sync. Today's render is 128 (this sync + # removed two knobs), so the slack is 3 — the next upstream removal trips + # this, and the fix is to re-count and lower the floor, not to widen it blindly. if [ "$keys" -lt 125 ]; then echo "only $keys scalar keys rendered (expected >= 125) — the converge" >&2 echo "has collapsed, so the schema check above proved little." >&2 diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index 8aeced0..32abda1 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -252,14 +252,36 @@ - name: Record string rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['string'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' + + # The quote case above is caught by the character class alone, so it would still + # pass with the old `$` anchor. This one pins `\Z`: a trailing newline renders a + # raw newline inside the TOML basic string, and `$` matches just before it. + - name: "Case string-newline — user_agent with a trailing newline" + block: + - name: Run decdn_node with a trailing newline in user_agent + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_cache_user_agent: "agent\n" + rescue: + - name: Record string-newline rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['string-newline'] }}" + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' # --- Cases: otlp_endpoint grammar -------------------------------------------- # Upstream resolves observability.otlp_endpoint as http://host:port ONLY (the # OTLP gRPC exporter has no TLS), so each class below is a daemon start-up # refusal the role has to catch first. One case per rejection class: without # them a later loosening of the pattern would re-admit an invalid endpoint with - # the whole suite still green. `_bad` is the tag suffix, not a knob name. + # the whole suite still green. The `otlp-*` strings are decdn_rejected tracker + # tags, not knob names. + # + # Each rescue matches the EXACT task name rather than the `^Validate optional` + # family (ten tasks share that prefix): these cases and the user_agent ones all + # target one assert, so a prefix match would let a regression that fails that + # assert unconditionally record every tag and "prove" guards that are broken. # (`block` takes no `loop`, so each class is its own case — same shape as above.) - name: "Case otlp-https — TLS scheme the gRPC exporter cannot speak" block: @@ -272,7 +294,7 @@ - name: Record otlp-https rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['otlp-https'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' - name: "Case otlp-noport — no collector port" block: @@ -285,7 +307,7 @@ - name: Record otlp-noport rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['otlp-noport'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' - name: "Case otlp-path — an OTLP/HTTP URL (port 4318, /v1/traces) is the wrong protocol" block: @@ -298,20 +320,24 @@ - name: Record otlp-path rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['otlp-path'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' + # `user@host`, NOT `user:pass@host`: the latter is rejected by the colon rule that + # otlp-colon already pins, so it would leave the '@' exclusion untested — drop '@' + # from the host class and a credential-bearing endpoint would render into the 0640 + # node.toml unnoticed. - name: "Case otlp-userinfo — credentials carried in the URL" block: - name: Run decdn_node with userinfo in otlp_endpoint ansible.builtin.include_role: name: decdn_node vars: - decdn_otlp_endpoint: "http://user:pass@collector:4317" + decdn_otlp_endpoint: "http://user@collector:4317" rescue: - name: Record otlp-userinfo rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['otlp-userinfo'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' # Only a bracketed IPv6 literal may carry a colon in the host. Unbracketed, # url::Url::parse upstream reads "bad:4317" as the port and refuses. @@ -326,7 +352,22 @@ - name: Record otlp-colon rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['otlp-colon'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' + + # An out-of-range port renders as VALID TOML, so the config-validate gate is the + # only other thing that would catch it — and that gate is skipped in check mode. + - name: "Case otlp-port — port above 65535" + block: + - name: Run decdn_node with an out-of-range otlp_endpoint port + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_otlp_endpoint: "http://collector:70000" + rescue: + - name: Record otlp-port rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['otlp-port'] }}" + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' # A trailing newline would break the rendered TOML basic string. Python's `$` # matches before a final newline, so this case is what pins the `\Z` anchor. @@ -341,7 +382,7 @@ - name: Record otlp-newline rejection (only if a validation assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['otlp-newline'] }}" - when: ansible_failed_task.name is match('^Validate optional') + when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' # --- Case: no RPC endpoint anywhere (#39) ------------------------------------- # decdn_rpc_url may now be empty — but ONLY when the operator has provisioned @@ -500,6 +541,6 @@ vars: _decdn_expected: ["cross-field", "binary-format", "range", "bounded-range", "shape", "bool", - "enum", "dict", "float", "list", "string", "otlp-https", "otlp-noport", - "otlp-path", "otlp-userinfo", "otlp-colon", "otlp-newline", "env-gate", - "env-extra", "env-content", "config-gate"] + "enum", "dict", "float", "list", "string", "string-newline", "otlp-https", + "otlp-noport", "otlp-path", "otlp-userinfo", "otlp-colon", "otlp-port", + "otlp-newline", "env-gate", "env-extra", "env-content", "config-gate"] diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index d8c2bd8..24b9c7b 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -105,7 +105,7 @@ decdn_discovery_peers: {} # gate (ADR 003 §Rate-floor enforcement). Setting this at or above the live # deliveryFloor is a revenue choice — full vote-weight credit — not a protocol # requirement (ADR 019 §Step 3.3). (`delivery_floor` / `delivery_ceiling` were removed -# upstream; see that ADR text for the authoritative behaviour.) +# upstream; ADR 003 §Rate-floor enforcement is authoritative for floor behaviour.) decdn_rate_per_mb: 10 # USDC base units (6 decimals) # Per-stream credit window (ADR 003 credit slow-start). credit_max is a bare # integer of bytes on the wire; ramp_divisor 0 opens the full ceiling immediately. @@ -166,8 +166,10 @@ decdn_disk_headroom_mb: "" decdn_max_blob_size_mb: 1024 # 1 GB # Buyer-side ABSOLUTE per-MB rate ceiling for paid pulls (#1375) — what this node # will accept a provider to quote on a cache-miss pull, on top of the always-applied -# probe-relative bound. Buyer-side only — nothing bounds this node's own SELLER -# quote. "" / 0 => unlimited. +# probe-relative bound. Buyer-side only: it does not constrain this node's own SELLER +# quote (decdn_rate_per_mb), which the delivery floor never clamps but which the wire +# caps at MAX_RATE_PER_MB = 1000 (ADR 005 §Rate bounds, ADR 019 §Step 3.3) — upstream +# refuses a config above that. "" / 0 => unlimited. decdn_max_rate_per_mb: "" # Cache tuning ("" / [] => omit + use the daemon default; an explicit 0 IS emitted — # 0 is meaningful here, e.g. gc_interval_sec = 0 disables the sweep and diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index a29fe66..a6bde2d 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -981,6 +981,12 @@ # requires otlp_endpoint to be exactly http://host:port (the gRPC exporter has no TLS, # so https:// is rejected, as are a missing port, a path/query/fragment and userinfo). # Neither is emitted through | int, so validate their shape here (guarded so "" / null pass). +# This guard is a deliberate SUPERSET of upstream's parser: it catches every plausible +# operator mistake (https, no port, /v1/traces, userinfo, a stray newline, a port out of +# range) but still admits fuzz upstream refuses — malformed IPv6 brackets like `[:::]`, +# a non-ASCII-idna host. Those reach the `decdn config validate` gate below and fail the +# deploy with the daemon's own message. It is never the other way round: no endpoint +# upstream accepts is rejected here (checked differentially against the real binary). - name: Validate optional string knobs (user_agent, otlp_endpoint) ansible.builtin.assert: that: @@ -997,11 +1003,20 @@ decdn_otlp_endpoint in ["", none] or (decdn_otlp_endpoint is match('^http://(\\[[0-9A-Fa-f:.]+\\]|[^\"\\s\\\\/@?#:]+):[0-9]+/?\\Z', ignorecase=true)) + # Port 1..=65535 — the range every other port knob in this role is checked at, and + # what upstream's url::Url::parse accepts (it refuses both 0 and >65535). Separate + # from the shape regex because an out-of-range port still renders VALID TOML, so + # without this only the daemon would catch it. Leading zeros are upstream-legal. + - >- + decdn_otlp_endpoint in ["", none] + or ((decdn_otlp_endpoint | regex_replace('^.*:0*([0-9]*)/?$', '\\1') | int) >= 1 + and (decdn_otlp_endpoint | regex_replace('^.*:0*([0-9]*)/?$', '\\1') | int) <= 65535) fail_msg: >- decdn_cache_user_agent must contain no double-quote, backslash or newline, and decdn_otlp_endpoint must be http://host:port (e.g. http://localhost:4317) with no - https, userinfo, path, query, fragment, quote, whitespace or backslash (both are - interpolated verbatim into node.toml). Leave either "" to omit. + https, userinfo, path, query, fragment, quote, whitespace or backslash — a bare + trailing "/" is fine (both are interpolated verbatim into node.toml). Leave + either "" to omit. # The REQUIRED scalars render raw (no `not in ["", none]` guard), so unlike every # optional knob they reach node.toml unchecked. `decdn_cache_size_mb: "20 GB"` From 79876b15e305a15ed1a44d306ffe2396ac4250aa Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 16 Sep 2026 03:39:12 +0300 Subject: [PATCH 4/4] docs(molecule): note that a password-in-URL fixture fails the KICS gate The otlp-userinfo case originally used `http://user:pass@collector:4317`, which tripped KICS's HIGH "Passwords And Secrets - Password in URL" query (CWE-798) and failed `make security` in CI. 889bf7c already replaced the value with `http://user@collector:4317` for a test-coverage reason; record the security reason next to it too, so the next person writing a userinfo fixture does not reintroduce the credential form. Comment-only. Verified: make lint, make security (exit 0, HIGH: 0 in both scans). Co-Authored-By: Claude Opus 5 (1M context) --- ansible/molecule/validation/converge.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index 32abda1..ea9d35d 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -322,10 +322,12 @@ decdn_rejected: "{{ decdn_rejected + ['otlp-path'] }}" when: ansible_failed_task.name == 'Validate optional string knobs (user_agent, otlp_endpoint)' - # `user@host`, NOT `user:pass@host`: the latter is rejected by the colon rule that - # otlp-colon already pins, so it would leave the '@' exclusion untested — drop '@' - # from the host class and a credential-bearing endpoint would render into the 0640 - # node.toml unnoticed. + # `user@host`, NOT `user:pass@host`, for two independent reasons: the latter is + # rejected by the colon rule that otlp-colon already pins, so it would leave the + # '@' exclusion untested — drop '@' from the host class and a credential-bearing + # endpoint would render into the 0640 node.toml unnoticed — AND a `scheme://u:p@` + # literal trips KICS's HIGH "Passwords And Secrets - Password in URL" query, which + # fails `make security` in CI. Keep this fixture password-free. - name: "Case otlp-userinfo — credentials carried in the URL" block: - name: Run decdn_node with userinfo in otlp_endpoint