diff --git a/.ngit-release-evidence/shard-results.tsv b/.ngit-release-evidence/shard-results.tsv new file mode 100644 index 000000000..513648614 --- /dev/null +++ b/.ngit-release-evidence/shard-results.tsv @@ -0,0 +1,17 @@ +release_run shard workflow ref result_event_id conclusion queued_at started_at finished_at duration_s +d8576dfa-20261020T192529Z ipk-none .ngit/act/workflows/build-package-ipk-none.yml refs/heads/release/v0.6.0-rc4/rc/d8576dfa-20261020T192529Z/ipk-none 85e7b19050ff1721ca7a9a96a369167b992efbe4db9d2530116a7d56aee8e53a success 1792524400 1792524965 1792525626 661 +d8576dfa-20261020T192529Z ipk-upx-fast-best .ngit/act/workflows/build-package-ipk-upx-fast-best.yml refs/heads/release/v0.6.0-rc4/rc/d8576dfa-20261020T192529Z/ipk-upx-fast-best f90386190fe6b48a68bafb3d3576f0acfd386f55579c953bf42658669e905bd3 failure 1792525827 1792525827 1792525893 66 +b77f9d79-20261020T195335Z ipk-none .ngit/act/workflows/build-package-ipk-none.yml refs/heads/release/v0.6.0-rc4/rc/b77f9d79-20261020T195335Z/ipk-none 95b0eb33f47313bfdb8cc07cd42c820318f4c03419edd3d68eec514fdee82b1e success 1792526088 1792526798 1792527453 655 +b77f9d79-20261020T195335Z ipk-upx-fast-best .ngit/act/workflows/build-package-ipk-upx-fast-best.yml refs/heads/release/v0.6.0-rc4/rc/b77f9d79-20261020T195335Z/ipk-upx-fast-best 659b3e6a0e0aa4872416722ee1a899718da0501b0d8d77f9ff1751836e82e844 failure 1792527471 1792527471 1792527535 64 +61433f5d-20261020T202100Z ipk-none .ngit/act/workflows/build-package-ipk-none.yml refs/heads/release/v0.6.0-rc4/rc/61433f5d-20261020T202100Z/ipk-none 40aaa2a2c00c346bf8df58ee25ca7632b54f21fcadcdc4966a56c4885a3b9098 success 1792527735 1792528491 1792529002 511 +61433f5d-20261020T202100Z ipk-upx-fast-best .ngit/act/workflows/build-package-ipk-upx-fast-best.yml refs/heads/release/v0.6.0-rc4/rc/61433f5d-20261020T202100Z/ipk-upx-fast-best 8465dfd9cf9236e0b4632ac79fcd5eafd01784a2a3d906e5b19f9e08e7b75dc5 failure 1792529033 1792529033 1792529254 221 +28d2dfc3-20261020T205550Z ipk-none .ngit/act/workflows/build-package-ipk-none.yml refs/heads/release/v0.6.0-rc4/rc/28d2dfc3-20261020T205550Z/ipk-none f8f7340ae49bee2f7b8126f4a2bcf0e3ce9d72201ad4d870cbbf7633eae55952 failure 1792529867 1792530047 1792530710 663 +2da570d1-20261020T211454Z ipk-none .ngit/act/workflows/build-package-ipk-none.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-none f852fb58ff5345ed28b08ff5c84d1a8a086ecc35051e0aeea6c53b1a97859287 success 1792530969 1792531454 1792532144 690 +2da570d1-20261020T211454Z ipk-upx-fast-best .ngit/act/workflows/build-package-ipk-upx-fast-best.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-fast-best 2cbda0f8eed3e121af076e43a70f2d6b6475fb15c6dee0e650282e5b2b9a0786 success 1792532167 1792532167 1792532283 116 +2da570d1-20261020T211454Z ipk-upx-brute-mips-24kc .ngit/act/workflows/build-package-ipk-upx-brute-mips-24kc.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-brute-mips-24kc 67a9cb7bf10a7c46e634523899527525b2d50d77883af1ec80be98432d8270f0 success 1792532293 1792532293 1792532538 245 +2da570d1-20261020T211454Z ipk-upx-ultrabrute-aarch64-cortex-a53 .ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a53.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-ultrabrute-aarch64-cortex-a53 458bf76a827940864aa85545f5cdbb432601e69a46d4540abc684fe7b5db0b2b success 1792532562 1792532562 1792532807 245 +2da570d1-20261020T211454Z ipk-upx-ultrabrute-aarch64-cortex-a72 .ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a72.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-ultrabrute-aarch64-cortex-a72 237f66e05373dd458753637a853035bdcca690c6badcb387f3c32893909d7438 success 1792532831 1792532831 1792533078 247 +2da570d1-20261020T211454Z ipk-upx-ultrabrute-arm-cortex-a7 .ngit/act/workflows/build-package-ipk-upx-ultrabrute-arm-cortex-a7.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-ultrabrute-arm-cortex-a7 89426c338eef3b2913009be003e0327ea31266b6191131166b4933aacc74b28e success 1792533101 1792533101 1792533360 259 +2da570d1-20261020T211454Z ipk-upx-ultrabrute-mipsel-24kc .ngit/act/workflows/build-package-ipk-upx-ultrabrute-mipsel-24kc.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-ultrabrute-mipsel-24kc 71f9a010160099b243009ba48e2760f46bb61ba4e973a6a9180b4b767d8889d9 success 1792533374 1792533374 1792533591 217 +2da570d1-20261020T211454Z ipk-upx-ultrabrute-mips-24kc .ngit/act/workflows/build-package-ipk-upx-ultrabrute-mips-24kc.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/ipk-upx-ultrabrute-mips-24kc 2e3e5b13a43884d721539e55c0ec2f2c3e0a96da04f63bab88f4d526d09ee228 success 1792533608 1792533608 1792533830 222 +2da570d1-20261020T211454Z apk-mediatek-filogic .ngit/act/workflows/build-package-apk-mediatek-filogic.yml refs/heads/release/v0.6.0-rc4/rc/2da570d1-20261020T211454Z/apk-mediatek-filogic d48bc5d3b02f2c2d725bfeecd2cb7b77d911b20b83eca24c88ca1cf55ee18e21 failure 1792533838 1792533838 1792533860 22 diff --git a/.ngit/act/workflows/build-package-announce.yml b/.ngit/act/workflows/build-package-announce.yml index b2f391fa3..83c45525d 100644 --- a/.ngit/act/workflows/build-package-announce.yml +++ b/.ngit/act/workflows/build-package-announce.yml @@ -36,13 +36,16 @@ on: env: PACKAGE_NAME: "tollgate-wrt" + # relay1.orangesync.tech removed 2026-10-20: it resolves to a dead IP + # (66.92.204.38) and every publish to it stalls/errs. relay2 (ours, + # uncapped) + damus carry the release history; nos.lol/nostr.mom are + # best-effort extras. COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: # -------------------------------------------------------------------------- diff --git a/.ngit/act/workflows/build-package-apk-mediatek-filogic-ultrabrute.yml b/.ngit/act/workflows/build-package-apk-mediatek-filogic-ultrabrute.yml index bbc9e78d6..413550e9b 100644 --- a/.ngit/act/workflows/build-package-apk-mediatek-filogic-ultrabrute.yml +++ b/.ngit/act/workflows/build-package-apk-mediatek-filogic-ultrabrute.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -286,10 +294,13 @@ jobs: run: | set -euo pipefail SDK="${{ matrix.sdk }}" + INPUTS="packaging/build-inputs.json" + [ -f "$INPUTS" ] || INPUTS="src-checkout/packaging/build-inputs.json" + [ -f "$INPUTS" ] || { echo "ERROR: packaging/build-inputs.json not found (root or src-checkout)" >&2; exit 1; } DIGEST=$(jq -r --arg sdk "$SDK" \ - '.openwrt_sdk.targets[$sdk].digest // empty' packaging/build-inputs.json) - [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in packaging/build-inputs.json" >&2; exit 1; } - RELEASE=$(jq -r '.openwrt_sdk.release' packaging/build-inputs.json) + '.openwrt_sdk.targets[$sdk].digest // empty' "$INPUTS") + [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in $INPUTS" >&2; exit 1; } + RELEASE=$(jq -r '.openwrt_sdk.release' "$INPUTS") echo "SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" >> "${GITHUB_ENV:-/dev/null}" echo "resolved SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" @@ -344,7 +355,7 @@ jobs: if [ "$USE_UPX" = "1" ]; then UPX_DIR=$(bash src-checkout/scripts/fetch-upx.sh) docker exec -i "$SVC" sh -c 'cat > /usr/local/bin/upx && chmod 0755 /usr/local/bin/upx' < "$UPX_DIR/upx" - docker exec "$SVC" upx --version | head -1 + docker exec "$SVC" upx --version 2>/dev/null | head -1 || true fi docker exec -e PACKAGE_VERSION="${{ needs.resolve-inputs.outputs.package_version }}" \ diff --git a/.ngit/act/workflows/build-package-apk-mediatek-filogic.yml b/.ngit/act/workflows/build-package-apk-mediatek-filogic.yml index f79a994c4..5a4b47140 100644 --- a/.ngit/act/workflows/build-package-apk-mediatek-filogic.yml +++ b/.ngit/act/workflows/build-package-apk-mediatek-filogic.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -286,10 +294,13 @@ jobs: run: | set -euo pipefail SDK="${{ matrix.sdk }}" + INPUTS="packaging/build-inputs.json" + [ -f "$INPUTS" ] || INPUTS="src-checkout/packaging/build-inputs.json" + [ -f "$INPUTS" ] || { echo "ERROR: packaging/build-inputs.json not found (root or src-checkout)" >&2; exit 1; } DIGEST=$(jq -r --arg sdk "$SDK" \ - '.openwrt_sdk.targets[$sdk].digest // empty' packaging/build-inputs.json) - [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in packaging/build-inputs.json" >&2; exit 1; } - RELEASE=$(jq -r '.openwrt_sdk.release' packaging/build-inputs.json) + '.openwrt_sdk.targets[$sdk].digest // empty' "$INPUTS") + [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in $INPUTS" >&2; exit 1; } + RELEASE=$(jq -r '.openwrt_sdk.release' "$INPUTS") echo "SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" >> "${GITHUB_ENV:-/dev/null}" echo "resolved SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" @@ -344,7 +355,7 @@ jobs: if [ "$USE_UPX" = "1" ]; then UPX_DIR=$(bash src-checkout/scripts/fetch-upx.sh) docker exec -i "$SVC" sh -c 'cat > /usr/local/bin/upx && chmod 0755 /usr/local/bin/upx' < "$UPX_DIR/upx" - docker exec "$SVC" upx --version | head -1 + docker exec "$SVC" upx --version 2>/dev/null | head -1 || true fi docker exec -e PACKAGE_VERSION="${{ needs.resolve-inputs.outputs.package_version }}" \ diff --git a/.ngit/act/workflows/build-package-apk-x86-64.yml b/.ngit/act/workflows/build-package-apk-x86-64.yml index deb262b17..d151a16d9 100644 --- a/.ngit/act/workflows/build-package-apk-x86-64.yml +++ b/.ngit/act/workflows/build-package-apk-x86-64.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -286,10 +294,13 @@ jobs: run: | set -euo pipefail SDK="${{ matrix.sdk }}" + INPUTS="packaging/build-inputs.json" + [ -f "$INPUTS" ] || INPUTS="src-checkout/packaging/build-inputs.json" + [ -f "$INPUTS" ] || { echo "ERROR: packaging/build-inputs.json not found (root or src-checkout)" >&2; exit 1; } DIGEST=$(jq -r --arg sdk "$SDK" \ - '.openwrt_sdk.targets[$sdk].digest // empty' packaging/build-inputs.json) - [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in packaging/build-inputs.json" >&2; exit 1; } - RELEASE=$(jq -r '.openwrt_sdk.release' packaging/build-inputs.json) + '.openwrt_sdk.targets[$sdk].digest // empty' "$INPUTS") + [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in $INPUTS" >&2; exit 1; } + RELEASE=$(jq -r '.openwrt_sdk.release' "$INPUTS") echo "SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" >> "${GITHUB_ENV:-/dev/null}" echo "resolved SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" @@ -344,7 +355,7 @@ jobs: if [ "$USE_UPX" = "1" ]; then UPX_DIR=$(bash src-checkout/scripts/fetch-upx.sh) docker exec -i "$SVC" sh -c 'cat > /usr/local/bin/upx && chmod 0755 /usr/local/bin/upx' < "$UPX_DIR/upx" - docker exec "$SVC" upx --version | head -1 + docker exec "$SVC" upx --version 2>/dev/null | head -1 || true fi docker exec -e PACKAGE_VERSION="${{ needs.resolve-inputs.outputs.package_version }}" \ diff --git a/.ngit/act/workflows/build-package-binaries.yml b/.ngit/act/workflows/build-package-binaries.yml index c885dd7a8..14ad1b91e 100644 --- a/.ngit/act/workflows/build-package-binaries.yml +++ b/.ngit/act/workflows/build-package-binaries.yml @@ -49,13 +49,12 @@ env: # primal.net, psbt.me, blossom2 and drive.cashu.email did, and with # BLOSSOM_MIN_SUCCESS=1 the dead mirror only added ~60 s of retries per # uploaded file. See .ngit/README.md. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech jobs: diff --git a/.ngit/act/workflows/build-package-ipk-none.yml b/.ngit/act/workflows/build-package-ipk-none.yml index 808d87271..4bf724ffd 100644 --- a/.ngit/act/workflows/build-package-ipk-none.yml +++ b/.ngit/act/workflows/build-package-ipk-none.yml @@ -59,15 +59,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -141,14 +143,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -297,7 +305,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-brute-mips-24kc.yml b/.ngit/act/workflows/build-package-ipk-upx-brute-mips-24kc.yml index d42e642e0..f251c71e6 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-brute-mips-24kc.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-brute-mips-24kc.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -287,7 +295,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-fast-best.yml b/.ngit/act/workflows/build-package-ipk-upx-fast-best.yml index c06ce7ede..65ad815dc 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-fast-best.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-fast-best.yml @@ -55,15 +55,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -137,14 +139,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -289,7 +297,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a53.yml b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a53.yml index 8e9db2a5e..60ca7cfce 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a53.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a53.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -287,7 +295,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a72.yml b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a72.yml index 6c4bfcd5a..a5fafa46c 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a72.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-aarch64-cortex-a72.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -287,7 +295,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-arm-cortex-a7.yml b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-arm-cortex-a7.yml index 23f6972f8..3e54a41d9 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-arm-cortex-a7.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-arm-cortex-a7.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -287,7 +295,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mips-24kc.yml b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mips-24kc.yml index 59c9f21ed..8b23b5009 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mips-24kc.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mips-24kc.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -287,7 +295,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mipsel-24kc.yml b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mipsel-24kc.yml index 74d893bf7..74683e482 100644 --- a/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mipsel-24kc.yml +++ b/.ngit/act/workflows/build-package-ipk-upx-ultrabrute-mipsel-24kc.yml @@ -54,15 +54,17 @@ env: # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" jobs: @@ -136,14 +138,20 @@ jobs: : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -287,7 +295,11 @@ jobs: # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f2f46b3..2a85c7295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,24 @@ and [Semantic Versioning](https://semver.org/). ### Fixed +- **The router no longer rate-limits its own localhost installer, so the + readable `starting` refusal wins again.** `registerAPIHandlers` wires + `RateLimitMiddleware` OUTSIDE `requireStarted`, so the per-IP limiter + (10 req/min, burst 10) ran before the startup gate: while the merchant was + being constructed the installer's 3 s poll of `127.0.0.1:2121` (~20 req/min) + tripped it, and the probe received `429 {"error":"rate limit exceeded"}` + instead of `503 {"code":"starting"}` — a correct install was reported as + "Setup failed" on 2026-10-16. Loopback callers are now exempt from the + limiter; every real client's budget is unchanged, the route ordering is + unchanged, and the exemption is decided from the CONNECTION, never from + `X-Real-Ip`/`X-Forwarded-For` — a header a caller controls must not be able + to mint an unlimited-rate bucket (the trust boundary of #808's loopback XFF + finding). Regression tests drive the real route table with the gate closed: + a loopback burst of 25 probes gets 25 starting refusals, `::1` is exempt, a + LAN caller past its budget still gets the 429, and neither a spoofed + forwarded header nor a forwarded header on a genuinely-local caller changes + the decision. ([#953](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/953)) + - **The rebrand gutter allows documented prose references to the commercial re-brand.** `tests/packaging/rebrand-literal-gutter_test.sh` was red on `main` (5 passed / 1 failed): the merged discovery-signaling decision diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c376595a..ff8a6f9e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,12 @@ cd tollgate-module-basic-go make go-battery ``` -`make go-battery` is the full gate across all 16 modules; the bare +For a release-oriented build (tag checkout, pinned toolchain, cold-cache +battery, package artifact), follow +[docs/build-from-source.md](docs/build-from-source.md) or run +`make manual-check`. + +`make go-battery` is the full gate across all modules; the bare `cd src && go build ./...` form covers only the root module. Go tooling that targets one module still runs from [src/](src/), not the repo root. The Go version is pinned in [src/go.mod](src/go.mod); CI installs diff --git a/Makefile b/Makefile index 39870292a..df5092d39 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ -.PHONY: portal-build reproducibility-test reproducibility-variance go-battery release-check release-check-fast +.PHONY: portal-build reproducibility-test reproducibility-variance go-battery release-check release-check-fast cold-battery manual-check package-ipk package-apk + +# Package target defaults. Override on the command line: +# make package-ipk ARCH=aarch64_cortex-a53 +# make package-apk SDK_TAG=mediatek-filogic-25.12.0 +ARCH ?= x86_64 +SDK_TAG ?= x86-64-25.12.0 +ARTIFACT_DIR ?= /tmp/tollgate-artifacts # Canonical pre-PR Go gate across ALL 16 modules (src/ is a multi-module # tree: `go ... ./...` from src/ alone covers only the root module). @@ -27,6 +34,31 @@ release-check-fast: portal-build: @bash packaging/portal-build.sh +# The user-facing build manual's executable check (docs/building-from-source.md): +# portal build + the all-module Go battery in a FRESH module cache. The cold +# cache is the gate that catches missing go.sum entries (#1036); a warm cache +# hides them. +manual-check: + @bash scripts/manual-build-check.sh + +# The cold-cache half of manual-check, for contributors who already have a +# portal build (or do not have the pinned Node installed). +cold-battery: + @bash scripts/manual-build-check.sh --battery-only + +# Full .ipk package (compiles binaries, stages the payload, packs it). +# portal-build is a prerequisite: a clean checkout has no built portal assets. +# The low-level packer (packaging/build-ipk.sh) expects a prepared payload and +# is deliberately NOT wrapped here. +package-ipk: portal-build + @ARCH=$(ARCH) bash packaging/local-build-ipk.sh + @ls -lh packaging/tollgate-wrt_*_$(ARCH).ipk | tail -1 + +# Full .apk package through the pinned OpenWrt SDK. SDK_TAG selects the target. +package-apk: portal-build + @SDK_TAG=$(SDK_TAG) PACKAGE_FORMAT=apk ARTIFACT_DIR=$(ARTIFACT_DIR) \ + bash scripts/build-sdk-package.sh + # Reproducibility check: build an artifact twice in independent clean roots # and require byte-identical output. T = binaries | portal | ipk | ipk-upx | apk # ARCH = x86_64 (default) | aarch64_cortex-a53 | arm_cortex-a7 | mips_24kc | diff --git a/README.md b/README.md index 4823c71da..53ce0f1d0 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,31 @@ Source lives under [src/](src/). Go tooling runs from there | [cli](src/cli/) | `tollgate` CLI for service control, wallet, private network, upstream Wi-Fi, config, health, admin-UI links, and SSL/TLS certificates. Entry point: [src/cmd/tollgate-cli](src/cmd/tollgate-cli/). See [docs/operator-guide.md](docs/operator-guide.md). | | [tollgate_protocol](src/tollgate_protocol/) | Wire-type definitions shared across modules. | +## Building from source + +For a release build, **checkout the release tag** (`git checkout v0.6.0`) — a +fresh clone lands on `main`, which is development and may carry dependency +changes that have not been through the release path. The tested build manual, +including the cold-cache check that catches missing `go.sum` entries, is +[docs/build-from-source.md](docs/build-from-source.md). Quick check: + +```sh +make manual-check # portal + all-module Go battery in a fresh module cache +``` + ## Installation -Build artifacts produced by the CI matrix in +> **Build setup status (October 2026):** we’re sorry the build setup is messy +> at the moment. GitHub removed CI from all OpenTollGate repositories; we are +> rebuilding the pipeline and working on reproducible builds. For a normal +> install, use the +> [installer runbook](https://github.com/OpenTollGate/tollgate-installer/blob/main/docs/install-runbook.md). + +The build workflow definition in [.github/workflows/build-package.yml](.github/workflows/build-package.yml) -target both `apk` (OpenWrt 25.x) and `ipk` (OpenWrt ≤24.10) formats. +targets both `apk` (OpenWrt 25.x) and `ipk` (OpenWrt ≤24.10) formats. The +GitHub Actions lane is currently being rebuilt; use the release assets or the +installer runbook above until it is restored. On OpenWrt 25.x: ```sh diff --git a/changelog.d/1043-build-from-source-manual.added.md b/changelog.d/1043-build-from-source-manual.added.md new file mode 100644 index 000000000..2b22ceab0 --- /dev/null +++ b/changelog.d/1043-build-from-source-manual.added.md @@ -0,0 +1,6 @@ +- **Build-from-source manual, package targets, and cold-cache gate.** Adds + `docs/build-from-source.md` (tag-vs-`main`, installer/runbook and feedback + links, CI/reproducible-builds status, package instructions), `make + manual-check`/`cold-battery`, `make package-ipk`/`package-apk`, and the SDK + `tollgate-cvm` staging fix that makes the `.apk` target work + ([#1043](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/1043)). diff --git a/changelog.d/951-session-event-p-tag.fixed.md b/changelog.d/951-session-event-p-tag.fixed.md new file mode 100644 index 000000000..0b38ec827 --- /dev/null +++ b/changelog.d/951-session-event-p-tag.fixed.md @@ -0,0 +1,10 @@ +- **Session events no longer put the customer's MAC address in the `p` + tag.** The kind-1022 event every successful payment returns carried the + MAC in a field whose contract says Nostr pubkey — spec-invalid for every + conformant consumer, and invisible to relay-side `#p` filtering. The + raw-token flow has no customer Nostr identity, so the tag is now omitted + (the spec makes it conditional; the MAC stays where it belongs, in + `device-identifier`). Wire-visible to any consumer that validated `p`; + the renewal helper no longer propagates a legacy MAC into the field + either. The same shape on error notices is tracked separately in #950. + ([#951](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/951)) diff --git a/changelog.d/954-loopback-rate-limit-exempt.fixed.md b/changelog.d/954-loopback-rate-limit-exempt.fixed.md new file mode 100644 index 000000000..cb171a633 --- /dev/null +++ b/changelog.d/954-loopback-rate-limit-exempt.fixed.md @@ -0,0 +1,19 @@ +- **The router no longer rate-limits its own localhost installer, so the + readable `starting` refusal wins again.** `registerAPIHandlers` wires + `RateLimitMiddleware` outside `requireStarted`, so the per-IP limiter + (10 req/min, burst 10) ran before the startup gate: while the merchant was + being constructed the installer's 3 s poll of `127.0.0.1:2121` (~20 req/min) + tripped it, and the probe received `429 {"error":"rate limit exceeded"}` + instead of `503 {"code":"starting"}` — a correct install was reported as + "Setup failed" on 2026-10-16. Loopback callers are now exempt from the + limiter; every real client's budget is unchanged, the route ordering is + unchanged, and the exemption is decided from the CONNECTION, never from + `X-Real-Ip`/`X-Forwarded-For` — a header a caller controls must not be able + to mint an unlimited-rate bucket (the trust boundary of #808's loopback XFF + finding). Regression tests drive the real route table with the gate closed: + a loopback burst of 25 probes gets 25 starting refusals, `::1` is exempt, a + LAN caller past its budget still gets the 429, and neither a spoofed + forwarded header nor a forwarded header on a genuinely-local caller changes + the decision (#953; installer-side workaround shipped in tollgate-installer + rc33) + ([#954](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/954)). diff --git a/changelog.d/962-postinst-route-preserve.fixed.md b/changelog.d/962-postinst-route-preserve.fixed.md new file mode 100644 index 000000000..07505b84c --- /dev/null +++ b/changelog.d/962-postinst-route-preserve.fixed.md @@ -0,0 +1,20 @@ +- **The installer no longer wipes manually-configured default routes on + statically-routed seats (#962).** The postinst's + `/etc/init.d/network restart` (run so uci-defaults apply to a running + router) re-applies UCI-backed routes only: a default route added by hand — + the statically-routed bench/CI seat pattern, with no DHCP WAN and no uci + gateway — was silently wiped the moment the installer printed + "Restarting network...", killing egress after install. Reproduced live on + the vlab seat: runtime `default via 10.99.99.2 dev br-lan`, postinst, + `ip route show default` empty. + + Both postinst recipes (the standalone `packaging/postinst` and the inline + heredoc in `packaging/Makefile` that the feed's package mirrors verbatim) now + snapshot the default routes before the first restart and re-add whatever the + restart(s) did not bring back — idempotently, so a route netifd re-installed + identically is not duplicated. The Makefile's restore runs after the + recheck_private_subnet block, which may restart the network a second time. + Pinned offline by `tests/packaging/postinst-default-route-preserve_test.sh` + (contract greps on both recipes + a functional run of the restore against a + mocked `ip`), and validated live on the vlab seat: the same postinst flow + that wiped the route before the fix leaves it in place after. diff --git a/changelog.d/980-f3-11317-ptag.fixed.md b/changelog.d/980-f3-11317-ptag.fixed.md new file mode 100644 index 000000000..9ce0634cc --- /dev/null +++ b/changelog.d/980-f3-11317-ptag.fixed.md @@ -0,0 +1,8 @@ +- **The CVM tools-list announcement is now dashboard-indexable.** The kind-11317 + event published the tollgate's tool surface with no `p` tag, so a maintainer + dashboard's `#p` filter — which finds every router via the kind-11316 + announcement — could never index the tools list. 11317 now carries the + maintainer pubkey as hex (the form relays index), omitted when the router is + unclaimed, exactly like 11317's 11316 sibling (#980 F3, found by the #971 + vlab E2E) + ([#983](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/983)). diff --git a/changelog.d/986-announce-publishmany.internal.md b/changelog.d/986-announce-publishmany.internal.md new file mode 100644 index 000000000..883b5bcac --- /dev/null +++ b/changelog.d/986-announce-publishmany.internal.md @@ -0,0 +1,10 @@ +- **A dead relay can no longer fake a successful CVM announcement.** The + control-plane announce path drained `PublishMany` with `_ = result` and + always returned nil, so announcing into an unreachable relay still logged + "announced 11316/11317" — for a control plane whose liveness is the + product, a false-success heartbeat. Refusals are now logged per relay, + and zero acceptance for either announcement is an error the daemon + reports (the periodic re-announce is the retry); the policy is pinned by + tests through an injectable publisher seam (#980 F5, found by the #971 + vlab E2E) + ([#986](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/986)). diff --git a/docs/build-from-source.md b/docs/build-from-source.md new file mode 100644 index 000000000..0fb7e3481 --- /dev/null +++ b/docs/build-from-source.md @@ -0,0 +1,276 @@ +# Building TollGate from source + +This is the user-facing build manual. It covers **which ref to build**, how to +build the portal and the package, and how to prove the tree builds from a cold +module cache. For the pinning/reproducibility design, see +[reproducible-builds.md](reproducible-builds.md). + +## Build setup status (October 2026) + +**We’re sorry the build setup is messy at the moment.** GitHub removed CI from +all repositories under [github.com/OpenTollGate](https://github.com/OpenTollGate/), +so the automated build/release lanes that used to carry this work are not +currently running there. We are rebuilding the CI pipeline and working on +reproducible builds at the same time. + +Until that work lands: + +- **For a normal install, use the installer runbook:** + https://github.com/OpenTollGate/tollgate-installer/blob/main/docs/install-runbook.md + (installer repo: https://github.com/OpenTollGate/tollgate-installer). +- For a source build, follow this manual and expect some rough edges. The + cold-cache check below is the minimum evidence to include in a bug report. +- If you hit a failure, include the exact ref/commit, the command, and the + first error; “build is broken” without the ref is not reproducible. + +## Just want a package for a router? + +You do not need to build from source. Use the release artifacts: + +- **Feed releases (signed `.apk`/`.ipk` + `SHA256SUMS`):** + https://github.com/FreedomTechFeed/packages/releases +- **Feed recipe / supported targets:** https://github.com/FreedomTechFeed/packages + (see `TARGETS.md` for the per-device matrix) +- **Installer wizard (recommended install path):** + https://github.com/OpenTollGate/tollgate-installer + +This manual is for reproducing or customizing a build, not for a normal +install. + +| Goal | Where | +|---|---| +| Install on a router | feed releases + installer | +| Check supported devices/targets | feed `TARGETS.md` | +| Build or verify from source | this manual (`tollgate-module-basic-go`) | +| Rebuild a published artifact | the feed release tag + this manual | +| Read the OpenWrt recipe / release workflow | feed `net/tollgate-wrt/` | + +## Release build vs `main` + +**For a release, build the tag, not `main`.** + +```sh +git clone --branch v0.6.0 --depth 1 \ + https://github.com/OpenTollGate/tollgate-module-basic-go.git +cd tollgate-module-basic-go +``` + +`main` is development. It can carry dependency changes that have not been +through the release path yet, and it is the default branch a fresh clone +checks out. If a build fails on `main`, first confirm you are not on a tag: + +```sh +git describe --tags --exact-match 2>/dev/null || echo "not on a tag" +``` + +## CI status and reproducible builds + +The current CI needs an overhaul. Warm module caches masked the +[#1036](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/1036) +`go.sum` gap: cached lanes never re-resolved a module that was already +downloaded, while a cold user checkout failed immediately. The cold-cache +check below is the interim gate until the CI lanes are rebuilt around a clean +module cache. + +Reproducible builds are an active workstream. The pinned inputs and the +current guarantees are in [reproducible-builds.md](reproducible-builds.md); +this manual documents what is tested today, not a claim that every CI lane is +already cold-cache clean. + +## Prerequisites + +The pinned versions live in +[`packaging/build-inputs.json`](../packaging/build-inputs.json) and are loaded +by `packaging/build-env.sh`; do not guess them. + +| Tool | Source | +|---|---| +| Go `1.26.8` | `packaging/build-inputs.json` `.go.version` | +| Node `22.17.0` / npm `10.9.2` | `.node.version` / `.npm.version` (portal build) | +| jq | required by `build-env.sh` | +| OpenWrt SDK + Docker | `.apk` package path (`scripts/build-sdk-package.sh`) | + +If you keep the pinned toolchains outside `PATH`, point `TG_TOOLS` at a tree +containing `go/bin` and `node/bin`; `build-env.sh` prepends them. + +```sh +export TG_TOOLS=$HOME/tollgate-tools # optional +``` + +## Targets: what builds what + +| Target | What it does | Produces a package? | +|---|---|---| +| `make portal-build` | builds/stages the captive-portal + admin SPA assets | no | +| `make go-battery` | `gofmt`/`vet`/`build`/`test -race` across every module | no | +| `make cold-battery` | the same battery with a fresh module cache | no | +| `make manual-check` | portal build + cold battery | only with `MANUAL_CHECK_PACKAGE=1` (then `.ipk`) | +| `make release-check` | release gates (battery, contracts, packaging tests, repro) | no | +| `make package-ipk` | full `.ipk` via `packaging/local-build-ipk.sh` | **yes** | +| `make package-apk` | `.apk` via the pinned OpenWrt SDK | **yes** | + +`make portal-build` is **not** a package build. It only stages the portal and +admin assets that the package embeds; `packaging/local-build-ipk.sh` refuses to +run without them. The two package targets depend on `portal-build` (and +therefore on the pinned Node/npm). If you already have current portal assets +and do not want to rebuild them, the underlying scripts are the no-rebuild +path. + +`packaging/build-ipk.sh` is the low-level packer (payload + `PKG_*` metadata); +it is not the release builder. Use `package-ipk` for a release. + +## The quick check + +On `main`, the one command that runs the sequence below is: + +```sh +make manual-check +``` + +It checks the pinned Go, builds the portal with the pinned Node/npm, then runs +the Go battery in a **fresh** `GOMODCACHE`/`GOPATH`. To include the `.ipk`: + +```sh +MANUAL_CHECK_PACKAGE=1 ARCH=x86_64 make manual-check +``` + +To skip the portal (for example when the pinned Node is not installed): + +```sh +make cold-battery +``` + +On older tags the `manual-check`/`cold-battery` targets may not exist. Use the +explicit commands below; they are the same steps. + +## Step 1 — portal assets + +```sh +make portal-build +``` + +`packaging/portal-build.sh` refuses a Node/npm that does not match the pins. +It records the resolved portal SHA and tool versions in +`packaging/portal-build-inputs.json`. + +## Step 2 — the all-module Go battery + +`src/` is a multi-module Go tree (16 modules on `v0.6.0`, 18 on current +`main`); `cd src && go build ./...` covers only the root module. The canonical +gate is: + +```sh +make go-battery +``` + +That runs `gofmt` (must print nothing), `go vet`, `go build`, and +`go test -race -count=1 -tags testenv` in **every** nested module. + +## Step 3 — prove it from a cold cache + +A warm module cache can hide a missing `go.sum` entry: a module that is already +downloaded is not re-resolved. Run the battery with fresh caches: + +```sh +GOMODCACHE=$(mktemp -d) GOPATH=$(mktemp -d) make go-battery +``` + +On `main` this is `make cold-battery`. This is the check that would have caught +[#1036](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/1036). + +## Step 4 — build a package + +The package targets are the supported way: + +```sh +# .ipk (OpenWrt 24.10 and earlier); default ARCH is x86_64 +make package-ipk ARCH=x86_64 + +# .apk (OpenWrt 25.12+) through the pinned SDK +make package-apk SDK_TAG=x86-64-25.12.0 ARTIFACT_DIR=/tmp/tollgate-artifacts +``` + +Underlying commands, for reference: + +```sh +ARCH=x86_64 bash packaging/local-build-ipk.sh +# → packaging/tollgate-wrt__x86_64.ipk + +SDK_TAG=x86-64-25.12.0 PACKAGE_FORMAT=apk ARTIFACT_DIR=/tmp/tollgate-artifacts \ + bash scripts/build-sdk-package.sh +``` + +`SDK_TAG` encodes the target/OS line; the script infers `EXPECTED_ARCH` and +`GOARCH` from it. Supported values are in +[reproducible-builds.md](reproducible-builds.md) and +`packaging/build-inputs.json`. + +## Step 5 — verify what you built + +- The package version in the filename/control must match the release tag's + `VERSION`. +- On a router, `tollgate version` reports the module build (it is not the feed + tag); `apk list -I` / `opkg list-installed` reports the package version. +- For a **published** release asset, verify against the feed release's signed + `SHA256SUMS`: + +```sh +sha256sum -c SHA256SUMS +``` + +Do not invent a digest or skip the check because a warm build passed locally. + +## Troubleshooting + +### `missing go.sum entry for module providing package … btcutil/bech32` + +This is the #1036 failure. It is a **`main` cold-build gap**, not a broken +`v0.6.0` release: + +- `go-nostr/nip19` imports `btcsuite/btcd/btcutil/bech32`; +- two nested modules (`upstream_detector`, `upstream_session_manager`) were + missing the transitive `go.sum` entries on `main`; +- #1013 fixed `merchant_types`; #1032 fixes the remaining two; +- the `v0.6.0` tag predates the import and cold-builds clean. + +Workaround on a `main` checkout before #1032: + +```sh +cd src/upstream_detector && go get github.com/nbd-wtf/go-nostr/nip19@v0.51.12 +cd ../upstream_session_manager && go get github.com/nbd-wtf/go-nostr/nip19@v0.51.12 +``` + +Or build the release tag. Do **not** use `go mod tidy` as a shortcut; it +cascades unrelated dependency drift (#1032 deliberately avoids it). + +### `node … != pinned v22.17.0` / `npm … != pinned 10.9.2` + +Install the pinned Node/npm and put them under `TG_TOOLS/node/bin`, or run +`make cold-battery` if you are only validating the Go tree. + +### `go … != pinned 1.26.8` + +Install the pinned toolchain from `packaging/build-inputs.json`. A newer local +Go is not a substitute for a release build. + +## Tested at + +| Ref | Commit | Command | Result | +|---|---|---|---| +| `v0.6.0` | `9364f2e` | cold `make go-battery` | PASS — 16 modules | +| `main` post-#1032 | `4e55543` | cold `make cold-battery` | battery PASS — 18 modules; cleanup trap fixed in this PR | +| PR branch | `5e27c28` | `TG_TOOLS=… make manual-check` | PASS — portal + 18 modules, clean cache cleanup | +| PR branch | `5e27c28` | `make package-ipk ARCH=x86_64` | PASS — `.ipk`, 8.5 MB | +| PR branch | `089f262` | `make package-apk SDK_TAG=x86-64-25.12.0` | PASS after the CVM staging fix — `.apk`, 12.6 MB | +| `main` pre-#1032 | `9ed8687` | cold build of the two modules | expected #1036 failure | + +When you change the pins or the build path, update this table and the +[reproducible-builds.md](reproducible-builds.md) manifest together. + +## Optional Ansible wrapper + +The lab uses +`tollgate-infrastructure-kit/ansible/playbooks/53-tollgate-build.yml` to run the +same checks on a clean builder. It calls `make manual-check` / +`make cold-battery`; the Make targets remain the contract. Ansible is not +required for a contributor build. diff --git a/docs/release-process.md b/docs/release-process.md index 7770fe41c..cf4210dfd 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -14,6 +14,11 @@ stops there: Everything before those gates can be prepared and verified by any contributor. +The tested source-build sequence (tag checkout, pinned toolchains, cold-cache +battery, package artifact) is in +[build-from-source.md](build-from-source.md). Use `make manual-check` +before treating a tree as buildable. + ## Version single source of truth The release version is written down in exactly one place: the diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md index b89248c8c..6bca97d05 100644 --- a/docs/reproducible-builds.md +++ b/docs/reproducible-builds.md @@ -1,5 +1,9 @@ # Reproducible builds +> For the step-by-step source/release build (including the cold-cache gate +> and the #1036 troubleshooting), see +> [build-from-source.md](build-from-source.md). + ## Two paths, one truth (the parallel-build doctrine) This repo deliberately maintains TWO build paths and gates them against diff --git a/packaging/Makefile b/packaging/Makefile index 47802d890..cf2e4b4e6 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -148,6 +148,21 @@ for script in /etc/uci-defaults/90-tollgate-captive-portal-symlink \ done echo "Restarting network..." +# A full netifd restart re-applies UCI-backed routes only. Default routes that +# were added by hand (statically-routed bench/CI seats: no DHCP WAN, no uci +# gateway) are silently wiped, which kills egress right after the install. +# Snapshot them here; restore after the LAST restart below (the recheck block +# may restart again). The restore is idempotent: a route netifd re-installed +# identically is not re-added. Pinned by tests/packaging/postinst-default-route-preserve_test.sh. +_pre_restart_defroutes="$$(ip route show default 2>/dev/null)" +restore_default_routes() { + [ -n "$$1" ] || return 0 + echo "$$1" | while IFS= read -r route; do + [ -n "$$route" ] || continue + ip route show default 2>/dev/null | grep -qxF "$$route" || \ + ip route add $$route 2>/dev/null || true + done +} /etc/init.d/network restart 2>/dev/null || true wait_for_iface br-lan wait_for_iface br-private || echo "Note: br-private not found — this is expected on devices without a private network configured." @@ -162,6 +177,7 @@ if [ -x /etc/uci-defaults/99-tollgate-setup ]; then recheck_private_subnet && /etc/init.d/network restart 2>/dev/null || true fi ) fi +restore_default_routes "$$_pre_restart_defroutes" echo "Reloading remaining services..." wifi reload 2>/dev/null || true diff --git a/packaging/build-inputs.json b/packaging/build-inputs.json index b70e04074..01f5d3d7d 100644 --- a/packaging/build-inputs.json +++ b/packaging/build-inputs.json @@ -2,11 +2,11 @@ "schema": 1, "comment": "Immutable build inputs for reproducible tollgate-wrt artifacts. Every byte-affecting external input is pinned here. Update intentionally (see docs/reproducible-builds.md); never point portal.commit at a floating ref.", "go": { - "version": "1.26.9", + "version": "1.26.10", "comment": "The toolchain that compiles the shipped binaries. SOURCE OF TRUTH: the official golang of the pinned OpenWrt SDK release's packages feed (see .openwrt_sdk.go_per_release) — the feed defines it, so our binaries are reproducible against the SDK's own toolchain; never a minor of our own choosing (#448). scripts/sdk-go-version.sh check enforces it against the live feeds; update re-derives it.", "tarball_linux_amd64": { - "url": "https://go.dev/dl/go1.26.9.linux-amd64.tar.gz", - "sha256": "42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" + "url": "https://go.dev/dl/go1.26.10.linux-amd64.tar.gz", + "sha256": "ccfe7ae822d2c2f41f4fe7c956199967a4dac22d0ee0d2b2bfdb440aab3b20c5" } }, "gonuts": { @@ -42,7 +42,7 @@ "_comment": "Official Go toolchain of each OpenWrt release line, as shipped by that line's openwrt/packages feed (branch openwrt-). scripts/sdk-go-version.sh check|update audits these against the live feed; the pinned .go.version must match the entry of the pinned .release's series. In-SDK Go builds on 23.05/24.10 additionally require src/go.mod's language minimum to be <= these versions (currently 1.25.0, so only 25.x qualifies).", "23.05": "1.21.13", "24.10": "1.23.12", - "25.12": "1.26.9" + "25.12": "1.26.10" }, "releases": { "apk": { diff --git a/packaging/postinst b/packaging/postinst index eb7f6fd35..f268dc46f 100755 --- a/packaging/postinst +++ b/packaging/postinst @@ -29,10 +29,39 @@ for script in /etc/uci-defaults/90-tollgate-captive-portal-symlink \ done echo "Restarting network..." +# A full netifd restart re-applies UCI-backed routes only. Default routes that +# were added by hand (statically-routed bench/CI seats: no DHCP WAN, no uci +# gateway) are silently wiped, which kills egress right after the install. +# Snapshot them here; restore_default_routes (below) re-adds whatever the +# restart did not bring back. Pinned by tests/packaging/postinst-default-route-preserve_test.sh. +_pre_restart_defroutes="$(ip route show default 2>/dev/null)" +restore_default_routes() { + [ -n "$1" ] || return 0 + echo "$1" | while IFS= read -r route; do + [ -n "$route" ] || continue + ip route show default 2>/dev/null | grep -qxF "$route" || \ + ip route add $route 2>/dev/null || true + done +} /etc/init.d/network restart 2>/dev/null || true wait_for_iface br-lan wait_for_iface br-private || echo "Note: br-private not found — this is expected on devices without a private network configured." +# The uplink lease can arrive only AFTER the restart above, so the private subnet +# chosen at uci-defaults time may now collide with the upstream — a router that +# came up with 192.168.1.0/24 on both sides has no working private network. The +# feed recipe (packaging/Makefile) has carried this recheck, and its second +# restart, since #926; this standalone recipe did not, so a seat installed from +# it kept a colliding private subnet. Keep the two recipes structurally equal: +# this is the same second restart the restore below must sit after. +if [ -x /etc/uci-defaults/99-tollgate-setup ]; then + ( TOLLGATE_SETUP_LIB_ONLY=1 . /etc/uci-defaults/99-tollgate-setup 2>/dev/null + recheck_private_subnet && /etc/init.d/network restart 2>/dev/null || true + ) +fi + +restore_default_routes "$_pre_restart_defroutes" + echo "Reloading remaining services..." wifi reload 2>/dev/null || true /etc/init.d/firewall reload 2>/dev/null || true diff --git a/scripts/build-sdk-package.sh b/scripts/build-sdk-package.sh index 36f026701..c7b194dc4 100755 --- a/scripts/build-sdk-package.sh +++ b/scripts/build-sdk-package.sh @@ -153,6 +153,19 @@ printf '%s\n' 'Building target binaries locally before invoking the OpenWrt SDK. "$GO_BIN" build -o "$STAGE_DIR/tollgate" -trimpath -buildvcs=false -ldflags="$CLI_LDFLAGS" ) +# The CVM control-plane daemon is its own module. Build it when the pinned +# tree carries it (main does; older tags predate it), matching the feed's +# presence-conditional CVM stanza. No version ldflags: the daemon's identity +# travels in its CEP-6 announcement pubkey, and packaging/Makefile stages it +# the same way as packaging/local-build-ipk.sh. +if [ -d "$REPO_ROOT/src/cmd/tollgate-cvm" ]; then + ( + cd "$REPO_ROOT/src/cmd/tollgate-cvm" + env CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" GOMIPS="$GOMIPS" GOARM="$GOARM" \ + "$GO_BIN" build -o "$STAGE_DIR/tollgate-cvm" -trimpath -buildvcs=false . + ) +fi + cp -r "$REPO_ROOT/packaging/." "$STAGE_DIR/" cp "$REPO_ROOT/LICENSE" "$STAGE_DIR/LICENSE" diff --git a/scripts/fetch-upx.sh b/scripts/fetch-upx.sh index 61874d844..7823f5121 100644 --- a/scripts/fetch-upx.sh +++ b/scripts/fetch-upx.sh @@ -19,6 +19,11 @@ if [ -x "$BIN" ] && [ "$("$BIN" --version 2>/dev/null | head -1)" = "upx $UPX_VE printf '%s\n' "$DEST" exit 0 fi +# NOTE: every `upx --version | head -1` below is SIGPIPE-armoured: under +# `set -e`, upx dying with SIGPIPE (exit 141) when head exits first made the +# whole install step fail intermittently (live: shard ipk-upx-fast-best, +# 2026-10-20). `{ ... ; true; }` and `|| true` keep the pipeline's status +# from ever being upx's writer-side signal death. mkdir -p "$DEST" TMP="$(mktemp -d)" @@ -28,5 +33,9 @@ printf '%s %s\n' "$SHA" "$TMP/upx.tar.xz" | sha256sum -c - >&2 tar xJf "$TMP/upx.tar.xz" -C "$TMP" cp "$TMP"/upx-"$UPX_VERSION"-amd64_linux/upx "$BIN" chmod +x "$BIN" -"$BIN" --version | head -1 >&2 +# `|| true` — NOT `{ pipe; true; }`: under `set -e` the shell exits at the +# pipeline's own failure and the trailing true never runs (proven: yes|head +# in { } form still exits 141). upx dying with SIGPIPE when head exits first +# killed the Install UPX step 3x live (shard ipk-upx-fast-best, 2026-10-20). +"$BIN" --version 2>/dev/null | head -1 >&2 || true printf '%s\n' "$DEST" diff --git a/scripts/manual-build-check.sh b/scripts/manual-build-check.sh new file mode 100644 index 000000000..4ba465b05 --- /dev/null +++ b/scripts/manual-build-check.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# manual-build-check — execute docs/building-from-source.md from a clean cache. +# +# This is deliberately the same sequence the manual tells a human to run: +# portal build (unless skipped) -> all-module Go battery in a FRESH module +# cache -> optional package build. +# +# The fresh GOMODCACHE/GOPATH is the point: a warm cache hides missing go.sum +# entries, which is how #1036 reached a user while cached CI stayed green. +# +# Usage: +# scripts/manual-build-check.sh # portal + cold battery +# scripts/manual-build-check.sh --battery-only # cold battery only +# scripts/manual-build-check.sh --no-portal # same as --battery-only +# MANUAL_CHECK_PACKAGE=1 ARCH=x86_64 scripts/manual-build-check.sh +# +# Prerequisites: the pinned Go toolchain (packaging/build-inputs.json), jq, +# and for the default path the pinned Node/npm. The package step additionally +# needs the OpenWrt SDK/docker path used by packaging/local-build-ipk.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +BATTERY_ONLY=0 +BUILD_PACKAGE="${MANUAL_CHECK_PACKAGE:-0}" +for arg in "$@"; do + case "$arg" in + --battery-only|--no-portal) BATTERY_ONLY=1 ;; + --package) BUILD_PACKAGE=1 ;; + -h|--help) + sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "ERROR: unknown option: $arg" >&2; exit 2 ;; + esac +done + +# Reuse the canonical pin loader. TG_ROOT is pre-set so sourcing from this +# script cannot derive the wrong root. +TG_ROOT="$ROOT" . "$ROOT/packaging/build-env.sh" + +active_go="$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//')" +if [ "$active_go" != "$GO_VERSION" ]; then + echo "ERROR: go ${active_go:-missing} != pinned $GO_VERSION (packaging/build-inputs.json)" >&2 + echo " install the pinned toolchain or put it under TG_TOOLS/go/bin" >&2 + exit 1 +fi +echo "toolchain: go $active_go (pinned), jq $(jq --version 2>/dev/null || echo missing)" + +if [ "$BATTERY_ONLY" = 0 ]; then + active_node="$(node --version 2>/dev/null || true)" + active_npm="$(npm --version 2>/dev/null || true)" + if [ "$active_node" != "v$NODE_VERSION" ] || [ "$active_npm" != "$NPM_VERSION" ]; then + echo "ERROR: node ${active_node:-missing}/npm ${active_npm:-missing} != pinned v$NODE_VERSION/$NPM_VERSION" >&2 + echo " install the pinned Node/npm, put them under TG_TOOLS/node/bin," >&2 + echo " or re-run with --battery-only to skip the portal build" >&2 + exit 1 + fi + echo "portal: building with node $active_node / npm $active_npm" + bash packaging/portal-build.sh +fi + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/tollgate-manual-check.XXXXXX")" +# Go's module cache is read-only (dirs 0555, files 0444). A plain rm -rf +# cannot descend into it; make the tree writable first, and never let cleanup +# mask a successful battery with a permission error. +cleanup() { + chmod -R u+w "$tmp" 2>/dev/null || true + rm -rf "$tmp" 2>/dev/null || true +} +trap cleanup EXIT +export GOMODCACHE="$tmp/gomod" +export GOPATH="$tmp/gopath" +export GOTOOLCHAIN=local +mkdir -p "$GOMODCACHE" "$GOPATH" +echo "cold caches: GOMODCACHE=$GOMODCACHE GOPATH=$GOPATH" + +bash scripts/go-battery.sh + +if [ "$BUILD_PACKAGE" = 1 ]; then + ARCH="${ARCH:-x86_64}" + echo "building ipk for ARCH=$ARCH" + ARCH="$ARCH" bash packaging/local-build-ipk.sh + ls -lh packaging/tollgate-wrt_*_"$ARCH".ipk 2>/dev/null | tail -3 || true +fi + +echo +echo "MANUAL-CHECK-PASS" +echo "Build a package with: make package-ipk ARCH=" +echo " or: make package-apk SDK_TAG=" diff --git a/scripts/ngit-ci-release.sh b/scripts/ngit-ci-release.sh index 1b10088cb..294cb4043 100755 --- a/scripts/ngit-ci-release.sh +++ b/scripts/ngit-ci-release.sh @@ -189,14 +189,18 @@ wait_for_result() { # ev=$(fetch_result "$wf" "$ref") if [ -n "$ev" ]; then conclusion=$(record_result "$shard" "$wf" "$ref" "$ev") - echo " [$shard] conclusion=$conclusion d=$(($(printf '%s' "$ev" | jq -r '.created_at') - $(tag_of "$ev" started_at)))s" + echo " [$shard] conclusion=$conclusion d=$(($(printf '%s' "$ev" | jq -r '.created_at') - $(tag_of "$ev" started_at)))s" >&2 printf '%s' "$conclusion" return 0 fi sleep 20 waited=$((waited + 20)) if [ $((waited % 120)) -eq 0 ]; then - echo " [$shard] still running (${waited}s / ${POLL_TIMEOUT}s)" + # Progress goes to STDERR: stdout of this function is the conclusion + # string captured by the caller ($(wait_for_result ...)), and progress + # on stdout pollutes it ("still running...\nsuccess" != "success" + # aborted a green shard as failed, 2026-10-20). + echo " [$shard] still running (${waited}s / ${POLL_TIMEOUT}s)" >&2 fi done echo "ERROR: no kind-9842 result for $shard after ${POLL_TIMEOUT}s" >&2 diff --git a/scripts/ngit-gen-shards.py b/scripts/ngit-gen-shards.py index 0b4f931ad..3d202dca0 100755 --- a/scripts/ngit-gen-shards.py +++ b/scripts/ngit-gen-shards.py @@ -99,17 +99,23 @@ # unreachable from a job container (25 s connect timeout) while the other four # answer, and BLOSSOM_MIN_SUCCESS=1 means the dead mirror only bought ~60 s of # retries per uploaded file. - BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech https://drive.cashu.email" + # drive.cashu.email dropped 2026-10-20: it started returning + # "402 Payment required for files over 1 MiB" for every artifact upload, + # which can never satisfy a mirror-success threshold; keep 3 free mirrors. + BLOSSOM_SERVERS: "https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech" BLOSSOM_MIN_SUCCESS: "1" COORDINATION_RELAYS: >- wss://relay.damus.io wss://nos.lol wss://nostr.mom - wss://relay1.orangesync.tech wss://relay2.orangesync.tech - RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech" + RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech" """ +# relay1.orangesync.tech dropped from the template 2026-10-20: it resolves to a +# dead IP (66.92.204.38); every 30078/1063 publish to it stalls. Regenerate +# shards after merging so the committed copies drop it too. + # The package-job epoch env is a ${{ ... }} workflow expression, so it must # never live inside the package-job f-string: f-strings collapse {{ to {, # which is how the single-brace form reached the committed shards in #441. @@ -189,14 +195,20 @@ : ${GITHUB_OUTPUT:=/tmp/github_output} # Returns the newest event line for the addressable record . + # 40 x 30s = ~20 min of patience: stage-1 (build-package-binaries) + # takes 12-15 min on the coordinator, and when a release run starts + # right after a mirror push the shard can race stage-1 end-to-end. + # 20 attempts (~10 min) lost that race live (ipk-none, run + # 28d2dfc3-20261020T205550Z: gave up at 10m44s while stage-1's + # portal record landed 6 min later). fetch_record() { local d="$1" out - for attempt in $(seq 1 20); do + for attempt in $(seq 1 40); do out=$(timeout 45 nak req -k 30078 --tag "d=tollgate-build/${BUILD_ID}/${d}" \\ --limit 1 $COORDINATION_RELAYS < /dev/null 2>/dev/null \\ | grep -m1 '^{' || true) if [ -n "$out" ]; then printf '%s' "$out"; return 0; fi - echo " ${d} record not published yet (attempt $attempt/20); waiting 30s" >&2 + echo " ${d} record not published yet (attempt $attempt/40); waiting 30s" >&2 sleep 30 done return 1 @@ -398,7 +410,11 @@ # is in this job's env, which packaging/build-env.sh requires. UPX_DIR=$(bash scripts/fetch-upx.sh) echo "PATH=$UPX_DIR:$PATH" >> "${GITHUB_ENV:-/dev/null}" - "$UPX_DIR/upx" --version | head -1 + # `|| true` — NOT `{ pipe; true; }`: under set -euo pipefail the + # shell exits at the pipeline's own failure and the trailing true + # never runs (proven locally: yes|head in { } form exits 141). + # upx SIGPIPE death here killed this shard 3x on 2026-10-20: + "$UPX_DIR/upx" --version 2>/dev/null | head -1 || true - name: Build .ipk id: build @@ -483,10 +499,13 @@ run: | set -euo pipefail SDK="${{ matrix.sdk }}" + INPUTS="packaging/build-inputs.json" + [ -f "$INPUTS" ] || INPUTS="src-checkout/packaging/build-inputs.json" + [ -f "$INPUTS" ] || { echo "ERROR: packaging/build-inputs.json not found (root or src-checkout)" >&2; exit 1; } DIGEST=$(jq -r --arg sdk "$SDK" \\ - '.openwrt_sdk.targets[$sdk].digest // empty' packaging/build-inputs.json) - [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in packaging/build-inputs.json" >&2; exit 1; } - RELEASE=$(jq -r '.openwrt_sdk.release' packaging/build-inputs.json) + '.openwrt_sdk.targets[$sdk].digest // empty' "$INPUTS") + [ -n "$DIGEST" ] || { echo "ERROR: no SDK digest pinned for target $SDK in $INPUTS" >&2; exit 1; } + RELEASE=$(jq -r '.openwrt_sdk.release' "$INPUTS") echo "SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" >> "${GITHUB_ENV:-/dev/null}" echo "resolved SDK_REF=openwrt/sdk:${SDK}-${RELEASE}@${DIGEST}" @@ -541,7 +560,7 @@ if [ "$USE_UPX" = "1" ]; then UPX_DIR=$(bash src-checkout/scripts/fetch-upx.sh) docker exec -i "$SVC" sh -c 'cat > /usr/local/bin/upx && chmod 0755 /usr/local/bin/upx' < "$UPX_DIR/upx" - docker exec "$SVC" upx --version | head -1 + docker exec "$SVC" upx --version 2>/dev/null | head -1 || true fi docker exec -e PACKAGE_VERSION="${{ needs.resolve-inputs.outputs.package_version }}" \\ diff --git a/scripts/ngit-release-announce.sh b/scripts/ngit-release-announce.sh index fd5644931..85ce0b403 100755 --- a/scripts/ngit-release-announce.sh +++ b/scripts/ngit-release-announce.sh @@ -67,7 +67,13 @@ esac [ -n "$VERSION" ] && [ -n "$CHANNEL" ] && [ -n "$RELEASE_RUN" ] && [ -n "$BUILD_ID" ] || { usage; exit 2; } PLAN=${PLAN:-packaging/ngit-release-matrix.json} -COORDINATION_RELAYS=${COORDINATION_RELAYS:-"wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech"} +# Relay set notes (measured 2026-10-20): relay1.orangesync.tech points at a +# dead IP (66.92.204.38) and adds nothing but publish latency and noise; +# relay2.orangesync.tech (ours, uncapped) and relay.damus.io carry the history. +# nos.lol/nostr.mom are flaky from this network but reachable from browsers +# elsewhere, so they stay. relay.tollgate.me no longer completes a Nostr +# websocket and is not listed. +COORDINATION_RELAYS=${COORDINATION_RELAYS:-"wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech"} RELAYS=${RELAYS:-"$COORDINATION_RELAYS"} PACKAGE_NAME=${PACKAGE_NAME:-tollgate-wrt} ROOT=${WT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)} @@ -203,6 +209,7 @@ while IFS= read -r content; do --tag filename="$filename" \ --tag A="$arch" --tag v="$VERSION" --tag c="$CHANNEL" \ --tag n="$PACKAGE_NAME" --tag compression="$comp" --tag format="$fmt" \ + --tag t="tollgate" \ $RELAYS < /dev/null 2>&1) || true ev=$(printf '%s' "$out" | grep -o '"id":"[0-9a-f]\{64\}"' | head -1 | cut -d'"' -f4) if [ -n "$ev" ]; then diff --git a/scripts/verify_publication.sh b/scripts/verify_publication.sh index ccb7c410b..2a0fb6bd3 100755 --- a/scripts/verify_publication.sh +++ b/scripts/verify_publication.sh @@ -58,7 +58,7 @@ CHANNEL="${2:-}" MATRIX="${3:--}" if [ -z "$VERSION" ] || [ -z "$CHANNEL" ]; then usage; exit 2; fi -VERIFY_RELAYS="${VERIFY_RELAYS:-wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay1.orangesync.tech wss://relay2.orangesync.tech}" +VERIFY_RELAYS="${VERIFY_RELAYS:-wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech}" VERIFY_MIRRORS="${VERIFY_MIRRORS:-2}" VERIFY_DOWNLOAD="${VERIFY_DOWNLOAD:-sample}" VERIFY_SCOPE="${VERIFY_SCOPE:-ipk,apk}" diff --git a/src/cmd/tollgate-cvm/announce.go b/src/cmd/tollgate-cvm/announce.go index ae77401c7..3e46d0e32 100644 --- a/src/cmd/tollgate-cvm/announce.go +++ b/src/cmd/tollgate-cvm/announce.go @@ -80,7 +80,7 @@ func BuildAnnouncement(serverSK, maintainerNpub string, relays ...string) (nostr // BuildToolsAnnouncement creates the signed kind-11317 tools-list // announcement, generated from ToolSurface() so the announced surface and the // dispatchable surface can never drift apart. -func BuildToolsAnnouncement(serverSK string) (nostr.Event, error) { +func BuildToolsAnnouncement(serverSK, maintainerNpub string) (nostr.Event, error) { tools := make([]map[string]interface{}, 0, len(ToolSurface())) for _, tool := range ToolSurface() { entry := map[string]interface{}{ @@ -98,11 +98,26 @@ func BuildToolsAnnouncement(serverSK string) (nostr.Event, error) { return nostr.Event{}, err } + // Same p-tag discipline as the 11316 announcement: the tag carries the + // maintainer pubkey as HEX (the form a relay indexes for #p), omitted — + // never empty — when the router is unclaimed. Without it the maintainer + // dashboard's `kinds=11317, #p=` filter cannot index the tools list + // at all (#980 F3, found by the #971 E2E). + maintainerHex, err := maintainerHexFromNpub(maintainerNpub) + if err != nil { + return nostr.Event{}, err + } + + tags := nostr.Tags{nostr.Tag{"d", annDSlug}} + if maintainerHex != "" { + tags = append(tags, nostr.Tag{"p", maintainerHex}) + } + evt := nostr.Event{ Kind: annKindTools, Content: string(contentJSON), CreatedAt: nostr.Now(), - Tags: nostr.Tags{nostr.Tag{"d", annDSlug}}, + Tags: tags, } if err := evt.Sign(serverSK); err != nil { return nostr.Event{}, fmt.Errorf("sign tools announcement: %w", err) @@ -110,24 +125,69 @@ func BuildToolsAnnouncement(serverSK string) (nostr.Event, error) { return evt, nil } +// relayFailure is one relay's refusal of one announcement event. +type relayFailure struct { + url string + err error +} + +// publishReporter publishes one event and reports how many relays accepted +// it plus the ones that refused. It is the seam that makes the announce +// policy testable without a relay: production wires publishToRelays, tests +// inject fakes (#980 F5). +type publishReporter func(ctx context.Context, relays []string, evt nostr.Event) (accepted int, failed []relayFailure) + +// publishToRelays drains PublishMany into the verdict — the drain is where +// the old code threw the results away (`_ = result`), which turned total +// publish failure into the "announced 11316/11317" success log (found by +// the #971 E2E announcing into a dead relay). +func publishToRelays(pool *nostr.SimplePool) publishReporter { + return func(ctx context.Context, relays []string, evt nostr.Event) (int, []relayFailure) { + accepted := 0 + var failed []relayFailure + for r := range pool.PublishMany(ctx, relays, evt) { + if r.Error != nil { + failed = append(failed, relayFailure{url: r.RelayURL, err: r.Error}) + } else { + accepted++ + } + } + return accepted, failed + } +} + // PublishAnnouncements signs and publishes both announcements to relays. // Replaceable kinds mean republishing replaces, not duplicates. +// +// Failure policy (#980 F5): a relay's refusal is LOGGED (lifecycle facts +// only, as everywhere) — one dead relay among several must not fail the +// daemon when the announcement still landed elsewhere. Zero acceptance for +// either event IS an error: for a control plane whose liveness is the +// product, "announced" must mean a relay accepted it, and the periodic +// re-announce in serve.go is the retry. func PublishAnnouncements(pool *nostr.SimplePool, relays []string, serverSK, maintainerNpub string) error { + return publishAnnouncements(publishToRelays(pool), relays, serverSK, maintainerNpub) +} + +func publishAnnouncements(pub publishReporter, relays []string, serverSK, maintainerNpub string) error { evt, err := BuildAnnouncement(serverSK, maintainerNpub, relays...) if err != nil { return err } - toolsEvt, err := BuildToolsAnnouncement(serverSK) + toolsEvt, err := BuildToolsAnnouncement(serverSK, maintainerNpub) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - for result := range pool.PublishMany(ctx, relays, evt) { - _ = result - } - for result := range pool.PublishMany(ctx, relays, toolsEvt) { - _ = result + for _, e := range []nostr.Event{evt, toolsEvt} { + accepted, failed := pub(ctx, relays, e) + for _, f := range failed { + logf("announce: relay %s refused kind %d: %v", f.url, e.Kind, f.err) + } + if accepted == 0 { + return fmt.Errorf("announce failed: kind %d accepted by 0 of %d relays (%d refused)", e.Kind, len(relays), len(failed)) + } } return nil } diff --git a/src/cmd/tollgate-cvm/announce_adapter_test.go b/src/cmd/tollgate-cvm/announce_adapter_test.go new file mode 100644 index 000000000..399499ca6 --- /dev/null +++ b/src/cmd/tollgate-cvm/announce_adapter_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/nbd-wtf/go-nostr" +) + +// The #986 policy tests inject fakePub, which pins publishAnnouncements and is +// the right seam for the DECISION table. But the adapter that production wires +// — publishToRelays, which drains a real SimplePool into that verdict — had no +// test, and mutating it away left the suite green: +// +// - removing the drain (`for range … {}`, keeping accepted=0) passes, because +// no test ever calls publishToRelays; +// - so does checking only the first event in the loop, since fakePub returns +// the same verdict for every event. +// +// Both are the #980 F5 failure mode in a different dress: results collected and +// then thrown away, so "announced" is logged for an event no relay took. The +// drain is the one line the whole fix depends on, so it gets a test that uses a +// real SimplePool rather than a fake. + +// An unreachable relay must be reported as a refusal, not swallowed. This is +// the assertion that fails if the drain disappears: an undrained channel leaves +// accepted=0 and failed=nil, which publishAnnouncements reads as "0 of 0" — +// indistinguishable from a clean run with no failures to report. +func TestPublishToRelaysSurfacesAnUnreachableRelay(t *testing.T) { + pool := nostr.NewSimplePool(context.Background()) + evt := nostr.Event{Kind: 11316, Content: "probe", CreatedAt: nostr.Now()} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Port 1 on loopback: refuses immediately, no network, no DNS. + accepted, failed := publishToRelays(pool)(ctx, []string{"ws://127.0.0.1:1"}, evt) + + if accepted != 0 { + t.Errorf("accepted = %d, want 0 for an unreachable relay", accepted) + } + if len(failed) != 1 { + t.Fatalf("failed = %d, want 1 — the adapter must surface the refusal (an undrained channel reports 0)", len(failed)) + } + if failed[0].url != "ws://127.0.0.1:1" { + t.Errorf("failed[0].url = %q, want the relay we asked", failed[0].url) + } + if failed[0].err == nil { + t.Error("failed[0].err = nil, want the connection error") + } +} + +// Every configured relay must produce exactly one verdict. A batched drain that +// drops results would under-report failures and could turn a partial outage into +// a clean-looking zero. +func TestPublishToRelaysReportsOneVerdictPerRelay(t *testing.T) { + pool := nostr.NewSimplePool(context.Background()) + evt := nostr.Event{Kind: 11316, Content: "probe", CreatedAt: nostr.Now()} + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Three distinct unreachable endpoints. + relays := []string{"ws://127.0.0.1:1", "ws://127.0.0.1:2", "ws://127.0.0.1:3"} + accepted, failed := publishToRelays(pool)(ctx, relays, evt) + + if accepted != 0 { + t.Errorf("accepted = %d, want 0", accepted) + } + if len(failed) != len(relays) { + t.Fatalf("failed = %d, want %d — one verdict per configured relay", len(failed), len(relays)) + } + seen := map[string]bool{} + for _, f := range failed { + seen[f.url] = true + } + for _, r := range relays { + if !seen[r] { + t.Errorf("no verdict for %s — a dropped result under-reports the outage", r) + } + } +} + +// policyErrorFacts is a tiny helper so the policy tests below assert on wording +// once, not three times. +func policyErrorFacts(err error) (kind string, zeroOf string) { + if err == nil { + return "", "" + } + msg := err.Error() + if i := strings.Index(msg, "kind "); i >= 0 { + rest := msg[i+len("kind "):] + if j := strings.IndexAny(rest, " "); j >= 0 { + kind = rest[:j] + } else { + kind = rest + } + } + if i := strings.Index(msg, "accepted by 0 of "); i >= 0 { + zeroOf = msg[i:] + } + return kind, zeroOf +} + +// The loop must vet BOTH announcements. With the injected fake returning one +// verdict for every call, a loop that only checks the first event passes every +// policy test — but a dead relay would then be reported for 11316 while 11317 +// failed silently, which is exactly what the #971 E2E caught. +// +// Counting calls is what pins the loop: it asserts the loop reached the second +// event at all. +func TestAnnounceChecksBothEvents(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + + var calls int + var kinds []int + recording := func(ctx context.Context, relays []string, evt nostr.Event) (int, []relayFailure) { + calls++ + kinds = append(kinds, evt.Kind) + // First call accepts, so the failure must come from the SECOND event + // being zero-accepted — proving the loop actually checked it. + if calls == 1 { + return len(relays), nil + } + return 0, []relayFailure{{url: "ws://dead.example", err: errors.New("relay is gone")}} + } + + err := publishAnnouncements(recording, []string{"ws://ok.example"}, serverSK, "") + if calls != 2 { + t.Fatalf("adapter called %d time(s), want 2 — both announcements must be vetted", calls) + } + if err == nil { + t.Fatal("the tools announcement was accepted by 0 relays but the announce returned nil") + } + kind, zeroOf := policyErrorFacts(err) + if kind != "11317" { + t.Errorf("error names kind %s, want 11317 (the event that actually failed): %v", kind, err) + } + if zeroOf == "" { + t.Errorf("error must carry the 0-of-N verdict: %v", err) + } + if len(kinds) != 2 { + t.Fatalf("distinct kinds seen = %v, want two events", kinds) + } +} diff --git a/src/cmd/tollgate-cvm/announce_publish_test.go b/src/cmd/tollgate-cvm/announce_publish_test.go new file mode 100644 index 000000000..7a4c7e306 --- /dev/null +++ b/src/cmd/tollgate-cvm/announce_publish_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/nbd-wtf/go-nostr" +) + +// #980 F5: the announce path used to drain PublishMany with `_ = result` +// and always return nil, so announcing into a DEAD relay still logged +// "announced 11316/11317" (found live by the #971 E2E). These tests pin the +// policy through the publishReporter seam: refusals are surfaced, zero +// acceptance is an error, and only real acceptance counts as announced. + +func fakePub(accepted int, failures []relayFailure) publishReporter { + return func(ctx context.Context, relays []string, evt nostr.Event) (int, []relayFailure) { + return accepted, failures + } +} + +func TestAnnounceZeroAcceptanceIsAnError(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + err := publishAnnouncements( + fakePub(0, []relayFailure{{url: "ws://dead.example", err: errors.New("connection refused")}}), + []string{"ws://dead.example"}, serverSK, "", + ) + if err == nil { + t.Fatal("zero-acceptance announce returned nil — the daemon would log 'announced' into a dead relay again") + } + if !strings.Contains(err.Error(), "accepted by 0 of 1") || !strings.Contains(err.Error(), "11316") { + t.Fatalf("error must name the kind and the 0-of-N verdict, got: %v", err) + } +} + +func TestAnnouncePartialAcceptanceIsNotAnError(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + err := publishAnnouncements( + fakePub(1, []relayFailure{{url: "ws://flaky.example", err: errors.New("timeout")}}), + []string{"ws://ok.example", "ws://flaky.example"}, serverSK, "", + ) + if err != nil { + t.Fatalf("one relay of two accepting must not fail the announce (the event landed): %v", err) + } +} + +func TestAnnounceNoRelaysConfiguredIsAnError(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + err := publishAnnouncements(fakePub(0, nil), nil, serverSK, "") + if err == nil { + t.Fatal("publishing to zero relays must be an error, not a silent no-op success") + } +} diff --git a/src/cmd/tollgate-cvm/announce_test.go b/src/cmd/tollgate-cvm/announce_test.go index a0000f841..c755d544a 100644 --- a/src/cmd/tollgate-cvm/announce_test.go +++ b/src/cmd/tollgate-cvm/announce_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" ) // The announcement is the discovery mechanism: a dashboard subscribes @@ -80,15 +81,42 @@ func TestAnnouncementShape(t *testing.T) { // Tools announcement (11317): generated from the surface, and the banned // tools must be structurally unable to appear. func TestToolsAnnouncement(t *testing.T) { + const maintainer = "npub1nng5mxkdh2mu593twukfr7j3fk5wxfy0v8ujf0e5g8nwwtzlphhqksqpew" serverSK := nostr.GeneratePrivateKey() - evt, err := BuildToolsAnnouncement(serverSK) + evt, err := BuildToolsAnnouncement(serverSK, maintainer) if err != nil { t.Fatalf("BuildToolsAnnouncement: %v", err) } if evt.Kind != 11317 { t.Fatalf("kind %d, want 11317", evt.Kind) } + + // The maintainer dashboard indexes the tools list with #p = maintainer + // HEX, exactly like the 11316 announcement — a missing p tag made 11317 + // invisible to that filter (#980 F3). + _, decodedNpub, err := nip19.Decode(maintainer) + if err != nil { + t.Fatalf("fixture npub: %v", err) + } + wantHex, ok := decodedNpub.(string) + if !ok { + t.Fatalf("fixture npub did not decode to a string pubkey") + } + p := evt.Tags.GetFirst([]string{"p"}) + if p == nil { + t.Fatalf("tools announcement carries no p tag — the dashboard #p filter cannot index it") + } + if p.Value() != wantHex { + t.Fatalf("p tag %q, want the maintainer pubkey hex %q (relays index hex, not bech32)", p.Value(), wantHex) + } + if d := evt.Tags.GetFirst([]string{"d"}); d == nil || d.Value() != "tollgate-control-plane" { + t.Fatalf("d tag lost: %v", evt.Tags) + } + if ok, err := evt.CheckSignature(); !ok || err != nil { + t.Fatalf("signature must verify after the tag change: ok=%v err=%v", ok, err) + } + var tools struct { Tools []struct { Name string `json:"name"` @@ -107,6 +135,20 @@ func TestToolsAnnouncement(t *testing.T) { } } +// An unclaimed router must NOT announce a maintainer p tag on the tools list +// either — an empty p tag would be a wildcard a dashboard could match on +// some relays (same rule as TestAnnouncementUnclaimed). +func TestToolsAnnouncementUnclaimedOmitsPTag(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + evt, err := BuildToolsAnnouncement(serverSK, "") + if err != nil { + t.Fatalf("BuildToolsAnnouncement unclaimed: %v", err) + } + if p := evt.Tags.GetFirst([]string{"p"}); p != nil { + t.Fatalf("unclaimed router announced p tag %q — this puts it in someone's fleet view", p.Value()) + } +} + // An unclaimed router must NOT announce a maintainer p tag — an empty p tag // would be a wildcard a dashboard could match on some relays. func TestAnnouncementUnclaimed(t *testing.T) { diff --git a/src/cmd/tollgate-cvm/cvm.go b/src/cmd/tollgate-cvm/cvm.go index 552cc22a6..eea3a9fc9 100644 --- a/src/cmd/tollgate-cvm/cvm.go +++ b/src/cmd/tollgate-cvm/cvm.go @@ -105,22 +105,27 @@ func WrapRequest(req JSONRPCRequest, serverPK, callerSK string) (nostr.Event, st } // UnwrapRequest opens a gift-wrapped request with the server's key and -// returns the JSON-RPC request plus the caller's identity (from the seal's -// pubkey — the gift wrap's own pubkey is a nonce and means nothing). -func UnwrapRequest(gw nostr.Event, serverSK string) (JSONRPCRequest, string, error) { +// returns the JSON-RPC request, the caller's identity (from the seal's +// pubkey — the gift wrap's own pubkey is a nonce and means nothing), and the +// RUMOR's created_at — the request's real clock. The gift wrap's own +// created_at is deliberately randomized by NIP-59 (nip59.GiftWrap stamps it +// "up to 6 hours in the past" for timing privacy) and must never be used for +// freshness decisions (#980 D1: doing so made every relay-delivered request +// stale by construction and the control plane silently inert). +func UnwrapRequest(gw nostr.Event, serverSK string) (JSONRPCRequest, string, nostr.Timestamp, error) { var req JSONRPCRequest rumor, err := nip59.GiftUnwrap(gw, decryptWith(serverSK)) if err != nil { - return req, "", fmt.Errorf("unwrap: %w", err) + return req, "", 0, fmt.Errorf("unwrap: %w", err) } // The caller is the seal's author; GiftUnwrap copies it into rumor.PubKey. if rumor.Kind != CVMKind { - return req, "", fmt.Errorf("rumor kind %d, want %d — not a CVM message", rumor.Kind, CVMKind) + return req, "", 0, fmt.Errorf("rumor kind %d, want %d — not a CVM message", rumor.Kind, CVMKind) } if err := json.Unmarshal([]byte(rumor.Content), &req); err != nil { - return req, "", fmt.Errorf("request json: %w", err) + return req, "", 0, fmt.Errorf("request json: %w", err) } - return req, rumor.PubKey, nil + return req, rumor.PubKey, rumor.CreatedAt, nil } // WrapResponse gift-wraps a JSON-RPC response from the server to a client. diff --git a/src/cmd/tollgate-cvm/cvm_test.go b/src/cmd/tollgate-cvm/cvm_test.go index 866acc20d..c407b0528 100644 --- a/src/cmd/tollgate-cvm/cvm_test.go +++ b/src/cmd/tollgate-cvm/cvm_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip59" ) // rpcFixture mirrors the JSON-RPC 2.0 shapes CVM carries in kind 25910. @@ -58,7 +59,7 @@ func TestGiftWrapRoundTrip(t *testing.T) { } // Server side: unwrap with the server key, recover caller + request. - gotReq, gotCaller, err := UnwrapRequest(gw, serverSK) + gotReq, gotCaller, _, err := UnwrapRequest(gw, serverSK) if err != nil { t.Fatalf("UnwrapRequest: %v", err) } @@ -116,7 +117,7 @@ func TestUnwrapWrongRecipientFails(t *testing.T) { if err != nil { t.Fatalf("WrapRequest to stranger: %v", err) } - if _, _, err := UnwrapRequest(gw, serverSK); err == nil { + if _, _, _, err := UnwrapRequest(gw, serverSK); err == nil { t.Fatal("server unwrapped a gift wrap addressed to a stranger — NIP-44 must refuse") } } @@ -144,7 +145,7 @@ func TestAuthorizeEndToEnd(t *testing.T) { if err != nil { t.Fatalf("WrapRequest: %v", err) } - _, caller, err := UnwrapRequest(gw, serverSK) + _, caller, _, err := UnwrapRequest(gw, serverSK) if err != nil { t.Fatalf("UnwrapRequest: %v", err) } @@ -154,3 +155,68 @@ func TestAuthorizeEndToEnd(t *testing.T) { _ = maintainerSK _ = time.Now } + +// The D1 regression (#980): NIP-59 gift wraps carry a deliberately randomized +// created_at (nip59.GiftWrap: "up to 6 hours in the past"), so freshness must +// be decided on the RUMOR's timestamp — the request's real clock. Checking +// the wrap's timestamp made every relay-delivered request stale by +// construction and the control plane silently inert. +func testKeys(t *testing.T) (string, string) { + t.Helper() + serverSK := nostr.GeneratePrivateKey() + clientSK := nostr.GeneratePrivateKey() + return serverSK, clientSK +} + +func TestRumorTimestampIsTheRealClockNotTheWrap(t *testing.T) { + serverSK, clientSK := testKeys(t) + serverPK, _ := nostr.GetPublicKey(serverSK) + + gw, _, err := WrapRequest(JSONRPCRequest{JSONRPC: "2.0", ID: float64(1), Method: "status"}, serverPK, clientSK) + if err != nil { + t.Fatalf("wrap: %v", err) + } + + _, _, rumorTS, err := UnwrapRequest(gw, serverSK) + if err != nil { + t.Fatalf("unwrap: %v", err) + } + // The rumor clock is fresh (WrapRequest stamps it now). + if err := CheckFreshness(int64(rumorTS), time.Now()); err != nil { + t.Errorf("fresh rumor rejected: %v", err) + } + // The wrap clock is NOT a valid freshness input: it is randomized into + // the past by design. Pin that the two clocks differ for at least one + // seed... they can coincide by chance (1/3600), so assert on semantics: + // the unwrap-exposed timestamp must be within the freshness window even + // when the wrap's own timestamp is not. + if gw.CreatedAt > rumorTS { + t.Logf("wrap ts %d older-or-equal than rumor ts %d (randomized, expected shape)", gw.CreatedAt, rumorTS) + } +} + +// A genuinely stale request (old RUMOR timestamp) must still be refused — +// the replay defense survives the clock change. +func TestStaleRumorStillRejected(t *testing.T) { + serverSK, clientSK := testKeys(t) + serverPK, _ := nostr.GetPublicKey(serverSK) + + payload, _ := json.Marshal(JSONRPCRequest{JSONRPC: "2.0", ID: float64(1), Method: "status"}) + rumor := nostr.Event{ + Kind: CVMKind, + Content: string(payload), + CreatedAt: nostr.Now() - 3600, // one hour old + Tags: nostr.Tags{nostr.Tag{"p", serverPK}}, + } + gw, err := nip59.GiftWrap(rumor, serverPK, encryptTo(clientSK, serverPK), signWith(clientSK), nil) + if err != nil { + t.Fatalf("giftwrap: %v", err) + } + _, _, rumorTS, err := UnwrapRequest(gw, serverSK) + if err != nil { + t.Fatalf("unwrap: %v", err) + } + if err := CheckFreshness(int64(rumorTS), time.Now()); err == nil { + t.Error("stale rumor must be rejected by CheckFreshness") + } +} diff --git a/src/cmd/tollgate-cvm/e2e_test.go b/src/cmd/tollgate-cvm/e2e_test.go index 5cdf2f0cb..0f33d97f8 100644 --- a/src/cmd/tollgate-cvm/e2e_test.go +++ b/src/cmd/tollgate-cvm/e2e_test.go @@ -55,7 +55,7 @@ func TestE2ERequestResponse(t *testing.T) { return CLIResponse{Success: true, Data: map[string]interface{}{"running": true}, Timestamp: time.Now()}, nil }} authz := NewAuthorizer("") // reads open - dispatcher := NewDispatcher(authz, bridge, serverPK) + dispatcher := NewDispatcher(authz, bridge) // Server loop in a goroutine (mirrors Serve, but in-test for assertions). type outcome struct { @@ -78,10 +78,15 @@ func TestE2ERequestResponse(t *testing.T) { if pTag == nil || pTag.Value() != serverPK { continue } - req, caller, err := UnwrapRequest(*evt, serverSK) + req, caller, rumorTS, err := UnwrapRequest(*evt, serverSK) if err != nil { continue } + // Mirrors Serve: freshness on the RUMOR clock, never the + // NIP-59-randomized wrap clock (the D1 bug). + if err := CheckFreshness(int64(rumorTS), time.Now()); err != nil { + continue + } resp := dispatcher.Dispatch(caller, req) respGW, err := WrapResponse(resp, caller, serverSK, nostr.Tag{"e", evt.ID}) if err != nil { diff --git a/src/cmd/tollgate-cvm/serve.go b/src/cmd/tollgate-cvm/serve.go index 5f4cb8a1e..dee11932d 100644 --- a/src/cmd/tollgate-cvm/serve.go +++ b/src/cmd/tollgate-cvm/serve.go @@ -8,6 +8,35 @@ import ( "github.com/nbd-wtf/go-nostr" ) +// unwrapFresh is the relay loop's per-event gate: unwrap the gift wrap, then +// decide freshness on the request's REAL clock — the rumor's created_at. +// +// It is deliberately NOT the gift wrap's created_at. NIP-59 randomizes the wrap +// (and the seal) up to hours into the past for timing privacy — +// nip59.GiftWrap stamps `nostr.Now() - 60*rand.Int63n(600)`, i.e. up to 10 +// hours backdated — so deciding freshness on the wrap rejects legitimate +// requests by construction. That was #980 D1: the relay-delivered control plane +// was silently inert, with no error to observe (#982 fixed it). +// +// Both steps live in one function so the loop cannot pair an unwrap with the +// wrong clock. That pairing is exactly what #982 fixed and what nothing pinned: +// reverting the loop to the wrap clock left the whole suite green, because the +// test re-implemented this sequence instead of calling it. With the gate named, +// TestUnwrapFreshUsesTheRumorClockNotTheWrap fails when the clock is wrong. +// +// One decryption, not two: the rumor timestamp comes from the same unwrap that +// yields the request and caller. +func unwrapFresh(gw nostr.Event, serverSK string, now time.Time) (JSONRPCRequest, string, error) { + req, caller, rumorTS, err := UnwrapRequest(gw, serverSK) + if err != nil { + return req, caller, err + } + if err := CheckFreshness(int64(rumorTS), now); err != nil { + return req, caller, err + } + return req, caller, nil +} + // claimPollInterval is how often the daemon re-reads maintainer_npub. It is // short enough that a handover takes effect while an operator is watching, // and long enough that polling a local unix socket costs nothing. @@ -142,19 +171,22 @@ func Serve(ctx context.Context, cfg *ServerConfig, bridge Bridge) error { continue } - // Freshness before decryption: a stale event is a replay attempt - // and refusing it costs nothing (audit Q1). - if err := CheckFreshness(int64(evt.CreatedAt), time.Now()); err != nil { - continue - } - // Duplicate delivery of the same event id executes once. if seen.Seen(evt.ID, time.Now()) { continue } // Unwrap + dispatch; an unwrap failure is someone else's traffic. - req, caller, err := UnwrapRequest(*evt, sk) + // + // Freshness is decided on the RUMOR's created_at — the request's + // real clock — never on the gift wrap's: NIP-59 randomizes the + // wrap's timestamp (up to hours into the past) for timing + // privacy, so a freshness check on the wrap rejects legitimate + // requests by construction (#980 D1 — the relay-delivered control + // plane was silently inert for exactly this reason). + // Unwrap + freshness in one gate, so the loop cannot pair the + // unwrap with the wrap's clock (#980 D1 / #982). + req, caller, err := unwrapFresh(*evt, sk, time.Now()) if err != nil { continue } diff --git a/src/cmd/tollgate-cvm/serve_clock_test.go b/src/cmd/tollgate-cvm/serve_clock_test.go new file mode 100644 index 000000000..31539203e --- /dev/null +++ b/src/cmd/tollgate-cvm/serve_clock_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip59" +) + +// unwrapFresh is the relay loop's gate: unwrap, then decide freshness on the +// request's real clock. #982 fixed the loop to use the rumor's created_at +// instead of the gift wrap's, but nothing pinned that choice — reverting the +// loop to the wrap clock left the whole suite green, because the existing test +// called UnwrapRequest and CheckFreshness itself rather than calling the loop's +// gate. These tests call the gate. + +// The regression this file exists for: a request whose WRAP is hours old but +// whose RUMOR is fresh must be ACCEPTED. If the gate reads the wrap clock, this +// fails — which is the #980 D1 bug. +func TestUnwrapFreshAcceptsFreshRumorBehindAnOldWrap(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + clientSK := nostr.GeneratePrivateKey() + serverPK, err := nostr.GetPublicKey(serverSK) + if err != nil { + t.Fatalf("server pk: %v", err) + } + + payload, _ := json.Marshal(JSONRPCRequest{JSONRPC: "2.0", ID: float64(1), Method: "status"}) + rumor := nostr.Event{ + Kind: CVMKind, + Content: string(payload), + CreatedAt: nostr.Now(), + Tags: nostr.Tags{nostr.Tag{"p", serverPK}}, + } + gw, err := nip59.GiftWrap(rumor, serverPK, encryptTo(clientSK, serverPK), signWith(clientSK), nil) + if err != nil { + t.Fatalf("giftwrap: %v", err) + } + // NIP-59 backdates the wrap. 10h is its maximum. + gw.CreatedAt = nostr.Now() - nostr.Timestamp((10 * time.Hour).Seconds()) + + req, caller, err := unwrapFresh(gw, serverSK, time.Now()) + if err != nil { + t.Fatalf("a fresh request behind a backdated wrap was refused (%v) — the gate is reading the wrap clock, which is #980 D1", err) + } + if req.Method != "status" { + t.Errorf("method = %q, want status", req.Method) + } + callerPK, _ := nostr.GetPublicKey(clientSK) + if caller != callerPK { + t.Errorf("caller = %q, want the seal author %q", caller, callerPK) + } +} + +// The other direction, kept so the gate is not weakened into accepting +// everything: a stale RUMOR is refused even when the wrap looks fresh. +func TestUnwrapFreshRefusesAStaleRumor(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + clientSK := nostr.GeneratePrivateKey() + serverPK, _ := nostr.GetPublicKey(serverSK) + + payload, _ := json.Marshal(JSONRPCRequest{JSONRPC: "2.0", ID: float64(1), Method: "status"}) + rumor := nostr.Event{ + Kind: CVMKind, + Content: string(payload), + CreatedAt: nostr.Now() - nostr.Timestamp((time.Hour).Seconds()), + Tags: nostr.Tags{nostr.Tag{"p", serverPK}}, + } + gw, err := nip59.GiftWrap(rumor, serverPK, encryptTo(clientSK, serverPK), signWith(clientSK), nil) + if err != nil { + t.Fatalf("giftwrap: %v", err) + } + gw.CreatedAt = nostr.Now() // wrap looks fresh; the rumor does not + + if _, _, err := unwrapFresh(gw, serverSK, time.Now()); err == nil { + t.Error("a one-hour-old rumor was accepted because its wrap looked fresh — the gate is reading the wrap clock") + } +} + +// A wrap addressed to someone else must be refused before freshness matters. +func TestUnwrapFreshRefusesAStrangerWrap(t *testing.T) { + serverSK := nostr.GeneratePrivateKey() + clientSK := nostr.GeneratePrivateKey() + strangerSK := nostr.GeneratePrivateKey() + strangerPK, _ := nostr.GetPublicKey(strangerSK) + + payload, _ := json.Marshal(JSONRPCRequest{JSONRPC: "2.0", ID: float64(1), Method: "status"}) + rumor := nostr.Event{ + Kind: CVMKind, + Content: string(payload), + CreatedAt: nostr.Now(), + Tags: nostr.Tags{nostr.Tag{"p", strangerPK}}, + } + gw, err := nip59.GiftWrap(rumor, strangerPK, encryptTo(clientSK, strangerPK), signWith(clientSK), nil) + if err != nil { + t.Fatalf("giftwrap: %v", err) + } + + if _, _, err := unwrapFresh(gw, serverSK, time.Now()); err == nil { + t.Error("server unwrapped a wrap addressed to a stranger — NIP-44 must refuse") + } +} diff --git a/src/main.go b/src/main.go index f8ad161f7..45d97c776 100644 --- a/src/main.go +++ b/src/main.go @@ -61,6 +61,43 @@ func getIPLimiter(ip string) *rate.Limiter { func RateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // The router's own loopback caller is exempt from the per-IP budget. + // + // WHY: registerAPIHandlers wires this middleware OUTSIDE requireStarted + // (RateLimitMiddleware(CorsMiddleware(requireStarted(...)))), so the + // limiter runs FIRST. The installer polls 127.0.0.1:2121 every 3 s + // (~20 req/min) while it waits for the merchant, against a default + // budget of 10 req/min — so the router used to rate-limit its own + // localhost installer, which then read `429 {"error":"rate limit + // exceeded"}` instead of the readable `503 {"code":"starting"}` refusal + // from startup_gate.go, and reported a correct install as "Setup + // failed" (2026-10-16 incident, tracked in #953). + // + // Exempting loopback fixes that at the narrowest possible point: no + // route ordering changes and no real client's budget changes. The + // starting refusal still wins for every other caller, because a + // starting gate is a truth about the router, not a load-shedding + // decision. + // + // The decision is made from the CONNECTION (isLocalRequest), never from + // a header. The identity helpers nearby (getIP, sourceNetworkKey, + // clientLimiterKey) deliberately read X-Real-Ip / X-Forwarded-For for a + // loopback peer, because a proxy on the box forwards a real customer's + // address that way. That is right for NAMING a client and wrong for + // deciding trust: this router runs no reverse proxy in front of :2121 + // (the guest portal and every route here call the API directly), and + // the packaging drops WAN-side callers at the firewall + // (packaging/files/etc/nftables.d/30-backend-firewall.nft admits only + // br-lan, br-private and lo). A loopback connection carrying a + // forwarded header is therefore either a local process or a caller who + // set the header itself, and a header a caller controls must never mint + // an unlimited-rate bucket — the same trust boundary as #808's loopback + // XFF finding. A LAN caller who cannot claim loopback still gets the + // 429, which is what bounds them. + if isLocalRequest(r) { + next(w, r) + return + } ip := getIP(r) if !getIPLimiter(ip).Allow() { mainLogger.WithField("ip", ip).Warn("Rate limit exceeded") @@ -707,7 +744,11 @@ func installMerchant(merchantInstance merchant.MerchantInterface) { if deg, ok := merchantInstance.(*merchant.MerchantDegraded); ok { mainLogger.Warn("Merchant started in degraded mode — wallet will initialize when a mint becomes reachable") - deg.OnUpgrade(func(full merchant.MerchantInterface) { + // WireStartupDegraded registers the upgrade consumer AND the recovery + // trigger as one pair (#10). Doing only the first left the tracker's + // onFirstReachable nil, so the service stayed degraded forever even + // after the mint returned. + deg.WireStartupDegraded(func(full merchant.MerchantInterface) { mainLogger.Info("Upgrading from degraded to full merchant") swapMerchant(full) registerReachableSetChangedCallback(full) diff --git a/src/merchant/merchant.go b/src/merchant/merchant.go index b0b88636d..974b0266b 100644 --- a/src/merchant/merchant.go +++ b/src/merchant/merchant.go @@ -1811,7 +1811,7 @@ func (m *Merchant) PurchaseSession(cashuToken string, macAddress string) (*nostr m.resolveReceiveIntent(reference, intentStateGranted, "session granted") // Create a success session event (using MAC address as identifier in logs) - sessionEvent, err := m.createSessionEvent(session, macAddress) + sessionEvent, err := m.createSessionEvent(session) if err != nil { return nil, fmt.Errorf("failed to create session event: %w", err) } @@ -2110,8 +2110,13 @@ func (m *Merchant) calculateAllotmentBytes(steps uint64) (uint64, error) { return totalBytes, nil } -// createSessionEvent creates a session event from the MAC-address based session -func (m *Merchant) createSessionEvent(session *CustomerSession, customerPubkey string) (*nostr.Event, error) { +// createSessionEvent creates a session event from the MAC-address based session. +// +// The event carries NO `p` tag: in the raw-token flow the customer has no +// Nostr identity, and `p` is defined as a Nostr pubkey (spec PR tollgate#26 +// makes it conditional and forbids non-pubkey values). The MAC already lives +// in `device-identifier`, where it belongs (#949). +func (m *Merchant) createSessionEvent(session *CustomerSession) (*nostr.Event, error) { deviceIdentifier := session.MacAddress identities := m.configManager.GetIdentities() @@ -2134,7 +2139,6 @@ func (m *Merchant) createSessionEvent(session *CustomerSession, customerPubkey s PubKey: tollgatePubkey, CreatedAt: nostr.Now(), Tags: nostr.Tags{ - {"p", customerPubkey}, {"device-identifier", "mac", deviceIdentifier}, {"allotment", fmt.Sprintf("%d", session.Allotment)}, {"metric", session.Metric}, @@ -2186,21 +2190,20 @@ func (m *Merchant) extendSessionEvent(existingSession *nostr.Event, additionalAl // Calculate new total allotment newTotalAllotment := existingAllotment + additionalAllotment - // Extract customer and device info from existing session - customerPubkey := "" + // Extract device info from the existing session. The `p` tag is NOT + // extracted or re-emitted: raw-token session events carry no customer + // pubkey (#949), and a legacy event's `p` is a MAC that must not be + // propagated into a field whose contract says pubkey. deviceIdentifier := "" for _, tag := range existingSession.Tags { - if len(tag) >= 2 && tag[0] == "p" { - customerPubkey = tag[1] - } if len(tag) >= 3 && tag[0] == "device-identifier" { deviceIdentifier = tag[2] } } - if customerPubkey == "" || deviceIdentifier == "" { - return nil, fmt.Errorf("failed to extract customer or device info from existing session") + if deviceIdentifier == "" { + return nil, fmt.Errorf("failed to extract device info from existing session") } identities := m.configManager.GetIdentities() @@ -2223,7 +2226,6 @@ func (m *Merchant) extendSessionEvent(existingSession *nostr.Event, additionalAl PubKey: tollgatePubkey, CreatedAt: nostr.Now(), Tags: nostr.Tags{ - {"p", customerPubkey}, {"device-identifier", "mac", deviceIdentifier}, {"allotment", fmt.Sprintf("%d", newTotalAllotment)}, {"metric", "milliseconds"}, diff --git a/src/merchant/merchant_degraded.go b/src/merchant/merchant_degraded.go index df56d6095..098e7c53c 100644 --- a/src/merchant/merchant_degraded.go +++ b/src/merchant/merchant_degraded.go @@ -63,6 +63,27 @@ func NewMerchantDegradedWithWallet(configManager *config_manager.ConfigManager, return deg } +// WireStartupDegraded performs the full startup wiring for a merchant that +// booted degraded: register the upgrade consumer, then wire the recovery +// trigger so the tracker can actually fire it. +// +// Both steps belong together. #400 added WireRecoveryTrigger to the runtime +// downgrade path, and the startup path (installMerchant in main.go) was missed +// — it registered the onUpgrade consumer but left the tracker's +// onFirstReachable nil, so the service stayed degraded forever even after the +// mint returned (#10). +// +// This exists as one method because the two steps are only correct as a pair, +// and because main.go's startup path and its test must call the same code. The +// previous test re-implemented the sequence inline, so deleting +// WireRecoveryTrigger() from installMerchant left the suite green: the test +// still called the library directly. With the sequence named, main.go calls +// this and the test calls this. +func (m *MerchantDegraded) WireStartupDegraded(onUpgrade func(MerchantInterface)) { + m.OnUpgrade(onUpgrade) + m.WireRecoveryTrigger() +} + func (m *MerchantDegraded) OnUpgrade(callback func(MerchantInterface)) { m.onUpgrade = callback } diff --git a/src/merchant/merchant_degraded_startup_recovery_test.go b/src/merchant/merchant_degraded_startup_recovery_test.go new file mode 100644 index 000000000..3ce1630e8 --- /dev/null +++ b/src/merchant/merchant_degraded_startup_recovery_test.go @@ -0,0 +1,116 @@ +package merchant + +import ( + "testing" + "time" +) + +// TestDegradedStartupWireRecoveryTrigger_Upgrades pins #10: a merchant that +// STARTS in degraded mode (all mints unreachable at boot, never had a full +// merchant) must also upgrade once a mint recovers. The runtime-downgrade path +// got WireRecoveryTrigger in #400; the startup path (installMerchant in +// main.go) was missed, leaving the tracker's onFirstReachable nil and the +// service stuck degraded forever even after the mint returned. +// +// This test goes through WireStartupDegraded — the SAME method main.go's +// startup path calls. It previously re-implemented the sequence inline +// (OnUpgrade then WireRecoveryTrigger), which meant deleting +// WireRecoveryTrigger() from installMerchant left this test green: it was +// exercising the library, not the startup wiring it claimed to pin. +func TestDegradedStartupWireRecoveryTrigger_Upgrades(t *testing.T) { + srv := newSwitchableMintServer(t) + + cm, testDir := setupTestConfigManager(t) + cfg := cm.GetConfig() + cfg.AcceptedMints = simpleMintConfig(srv.URL) + + tracker := newTestTracker(cfg, nil) + tracker.RunInitialProbe() + if tracker.IsReachable(srv.URL) { + t.Fatal("precondition: mint must be unreachable for a degraded start") + } + + // The startup construction from installMerchant in main.go, via the same + // entry point it uses. + deg := NewMerchantDegradedWithWallet(cm, tracker, DefaultWalletFactory, testDir) + upgraded := make(chan MerchantInterface, 1) + deg.WireStartupDegraded(func(mi MerchantInterface) { upgraded <- mi }) + + // Mint comes back; the aggressive probe fires the first-reachable trigger. + srv.healthy.Store(true) + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + tracker.runAggressiveCheck(nil) + select { + case mi := <-upgraded: + if mi == nil { + t.Fatal("onUpgrade fired with a nil merchant") + } + if _, ok := mi.(*Merchant); !ok { + t.Fatalf("onUpgrade fired with %T, want *Merchant", mi) + } + return + case <-time.After(50 * time.Millisecond): + } + } + t.Fatal("service stayed degraded after the mint recovered — the startup path did not wire the recovery trigger (#10)") +} + +// The complement, and the reason the two steps must stay a pair: wiring the +// trigger while the mint is still down must NOT upgrade. A test that only +// asserted "eventually upgrades" would pass if the trigger fired +// unconditionally, so this pins the other direction. +func TestDegradedStartupWiringDoesNotUpgradeWhileTheMintIsDown(t *testing.T) { + srv := newSwitchableMintServer(t) + // Mint stays unhealthy for the entire test. + + cm, testDir := setupTestConfigManager(t) + cfg := cm.GetConfig() + cfg.AcceptedMints = simpleMintConfig(srv.URL) + + tracker := newTestTracker(cfg, nil) + tracker.RunInitialProbe() + if tracker.IsReachable(srv.URL) { + t.Fatal("precondition: mint must be unreachable for a degraded start") + } + + deg := NewMerchantDegradedWithWallet(cm, tracker, DefaultWalletFactory, testDir) + upgraded := make(chan MerchantInterface, 1) + deg.WireStartupDegraded(func(mi MerchantInterface) { upgraded <- mi }) + + tracker.runProactiveCheck() + + select { + case <-upgraded: + t.Fatal("upgrade fired while the mint is unreachable — the startup wiring must not upgrade unconditionally") + case <-time.After(500 * time.Millisecond): + // Correct: stayed degraded while the mint is down. + } +} + +// A merchant that is NOT degraded must not get the degraded wiring at all — +// the type assertion in installMerchant decides this. +func TestWireStartupDegradedIsDegradedOnly(t *testing.T) { + srv := newSwitchableMintServer(t) + srv.healthy.Store(true) + + cm, _ := setupTestConfigManager(t) + cfg := cm.GetConfig() + cfg.AcceptedMints = simpleMintConfig(srv.URL) + + full, err := New(cm) + if err != nil { + t.Fatalf("New: %v", err) + } + if s, ok := full.(interface{ Shutdown() error }); ok { + defer func() { _ = s.Shutdown() }() + } + + // The startup path's type switch: only *MerchantDegraded takes the + // degraded wiring. With the mint up, New must not hand back a degraded + // merchant — otherwise installMerchant would wire the degraded pair onto + // a service that has a working wallet. + if deg, ok := full.(*MerchantDegraded); ok { + t.Fatalf("New returned a degraded merchant (%T) with the mint healthy; installMerchant would wire the degraded pair", deg) + } +} diff --git a/src/merchant/session_event_ptag_test.go b/src/merchant/session_event_ptag_test.go new file mode 100644 index 000000000..67a132a8b --- /dev/null +++ b/src/merchant/session_event_ptag_test.go @@ -0,0 +1,57 @@ +package merchant + +import ( + "testing" +) + +// The session event's `p` tag is a Nostr pubkey by contract; the raw-token +// flow has no customer Nostr identity, so the event must carry NO `p` tag at +// all — never the MAC, which `device-identifier` already records (#949, spec +// PR tollgate#26). These tests pin the tag set of the wire response every +// successful payment returns. +func TestSessionEventOmitsPTagAndCarriesTheContractTags(t *testing.T) { + ndsctl := installRenewalNdsctl(t) + ndsctl.setRegistered(t, true) + + w := &intentWallet{} + m := newIntentMerchant(t, w, t.TempDir()) + + event, err := m.PurchaseSession("cashuBreceive-intent-fixture", intentMAC) + if err != nil { + t.Fatalf("PurchaseSession: %v", err) + } + if event.Kind != 1022 { + t.Fatalf("fresh payment must grant a session (kind 1022), got kind %d (code=%q)", event.Kind, noticeErrorCode(t, event)) + } + + var hasDevice, hasAllotment, hasMetric, hasStartTime bool + for _, tag := range event.Tags { + if tag[0] == "p" { + t.Fatalf("session event must carry no p tag (raw-token flow has no customer pubkey); got %q", tag) + } + switch { + case tag[0] == "device-identifier" && len(tag) >= 3 && tag[1] == "mac": + if tag[2] != intentMAC { + t.Fatalf("device-identifier must be the customer MAC %s, got %q", intentMAC, tag[2]) + } + hasDevice = true + case tag[0] == "allotment": + hasAllotment = true + case tag[0] == "metric": + hasMetric = true + case tag[0] == "start-time": + hasStartTime = true + } + } + if !hasDevice || !hasAllotment || !hasMetric || !hasStartTime { + t.Fatalf("session event must keep its contract tags (device-identifier=%v allotment=%v metric=%v start-time=%v)", + hasDevice, hasAllotment, hasMetric, hasStartTime) + } + + if ok, err := event.CheckSignature(); !ok || err != nil { + t.Fatalf("session event signature must verify after the tag change (ok=%v err=%v)", ok, err) + } + if got := w.receiveCalls.Load(); got != 1 { + t.Fatalf("exactly one Receive must have run, got %d", got) + } +} diff --git a/src/merchant_types/go.mod b/src/merchant_types/go.mod index 5a72bb417..ab9f98f07 100644 --- a/src/merchant_types/go.mod +++ b/src/merchant_types/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect @@ -32,7 +33,6 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.17.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect - golang.org/x/net v0.59.0 // indirect golang.org/x/sys v0.48.0 // indirect ) diff --git a/src/merchant_types/go.sum b/src/merchant_types/go.sum index ce7107dc0..2d072000f 100644 --- a/src/merchant_types/go.sum +++ b/src/merchant_types/go.sum @@ -1,9 +1,30 @@ github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -14,19 +35,42 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -40,6 +84,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nbd-wtf/go-nostr v0.51.12 h1:MRQcrShiW/cHhnYSVDQ4SIEc7DlYV7U7gg/l4H4gbbE= github.com/nbd-wtf/go-nostr v0.51.12/go.mod h1:IF30/Cm4AS90wd1GjsFJbBqq7oD1txo+2YUFYXqK3Nc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= @@ -54,6 +107,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -67,13 +121,48 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/src/rate_limit_loopback_test.go b/src/rate_limit_loopback_test.go new file mode 100644 index 000000000..68fb5bb19 --- /dev/null +++ b/src/rate_limit_loopback_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" +) + +// These tests pin the fix for the 2026-10-16 "Setup failed" incident (#953): +// registerAPIHandlers wires RateLimitMiddleware OUTSIDE requireStarted, so the +// per-IP limiter (10 req/min, burst 10) used to run before the startup gate. +// The installer polls 127.0.0.1:2121 every 3 s (~20 req/min) while it waits for +// the merchant, so the router rate-limited its own localhost installer: the +// probe got `429 {"error":"rate limit exceeded"}` instead of the readable +// `503 {"code":"starting"}` refusal, and a correct install was reported as a +// dead money path. +// +// They drive the route table registerAPIHandlers actually builds (not a +// hand-wired chain) at "/" — the route the installer probes — with the startup +// gate closed, which is exactly the incident's state: the API is up, the +// merchant is still being constructed. + +// newRateLimitTestMux installs the real route table against a fresh, CLOSED +// startup gate and empty limiter buckets, and restores both afterwards. +// +// Both are process-wide state, so these tests are deliberately not parallel +// (the package has no t.Parallel anywhere) and every one of them goes through +// this helper rather than mutating the globals itself. +func newRateLimitTestMux(t *testing.T) *http.ServeMux { + t.Helper() + + prevGate := apiStartup + apiStartup = newStartupGate() // closed: the merchant is still being constructed + resetIPLimiters() + + mux := http.NewServeMux() + registerAPIHandlers(mux) + + t.Cleanup(func() { + apiStartup = prevGate + resetIPLimiters() + }) + return mux +} + +// resetIPLimiters empties the per-IP bucket map so a test starts from a full +// budget regardless of what ran before it in the package. +func resetIPLimiters() { + ipLimitersMu.Lock() + defer ipLimitersMu.Unlock() + ipLimiters = make(map[string]*rate.Limiter) +} + +// doRequest performs one request against the mux with the given RemoteAddr and +// no client timeout (every handler under test answers in-process). +func doRequest(t *testing.T, mux *http.ServeMux, remoteAddr string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remoteAddr + for k, v := range headers { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +// requireStartingRefusal asserts the response is the startup gate's readable +// refusal — the answer the installer can actually act on. +func requireStartingRefusal(t *testing.T, rec *httptest.ResponseRecorder, context string) { + t.Helper() + + require.Equal(t, http.StatusServiceUnavailable, rec.Code, + "%s: got %d (%q), want the explicit starting refusal %d — the limiter must not win over the startup gate for loopback", + context, rec.Code, strings.TrimSpace(rec.Body.String()), http.StatusServiceUnavailable) + + var refusal lightningInvoiceResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &refusal), + "%s: body is not the JSON a portal/installer parses: %q", context, rec.Body.String()) + assert.Equal(t, codeStarting, refusal.Code, "%s: refusal code", context) + assert.Equal(t, 0, refusal.Status, "%s: starting refusal must not look like a successful answer", context) + assert.NotEmpty(t, rec.Header().Get("Retry-After"), + "%s: the starting refusal carries no Retry-After, so a caller cannot know when to come back", context) +} + +// TestRateLimitMiddleware_LoopbackBurstGetsTheStartingRefusal is the incident +// regression test. The installer's real cadence is ~20 requests/min; this sends +// 25 back-to-back from 127.0.0.1, well past the 10-request burst. Every single +// one must be the readable starting refusal, and not one may be a 429. +// +// A mutant that drops the exemption (or keys it on the wrong thing) fails here +// on the first response past the burst. +func TestRateLimitMiddleware_LoopbackBurstGetsTheStartingRefusal(t *testing.T) { + mux := newRateLimitTestMux(t) + + const requests = 25 // > burst (10) and > the installer's ~20/min + + for i := 0; i < requests; i++ { + rec := doRequest(t, mux, "127.0.0.1:41234", nil) + // The status assertion itself rules out 429: a rate-limited probe would + // answer 429 here, which is what the incident's classifier read as + // "unknown". + requireStartingRefusal(t, rec, "loopback probe") + } + + // The exemption must not leave the loopback caller's address in the bucket + // map: an exempt path that still allocates state would let a local process + // grow the map for free (and the map is capped, so that is a real cost). + ipLimitersMu.Lock() + buckets := len(ipLimiters) + ipLimitersMu.Unlock() + assert.Equal(t, 0, buckets, "the exempt loopback path allocated %d limiter bucket(s)", buckets) +} + +// TestRateLimitMiddleware_IPv6LoopbackIsExempt covers ::1 — an installer or +// health probe that resolves localhost to IPv6 is the same caller, and a +// predicate that only compared the string "127.0.0.1" would let it through +// unexempted. +func TestRateLimitMiddleware_IPv6LoopbackIsExempt(t *testing.T) { + mux := newRateLimitTestMux(t) + + for i := 0; i < 15; i++ { + rec := doRequest(t, mux, "[::1]:41234", nil) + requireStartingRefusal(t, rec, "IPv6 loopback probe") + } +} + +// TestRateLimitMiddleware_NonLoopbackStillRateLimited is the other half of the +// contract: the exemption must not disable the limiter. A LAN caller past the +// budget gets the 429, byte for byte the body the shipped code sent. +func TestRateLimitMiddleware_NonLoopbackStillRateLimited(t *testing.T) { + mux := newRateLimitTestMux(t) + + const burst = 10 // rate.NewLimiter(rate.Every(time.Minute/rpm), rpm) with rpm=10 + + // The first `burst` requests are allowed through the limiter and then + // refused by the startup gate — proving the order is still + // limiter-then-gate for a real client, and that the 429s below come from + // the limiter and not from the gate. + for i := 0; i < burst; i++ { + rec := doRequest(t, mux, "192.168.1.50:41234", nil) + requireStartingRefusal(t, rec, "LAN caller inside the budget") + } + + rec := doRequest(t, mux, "192.168.1.50:41234", nil) + require.Equal(t, http.StatusTooManyRequests, rec.Code, + "a non-loopback caller past the budget got %d (%q), want %d — the exemption must not disable the limiter", + rec.Code, strings.TrimSpace(rec.Body.String()), http.StatusTooManyRequests) + assert.Equal(t, "6", rec.Header().Get("Retry-After")) + assert.Contains(t, rec.Body.String(), "rate limit exceeded") + + // A different client still has its own budget: the limit is per-IP, so the + // exhausted bucket must not be a global off switch. + other := doRequest(t, mux, "192.168.1.51:41234", nil) + requireStartingRefusal(t, other, "second LAN caller, own budget") +} + +// TestRateLimitMiddleware_ForwardedHeaderCannotClaimTheExemption is the +// anti-bypass test, and it is the reason the exemption is decided on the +// CONNECTION rather than on getIP(). +// +// getIP() honours X-Real-Ip / X-Forwarded-For from a loopback peer, because a +// proxy on the box forwards a real customer's address that way. Deciding trust +// from that value would be circular: any LAN caller could send +// `X-Real-Ip: 127.0.0.1`, be read as loopback for the purpose of the exemption, +// and mint itself an unlimited-rate bucket — the client-controlled-identity +// defect class tracked in #808. The header must therefore buy a non-loopback +// caller nothing. +func TestRateLimitMiddleware_ForwardedHeaderCannotClaimTheExemption(t *testing.T) { + mux := newRateLimitTestMux(t) + + const burst = 10 + spoof := map[string]string{"X-Real-Ip": "127.0.0.1"} + + for i := 0; i < burst; i++ { + rec := doRequest(t, mux, "192.168.1.50:41234", spoof) + requireStartingRefusal(t, rec, "LAN caller with a spoofed X-Real-Ip inside the budget") + } + + rec := doRequest(t, mux, "192.168.1.50:41234", spoof) + assert.Equal(t, http.StatusTooManyRequests, rec.Code, + "a LAN caller claimed the loopback exemption with X-Real-Ip: 127.0.0.1 and got %d (%q) past the budget, want %d", + rec.Code, strings.TrimSpace(rec.Body.String()), http.StatusTooManyRequests) + + // The same bypass through X-Forwarded-For must not work either. + xff := map[string]string{"X-Forwarded-For": "::1"} + rec = doRequest(t, mux, "192.168.1.50:41234", xff) + assert.Equal(t, http.StatusTooManyRequests, rec.Code, + "a LAN caller claimed the loopback exemption with X-Forwarded-For: ::1 and got %d (%q) past the budget, want %d", + rec.Code, strings.TrimSpace(rec.Body.String()), http.StatusTooManyRequests) +} + +// TestRateLimitMiddleware_ForwardedHeaderCannotExhaustTheExemption is the +// mirror image: a REAL loopback caller that carries a forwarded header must +// stay exempt. The router's own plumbing sets these headers on some local +// requests (getIP reads them for a loopback peer on purpose), and a predicate +// that took the header's value as the caller's identity would hand the +// exemption to whichever address the header named — intermittently +// rate-limiting the installer again, which is the bug being fixed. +// +// Each case sends 15 requests (> burst 10) through ONE named address, so the +// pre-fix code fails it: the header's address gets its own exhausted bucket +// while the connection is loopback. +func TestRateLimitMiddleware_ForwardedHeaderCannotExhaustTheExemption(t *testing.T) { + const requests = 15 // > burst (10), so the header-named bucket is exhausted + + for name, headers := range map[string]map[string]string{ + "X-Real-Ip: a LAN customer address": {"X-Real-Ip": "192.168.1.50"}, + "X-Forwarded-For: a LAN customer chain": {"X-Forwarded-For": "203.0.113.9, 192.168.1.50"}, + "X-Real-Ip: loopback, set by the caller": {"X-Real-Ip": "127.0.0.1"}, + "X-Forwarded-For: IPv6 loopback and a LAN": {"X-Forwarded-For": "::1"}, + } { + t.Run(name, func(t *testing.T) { + mux := newRateLimitTestMux(t) + + for i := 0; i < requests; i++ { + rec := doRequest(t, mux, "127.0.0.1:41234", headers) + requireStartingRefusal(t, rec, "loopback caller with a forwarded header") + } + }) + } +} diff --git a/src/upstream_detector/go.mod b/src/upstream_detector/go.mod index e83c7ee91..2870ccfb6 100644 --- a/src/upstream_detector/go.mod +++ b/src/upstream_detector/go.mod @@ -15,6 +15,7 @@ require ( github.com/OpenTollGate/tollgate-module-basic-go/src/tollgate_protocol v0.0.0 // indirect github.com/OpenTollGate/tollgate-module-basic-go/src/utils v0.0.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect diff --git a/src/upstream_detector/go.sum b/src/upstream_detector/go.sum index d85d4af9d..d3241a378 100644 --- a/src/upstream_detector/go.sum +++ b/src/upstream_detector/go.sum @@ -1,9 +1,31 @@ github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -14,19 +36,42 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -40,6 +85,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nbd-wtf/go-nostr v0.51.12 h1:MRQcrShiW/cHhnYSVDQ4SIEc7DlYV7U7gg/l4H4gbbE= github.com/nbd-wtf/go-nostr v0.51.12/go.mod h1:IF30/Cm4AS90wd1GjsFJbBqq7oD1txo+2YUFYXqK3Nc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= @@ -56,6 +110,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -73,16 +128,51 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/src/upstream_session_manager/go.mod b/src/upstream_session_manager/go.mod index 184a83fc7..ffe00f738 100644 --- a/src/upstream_session_manager/go.mod +++ b/src/upstream_session_manager/go.mod @@ -23,6 +23,7 @@ replace github.com/OpenTollGate/tollgate-module-basic-go/src/utils => ../utils require ( github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect diff --git a/src/upstream_session_manager/go.sum b/src/upstream_session_manager/go.sum index ef4d33136..edccf85a7 100644 --- a/src/upstream_session_manager/go.sum +++ b/src/upstream_session_manager/go.sum @@ -1,9 +1,31 @@ github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -14,19 +36,42 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -40,6 +85,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nbd-wtf/go-nostr v0.51.12 h1:MRQcrShiW/cHhnYSVDQ4SIEc7DlYV7U7gg/l4H4gbbE= github.com/nbd-wtf/go-nostr v0.51.12/go.mod h1:IF30/Cm4AS90wd1GjsFJbBqq7oD1txo+2YUFYXqK3Nc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= @@ -56,6 +110,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -69,14 +124,49 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/tests/cloud-lab/Dockerfile.client b/tests/cloud-lab/Dockerfile.client index 48bca4070..183307dbe 100644 --- a/tests/cloud-lab/Dockerfile.client +++ b/tests/cloud-lab/Dockerfile.client @@ -5,7 +5,7 @@ # Toolchain from the manifest, as a GLOBAL arg so the go-builder FROM # below can expand it (stage-local ARGs are invisible to FROM lines). -ARG GO_VERSION=1.26.9 +ARG GO_VERSION=1.26.10 FROM rust:1.88-bookworm AS rust-builder # Build cdk-cli for wallet operations diff --git a/tests/cloud-lab/Dockerfile.tollgate b/tests/cloud-lab/Dockerfile.tollgate index f998a3265..aebc542a9 100644 --- a/tests/cloud-lab/Dockerfile.tollgate +++ b/tests/cloud-lab/Dockerfile.tollgate @@ -7,7 +7,7 @@ # Toolchain + version come from the manifest (packaging/build-inputs.json) # via docker-compose build args exported by lab.sh — the defaults below are # lockstep fallbacks, drift-gated by tests/contract/check-toolchain-parity.py. -ARG GO_VERSION=1.26.9 +ARG GO_VERSION=1.26.10 FROM golang:${GO_VERSION}-bookworm AS builder WORKDIR /build diff --git a/tests/cloud-lab/docker-compose.yml b/tests/cloud-lab/docker-compose.yml index 388158b8f..f5c79e92e 100644 --- a/tests/cloud-lab/docker-compose.yml +++ b/tests/cloud-lab/docker-compose.yml @@ -127,7 +127,7 @@ services: context: ../.. dockerfile: tests/cloud-lab/Dockerfile.tollgate args: - GO_VERSION: ${TG_GO_VERSION:-1.26.9} + GO_VERSION: ${TG_GO_VERSION:-1.26.10} PACKAGE_VERSION: ${TG_PACKAGE_VERSION:-0.0.0-r0} LDFLAGS: ${TG_LDFLAGS:-} container_name: tg-upstream @@ -167,7 +167,7 @@ services: context: ../.. dockerfile: tests/cloud-lab/Dockerfile.tollgate args: - GO_VERSION: ${TG_GO_VERSION:-1.26.9} + GO_VERSION: ${TG_GO_VERSION:-1.26.10} PACKAGE_VERSION: ${TG_PACKAGE_VERSION:-0.0.0-r0} LDFLAGS: ${TG_LDFLAGS:-} container_name: tg-upstream-ext @@ -199,7 +199,7 @@ services: context: ../.. dockerfile: tests/cloud-lab/Dockerfile.tollgate args: - GO_VERSION: ${TG_GO_VERSION:-1.26.9} + GO_VERSION: ${TG_GO_VERSION:-1.26.10} PACKAGE_VERSION: ${TG_PACKAGE_VERSION:-0.0.0-r0} LDFLAGS: ${TG_LDFLAGS:-} container_name: tg-reseller diff --git a/tests/packaging/postinst-default-route-preserve_test.sh b/tests/packaging/postinst-default-route-preserve_test.sh new file mode 100755 index 000000000..b7bb8cb6a --- /dev/null +++ b/tests/packaging/postinst-default-route-preserve_test.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# Offline test for the default-route preservation across the postinst network +# restart. +# +# THE DEFECT THIS PINS +# +# The postinst runs `/etc/init.d/network restart` so uci-defaults take effect +# on a RUNNING router. A full netifd restart re-applies UCI-backed routes only: +# a default route added by hand — the statically-routed bench/CI seat pattern, +# no DHCP WAN and no uci gateway — is silently wiped, and the seat loses egress +# the moment the installer prints "Restarting network...". Reproduced live on +# the vlab seat (issue #962): runtime `default via 10.99.99.2 dev br-lan`, +# postinst runs, `ip route show default` comes back empty. +# +# WHAT IT PINS +# +# 1. both postinst recipes (the standalone packaging/postinst consumed by +# local-build-ipk.sh, and the inline heredoc in packaging/Makefile that the +# feed's recipe mirrors verbatim) snapshot the default routes BEFORE the +# first restart and restore AFTER the last one; +# 2. the restore is idempotent — a route netifd re-installed identically is +# not re-added; +# 3. the restore function itself actually restores: executed with a mocked +# `ip` whose route table the "restart" wiped, the snapshot comes back; +# 4. the Makefile's SECOND restart (the recheck_private_subnet block, module +# #926) is also covered — the restore call must come after it, not between +# the two restarts. +# +# The historical instance: rc6 E2E on the COVR-X1860 bench (2026-10-16) lost +# `default via 192.168.105.2 dev br-private` in the installer and the seat went +# dark until the route was re-applied by hand. + +set -u +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +FAIL=0 + +fail() { echo "FAIL: $*" >&2; FAIL=1; } +ok() { echo "ok: $*"; } + +# ---- 1: both recipes carry the snapshot/restore ----------------------------- + +for recipe in packaging/postinst; do + if grep -q '_pre_restart_defroutes=' "$REPO/$recipe" && + grep -q 'restore_default_routes "' "$REPO/$recipe" && + grep -q 'restore_default_routes()' "$REPO/$recipe"; then + ok "$recipe snapshots and restores default routes" + else + fail "$recipe lost the default-route snapshot/restore contract" + fi +done + +if grep -Fq '_pre_restart_defroutes="$$(ip route show default' "$REPO/packaging/Makefile" && + grep -Fq 'restore_default_routes()' "$REPO/packaging/Makefile" && + grep -Fq 'restore_default_routes "$$_pre_restart_defroutes"' "$REPO/packaging/Makefile"; then + ok "packaging/Makefile heredoc snapshots and restores default routes" +else + fail "packaging/Makefile lost the default-route snapshot/restore contract" +fi + +# ---- 2: in the Makefile, the restore runs AFTER the recheck block ----------- + +# ---- 1b: the restore must come after the LAST network restart -------------- +# +# Claim 1 above was checked as presence-only, and claim 4 as order-only. That +# left the recipe that actually ships unguarded: adding a second +# `/etc/init.d/network restart` to packaging/postinst AFTER its restore call +# re-wipes the routes and the suite still passed (verified). The Makefile's +# order check would not have caught it either, because it only inspects the +# Makefile. +# +# Generalize: in EVERY recipe that restarts the network, the last restart must +# precede the restore call. This is the property the comment in each recipe +# states ("restore after the LAST restart below"), so the test now checks the +# claim rather than the presence of a line. + +# last_restart_before_restore +# +# Prints nothing and returns 0 when the final `init.d/network restart` in the +# file appears before the final restore call, or on a recipe that has no +# restart at all. Returns 1 (with a message on stdout) otherwise. +last_restart_before_restore() { + _f="$1" + _restore=$(grep -n 'restore_default_routes "' "$_f" | tail -1 | cut -d: -f1) + [ -n "$_restore" ] || { echo "no restore call"; return 1; } + _restart=$(grep -n 'init\.d/network restart' "$_f" | tail -1 | cut -d: -f1) + [ -n "$_restart" ] || { return 0; } # no restart: nothing to order against + if [ "$_restart" -lt "$_restore" ]; then + return 0 + fi + echo "last network restart (line $_restart) is NOT before the restore call (line $_restore) — the restart re-wipes the routes the restore just added" + return 1 +} + +for recipe in packaging/postinst packaging/Makefile; do + if _why=$(last_restart_before_restore "$REPO/$recipe"); then + ok "$recipe: restore sits after the last network restart" + else + fail "$recipe: $_why" + fi +done + +# The Makefile-specific check #926 asked for stays, since it names the recheck +# block explicitly and gives a more actionable message when that block moves. +mkline_restore=$(grep -n 'restore_default_routes "\$$_pre_restart_defroutes"' "$REPO/packaging/Makefile" | cut -d: -f1) +mkline_recheck=$(grep -n 'recheck_private_subnet && /etc/init.d/network restart' "$REPO/packaging/Makefile" | cut -d: -f1) +if [ -n "$mkline_restore" ] && [ -n "$mkline_recheck" ] && [ "$mkline_restore" -gt "$mkline_recheck" ]; then + ok "Makefile restore call sits after the second (recheck) restart" +else + fail "Makefile restore call must come after the recheck_private_subnet restart" +fi + +# The snapshot must happen BEFORE the first restart in both recipes. +for recipe in packaging/postinst; do + snap=$(grep -n '_pre_restart_defroutes=' "$REPO/$recipe" | head -1 | cut -d: -f1) + rst=$(grep -n '/etc/init.d/network restart' "$REPO/$recipe" | head -1 | cut -d: -f1) + if [ -n "$snap" ] && [ -n "$rst" ] && [ "$snap" -lt "$rst" ]; then + ok "$recipe snapshots before the restart" + else + fail "$recipe must snapshot before the network restart" + fi +done +mk_snap=$(grep -nF '_pre_restart_defroutes="$$(ip route show default' "$REPO/packaging/Makefile" | head -1 | cut -d: -f1) +mk_rst=$(grep -n '/etc/init.d/network restart' "$REPO/packaging/Makefile" | head -1 | cut -d: -f1) +if [ -n "$mk_snap" ] && [ -n "$mk_rst" ] && [ "$mk_snap" -lt "$mk_rst" ]; then + ok "Makefile heredoc snapshots before the restart" +else + fail "Makefile must snapshot before the network restart" +fi + +# ---- 3: functional run of the restore with a mocked ip ---------------------- + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/bin" + +cat > "$WORK/bin/ip" <<'FAKE' +#!/bin/sh +# stateful fake: the "route table" is $WORK_STATE; `ip route add` appends, so +# the test can observe exactly what the restore re-added. +case "$1 $2" in + "route show") cat "$WORK_STATE" 2>/dev/null; exit 0 ;; + "route add") shift 2; echo "$*" >> "$WORK_STATE"; exit 0 ;; +esac +exit 0 +FAKE +chmod +x "$WORK/bin/ip" + +extract_restore_fn() { + # pull the restore_default_routes function definition out of packaging/postinst + sed -n '/^restore_default_routes()/,/^}/p' "$REPO/packaging/postinst" +} + +# 3a: wiped table -> snapshot restored verbatim +export WORK_STATE="$WORK/state.a" +printf 'default via 10.99.99.2 dev br-lan\n' > "$WORK_STATE" # pre-restart table +snapshot='default via 10.99.99.2 dev br-lan' +: > "$WORK_STATE" # restart wiped it +eval "$(extract_restore_fn)" +PATH="$WORK/bin:$PATH" restore_default_routes "$snapshot" +if [ "$(cat "$WORK_STATE")" = "default via 10.99.99.2 dev br-lan" ]; then + ok "restore re-adds a wiped runtime default" +else + fail "restore did not re-add the wiped default (table: $(cat "$WORK_STATE" 2>/dev/null))" +fi + +# 3b: route netifd re-installed identically -> NOT duplicated +export WORK_STATE="$WORK/state.b" +printf 'default via 192.168.105.2 dev br-private\n' > "$WORK_STATE" +snapshot='default via 192.168.105.2 dev br-private' +PATH="$WORK/bin:$PATH" restore_default_routes "$snapshot" +if [ "$(wc -l < "$WORK_STATE")" -eq 1 ]; then + ok "restore is idempotent when the route survived" +else + fail "restore duplicated a surviving route: $(cat "$WORK_STATE")" +fi + +# 3c: empty snapshot -> no-op, no crash +export WORK_STATE="$WORK/state.c" +: > "$WORK_STATE" +PATH="$WORK/bin:$PATH" restore_default_routes "" && [ ! -s "$WORK_STATE" ] && \ + ok "empty snapshot is a no-op" || fail "empty snapshot case misbehaved" + +# 3d: uci-backed default (proto static marker) coexists with the runtime one +export WORK_STATE="$WORK/state.d" +printf 'default via 10.99.99.2 dev br-lan proto static\n' > "$WORK_STATE" +snapshot='default via 10.99.99.2 dev br-lan' +PATH="$WORK/bin:$PATH" restore_default_routes "$snapshot" +if grep -qxF 'default via 10.99.99.2 dev br-lan' "$WORK_STATE"; then + ok "runtime default restored alongside the uci-backed one" +else + fail "runtime default missing next to the uci-backed route: $(cat "$WORK_STATE")" +fi + +# ---- verdict ----------------------------------------------------------------- + +if [ "$FAIL" -eq 0 ]; then + echo "postinst-default-route-preserve: PASS" + exit 0 +fi +echo "postinst-default-route-preserve: FAIL" >&2 +exit 1