diff --git a/ansible/galaxy/CHANGELOG.md b/ansible/galaxy/CHANGELOG.md index 9a8a90f..679bf1e 100644 --- a/ansible/galaxy/CHANGELOG.md +++ b/ansible/galaxy/CHANGELOG.md @@ -6,6 +6,21 @@ 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) removed `payment.delivery_floor` and + `blockchain.rate_bounds_poll_interval_sec`, so emitting either is a startup + 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 + +- `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..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 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: 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 e19b691..52ef2f0 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -45,10 +45,14 @@ # "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 - 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..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", @@ -191,8 +194,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/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 081eb3f..ea9d35d 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -252,7 +252,139 @@ - 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. 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: + - 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 == 'Validate optional string knobs (user_agent, otlp_endpoint)' + + - 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 == '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: + - 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 == 'Validate optional string knobs (user_agent, otlp_endpoint)' + + # `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 + ansible.builtin.include_role: + name: decdn_node + vars: + 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 == '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. + - 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 == '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. + - 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 == '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 @@ -411,5 +543,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", "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/README.md b/ansible/roles/decdn_node/README.md index da9620c..5406825 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,14 @@ 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 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), @@ -230,7 +232,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 +253,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..24b9c7b 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -100,13 +100,13 @@ decdn_discovery_dns_origin: "" decdn_discovery_peers: {} # --- Economics ---------------------------------------------------------------- +# 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; ADR 003 §Rate-floor enforcement is authoritative for floor behaviour.) 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 +141,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 +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. Distinct from payment.delivery_floor, which raises 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 @@ -274,7 +275,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..a6bde2d 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,45 @@ # 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). +# 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: # 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\\\\]+$')) + # `\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). 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://(\\[[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 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 — 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"` 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