From 611c21c7245701668bc79a6553d5cc4b32b19681 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 12 Jul 2026 20:40:42 +0300 Subject: [PATCH 1/2] feat(decdn_node): expose operator-facing config knobs (#29) The decdn-node daemon reads ~60 config fields, but the role only templated the subset upstream ships in its own operator config. Fields with no role knob had to be hand-edited into /etc/decdn/node.toml, which the next `make deploy` silently reverts. Issue #29 is the concrete case: cache.node_to_node_pull_through_enabled gates all serve-time origin pull-through but had no variable. Expose ~27 operator-facing knobs (node-to-node pull-through, settlement thresholds, delivery clamps, cache tuning, blockchain watchers, network, observability) using the role's existing discipline: each defaults to an unset sentinel ("" / []) meaning "omit the key, use the daemon default", so node.toml stays byte-identical unless an operator opts in. An explicit 0/false is emitted (0 is meaningful, e.g. gc_interval_sec = 0 disables the sweep). Every knob is fail-loud validated at deploy time so a bad value never reaches the daemon (which would crash-loop under deny_unknown_fields): bool/integer shape, i64::MAX cap, per-field ranges, cross-field constraints compared against the daemon's default-filled values, and TOML-injection guards on string/list knobs. Bytes/Percent render as bare integers; new [cache] scalars render before [cache.origin] so the sub-table header doesn't absorb them. Add a negative-path molecule scenario (validation) that asserts each bad-value family is rejected by the role's own validation, plus positive coverage in the default scenario for the emit-0/bare-int/bool/two-element-list forms. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/molecule/default/converge.yml | 20 +++ ansible/molecule/default/verify.yml | 33 ++++ ansible/molecule/validation/converge.yml | 130 ++++++++++++++++ ansible/molecule/validation/molecule.yml | 37 +++++ ansible/roles/decdn_node/README.md | 32 ++++ ansible/roles/decdn_node/defaults/main.yml | 47 +++++- ansible/roles/decdn_node/tasks/main.yml | 143 ++++++++++++++++++ .../roles/decdn_node/templates/node.toml.j2 | 82 +++++++++- 8 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 ansible/molecule/validation/converge.yml create mode 100644 ansible/molecule/validation/molecule.yml diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index b0e18b5..5d1ee62 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -27,6 +27,26 @@ # it (verify.yml asserts owner/group/mode). Keep in sync with verify.yml. decdn_cache_origin_kind: "fs" decdn_cache_origin_path: "/var/lib/decdn/origin" + # Operator-facing tuning knobs (#29 + siblings): exercise the new emission + + # validation. Chosen to cover the forms a naive template would get wrong — an + # explicit 0 (must EMIT, not be dropped), bare-integer Bytes/Percent (never + # "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 + decdn_gc_interval_sec: 0 + decdn_pull_ahead_bytes: 2097152 + decdn_pull_share_ratio_percent: 400 + decdn_enable_0rtt: false + decdn_delivery_floor: 0 + decdn_settlement_auto_threshold_micro_usdc: 50000000 + # Two entries each so the {% for %} comma-join branch (loop.last) is exercised — + # a single-element list never renders the separator. verify.yml asserts order. + decdn_relay_urls: + - "https://relay1.example.invalid:443" + - "https://relay2.example.invalid:443" + decdn_pinned_hashes: + - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" # Exercise baseline's named-sudo-users feature (see pre_tasks below). Throwaway # keys, generated for this test only — public keys, not credentials. Two users # on purpose: `alice.smith` has a '.' (so the sudoers.d filename-sanitize maps it diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index 9a84d34..025c56d 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -96,6 +96,39 @@ if origin.get("kind") != "fs" or origin.get("path") != "/var/lib/decdn/origin": print("node.toml [cache.origin] mismatch (got):", origin, file=sys.stderr) sys.exit(1) + # Operator-facing tuning knobs (#29 + siblings), set in converge.yml. Assert + # exact value AND type: a dropped `0`, a stringified Bytes, or a bool rendered + # as an int/string are real regressions a loose `==` would miss (in Python + # `0 == False` and `1 == True`, so isinstance guards are load-bearing here). + def _is_int(x): + return isinstance(x, int) and not isinstance(x, bool) + cache, net, pay, bc = (d.get(s, {}) for s in ("cache", "network", "payment", "blockchain")) + knob_checks = [ + ("cache.node_to_node_pull_through_enabled", + cache.get("node_to_node_pull_through_enabled") is True), + ("cache.gc_interval_sec (explicit 0 must emit)", + _is_int(cache.get("gc_interval_sec")) and cache.get("gc_interval_sec") == 0), + ("cache.pull_ahead_bytes (bare-int Bytes)", + _is_int(cache.get("pull_ahead_bytes")) and cache.get("pull_ahead_bytes") == 2097152), + ("cache.pull_share_ratio_percent (bare-int Percent)", + _is_int(cache.get("pull_share_ratio_percent")) and cache.get("pull_share_ratio_percent") == 400), + # Two-element lists in exact order — exercises the comma-join branch. + ("cache.pinned_hashes", cache.get("pinned_hashes") == ["a" * 64, "b" * 64]), + ("network.relay_urls", + net.get("relay_urls") == ["https://relay1.example.invalid:443", + "https://relay2.example.invalid:443"]), + ("network.enable_0rtt", net.get("enable_0rtt") is False), + ("payment.delivery_floor (explicit 0 must emit)", + _is_int(pay.get("delivery_floor")) and pay.get("delivery_floor") == 0), + ("blockchain.settlement_auto_threshold_micro_usdc", + _is_int(bc.get("settlement_auto_threshold_micro_usdc")) + and bc.get("settlement_auto_threshold_micro_usdc") == 50000000), + ] + knob_bad = [name for name, ok in knob_checks if not ok] + if knob_bad: + print("node.toml tuning-knob mismatch:", knob_bad, file=sys.stderr) + print(" cache=", cache, "\n network=", net, "\n payment=", pay, file=sys.stderr) + sys.exit(1) - name: Assert node.toml is valid TOML and renders the expected content ansible.builtin.command: diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml new file mode 100644 index 0000000..515ce82 --- /dev/null +++ b/ansible/molecule/validation/converge.yml @@ -0,0 +1,130 @@ +--- +# NEGATIVE-path scenario: proves the role's fail-loud tuning-knob validation actually +# REJECTS bad values (AGENTS.md hard-rule 4), so a regression that weakens an assert +# fails CI. Each case runs decdn_node with a VALID required config plus ONE bad +# optional knob, wrapped in block/rescue. The role aborts at its validation asserts +# (which run before any host mutation), so cases don't interfere and no service starts. +# +# The rescue records a case as "rejected" ONLY when the failing task is one of the +# role's own "Validate optional ..." asserts (matched on ansible_failed_task.name). +# That is what makes this a real regression guard: if a bad value slipped PAST +# validation, the role would run on and fail later at a differently-named task (e.g. +# the keystore gate) — which is NOT counted, so the final assert catches the miss. +- name: Converge (expect validation failures) + hosts: all + become: true + vars: + stub_bin: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/molecule/default/files/decdn-node-stub" + # Valid baseline so execution reaches the OPTIONAL-knob asserts (required-var + # asserts all pass); each case below overrides exactly one optional knob. + decdn_node_install_method: manual + decdn_node_manual_bin_src: "{{ stub_bin }}" + decdn_cli_manual_bin_src: "{{ stub_bin }}" + decdn_rpc_url: "https://rpc.example.invalid/" + decdn_region: "US" + decdn_payment_channel_address: "0x1111111111111111111111111111111111111111" + decdn_capacity_bond_address: "0x2222222222222222222222222222222222222222" + decdn_slash_judge_address: "0x3333333333333333333333333333333333333333" + tasks: + - name: Initialize the rejected-by-validation tracker + ansible.builtin.set_fact: + decdn_rejected: [] + + # --- Case: cross-field (the regression guard for the effective-value fix) ------ + # A raised pull_ahead with the leech cap left UNSET (resolves to 256 MiB): the + # daemon would reject floor>cap, so the role must too. + - name: "Case cross-field — pull_ahead above the default (unset) leech cap" + block: + - name: Run decdn_node with pull_ahead > default leech cap + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_pull_ahead_bytes: 536870912 + rescue: + - name: Record cross-field rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['cross-field'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: range (numeric but below the daemon's minimum) --------------------- + - name: "Case range — event_poll_interval_ms below 250" + block: + - name: Run decdn_node with event_poll below the daemon minimum + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_event_poll_interval_ms: 100 + rescue: + - name: Record range rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['range'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: shape (non-numeric string for an integer knob) --------------------- + - name: "Case shape — non-numeric integer knob" + block: + - name: Run decdn_node with a non-numeric integer knob + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_gc_interval_sec: "12x" + rescue: + - name: Record shape rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['shape'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: bool (quoted non-boolean) ------------------------------------------ + - name: "Case bool — quoted non-boolean" + block: + - name: Run decdn_node with a quoted non-boolean + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_enable_0rtt: "yes" + rescue: + - name: Record bool rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['bool'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: list (uppercase pinned hash — upstream rejects uppercase) ---------- + - name: "Case list — uppercase pinned hash" + block: + - name: Run decdn_node with an uppercase pinned hash + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_pinned_hashes: + - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + rescue: + - name: Record list rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['list'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: string (double-quote breaks the rendered TOML string) -------------- + - name: "Case string — user_agent containing a double-quote" + block: + - name: Run decdn_node with a quote in user_agent + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_cache_user_agent: 'bad"agent' + rescue: + - 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') + + - name: Confirm every bad value was rejected by the role's own validation + ansible.builtin.assert: + that: + - decdn_rejected | sort == _decdn_expected | sort + fail_msg: >- + decdn_node did NOT reject every bad value at its validation asserts. + Expected {{ _decdn_expected | sort }}, got {{ decdn_rejected | sort }} — + a missing tag means that bad value slipped past validation (it would reach + the daemon and crash-loop it at config load). + vars: + _decdn_expected: ["cross-field", "range", "shape", "bool", "list", "string"] diff --git a/ansible/molecule/validation/molecule.yml b/ansible/molecule/validation/molecule.yml new file mode 100644 index 0000000..05aeb0b --- /dev/null +++ b/ansible/molecule/validation/molecule.yml @@ -0,0 +1,37 @@ +--- +# NEGATIVE-path scenario: proves the role's fail-loud tuning-knob validation +# actually REJECTS bad values (AGENTS.md hard-rule 4). converge.yml runs the role +# with an otherwise-valid config plus one bad knob per case and asserts each is +# rejected — so a regression that weakens an assert fails this scenario. The role +# aborts at its validation asserts (before any host mutation), so no service starts +# here; there is no verify/idempotence step — the converge IS the assertion. Reuses +# the default scenario's stub binary. `baseline` is not exercised (host-only — see +# molecule/default/molecule.yml). +dependency: + name: galaxy + options: + requirements-file: ../../requirements.yml +driver: + name: docker +platforms: + - name: decdn-node-validation + # Same digest-pinned image as the default scenario (repo convention). Re-resolve + # both together to bump. + image: geerlingguy/docker-debian12-ansible@sha256:4553092be2c00b1ffe580927b9ff03f3c3a0df32b7dd693a3eb02efb6c2b77b7 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw +provisioner: + name: ansible + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles" + ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections" +scenario: + test_sequence: + - dependency + - create + - converge + - destroy diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 4e6f82a..434c556 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -118,6 +118,38 @@ Optional (omitted from `node.toml` unless set): defaults). A **timeout** only warns; it never fails the deploy — but a non-200 *answer* does. See [Readiness](#readiness). +### Optional tuning knobs + +These expose daemon config fields that most operators never touch. Each defaults to +`""` (or `[]`), meaning **omit the key and use the daemon's own default** — set one only +to override. Unlike the `*_from_block` knobs above, an explicit `0`/`false` **is** emitted +(`0` is meaningful — e.g. `decdn_gc_interval_sec: 0` disables the GC sweep). A malformed +or out-of-range value fails loud at deploy time (the daemon would otherwise reject it at +load and crash-loop). See `defaults/main.yml` for every knob's upstream default and unit. + +- **Node-to-node pull-through (#29)** — `decdn_node_to_node_pull_through_enabled` (bool) + gates all serve-time origin pull from upstream nodes; the tuning knobs + (`decdn_node_pull_probe_fanout`, `decdn_node_pull_timeout_sec`, `decdn_pull_ahead_bytes`, + `decdn_max_unrecouped_leech_bytes`, `decdn_pull_share_ratio_percent`, + `decdn_pull_through_require_authorized_origin`) only take effect when it is `true`. + `*_bytes` / `*_percent` are **bare integers** (bytes; percent at scale 100, `400` = 4.0×). +- **Settlement / channels** — `decdn_redeem_threshold_micro_usdc`, + `decdn_buyer_deposit_micro_usdc`, `decdn_buyer_max_approve` (bool), and the opt-in + auto-close pair `decdn_settlement_auto_threshold_micro_usdc` / + `decdn_settlement_auto_by_voucher_nonce_span` (`""` disables — a configured `0` is + rejected upstream). All µUSDC. +- **Delivery-rate clamps** — `decdn_delivery_floor` / `decdn_delivery_ceiling` (µUSDC; + floor ≤ ceiling) and `decdn_voucher_interval_mb` (`1..=1024`). +- **Cache tuning** — `decdn_max_probe_holds`, `decdn_stake_lane_reserved_holds`, + `decdn_gc_interval_sec`, `decdn_pinned_hashes` (list of 64-char **lowercase-hex** BLAKE3 + hashes), `decdn_cache_user_agent`. +- **Blockchain watchers** — `decdn_rpc_watchdog_interval_sec` (`0` or `>= 10`), + `decdn_event_poll_interval_ms` (`>= 250`), `decdn_content_blacklist_poll_interval_sec` (`>= 1`). +- **Network** — `decdn_relay_urls` (list; when non-empty the role emits it and omits the + singular `decdn_relay_url`), `decdn_enable_0rtt` (bool). +- **Observability** — `decdn_otlp_endpoint` (OTLP span export; needs the node built + `--features otlp`), `decdn_region_accounting_interval_sec`. + Source contract addresses / chain-id from the deCDN contract deployment for your target chain, or the relevant ADR — never guess. See `roles/decdn_node/defaults/main.yml` for the full knob list and defaults. diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 50ba679..2dbe92a 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -55,14 +55,56 @@ decdn_chain_id: 421614 # Arbitrum Sepolia (matches decdn-node's --chain # --- Network ------------------------------------------------------------------ decdn_bind_port: 4433 # public QUIC (udp); opened in the baseline firewall -decdn_relay_url: "" # optional iroh relay for NAT traversal +decdn_relay_url: "" # optional iroh relay for NAT traversal (deprecated single alias) +# iroh relay URLs; [] => n0 default relays. When this list is non-empty the template +# emits it and omits decdn_relay_url (this list supersedes the singular alias). +# e.g. ["https://relay1.example:443", "https://relay2.example:443"] +decdn_relay_urls: [] +# The optional knobs below default to "" (or []) => "omit the key, use the daemon's +# own default". Set a value only to override; an explicit 0/false IS emitted. +decdn_enable_0rtt: "" # bool; daemon dflt true — QUIC 0-RTT for cdn/probe/v1 (ADR 015) # --- Economics ---------------------------------------------------------------- decdn_rate_per_mb: 10 # USDC base units (6 decimals) +# Delivery-rate clamps + voucher cadence (µUSDC; "" => daemon default). +decdn_delivery_floor: "" # daemon dflt 0 — rate clamp floor before signing ProbeResponse +decdn_delivery_ceiling: "" # daemon dflt 1_000_000_000_000 (protocol MAX) — rate clamp ceiling +decdn_voucher_interval_mb: "" # daemon dflt 1; range 1..=1024 — voucher cadence (ADR 003) + +# --- Settlement / channels (all µUSDC; "" => daemon default) ------------------ +decdn_redeem_threshold_micro_usdc: "" # daemon dflt 1_000_000 (1 USDC) — accrued µUSDC to trigger withdraw +decdn_buyer_deposit_micro_usdc: "" # daemon dflt 10_000_000 (10 USDC) — escrow on a buyer-channel open +decdn_buyer_max_approve: "" # bool; daemon dflt true — one-time max USDC approval to PaymentChannel +# Auto-closeChannel triggers (OR'd). "" => off (disabled upstream); a configured 0 is rejected. +decdn_settlement_auto_threshold_micro_usdc: "" # un-redeemed µUSDC threshold +decdn_settlement_auto_by_voucher_nonce_span: "" # nonce-span companion trigger + +# --- Blockchain watcher tuning ("" => daemon default) ------------------------- +decdn_rpc_watchdog_interval_sec: "" # daemon dflt 30; 0 disables, else >= 10 — RPC watchdog +decdn_event_poll_interval_ms: "" # daemon dflt 7000; >= 250 — eth_getFilterChanges poll +decdn_content_blacklist_poll_interval_sec: "" # daemon dflt 600; >= 1 — blacklist re-scope cadence # --- Cache -------------------------------------------------------------------- decdn_cache_size_mb: 10240 # 10 GB decdn_max_blob_size_mb: 1024 # 1 GB +# 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 +# max_unrecouped_leech_bytes = 0 turns the cap off). +decdn_max_probe_holds: "" # daemon dflt 256 — eviction-exempt holds (ADR 005); 0 = has_blob answers false +decdn_stake_lane_reserved_holds: "" # daemon dflt 0 — holds reserved for node-to-node probes (#757) +decdn_gc_interval_sec: "" # daemon dflt 300 — iroh-blobs GC sweep; 0 = off +decdn_pinned_hashes: [] # eviction-exempt pins; each a 64-char lowercase-hex BLAKE3 hash +decdn_cache_user_agent: "" # daemon dflt decdn-node/ — HTTP origin User-Agent +# Node-to-node pull-through (#29). The toggle gates all serve-time origin pull from +# upstream nodes; the knobs below only take effect when it is true. *_bytes / *_percent +# are BARE INTEGERS on the wire (bytes; percent at scale 100, so 400 = 4.0x). +decdn_node_to_node_pull_through_enabled: "" # bool; daemon dflt false — enable pull from upstream nodes (#29) +decdn_node_pull_probe_fanout: "" # daemon dflt 5 — providers probed per miss; 0 disables pull +decdn_node_pull_timeout_sec: "" # daemon dflt 20 — per-upstream pull timeout +decdn_pull_ahead_bytes: "" # daemon dflt 1048576 (1 MiB) — speculative pull window (ADR 037) +decdn_max_unrecouped_leech_bytes: "" # daemon dflt 268435456 (256 MiB) — speculative-spend cap; 0 = off +decdn_pull_share_ratio_percent: "" # daemon dflt 400 (=4.0x) — per-peer speculative ceiling +decdn_pull_through_require_authorized_origin: "" # bool; daemon dflt false — gate pulls on the ADR 022 directory # Pull-through origin (#437): what the node fetches on a cache miss. Empty kind => # the [cache.origin] table is omitted and misses fail NoOrigin — a serving node # needs an origin. Pick ONE kind and set that kind's fields; the others are ignored. @@ -82,6 +124,9 @@ decdn_log_format: json 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_region_accounting_interval_sec: "" # daemon dflt 3600 — per-region bandwidth log; 0 = off # --- Readiness probe (advisory) ----------------------------------------------- # After start, the role probes http://127.0.0.1:/metrics as a diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 69f7f9e..b1b3c80 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -176,6 +176,149 @@ kind empty to omit [cache.origin] (cache misses then fail NoOrigin). when: decdn_cache_origin_kind | length > 0 +# --- Optional tuning knobs ("" => omit + use the daemon default) -------------- +# These template with a `!= ""` gate (NOT `| int > 0`) because 0/false are +# meaningful values here. A bad value would render TOML the daemon rejects at +# load — an opaque crash-loop — so validate shape + upstream range/constraints at +# deploy time instead (AGENTS.md hard-rule 4). Every check is guarded `== "" or +# (...)` so the unset default always passes. + +- name: Validate optional boolean tuning knobs + ansible.builtin.assert: + that: + - decdn_enable_0rtt == "" or decdn_enable_0rtt is boolean + - decdn_buyer_max_approve == "" or decdn_buyer_max_approve is boolean + - decdn_node_to_node_pull_through_enabled == "" or decdn_node_to_node_pull_through_enabled is boolean + - decdn_pull_through_require_authorized_origin == "" or decdn_pull_through_require_authorized_origin is boolean + fail_msg: >- + decdn_enable_0rtt, decdn_buyer_max_approve, + decdn_node_to_node_pull_through_enabled and + decdn_pull_through_require_authorized_origin must each be a real boolean + (true/false), not a quoted string — they template through `| lower` into a + bare TOML bool. Leave a knob "" to omit it (daemon default applies). + +# Integer shape first (a separate task): if a value is non-numeric this fails loud +# BEFORE the range task below runs its `| int` comparisons, so `| int` there never +# silently coerces a typo to 0. +- name: Validate optional integer tuning knobs are non-negative integers + ansible.builtin.assert: + that: + # Shape AND magnitude: TOML integers are i64-domain, so a value above + # i64::MAX (9223372036854775807) renders but fails the daemon's TOML parse + # (a crash-loop) even though it is a "valid u64". Cap it here. + - item == "" or (item | string is match('^[0-9]+$') and item | int <= 9223372036854775807) + quiet: true + fail_msg: >- + "{{ item }}" is not a non-negative integer <= i64::MAX (9223372036854775807). + Every optional numeric knob (delivery/settlement/watcher/cache-tuning/ + pull-through) must be a bare u64 in that range, or "" to omit it. See + roles/decdn_node/defaults/main.yml for the full list. + loop: + - "{{ decdn_delivery_floor }}" + - "{{ decdn_delivery_ceiling }}" + - "{{ decdn_voucher_interval_mb }}" + - "{{ decdn_redeem_threshold_micro_usdc }}" + - "{{ decdn_buyer_deposit_micro_usdc }}" + - "{{ decdn_settlement_auto_threshold_micro_usdc }}" + - "{{ decdn_settlement_auto_by_voucher_nonce_span }}" + - "{{ decdn_rpc_watchdog_interval_sec }}" + - "{{ decdn_event_poll_interval_ms }}" + - "{{ decdn_content_blacklist_poll_interval_sec }}" + - "{{ decdn_max_probe_holds }}" + - "{{ decdn_stake_lane_reserved_holds }}" + - "{{ decdn_gc_interval_sec }}" + - "{{ decdn_node_pull_probe_fanout }}" + - "{{ decdn_node_pull_timeout_sec }}" + - "{{ decdn_pull_ahead_bytes }}" + - "{{ decdn_max_unrecouped_leech_bytes }}" + - "{{ decdn_pull_share_ratio_percent }}" + - "{{ decdn_region_accounting_interval_sec }}" + +# Range / nonzero constraints the daemon enforces at load (out-of-range => the node +# refuses to start). Values are integer-shaped by the task above, so `| int` is safe. +- name: Validate optional knob ranges the daemon rejects out-of-range + ansible.builtin.assert: + that: + # rpc_watchdog: 0 disables, otherwise must be >= 10 (1..9 rejected upstream). + - >- + decdn_rpc_watchdog_interval_sec == "" + or (decdn_rpc_watchdog_interval_sec | int == 0 or decdn_rpc_watchdog_interval_sec | int >= 10) + - decdn_event_poll_interval_ms == "" or (decdn_event_poll_interval_ms | int >= 250) + - decdn_content_blacklist_poll_interval_sec == "" or (decdn_content_blacklist_poll_interval_sec | int >= 1) + - decdn_redeem_threshold_micro_usdc == "" or (decdn_redeem_threshold_micro_usdc | int >= 1) + - decdn_buyer_deposit_micro_usdc == "" or (decdn_buyer_deposit_micro_usdc | int >= 1) + # settlement auto-close: 0 is rejected (omit to disable), so require >= 1. + - decdn_settlement_auto_threshold_micro_usdc == "" or (decdn_settlement_auto_threshold_micro_usdc | int >= 1) + - decdn_settlement_auto_by_voucher_nonce_span == "" or (decdn_settlement_auto_by_voucher_nonce_span | int >= 1) + - >- + decdn_delivery_ceiling == "" + or (decdn_delivery_ceiling | int >= 1 and decdn_delivery_ceiling | int <= 1000000000000) + - >- + decdn_voucher_interval_mb == "" + or (decdn_voucher_interval_mb | int >= 1 and decdn_voucher_interval_mb | int <= 1024) + fail_msg: >- + An optional knob is out of the range the daemon accepts: + decdn_rpc_watchdog_interval_sec (0 or >= 10), + decdn_event_poll_interval_ms (>= 250), + decdn_content_blacklist_poll_interval_sec (>= 1), + decdn_redeem_threshold_micro_usdc / decdn_buyer_deposit_micro_usdc (>= 1), + decdn_settlement_auto_threshold_micro_usdc / + decdn_settlement_auto_by_voucher_nonce_span (>= 1 — leave "" to disable, 0 is + rejected), decdn_delivery_ceiling (1..=1_000_000_000_000), + decdn_voucher_interval_mb (1..=1024). + +- name: Validate optional cross-field knob constraints + ansible.builtin.assert: + that: + # The daemon fills each UNSET side with its own default and THEN enforces the + # constraint, so compare effective (default-substituted) values — NOT "skip if + # either side is unset". Skipping would let a raised pull_ahead with an unset + # leech cap (which resolves to 256 MiB), or a floor above the default ceiling, + # slip through here and crash-loop the daemon at config load. + - _eff_dfloor | int <= _eff_dceil | int + # leech cap: a RESOLVED 0 disables it; otherwise pull_ahead <= leech. + - _eff_leech | int == 0 or (_eff_pull | int <= _eff_leech | int) + fail_msg: >- + Cross-field constraint violated (unset sides compared against the daemon + default): decdn_delivery_floor must be <= decdn_delivery_ceiling (default + 1_000_000_000_000), and decdn_pull_ahead_bytes must be <= + decdn_max_unrecouped_leech_bytes (default 268435456) unless that cap is 0 (off). + vars: + _eff_dfloor: "{{ decdn_delivery_floor if decdn_delivery_floor != '' else 0 }}" + _eff_dceil: "{{ decdn_delivery_ceiling if decdn_delivery_ceiling != '' else 1000000000000 }}" + _eff_pull: "{{ decdn_pull_ahead_bytes if decdn_pull_ahead_bytes != '' else 1048576 }}" + _eff_leech: "{{ decdn_max_unrecouped_leech_bytes if decdn_max_unrecouped_leech_bytes != '' else 268435456 }}" + +- name: Validate optional list knobs (relay_urls, pinned_hashes) + ansible.builtin.assert: + that: + # Fully anchored (^...$) with [^"\s]: a start-anchored '^https?://' prefix + # match would accept a value like 'https://x" injected = 1' whose unescaped + # quote breaks the rendered TOML array (a crash-loop). Disallow quotes/space. + - decdn_relay_urls | reject('match', '^https?://[^\"\\s]+$') | list | length == 0 + - decdn_pinned_hashes | reject('match', '^[0-9a-f]{64}$') | list | length == 0 + fail_msg: >- + decdn_relay_urls entries must each be an http(s):// URL with no quote or + whitespace, and decdn_pinned_hashes entries must each be a 64-char + lowercase-hex BLAKE3 hash (uppercase is rejected upstream). Leave either + list empty ([]) to omit it. + +# user_agent + otlp_endpoint interpolate into TOML basic strings ("..."). An +# unescaped '"' or newline breaks the rendered node.toml (a daemon crash-loop), 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 unset passes). +- name: Validate optional string knobs (user_agent, otlp_endpoint) + ansible.builtin.assert: + that: + # No '"', CR or LF (would break the TOML string / are rejected upstream). + - decdn_cache_user_agent == "" or (decdn_cache_user_agent is match('^[^\"\\r\\n]+$')) + # http(s):// URL, and no quote/whitespace (same TOML-injection guard as relay_urls). + - decdn_otlp_endpoint == "" or (decdn_otlp_endpoint is match('^https?://[^\"\\s]+$')) + fail_msg: >- + decdn_cache_user_agent must contain no double-quote or newline, and + decdn_otlp_endpoint must be an http(s):// URL with no quote or whitespace + (both are interpolated verbatim into node.toml). Leave either "" to omit it. + # --- User & directories ------------------------------------------------------- - name: Create decdn system group ansible.builtin.group: diff --git a/ansible/roles/decdn_node/templates/node.toml.j2 b/ansible/roles/decdn_node/templates/node.toml.j2 index df20361..69d4d11 100644 --- a/ansible/roles/decdn_node/templates/node.toml.j2 +++ b/ansible/roles/decdn_node/templates/node.toml.j2 @@ -10,9 +10,14 @@ region = "{{ decdn_region }}" [network] bind_port = {{ decdn_bind_port }} -{% if decdn_relay_url | length > 0 %} +{% if decdn_relay_urls | length > 0 %} +relay_urls = [{% for u in decdn_relay_urls %}"{{ u }}"{% if not loop.last %}, {% endif %}{% endfor %}] +{% elif decdn_relay_url | length > 0 %} relay_url = "{{ decdn_relay_url }}" {% endif %} +{% if decdn_enable_0rtt != "" %} +enable_0rtt = {{ decdn_enable_0rtt | lower }} +{% endif %} [blockchain] chain_id = {{ decdn_chain_id }} @@ -39,14 +44,83 @@ content_blacklist_address = "{{ decdn_content_blacklist_address }}" content_blacklist_from_block = {{ decdn_content_blacklist_from_block | int }} {% endif %} {% endif %} +{% if decdn_rpc_watchdog_interval_sec != "" %} +rpc_watchdog_interval_sec = {{ decdn_rpc_watchdog_interval_sec | int }} +{% endif %} +{% if decdn_event_poll_interval_ms != "" %} +event_poll_interval_ms = {{ decdn_event_poll_interval_ms | int }} +{% endif %} +{% if decdn_content_blacklist_poll_interval_sec != "" %} +content_blacklist_poll_interval_sec = {{ decdn_content_blacklist_poll_interval_sec | int }} +{% endif %} +{% if decdn_redeem_threshold_micro_usdc != "" %} +redeem_threshold_micro_usdc = {{ decdn_redeem_threshold_micro_usdc | int }} +{% endif %} +{% if decdn_buyer_deposit_micro_usdc != "" %} +buyer_deposit_micro_usdc = {{ decdn_buyer_deposit_micro_usdc | int }} +{% endif %} +{% if decdn_buyer_max_approve != "" %} +buyer_max_approve = {{ decdn_buyer_max_approve | lower }} +{% endif %} +{% if decdn_settlement_auto_threshold_micro_usdc != "" %} +settlement_auto_threshold_micro_usdc = {{ decdn_settlement_auto_threshold_micro_usdc | int }} +{% endif %} +{% if decdn_settlement_auto_by_voucher_nonce_span != "" %} +settlement_auto_by_voucher_nonce_span = {{ decdn_settlement_auto_by_voucher_nonce_span | int }} +{% endif %} [payment] rate_per_mb = {{ decdn_rate_per_mb }} +{% if decdn_delivery_floor != "" %} +delivery_floor = {{ decdn_delivery_floor | int }} +{% endif %} +{% if decdn_delivery_ceiling != "" %} +delivery_ceiling = {{ decdn_delivery_ceiling | int }} +{% endif %} +{% if decdn_voucher_interval_mb != "" %} +voucher_interval_mb = {{ decdn_voucher_interval_mb | int }} +{% endif %} [cache] cache_dir = "{{ decdn_cache_dir }}" cache_size_mb = {{ decdn_cache_size_mb }} max_blob_size_mb = {{ decdn_max_blob_size_mb }} +{% if decdn_max_probe_holds != "" %} +max_probe_holds = {{ decdn_max_probe_holds | int }} +{% endif %} +{% if decdn_stake_lane_reserved_holds != "" %} +stake_lane_reserved_holds = {{ decdn_stake_lane_reserved_holds | int }} +{% endif %} +{% if decdn_gc_interval_sec != "" %} +gc_interval_sec = {{ decdn_gc_interval_sec | int }} +{% endif %} +{% if decdn_pinned_hashes | length > 0 %} +pinned_hashes = [{% for h in decdn_pinned_hashes %}"{{ h }}"{% if not loop.last %}, {% endif %}{% endfor %}] +{% endif %} +{% if decdn_cache_user_agent != "" %} +user_agent = "{{ decdn_cache_user_agent }}" +{% endif %} +{% if decdn_node_to_node_pull_through_enabled != "" %} +node_to_node_pull_through_enabled = {{ decdn_node_to_node_pull_through_enabled | lower }} +{% endif %} +{% if decdn_node_pull_probe_fanout != "" %} +node_pull_probe_fanout = {{ decdn_node_pull_probe_fanout | int }} +{% endif %} +{% if decdn_node_pull_timeout_sec != "" %} +node_pull_timeout_sec = {{ decdn_node_pull_timeout_sec | int }} +{% endif %} +{% if decdn_pull_ahead_bytes != "" %} +pull_ahead_bytes = {{ decdn_pull_ahead_bytes | int }} +{% endif %} +{% if decdn_max_unrecouped_leech_bytes != "" %} +max_unrecouped_leech_bytes = {{ decdn_max_unrecouped_leech_bytes | int }} +{% endif %} +{% if decdn_pull_share_ratio_percent != "" %} +pull_share_ratio_percent = {{ decdn_pull_share_ratio_percent | int }} +{% endif %} +{% if decdn_pull_through_require_authorized_origin != "" %} +pull_through_require_authorized_origin = {{ decdn_pull_through_require_authorized_origin | lower }} +{% endif %} {% if decdn_cache_origin_kind | length > 0 %} [cache.origin] @@ -79,3 +153,9 @@ log_format = "{{ decdn_log_format }}" metrics_port = {{ decdn_metrics_port }} metrics_bind = "{{ decdn_metrics_bind }}" admin_port = {{ decdn_admin_port }} +{% if decdn_otlp_endpoint != "" %} +otlp_endpoint = "{{ decdn_otlp_endpoint }}" +{% endif %} +{% if decdn_region_accounting_interval_sec != "" %} +region_accounting_interval_sec = {{ decdn_region_accounting_interval_sec | int }} +{% endif %} From 7cac785f630f42f3d2503a1fa59325fcfc63be01 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 12 Jul 2026 21:03:54 +0300 Subject: [PATCH 2/2] fix(decdn_node): treat null as unset and reject backslashes in TOML strings Address code-review feedback on the tuning-knob validation: - null/None now behaves like the "" / [] unset sentinel everywhere (Gemini). An operator writing `decdn_x:` (null) previously fail-loud'd on the shape assert or crash'd `| length` on None; now it omits the key and uses the daemon default, matching Ansible idiom. Scalars use `not in ["", none]` (NOT `| length`, which errors on an integer value); lists use `is not none and | length`; the integer-shape loop is a list expression so null keeps its type instead of becoming the string "None". - Reject backslashes in the interpolated string/URL knobs (Copilot). A backslash in a TOML basic string starts an escape and a trailing `\` escapes the closing quote (a daemon crash-loop); relay_urls/relay_url/user_agent/otlp_endpoint now disallow it. Also validate the previously-unchecked singular decdn_relay_url. Verified: null overrides render as omitted (no crash, default parity holds); backslash/quote/whitespace values are rejected at deploy time; all prior positive/negative cases still hold; ansible-lint + yamllint clean; all four molecule scenarios pass (validation rescued=6). Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/decdn_node/tasks/main.yml | 134 ++++++++++-------- .../roles/decdn_node/templates/node.toml.j2 | 56 ++++---- 2 files changed, 101 insertions(+), 89 deletions(-) diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index b1b3c80..892a32a 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -186,16 +186,18 @@ - name: Validate optional boolean tuning knobs ansible.builtin.assert: that: - - decdn_enable_0rtt == "" or decdn_enable_0rtt is boolean - - decdn_buyer_max_approve == "" or decdn_buyer_max_approve is boolean - - decdn_node_to_node_pull_through_enabled == "" or decdn_node_to_node_pull_through_enabled is boolean - - decdn_pull_through_require_authorized_origin == "" or decdn_pull_through_require_authorized_origin is boolean + - decdn_enable_0rtt in ["", none] or decdn_enable_0rtt is boolean + - decdn_buyer_max_approve in ["", none] or decdn_buyer_max_approve is boolean + - decdn_node_to_node_pull_through_enabled in ["", none] or decdn_node_to_node_pull_through_enabled is boolean + - >- + decdn_pull_through_require_authorized_origin in ["", none] + or decdn_pull_through_require_authorized_origin is boolean fail_msg: >- decdn_enable_0rtt, decdn_buyer_max_approve, decdn_node_to_node_pull_through_enabled and decdn_pull_through_require_authorized_origin must each be a real boolean (true/false), not a quoted string — they template through `| lower` into a - bare TOML bool. Leave a knob "" to omit it (daemon default applies). + bare TOML bool. Leave a knob "" (or null) to omit it (daemon default applies). # Integer shape first (a separate task): if a value is non-numeric this fails loud # BEFORE the range task below runs its `| int` comparisons, so `| int` there never @@ -205,34 +207,27 @@ that: # Shape AND magnitude: TOML integers are i64-domain, so a value above # i64::MAX (9223372036854775807) renders but fails the daemon's TOML parse - # (a crash-loop) even though it is a "valid u64". Cap it here. - - item == "" or (item | string is match('^[0-9]+$') and item | int <= 9223372036854775807) + # (a crash-loop) even though it is a "valid u64". Cap it here. "" and null + # (None) both mean "unset" and pass. + - item in ["", none] or (item | string is match('^[0-9]+$') and item | int <= 9223372036854775807) quiet: true fail_msg: >- "{{ item }}" is not a non-negative integer <= i64::MAX (9223372036854775807). Every optional numeric knob (delivery/settlement/watcher/cache-tuning/ - pull-through) must be a bare u64 in that range, or "" to omit it. See + pull-through) must be a bare u64 in that range, or "" / null to omit it. See roles/decdn_node/defaults/main.yml for the full list. - loop: - - "{{ decdn_delivery_floor }}" - - "{{ decdn_delivery_ceiling }}" - - "{{ decdn_voucher_interval_mb }}" - - "{{ decdn_redeem_threshold_micro_usdc }}" - - "{{ decdn_buyer_deposit_micro_usdc }}" - - "{{ decdn_settlement_auto_threshold_micro_usdc }}" - - "{{ decdn_settlement_auto_by_voucher_nonce_span }}" - - "{{ decdn_rpc_watchdog_interval_sec }}" - - "{{ decdn_event_poll_interval_ms }}" - - "{{ decdn_content_blacklist_poll_interval_sec }}" - - "{{ decdn_max_probe_holds }}" - - "{{ decdn_stake_lane_reserved_holds }}" - - "{{ decdn_gc_interval_sec }}" - - "{{ decdn_node_pull_probe_fanout }}" - - "{{ decdn_node_pull_timeout_sec }}" - - "{{ decdn_pull_ahead_bytes }}" - - "{{ decdn_max_unrecouped_leech_bytes }}" - - "{{ decdn_pull_share_ratio_percent }}" - - "{{ decdn_region_accounting_interval_sec }}" + # A list EXPRESSION (not a list of "{{ }}" strings) so each item keeps its type — + # 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_delivery_ceiling, decdn_voucher_interval_mb, + decdn_redeem_threshold_micro_usdc, decdn_buyer_deposit_micro_usdc, + decdn_settlement_auto_threshold_micro_usdc, decdn_settlement_auto_by_voucher_nonce_span, + decdn_rpc_watchdog_interval_sec, decdn_event_poll_interval_ms, + decdn_content_blacklist_poll_interval_sec, decdn_max_probe_holds, + decdn_stake_lane_reserved_holds, decdn_gc_interval_sec, decdn_node_pull_probe_fanout, + decdn_node_pull_timeout_sec, decdn_pull_ahead_bytes, decdn_max_unrecouped_leech_bytes, + decdn_pull_share_ratio_percent, decdn_region_accounting_interval_sec] }} # Range / nonzero constraints the daemon enforces at load (out-of-range => the node # refuses to start). Values are integer-shaped by the task above, so `| int` is safe. @@ -240,21 +235,29 @@ ansible.builtin.assert: that: # rpc_watchdog: 0 disables, otherwise must be >= 10 (1..9 rejected upstream). + # ("" and null both mean "unset" and skip the check — the shape task above + # already rejected any non-numeric, non-null value.) - >- - decdn_rpc_watchdog_interval_sec == "" + decdn_rpc_watchdog_interval_sec in ["", none] or (decdn_rpc_watchdog_interval_sec | int == 0 or decdn_rpc_watchdog_interval_sec | int >= 10) - - decdn_event_poll_interval_ms == "" or (decdn_event_poll_interval_ms | int >= 250) - - decdn_content_blacklist_poll_interval_sec == "" or (decdn_content_blacklist_poll_interval_sec | int >= 1) - - decdn_redeem_threshold_micro_usdc == "" or (decdn_redeem_threshold_micro_usdc | int >= 1) - - decdn_buyer_deposit_micro_usdc == "" or (decdn_buyer_deposit_micro_usdc | int >= 1) + - decdn_event_poll_interval_ms in ["", none] or (decdn_event_poll_interval_ms | int >= 250) + - >- + decdn_content_blacklist_poll_interval_sec in ["", none] + or (decdn_content_blacklist_poll_interval_sec | int >= 1) + - decdn_redeem_threshold_micro_usdc in ["", none] or (decdn_redeem_threshold_micro_usdc | int >= 1) + - decdn_buyer_deposit_micro_usdc in ["", none] or (decdn_buyer_deposit_micro_usdc | int >= 1) # settlement auto-close: 0 is rejected (omit to disable), so require >= 1. - - decdn_settlement_auto_threshold_micro_usdc == "" or (decdn_settlement_auto_threshold_micro_usdc | int >= 1) - - decdn_settlement_auto_by_voucher_nonce_span == "" or (decdn_settlement_auto_by_voucher_nonce_span | int >= 1) - >- - decdn_delivery_ceiling == "" + decdn_settlement_auto_threshold_micro_usdc in ["", none] + or (decdn_settlement_auto_threshold_micro_usdc | int >= 1) + - >- + decdn_settlement_auto_by_voucher_nonce_span in ["", none] + or (decdn_settlement_auto_by_voucher_nonce_span | int >= 1) + - >- + decdn_delivery_ceiling in ["", none] or (decdn_delivery_ceiling | int >= 1 and decdn_delivery_ceiling | int <= 1000000000000) - >- - decdn_voucher_interval_mb == "" + decdn_voucher_interval_mb in ["", none] or (decdn_voucher_interval_mb | int >= 1 and decdn_voucher_interval_mb | int <= 1024) fail_msg: >- An optional knob is out of the range the daemon accepts: @@ -284,40 +287,49 @@ 1_000_000_000_000), and decdn_pull_ahead_bytes must be <= decdn_max_unrecouped_leech_bytes (default 268435456) unless that cap is 0 (off). vars: - _eff_dfloor: "{{ decdn_delivery_floor if decdn_delivery_floor != '' else 0 }}" - _eff_dceil: "{{ decdn_delivery_ceiling if decdn_delivery_ceiling != '' else 1000000000000 }}" - _eff_pull: "{{ decdn_pull_ahead_bytes if decdn_pull_ahead_bytes != '' else 1048576 }}" - _eff_leech: "{{ decdn_max_unrecouped_leech_bytes if decdn_max_unrecouped_leech_bytes != '' else 268435456 }}" - -- name: Validate optional list knobs (relay_urls, pinned_hashes) + # "" and null both mean "unset" => substitute the daemon default. + _eff_dfloor: "{{ decdn_delivery_floor if decdn_delivery_floor not in ['', none] else 0 }}" + _eff_dceil: "{{ decdn_delivery_ceiling if decdn_delivery_ceiling not in ['', none] else 1000000000000 }}" + _eff_pull: "{{ decdn_pull_ahead_bytes if decdn_pull_ahead_bytes not in ['', none] else 1048576 }}" + _eff_leech: >- + {{ decdn_max_unrecouped_leech_bytes if decdn_max_unrecouped_leech_bytes not in ['', none] + else 268435456 }} + +- name: Validate optional list + singular-relay knobs ansible.builtin.assert: that: - # Fully anchored (^...$) with [^"\s]: a start-anchored '^https?://' prefix + # Fully anchored (^...$) with [^"\s\\]: a start-anchored '^https?://' prefix # match would accept a value like 'https://x" injected = 1' whose unescaped - # quote breaks the rendered TOML array (a crash-loop). Disallow quotes/space. - - decdn_relay_urls | reject('match', '^https?://[^\"\\s]+$') | list | length == 0 - - decdn_pinned_hashes | reject('match', '^[0-9a-f]{64}$') | list | length == 0 + # quote breaks the rendered TOML array (a crash-loop). Also reject backslash — + # in a TOML basic string it starts an escape, and a trailing '\' escapes the + # closing quote. A null list is treated as unset (is none => pass). + - decdn_relay_urls is none or decdn_relay_urls | reject('match', '^https?://[^\"\\s\\\\]+$') | list | length == 0 + # The singular relay_url is templated too (when relay_urls is empty), so guard it + # with the same rule — a bad value there breaks node.toml just the same. + - decdn_relay_url in ["", none] or (decdn_relay_url is match('^https?://[^\"\\s\\\\]+$')) + - decdn_pinned_hashes is none or decdn_pinned_hashes | reject('match', '^[0-9a-f]{64}$') | list | length == 0 fail_msg: >- - decdn_relay_urls entries must each be an http(s):// URL with no quote or - whitespace, and decdn_pinned_hashes entries must each be a 64-char - lowercase-hex BLAKE3 hash (uppercase is rejected upstream). Leave either - list empty ([]) to omit it. + decdn_relay_urls / decdn_relay_url entries must each be an http(s):// URL with + no quote, whitespace or backslash, and decdn_pinned_hashes entries must each be + a 64-char lowercase-hex BLAKE3 hash (uppercase is rejected upstream). Leave a + list empty ([]) or a scalar "" to omit it. # user_agent + otlp_endpoint interpolate into TOML basic strings ("..."). An -# unescaped '"' or newline breaks the rendered node.toml (a daemon crash-loop), 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 unset passes). +# 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). - name: Validate optional string knobs (user_agent, otlp_endpoint) ansible.builtin.assert: that: - # No '"', CR or LF (would break the TOML string / are rejected upstream). - - decdn_cache_user_agent == "" or (decdn_cache_user_agent is match('^[^\"\\r\\n]+$')) - # http(s):// URL, and no quote/whitespace (same TOML-injection guard as relay_urls). - - decdn_otlp_endpoint == "" or (decdn_otlp_endpoint is match('^https?://[^\"\\s]+$')) + # 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\\\\]+$')) fail_msg: >- - decdn_cache_user_agent must contain no double-quote or newline, and - decdn_otlp_endpoint must be an http(s):// URL with no quote or whitespace - (both are interpolated verbatim into node.toml). Leave either "" to omit it. + 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. # --- User & directories ------------------------------------------------------- - name: Create decdn system group diff --git a/ansible/roles/decdn_node/templates/node.toml.j2 b/ansible/roles/decdn_node/templates/node.toml.j2 index 69d4d11..1486b3c 100644 --- a/ansible/roles/decdn_node/templates/node.toml.j2 +++ b/ansible/roles/decdn_node/templates/node.toml.j2 @@ -10,12 +10,12 @@ region = "{{ decdn_region }}" [network] bind_port = {{ decdn_bind_port }} -{% if decdn_relay_urls | length > 0 %} +{% if decdn_relay_urls is not none and decdn_relay_urls | length > 0 %} relay_urls = [{% for u in decdn_relay_urls %}"{{ u }}"{% if not loop.last %}, {% endif %}{% endfor %}] -{% elif decdn_relay_url | length > 0 %} +{% elif decdn_relay_url not in ["", none] %} relay_url = "{{ decdn_relay_url }}" {% endif %} -{% if decdn_enable_0rtt != "" %} +{% if decdn_enable_0rtt not in ["", none] %} enable_0rtt = {{ decdn_enable_0rtt | lower }} {% endif %} @@ -44,40 +44,40 @@ content_blacklist_address = "{{ decdn_content_blacklist_address }}" content_blacklist_from_block = {{ decdn_content_blacklist_from_block | int }} {% endif %} {% endif %} -{% if decdn_rpc_watchdog_interval_sec != "" %} +{% if decdn_rpc_watchdog_interval_sec not in ["", none] %} rpc_watchdog_interval_sec = {{ decdn_rpc_watchdog_interval_sec | int }} {% endif %} -{% if decdn_event_poll_interval_ms != "" %} +{% if decdn_event_poll_interval_ms not in ["", none] %} event_poll_interval_ms = {{ decdn_event_poll_interval_ms | int }} {% endif %} -{% if decdn_content_blacklist_poll_interval_sec != "" %} +{% if decdn_content_blacklist_poll_interval_sec not in ["", none] %} content_blacklist_poll_interval_sec = {{ decdn_content_blacklist_poll_interval_sec | int }} {% endif %} -{% if decdn_redeem_threshold_micro_usdc != "" %} +{% if decdn_redeem_threshold_micro_usdc not in ["", none] %} redeem_threshold_micro_usdc = {{ decdn_redeem_threshold_micro_usdc | int }} {% endif %} -{% if decdn_buyer_deposit_micro_usdc != "" %} +{% if decdn_buyer_deposit_micro_usdc not in ["", none] %} buyer_deposit_micro_usdc = {{ decdn_buyer_deposit_micro_usdc | int }} {% endif %} -{% if decdn_buyer_max_approve != "" %} +{% if decdn_buyer_max_approve not in ["", none] %} buyer_max_approve = {{ decdn_buyer_max_approve | lower }} {% endif %} -{% if decdn_settlement_auto_threshold_micro_usdc != "" %} +{% if decdn_settlement_auto_threshold_micro_usdc not in ["", none] %} settlement_auto_threshold_micro_usdc = {{ decdn_settlement_auto_threshold_micro_usdc | int }} {% endif %} -{% if decdn_settlement_auto_by_voucher_nonce_span != "" %} +{% if decdn_settlement_auto_by_voucher_nonce_span not in ["", none] %} settlement_auto_by_voucher_nonce_span = {{ decdn_settlement_auto_by_voucher_nonce_span | int }} {% endif %} [payment] rate_per_mb = {{ decdn_rate_per_mb }} -{% if decdn_delivery_floor != "" %} +{% if decdn_delivery_floor not in ["", none] %} delivery_floor = {{ decdn_delivery_floor | int }} {% endif %} -{% if decdn_delivery_ceiling != "" %} +{% if decdn_delivery_ceiling not in ["", none] %} delivery_ceiling = {{ decdn_delivery_ceiling | int }} {% endif %} -{% if decdn_voucher_interval_mb != "" %} +{% if decdn_voucher_interval_mb not in ["", none] %} voucher_interval_mb = {{ decdn_voucher_interval_mb | int }} {% endif %} @@ -85,40 +85,40 @@ voucher_interval_mb = {{ decdn_voucher_interval_mb | int }} cache_dir = "{{ decdn_cache_dir }}" cache_size_mb = {{ decdn_cache_size_mb }} max_blob_size_mb = {{ decdn_max_blob_size_mb }} -{% if decdn_max_probe_holds != "" %} +{% if decdn_max_probe_holds not in ["", none] %} max_probe_holds = {{ decdn_max_probe_holds | int }} {% endif %} -{% if decdn_stake_lane_reserved_holds != "" %} +{% if decdn_stake_lane_reserved_holds not in ["", none] %} stake_lane_reserved_holds = {{ decdn_stake_lane_reserved_holds | int }} {% endif %} -{% if decdn_gc_interval_sec != "" %} +{% if decdn_gc_interval_sec not in ["", none] %} gc_interval_sec = {{ decdn_gc_interval_sec | int }} {% endif %} -{% if decdn_pinned_hashes | length > 0 %} +{% if decdn_pinned_hashes is not none and decdn_pinned_hashes | length > 0 %} pinned_hashes = [{% for h in decdn_pinned_hashes %}"{{ h }}"{% if not loop.last %}, {% endif %}{% endfor %}] {% endif %} -{% if decdn_cache_user_agent != "" %} +{% if decdn_cache_user_agent not in ["", none] %} user_agent = "{{ decdn_cache_user_agent }}" {% endif %} -{% if decdn_node_to_node_pull_through_enabled != "" %} +{% if decdn_node_to_node_pull_through_enabled not in ["", none] %} node_to_node_pull_through_enabled = {{ decdn_node_to_node_pull_through_enabled | lower }} {% endif %} -{% if decdn_node_pull_probe_fanout != "" %} +{% if decdn_node_pull_probe_fanout not in ["", none] %} node_pull_probe_fanout = {{ decdn_node_pull_probe_fanout | int }} {% endif %} -{% if decdn_node_pull_timeout_sec != "" %} +{% if decdn_node_pull_timeout_sec not in ["", none] %} node_pull_timeout_sec = {{ decdn_node_pull_timeout_sec | int }} {% endif %} -{% if decdn_pull_ahead_bytes != "" %} +{% if decdn_pull_ahead_bytes not in ["", none] %} pull_ahead_bytes = {{ decdn_pull_ahead_bytes | int }} {% endif %} -{% if decdn_max_unrecouped_leech_bytes != "" %} +{% if decdn_max_unrecouped_leech_bytes not in ["", none] %} max_unrecouped_leech_bytes = {{ decdn_max_unrecouped_leech_bytes | int }} {% endif %} -{% if decdn_pull_share_ratio_percent != "" %} +{% if decdn_pull_share_ratio_percent not in ["", none] %} pull_share_ratio_percent = {{ decdn_pull_share_ratio_percent | int }} {% endif %} -{% if decdn_pull_through_require_authorized_origin != "" %} +{% if decdn_pull_through_require_authorized_origin not in ["", none] %} pull_through_require_authorized_origin = {{ decdn_pull_through_require_authorized_origin | lower }} {% endif %} {% if decdn_cache_origin_kind | length > 0 %} @@ -153,9 +153,9 @@ log_format = "{{ decdn_log_format }}" metrics_port = {{ decdn_metrics_port }} metrics_bind = "{{ decdn_metrics_bind }}" admin_port = {{ decdn_admin_port }} -{% if decdn_otlp_endpoint != "" %} +{% if decdn_otlp_endpoint not in ["", none] %} otlp_endpoint = "{{ decdn_otlp_endpoint }}" {% endif %} -{% if decdn_region_accounting_interval_sec != "" %} +{% if decdn_region_accounting_interval_sec not in ["", none] %} region_accounting_interval_sec = {{ decdn_region_accounting_interval_sec | int }} {% endif %}