From 5e668ab5f10e0ad77a8d851b5d0c0df430c00272 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:42:13 -0400 Subject: [PATCH 01/11] deploy: support dedicated v4 Elder hosts --- bootnode/deploy/bootstrap.selftest.mjs | 30 +++- bootnode/deploy/bootstrap.sh | 133 +++++++++++++----- deploy/v4/README.md | 8 +- deploy/v4/ansible.selftest.mjs | 2 +- .../roles/shade_tree_v4/handlers/main.yml | 4 +- .../roles/shade_tree_v4/tasks/main.yml | 99 ++++++++----- 6 files changed, 198 insertions(+), 78 deletions(-) diff --git a/bootnode/deploy/bootstrap.selftest.mjs b/bootnode/deploy/bootstrap.selftest.mjs index 7510271..facf884 100644 --- a/bootnode/deploy/bootstrap.selftest.mjs +++ b/bootnode/deploy/bootstrap.selftest.mjs @@ -13,6 +13,8 @@ // 3. SHADE_TREE_BOOTNODE_ONION= (GAP-6, gateway-only): NO shade-tree-bootnode unit, NO bootnode HS // block, the heartbeat announces to the REMOTE onion (with/without .onion suffix accepted), // the gateway unit is byte-identical to the default one, a malformed onion is rejected. +// 3b. SHADE_TREE_ELDER_ONLY=1: ONLY the Elder hidden service + unit are rendered; no gateway +// identity, gateway unit, or heartbeat exists, and incompatible gateway tunables fail closed. // 4. SHADE_TREE_GATEWAY_REGION passthrough into the heartbeat unit; invalid bucket rejected. // 5. Nothing outside is touched (the render dir is the only side effect). // 6. SHADE_TREE_HELIOS=1 (T-DEV-9b, opt-in): a 4th unit shade-tree-helios.service (hardened, loopback-only, @@ -119,7 +121,7 @@ async function main() { ok(/MEMBERS_DEST="\/etc\/shade-tree\/members\.json"/.test(bootstrapSource) && /install -o root -g "\$RUN_USER" -m 0640 "\$MEMBERS_SOURCE" "\$MEMBERS_DEST"/.test(bootstrapSource), "live bootstrap installs the invited admission root as root-owned and node-readable"); ok(!/chown "\$RUN_USER:\$RUN_USER" "\$MEMBERS_DEST"/.test(bootstrapSource) && /ProtectSystem=strict/.test(bootstrapSource) && /ReadWritePaths=\$\{SHADE_TREE_DIR\}\/deploy-state/.test(bootstrapSource), "the node cannot make its trusted member set service-writable through the systemd sandbox"); ok(/else\s+# An omitted peer list is the off switch[\s\S]*?rm -f "\$FLEET_TALLY_ENV_FILE"\s+fi/.test(bootstrapSource), "disabling the optional fleet tally removes its stale bearer-token file"); - ok(/systemctl is-active --quiet shade-tree-gateway/.test(bootstrapSource) && /elif \[ "\$GATEWAY_WAS_ACTIVE" = "1" \]; then\s+systemctl restart shade-tree-gateway/.test(bootstrapSource), "an idempotent re-run restarts an already-running gateway so tally rotation or disablement takes effect"); + ok(/systemctl is-active --quiet shade-tree-gateway/.test(bootstrapSource) && /elif \[ "\$WITH_GATEWAY" = "1" \] && \[ "\$GATEWAY_WAS_ACTIVE" = "1" \]; then\s+systemctl restart shade-tree-gateway/.test(bootstrapSource), "an idempotent re-run restarts an already-running gateway so tally rotation or disablement takes effect"); ok(/mktemp \/etc\/shade-tree\/\.fleet-tally\.env\.XXXXXX/.test(bootstrapSource) && /mv -f "\$FLEET_TALLY_ENV_TMP" "\$FLEET_TALLY_ENV_FILE"/.test(bootstrapSource), "fleet tally token rotation replaces the root-only environment file atomically"); ok(/safe `shade-tree proxy` template/.test(deployReadme) && /exact\s+enrolled tier/.test(deployReadme) && !/shade-tree client/.test(deployReadme), "deploy README promises a template and names the operator values it cannot invent"); @@ -220,6 +222,32 @@ async function main() { const cli = render(work, "cli", { SHADE_TREE_BOOTNODE_ONION: ONION }, ["--render", join(work, "cli")]); ok(cli.status === 0 && (await readAll(join(work, "cli"))).get("etc/systemd/system/shade-tree-heartbeat.service") === hb, "`--render ` == SHADE_TREE_RENDER_ONLY="); + // ---------------------------------------------------------------- 3b. dedicated Elder mode + console.log("SHADE_TREE_ELDER_ONLY dedicated control plane:"); + const elderOnly = render(work, "elder-only", { SHADE_TREE_ELDER_ONLY: "1" }); + ok(elderOnly.status === 0 && /elder-only/.test(elderOnly.stdout), `Elder-only renders (${(elderOnly.stdout || "").trim()})`); + const elderFiles = await readAll(elderOnly.out); + ok([...elderFiles.keys()].join(",") === "etc/systemd/system/shade-tree-bootnode.service,etc/tor/torrc.d-shade-tree", + `emits torrc + Elder unit ONLY (${[...elderFiles.keys()].join(", ")})`); + const elderBlocks = hsBlocks(elderFiles.get("etc/tor/torrc.d-shade-tree")); + ok(elderBlocks.length === 1 && elderBlocks[0].dir === "/var/lib/tor/shade-tree-bootnode", "Elder-only torrc contains exactly the Elder hidden service"); + ok(elderBlocks[0].lines[0] === "HiddenServicePort 80 127.0.0.1:8877" && elderBlocks[0].lines[1] === "HiddenServicePoWDefensesEnabled 0", "Elder-only backend remains loopback-only with PoW off by default"); + ok(elderFiles.get("etc/systemd/system/shade-tree-bootnode.service") === elderDef, "Elder-only unit is byte-identical to the default Elder unit"); + for (const alias of ["true", "yes", "on"]) { + const r = render(work, `elder-only-${alias}`, { SHADE_TREE_ELDER_ONLY: alias }); + ok(r.status === 0 && (await readAll(r.out)).size === 2, `SHADE_TREE_ELDER_ONLY=${alias} == 1`); + } + for (const [name, env, message] of [ + ["elder-remote", { SHADE_TREE_ELDER_ONLY: "1", SHADE_TREE_BOOTNODE_ONION: ONION }, /mutually exclusive/], + ["elder-region", { SHADE_TREE_ELDER_ONLY: "1", SHADE_TREE_GATEWAY_REGION: "na" }, /not valid in Elder-only mode/], + ["elder-helios", { SHADE_TREE_ELDER_ONLY: "1", SHADE_TREE_HELIOS: "1" }, /requires a gateway/], + ]) { + const r = render(work, name, env); + ok(r.status !== 0 && message.test(r.stderr), `${name} is rejected before rendering`); + } + const elderBad = render(work, "elder-bad", { SHADE_TREE_ELDER_ONLY: "maybe" }); + ok(elderBad.status !== 0 && /SHADE_TREE_ELDER_ONLY must be 1 or 0/.test(elderBad.stderr), "invalid Elder-only toggle is rejected"); + // ---------------------------------------------------------------- 4. region passthrough console.log("SHADE_TREE_GATEWAY_REGION passthrough:"); const reg = render(work, "region", { SHADE_TREE_GATEWAY_REGION: "eu" }); diff --git a/bootnode/deploy/bootstrap.sh b/bootnode/deploy/bootstrap.sh index c098e11..8c5fc97 100644 --- a/bootnode/deploy/bootstrap.sh +++ b/bootnode/deploy/bootstrap.sh @@ -42,6 +42,9 @@ # section 2). Optional companions, only read in this mode: # SHADE_TREE_BOOTNODE_SIGNER the remote bootnode's pinned signer pubkey -- printed into the # client command at the end (the heartbeat does not need it). +# SHADE_TREE_ELDER_ONLY 1 | 0 (default: 0) DEDICATED-ELDER mode. Publishes only +# the Elder onion and runs only shade-tree-bootnode (no gateway onion, gateway, +# or heartbeat). Mutually exclusive with SHADE_TREE_BOOTNODE_ONION. # SHADE_TREE_GATEWAY_REGION na|sa|eu|af|as|oc|aq|unknown (default: unset = not advertised) # coarse region bucket the heartbeat advertises in signed caps (docs/CONFIG.md). # SHADE_TREE_HELIOS 1 | 0 (default: 0) OPT-IN Helios light-client sidecar (T-DEV-9b, @@ -142,6 +145,7 @@ SHADE_TREE_GATEWAY_PORT="${SHADE_TREE_GATEWAY_PORT:-8443}" SHADE_TREE_ENABLE_POW="${SHADE_TREE_ENABLE_POW:-0}" SHADE_TREE_BOOTNODE_ONION="${SHADE_TREE_BOOTNODE_ONION:-}" SHADE_TREE_BOOTNODE_SIGNER="${SHADE_TREE_BOOTNODE_SIGNER:-}" +SHADE_TREE_ELDER_ONLY="${SHADE_TREE_ELDER_ONLY:-0}" SHADE_TREE_GATEWAY_REGION="${SHADE_TREE_GATEWAY_REGION:-}" SHADE_TREE_RENDER_ONLY="${SHADE_TREE_RENDER_ONLY:-}" RUN_USER="${SHADE_TREE_USER:-shade-tree}" @@ -199,6 +203,11 @@ case "$SHADE_TREE_ENABLE_POW" in 0|false|no|off) SHADE_TREE_ENABLE_POW=0 ;; *) die "SHADE_TREE_ENABLE_POW must be 1 or 0 (got '$SHADE_TREE_ENABLE_POW')" ;; esac +case "$SHADE_TREE_ELDER_ONLY" in + 1|true|yes|on) SHADE_TREE_ELDER_ONLY=1 ;; + 0|false|no|off) SHADE_TREE_ELDER_ONLY=0 ;; + *) die "SHADE_TREE_ELDER_ONLY must be 1 or 0 (got '$SHADE_TREE_ELDER_ONLY')" ;; +esac case "$SHADE_TREE_ADMISSION" in open|stake) ;; *) die "SHADE_TREE_ADMISSION must be open or stake (got '$SHADE_TREE_ADMISSION')" ;; esac case "$SHADE_TREE_LOG_LEVEL" in debug|info|warn|error|off) ;; *) die "SHADE_TREE_LOG_LEVEL must be debug, info, warn, error, or off" ;; esac case "$SHADE_TREE_LOG_FORMAT" in auto|pretty|text|json) ;; *) die "SHADE_TREE_LOG_FORMAT must be auto, pretty, text, or json" ;; esac @@ -214,25 +223,34 @@ for metrics_port in "$SHADE_TREE_ELDER_METRICS_PORT" "$SHADE_TREE_NODE_METRICS_P case " $metrics_ports_seen " in *" $metrics_port "*) die "operator metrics ports must be distinct (duplicate '$metrics_port')" ;; esac metrics_ports_seen="$metrics_ports_seen $metrics_port" done -# Mode: WITH_BOOTNODE=1 -> this box runs bootnode + gateway (default, unchanged behaviour); -# WITH_BOOTNODE=0 -> gateway-only, heartbeat -> the remote SHADE_TREE_BOOTNODE_ONION. +# Mode: default is bootnode + gateway; SHADE_TREE_BOOTNODE_ONION selects gateway-only; +# SHADE_TREE_ELDER_ONLY=1 selects a dedicated Elder with no gateway or heartbeat. WITH_BOOTNODE=1 +WITH_GATEWAY=1 +if [ "$SHADE_TREE_ELDER_ONLY" = "1" ] && [ -n "$SHADE_TREE_BOOTNODE_ONION" ]; then + die "SHADE_TREE_ELDER_ONLY is mutually exclusive with SHADE_TREE_BOOTNODE_ONION" +fi if [ -n "$SHADE_TREE_BOOTNODE_ONION" ]; then SHADE_TREE_BOOTNODE_ONION="${SHADE_TREE_BOOTNODE_ONION%.onion}.onion" [[ "$SHADE_TREE_BOOTNODE_ONION" =~ ^[a-z2-7]{56}\.onion$ ]] \ || die "SHADE_TREE_BOOTNODE_ONION must be a v3 onion address (56 base32 chars, optional .onion suffix)" WITH_BOOTNODE=0 fi +if [ "$SHADE_TREE_ELDER_ONLY" = "1" ]; then WITH_GATEWAY=0; fi if [ -n "$SHADE_TREE_GATEWAY_REGION" ]; then case "$SHADE_TREE_GATEWAY_REGION" in na|sa|eu|af|as|oc|aq|unknown) ;; *) die "SHADE_TREE_GATEWAY_REGION must be one of na sa eu af as oc aq unknown (got '$SHADE_TREE_GATEWAY_REGION')" ;; esac fi +if [ "$WITH_GATEWAY" = "0" ] && [ -n "$SHADE_TREE_GATEWAY_REGION" ]; then + die "SHADE_TREE_GATEWAY_REGION is not valid in Elder-only mode" +fi case "$SHADE_TREE_HELIOS" in 1|true|yes|on) SHADE_TREE_HELIOS=1 ;; 0|false|no|off) SHADE_TREE_HELIOS=0 ;; *) die "SHADE_TREE_HELIOS must be 1 or 0 (got '$SHADE_TREE_HELIOS')" ;; esac if [ "$SHADE_TREE_HELIOS" = "1" ]; then + [ "$WITH_GATEWAY" = "1" ] || die "SHADE_TREE_HELIOS=1 requires a gateway; it is not valid in Elder-only mode" # URLs: http(s) (ws(s) too for the execution RPC), no whitespace/quotes/semicolons (they land in unit files). [[ "$SHADE_TREE_HELIOS_CONSENSUS_RPC" =~ ^https?://[A-Za-z0-9._~:/?#@!$\&*+,=%-]+$ ]] \ || die "SHADE_TREE_HELIOS=1 needs SHADE_TREE_HELIOS_CONSENSUS_RPC=" @@ -268,7 +286,7 @@ if [ "$ADMIT_STAKED" = "1" ]; then [[ "$SHADE_TREE_GROUP_CONTRACT" =~ ^0x[0-9a-fA-F]{40}(,0x[0-9a-fA-F]{40})*$ ]] \ || die "SHADE_TREE_ADMIT names staked: needs SHADE_TREE_GROUP_CONTRACT=<0x StakedReputationSet address[,...]>" fi -if [ "$ADMIT_INVITED" = "1" ] && [ -z "$SHADE_TREE_RENDER_ONLY" ]; then +if [ "$WITH_GATEWAY" = "1" ] && [ "$ADMIT_INVITED" = "1" ] && [ -z "$SHADE_TREE_RENDER_ONLY" ]; then [ -n "$SHADE_TREE_MEMBERS_FILE" ] \ || die "SHADE_TREE_ADMIT includes invited: pass SHADE_TREE_MEMBERS_FILE=/absolute/path/to/operator-members.json (the committed group/members.json is demo data and is never trusted by the live bootstrap)" [[ "$SHADE_TREE_MEMBERS_FILE" = /* ]] \ @@ -279,7 +297,7 @@ fi [ -z "$SHADE_TREE_MEMBERS_FILE" ] || [[ "$SHADE_TREE_MEMBERS_FILE" =~ ^/[A-Za-z0-9._/+:-]+$ ]] \ || die "SHADE_TREE_MEMBERS_FILE contains unsupported path characters (use an absolute path without spaces)" SHADE_TREE_MEMBERS_RUNTIME_FILE="$SHADE_TREE_MEMBERS_FILE" -if [ "$ADMIT_INVITED" = "1" ] && [ -n "$SHADE_TREE_MEMBERS_FILE" ]; then +if [ "$WITH_GATEWAY" = "1" ] && [ "$ADMIT_INVITED" = "1" ] && [ -n "$SHADE_TREE_MEMBERS_FILE" ]; then SHADE_TREE_MEMBERS_RUNTIME_FILE="/etc/shade-tree/members.json" fi if [ "$ADMIT_PAID" = "1" ]; then @@ -310,6 +328,7 @@ SHADE_TREE_PAY_PROTOCOLS="" [ "$PAY_X402" = "1" ] && SHADE_TREE_PAY_PROTOCOLS="x402" [ "$PAY_MPP" = "1" ] && SHADE_TREE_PAY_PROTOCOLS="${SHADE_TREE_PAY_PROTOCOLS:+$SHADE_TREE_PAY_PROTOCOLS,}mpp" if [ "$SHADE_TREE_REGISTRAR" = "1" ]; then + [ "$WITH_GATEWAY" = "1" ] || die "SHADE_TREE_REGISTRAR=1 requires a gateway; it is not valid in Elder-only mode" [ "$ADMIT_PAID" = "1" ] || die "SHADE_TREE_REGISTRAR=1 sells paid leaves but SHADE_TREE_ADMIT=${SHADE_TREE_ADMIT} does not admit them; set SHADE_TREE_ADMIT=${SHADE_TREE_ADMIT},paid (a gateway must honour what it sells)" [[ "$SHADE_TREE_PAID_ACCESS_CONTRACT" =~ ^0x[0-9a-fA-F]{40}$ ]] \ || die "SHADE_TREE_REGISTRAR=1 needs SHADE_TREE_PAID_ACCESS_CONTRACT=<0x PaidAccessSet address>" @@ -327,6 +346,7 @@ fi FLEET_TALLY_ENABLED=0 if [ -n "$SHADE_TREE_FLEET_TALLY_PEERS" ]; then + [ "$WITH_GATEWAY" = "1" ] || die "SHADE_TREE_FLEET_TALLY_PEERS requires a gateway; it is not valid in Elder-only mode" FLEET_TALLY_ENABLED=1 { [[ "$SHADE_TREE_FLEET_TALLY_PORT" =~ ^[0-9]{4,5}$ ]] && [ "$SHADE_TREE_FLEET_TALLY_PORT" -ge 1024 ] && [ "$SHADE_TREE_FLEET_TALLY_PORT" -le 65535 ]; } \ || die "SHADE_TREE_FLEET_TALLY_PORT must be a port in 1024..65535 (got '$SHADE_TREE_FLEET_TALLY_PORT')" @@ -353,9 +373,11 @@ reserve_runtime_port() { # $1 = label, $2 = port runtime_ports_seen="$runtime_ports_seen $2" } reserve_runtime_port "Tor SOCKS" "9050" -reserve_runtime_port "gateway backend" "$SHADE_TREE_GATEWAY_PORT" -reserve_runtime_port "node metrics" "$SHADE_TREE_NODE_METRICS_PORT" -reserve_runtime_port "heartbeat metrics" "$SHADE_TREE_HEARTBEAT_METRICS_PORT" +if [ "$WITH_GATEWAY" = "1" ]; then + reserve_runtime_port "gateway backend" "$SHADE_TREE_GATEWAY_PORT" + reserve_runtime_port "node metrics" "$SHADE_TREE_NODE_METRICS_PORT" + reserve_runtime_port "heartbeat metrics" "$SHADE_TREE_HEARTBEAT_METRICS_PORT" +fi if [ "$WITH_BOOTNODE" = "1" ]; then reserve_runtime_port "bootnode backend" "$SHADE_TREE_BOOTNODE_PORT" reserve_runtime_port "Elder metrics" "$SHADE_TREE_ELDER_METRICS_PORT" @@ -395,7 +417,11 @@ fi render_torrc() { # $1 = output file { if [ "$WITH_BOOTNODE" = "1" ]; then - echo "# shade-tree: two onion services (bootnode + gateway). PoW defense: SHADE_TREE_ENABLE_POW=${SHADE_TREE_ENABLE_POW}." + if [ "$WITH_GATEWAY" = "1" ]; then + echo "# shade-tree: two onion services (bootnode + gateway). PoW defense: SHADE_TREE_ENABLE_POW=${SHADE_TREE_ENABLE_POW}." + else + echo "# shade-tree: dedicated Elder onion service. PoW defense: SHADE_TREE_ENABLE_POW=${SHADE_TREE_ENABLE_POW}." + fi echo "HiddenServiceDir /var/lib/tor/shade-tree-bootnode" echo "HiddenServicePort 80 127.0.0.1:${SHADE_TREE_BOOTNODE_PORT}" # The 402 registrar rides the SAME onion on an extra virtual port (SHADE_TREE_REGISTRAR=1). @@ -404,12 +430,14 @@ render_torrc() { # $1 = output file else echo "# shade-tree: gateway-only box (bootnode is remote: ${SHADE_TREE_BOOTNODE_ONION}). PoW defense: SHADE_TREE_ENABLE_POW=${SHADE_TREE_ENABLE_POW}." fi - echo "HiddenServiceDir /var/lib/tor/shade-tree-gateway" - echo "HiddenServicePort 80 127.0.0.1:${SHADE_TREE_GATEWAY_PORT}" - [ "$FLEET_TALLY_ENABLED" = "1" ] && echo "HiddenServicePort ${SHADE_TREE_FLEET_TALLY_PORT} 127.0.0.1:${SHADE_TREE_FLEET_TALLY_PORT}" - # Gateway-only box: the 402 registrar rides the GATEWAY onion on an extra virtual port (T-FEAT-9). - [ "$SHADE_TREE_REGISTRAR" = "1" ] && [ "$WITH_BOOTNODE" = "0" ] && echo "HiddenServicePort ${SHADE_TREE_REGISTRAR_PORT} 127.0.0.1:${SHADE_TREE_REGISTRAR_PORT}" - echo "HiddenServicePoWDefensesEnabled ${SHADE_TREE_ENABLE_POW}" + if [ "$WITH_GATEWAY" = "1" ]; then + echo "HiddenServiceDir /var/lib/tor/shade-tree-gateway" + echo "HiddenServicePort 80 127.0.0.1:${SHADE_TREE_GATEWAY_PORT}" + [ "$FLEET_TALLY_ENABLED" = "1" ] && echo "HiddenServicePort ${SHADE_TREE_FLEET_TALLY_PORT} 127.0.0.1:${SHADE_TREE_FLEET_TALLY_PORT}" + # Gateway-only box: the 402 registrar rides the GATEWAY onion on an extra virtual port (T-FEAT-9). + [ "$SHADE_TREE_REGISTRAR" = "1" ] && [ "$WITH_BOOTNODE" = "0" ] && echo "HiddenServicePort ${SHADE_TREE_REGISTRAR_PORT} 127.0.0.1:${SHADE_TREE_REGISTRAR_PORT}" + echo "HiddenServicePoWDefensesEnabled ${SHADE_TREE_ENABLE_POW}" + fi } > "$1" } @@ -705,18 +733,19 @@ GW_HS="$SHADE_TREE_DIR/deploy-state/gateway-hs" # --- RENDER mode: emit the files and stop ------------------------------------------------- if [ -n "$SHADE_TREE_RENDER_ONLY" ]; then NODE_BIN="${SHADE_TREE_NODE_BIN:-/usr/bin/node}" - GW_ONION="gatewayplaceholderplaceholderplaceholderplaceholderplace.onion" + if [ "$WITH_GATEWAY" = "1" ]; then GW_ONION="gatewayplaceholderplaceholderplaceholderplaceholderplace.onion"; else GW_ONION=""; fi if [ "$WITH_BOOTNODE" = "1" ]; then BN_ONION="bootnodeplaceholderplaceholderplaceholderplaceholderplac.onion"; else BN_ONION="$SHADE_TREE_BOOTNODE_ONION"; fi if [ "$WITH_BOOTNODE" = "1" ]; then REG_ONION="$BN_ONION"; else REG_ONION="$GW_ONION"; fi out="$SHADE_TREE_RENDER_ONLY" mkdir -p "$out/etc/tor" "$out/etc/systemd/system" render_torrc "$out/etc/tor/torrc.d-shade-tree" [ "$WITH_BOOTNODE" = "1" ] && render_bootnode_unit "$out/etc/systemd/system/shade-tree-bootnode.service" - render_gateway_unit "$out/etc/systemd/system/shade-tree-gateway.service" - render_heartbeat_unit "$out/etc/systemd/system/shade-tree-heartbeat.service" + [ "$WITH_GATEWAY" = "1" ] && render_gateway_unit "$out/etc/systemd/system/shade-tree-gateway.service" + [ "$WITH_GATEWAY" = "1" ] && render_heartbeat_unit "$out/etc/systemd/system/shade-tree-heartbeat.service" [ "$SHADE_TREE_HELIOS" = "1" ] && render_helios_unit "$out/etc/systemd/system/shade-tree-helios.service" [ "$SHADE_TREE_REGISTRAR" = "1" ] && render_registrar_unit "$out/etc/systemd/system/shade-tree-registrar.service" - echo "rendered to $out (mode: $([ "$WITH_BOOTNODE" = "1" ] && echo bootnode+gateway || echo gateway-only), pow=${SHADE_TREE_ENABLE_POW}, helios=${SHADE_TREE_HELIOS}, registrar=${SHADE_TREE_REGISTRAR}, admit=${SHADE_TREE_ADMIT}$([ "$SHADE_TREE_REGISTRAR" = "1" ] && echo ", pay=${SHADE_TREE_PAY_PROTOCOLS}"))" + if [ "$WITH_GATEWAY" = "0" ]; then render_mode="elder-only"; elif [ "$WITH_BOOTNODE" = "1" ]; then render_mode="bootnode+gateway"; else render_mode="gateway-only"; fi + echo "rendered to $out (mode: ${render_mode}, pow=${SHADE_TREE_ENABLE_POW}, helios=${SHADE_TREE_HELIOS}, registrar=${SHADE_TREE_REGISTRAR}, admit=${SHADE_TREE_ADMIT}$([ "$SHADE_TREE_REGISTRAR" = "1" ] && echo ", pay=${SHADE_TREE_PAY_PROTOCOLS}"))" exit 0 fi @@ -781,7 +810,7 @@ fi # service-writable directory. The root-owned file is group-readable by the node, but the node # cannot rewrite its own admission root. This also keeps a later bootstrap invocation idempotent: # the source remains explicit, while the unit always reads the canonical protected copy. -if [ "$ADMIT_INVITED" = "1" ]; then +if [ "$WITH_GATEWAY" = "1" ] && [ "$ADMIT_INVITED" = "1" ]; then MEMBERS_SOURCE="$SHADE_TREE_MEMBERS_FILE" node - "$MEMBERS_SOURCE" <<'NODE' const { readFileSync } = require("node:fs"); @@ -813,14 +842,19 @@ if [ "$WITH_BOOTNODE" = "1" ]; then else BN_ONION="$SHADE_TREE_BOOTNODE_ONION" # remote; nothing minted here fi -[ -f "$GW_HS/hostname" ] || node "$SHADE_TREE_DIR/bootnode/keygen.mjs" "$GW_HS" --label gateway >/dev/null -GW_ONION="$(cat "$GW_HS/hostname")" +if [ "$WITH_GATEWAY" = "1" ]; then + [ -f "$GW_HS/hostname" ] || node "$SHADE_TREE_DIR/bootnode/keygen.mjs" "$GW_HS" --label gateway >/dev/null + GW_ONION="$(cat "$GW_HS/hostname")" +else + GW_ONION="" +fi if [ "$WITH_BOOTNODE" = "1" ]; then REG_ONION="$BN_ONION"; else REG_ONION="$GW_ONION"; fi # the onion the 402 registrar rides -if [ "$WITH_BOOTNODE" = "1" ]; then log "tor config (two hidden services, pow=${SHADE_TREE_ENABLE_POW})"; else log "tor config (gateway hidden service only, pow=${SHADE_TREE_ENABLE_POW})"; fi +if [ "$WITH_GATEWAY" = "0" ]; then log "tor config (dedicated Elder hidden service only, pow=${SHADE_TREE_ENABLE_POW})"; elif [ "$WITH_BOOTNODE" = "1" ]; then log "tor config (two hidden services, pow=${SHADE_TREE_ENABLE_POW})"; else log "tor config (gateway hidden service only, pow=${SHADE_TREE_ENABLE_POW})"; fi # Tor owns the HS dirs; copy the minted keys into tor's own dirs (Tor is strict about perms). -HS_PAIRS=("$GW_HS:/var/lib/tor/shade-tree-gateway") -[ "$WITH_BOOTNODE" = "1" ] && HS_PAIRS=("$BN_HS:/var/lib/tor/shade-tree-bootnode" "${HS_PAIRS[@]}") +HS_PAIRS=() +[ "$WITH_BOOTNODE" = "1" ] && HS_PAIRS+=("$BN_HS:/var/lib/tor/shade-tree-bootnode") +[ "$WITH_GATEWAY" = "1" ] && HS_PAIRS+=("$GW_HS:/var/lib/tor/shade-tree-gateway") for pair in "${HS_PAIRS[@]}"; do src="${pair%%:*}"; dst="${pair##*:}" install -d -o debian-tor -g debian-tor -m 0700 "$dst" @@ -889,35 +923,47 @@ fi # `enable --now` starts an inactive unit but deliberately does not reload an active one. Remember # the pre-run state so a live re-run applies new peer/token configuration (or stops a disabled # tally listener) without needlessly double-starting the gateway on its first install. -if systemctl is-active --quiet shade-tree-gateway; then +if [ "$WITH_GATEWAY" = "1" ] && systemctl is-active --quiet shade-tree-gateway; then GATEWAY_WAS_ACTIVE=1 else GATEWAY_WAS_ACTIVE=0 fi -UNITS="shade-tree-gateway" +UNITS="" if [ "$WITH_BOOTNODE" = "1" ]; then render_bootnode_unit /etc/systemd/system/shade-tree-bootnode.service - UNITS="shade-tree-bootnode $UNITS" + UNITS="shade-tree-bootnode" elif [ -f /etc/systemd/system/shade-tree-bootnode.service ]; then # A previous run of this box was bootnode+gateway; gateway-only means that unit must go. systemctl disable --now shade-tree-bootnode >/dev/null 2>&1 || true rm -f /etc/systemd/system/shade-tree-bootnode.service fi -render_gateway_unit /etc/systemd/system/shade-tree-gateway.service +if [ "$WITH_GATEWAY" = "1" ]; then + render_gateway_unit /etc/systemd/system/shade-tree-gateway.service + UNITS="${UNITS:+$UNITS }shade-tree-gateway" +elif [ -f /etc/systemd/system/shade-tree-gateway.service ]; then + systemctl disable --now shade-tree-gateway >/dev/null 2>&1 || true + rm -f /etc/systemd/system/shade-tree-gateway.service +fi # the deploy-state dir must be writable by the service user (signer key is minted at runtime) chown -R "$RUN_USER":"$RUN_USER" "$SHADE_TREE_DIR/deploy-state" systemctl daemon-reload # shellcheck disable=SC2086 if ! systemctl enable --now $UNITS >/dev/null 2>&1; then systemctl restart $UNITS -elif [ "$GATEWAY_WAS_ACTIVE" = "1" ]; then +elif [ "$WITH_GATEWAY" = "1" ] && [ "$GATEWAY_WAS_ACTIVE" = "1" ]; then systemctl restart shade-tree-gateway fi -log "gateway heartbeat -> bootnode ${BN_ONION}" -render_heartbeat_unit /etc/systemd/system/shade-tree-heartbeat.service -systemctl daemon-reload -systemctl enable --now shade-tree-heartbeat >/dev/null 2>&1 || systemctl restart shade-tree-heartbeat +if [ "$WITH_GATEWAY" = "1" ]; then + log "gateway heartbeat -> bootnode ${BN_ONION}" + render_heartbeat_unit /etc/systemd/system/shade-tree-heartbeat.service + systemctl daemon-reload + systemctl enable --now shade-tree-heartbeat >/dev/null 2>&1 || systemctl restart shade-tree-heartbeat +elif [ -f /etc/systemd/system/shade-tree-heartbeat.service ]; then + systemctl disable --now shade-tree-heartbeat >/dev/null 2>&1 || true + rm -f /etc/systemd/system/shade-tree-heartbeat.service + systemctl daemon-reload +fi if [ "$SHADE_TREE_REGISTRAR" = "1" ]; then log "402 registrar on ${REG_ONION}:${SHADE_TREE_REGISTRAR_PORT} (rails: ${SHADE_TREE_PAY_PROTOCOLS}; onion: $([ "$WITH_BOOTNODE" = "1" ] && echo bootnode || echo gateway))" @@ -960,7 +1006,26 @@ EOF fi } -if [ "$WITH_BOOTNODE" = "1" ]; then +if [ "$WITH_GATEWAY" = "0" ]; then + log "waiting for the Elder signer + onion descriptor (~15s)…" + sleep 15 + SIGNER="$(node -e "console.log(JSON.parse(require('fs').readFileSync('${SHADE_TREE_DIR}/deploy-state/bootnode-signer.key')).pub)" 2>/dev/null || echo '')" + cat </dev/null || echo '')" diff --git a/deploy/v4/README.md b/deploy/v4/README.md index 783c33f..70a5b26 100644 --- a/deploy/v4/README.md +++ b/deploy/v4/README.md @@ -51,9 +51,9 @@ node deploy/v4/preflight.mjs \ ## 2. Prepare secret inputs outside Git -Restore each host's identity backup into a controller directory. The role requires complete -`gateway-hs/` material; an Elder host also requires `bootnode-hs/` and -`bootnode-signer.key`. Before any remote mutation it derives the onions from the Tor secret keys +Restore each host's identity backup into a controller directory. A `node` target requires complete +`gateway-hs/` material; an `elder` target requires `bootnode-hs/` and `bootnode-signer.key`; +the backward-compatible `elder-and-node` mode requires both. Before any remote mutation it derives the onions from the Tor secret keys and compares the Elder onion and Canopy signer to the public record. Keep the operator member file, slashing key, gateway-operator key, and credentialed RPC URL in @@ -77,7 +77,7 @@ replace its placeholders. Set host variables there or in encrypted vars: ```yaml shade_tree_deployment_record: /absolute/controller/path/network/research-v4/deployment.json -shade_tree_target_mode: elder-and-node # or node +shade_tree_target_mode: elder # or node; elder-and-node remains available for a colocated test host shade_tree_admin_cidr: 198.51.100.24/32 shade_tree_identity_source: /absolute/controller/path/restored-host-identity shade_tree_members_file: /absolute/controller/path/operator-members.json diff --git a/deploy/v4/ansible.selftest.mjs b/deploy/v4/ansible.selftest.mjs index dc315f6..a6244eb 100644 --- a/deploy/v4/ansible.selftest.mjs +++ b/deploy/v4/ansible.selftest.mjs @@ -28,6 +28,7 @@ ok(/services\.node/.test(TASKS) && /version: "\{\{ shade_tree_service_pin\.commi ok(!/version:\s*(main|master|HEAD)\b/.test(TASKS + DEFAULTS), "role has no floating git ref fallback"); console.log("identity and admission:"); +ok(/shade_tree_target_mode in \['elder', 'elder-and-node', 'node'\]/.test(TASKS) && /SHADE_TREE_ELDER_ONLY/.test(TASKS), "dedicated Elder targets select bootstrap Elder-only mode"); ok(/onion-identity\.mjs[\s\S]*derive/.test(TASKS) && /canopySigner/.test(TASKS), "restored onion and Canopy signer are checked before cutover"); ok(/membersSha256/.test(TASKS) && /checksum_algorithm: sha256/.test(TASKS), "invited member bytes must match the reviewed root hash"); ok(/shade_tree_slash_key is match/.test(TASKS) && /shade_tree_gateway_operator_key is match/.test(TASKS), "on-chain private keys are syntactically required before mutation"); @@ -45,4 +46,3 @@ ok(/hosts: shade_tree_v4/.test(PLAYBOOK) && !/(ansible_host|[0-9]{1,3}(?:\.[0-9] console.log(failures ? `\n${failures} FAILED` : "\nall v4 Ansible safety checks passed"); process.exit(failures ? 1 : 0); - diff --git a/deploy/v4/ansible/roles/shade_tree_v4/handlers/main.yml b/deploy/v4/ansible/roles/shade_tree_v4/handlers/main.yml index 910fa2a..0726e12 100644 --- a/deploy/v4/ansible/roles/shade_tree_v4/handlers/main.yml +++ b/deploy/v4/ansible/roles/shade_tree_v4/handlers/main.yml @@ -4,16 +4,18 @@ name: shade-tree-gateway state: restarted daemon_reload: true + when: shade_tree_runs_node | bool - name: Restart Shade Tree heartbeat ansible.builtin.systemd_service: name: shade-tree-heartbeat state: restarted daemon_reload: true + when: shade_tree_runs_node | bool - name: Restart Elder Tree ansible.builtin.systemd_service: name: shade-tree-bootnode state: restarted daemon_reload: true - when: shade_tree_target_mode == 'elder-and-node' + when: shade_tree_runs_elder | bool diff --git a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml index 7d2acd3..fc69168 100644 --- a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml +++ b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml @@ -34,6 +34,8 @@ - name: Derive deployment policy facts ansible.builtin.set_fact: + shade_tree_runs_elder: "{{ shade_tree_target_mode in ['elder', 'elder-and-node'] }}" + shade_tree_runs_node: "{{ shade_tree_target_mode in ['node', 'elder-and-node'] }}" shade_tree_onchain_admission: "{{ ('staked' in shade_tree_deployment.admission.paths) or ('paid' in shade_tree_deployment.admission.paths) }}" shade_tree_invited_admission: "{{ 'invited' in shade_tree_deployment.admission.paths }}" shade_tree_admit: "{{ shade_tree_deployment.admission.paths | join(',') }}" @@ -55,16 +57,17 @@ else (shade_tree_deployment.admission.roots.paid.contract if shade_tree_deployment.admission.roots.paid is not none else '') }} shade_tree_expected_identity_files: >- - {{ ['gateway-hs/identity.local.json', 'gateway-hs/hostname', - 'gateway-hs/hs_ed25519_secret_key', 'gateway-hs/hs_ed25519_public_key'] + {{ (['gateway-hs/identity.local.json', 'gateway-hs/hostname', + 'gateway-hs/hs_ed25519_secret_key', 'gateway-hs/hs_ed25519_public_key'] + if shade_tree_runs_node else []) + (['bootnode-hs/identity.local.json', 'bootnode-hs/hostname', 'bootnode-hs/hs_ed25519_secret_key', 'bootnode-hs/hs_ed25519_public_key', - 'bootnode-signer.key'] if shade_tree_target_mode == 'elder-and-node' else []) }} + 'bootnode-signer.key'] if shade_tree_runs_elder else []) }} - name: Validate all operator choices before the first remote mutation ansible.builtin.assert: that: - - shade_tree_target_mode in ['elder-and-node', 'node'] + - shade_tree_target_mode in ['elder', 'elder-and-node', 'node'] - shade_tree_admin_cidr | length > 0 - shade_tree_admin_cidr is match('^[0-9A-Fa-f:.]+/[0-9]{1,3}$') - shade_tree_admin_cidr not in ['0.0.0.0/0', '::/0'] @@ -79,7 +82,7 @@ - (shade_tree_deployment.elder.admission != 'stake') or (shade_tree_rpc_url | length > 0) - (shade_tree_deployment.elder.admission != 'stake') or (shade_tree_gateway_operator_key | length > 0) - (shade_tree_deployment.elder.admission != 'stake') or (shade_tree_gateway_operator_key is match('^0x[0-9A-Fa-f]{64}$')) - - (not shade_tree_invited_admission | bool) or (shade_tree_members_file | length > 0) + - (not shade_tree_runs_node | bool) or (not shade_tree_invited_admission | bool) or (shade_tree_members_file | length > 0) fail_msg: >- Deployment is fail-closed. Supply an exact admin CIDR and restored identities; invited admission needs the operator member set; any on-chain path needs explicit authorization, @@ -133,6 +136,7 @@ register: shade_tree_gateway_onion changed_when: false no_log: true + when: shade_tree_runs_node | bool - name: Derive the restored Elder onion on the controller ansible.builtin.command: @@ -141,7 +145,7 @@ - "{{ shade_tree_controller_repo_root }}/scripts/onion-identity.mjs" - derive - "{{ shade_tree_identity_source }}/bootnode-hs/hs_ed25519_secret_key" - when: shade_tree_target_mode == 'elder-and-node' + when: shade_tree_runs_elder | bool delegate_to: localhost become: false register: shade_tree_elder_onion @@ -151,9 +155,9 @@ - name: Verify restored public identities against the record before cutover ansible.builtin.assert: that: - - shade_tree_gateway_onion.stdout | trim == (lookup('ansible.builtin.file', shade_tree_identity_source ~ '/gateway-hs/hostname') | trim) - - shade_tree_target_mode != 'elder-and-node' or (shade_tree_elder_onion.stdout | trim == shade_tree_deployment.elder.onion) - - shade_tree_target_mode != 'elder-and-node' or ((lookup('ansible.builtin.file', shade_tree_identity_source ~ '/bootnode-signer.key') | from_json).pub == shade_tree_deployment.elder.canopySigner) + - (not shade_tree_runs_node | bool) or (shade_tree_gateway_onion.stdout | trim == (lookup('ansible.builtin.file', shade_tree_identity_source ~ '/gateway-hs/hostname') | trim)) + - (not shade_tree_runs_elder | bool) or (shade_tree_elder_onion.stdout | trim == shade_tree_deployment.elder.onion) + - (not shade_tree_runs_elder | bool) or ((lookup('ansible.builtin.file', shade_tree_identity_source ~ '/bootnode-signer.key') | from_json).pub == shade_tree_deployment.elder.canopySigner) fail_msg: "Restored onion or Canopy signer does not match the reviewed deployment record." no_log: true @@ -161,7 +165,9 @@ ansible.builtin.stat: path: "{{ shade_tree_members_file }}" checksum_algorithm: sha256 - when: shade_tree_invited_admission | bool + when: + - shade_tree_runs_node | bool + - shade_tree_invited_admission | bool delegate_to: localhost become: false register: shade_tree_members_stat @@ -174,7 +180,9 @@ - shade_tree_members_stat.stat.isreg - shade_tree_members_stat.stat.checksum == shade_tree_deployment.admission.roots.invited.membersSha256 fail_msg: "Operator member-set bytes do not match admission.roots.invited.membersSha256." - when: shade_tree_invited_admission | bool + when: + - shade_tree_runs_node | bool + - shade_tree_invited_admission | bool no_log: true - name: Require the supported target platform @@ -253,7 +261,9 @@ owner: root group: root mode: "0600" - when: shade_tree_invited_admission | bool + when: + - shade_tree_runs_node | bool + - shade_tree_invited_admission | bool register: shade_tree_members_copy no_log: true @@ -281,10 +291,10 @@ ansible.builtin.stat: path: "{{ item }}" loop: >- - {{ ['/etc/tor/torrc.d-shade-tree', - '/etc/systemd/system/shade-tree-gateway.service', - '/etc/systemd/system/shade-tree-heartbeat.service'] - + (['/etc/systemd/system/shade-tree-bootnode.service'] if shade_tree_target_mode == 'elder-and-node' else []) }} + {{ ['/etc/tor/torrc.d-shade-tree'] + + (['/etc/systemd/system/shade-tree-gateway.service', + '/etc/systemd/system/shade-tree-heartbeat.service'] if shade_tree_runs_node else []) + + (['/etc/systemd/system/shade-tree-bootnode.service'] if shade_tree_runs_elder else []) }} register: shade_tree_rendered_files - name: Record whether rendered service files need repair @@ -302,6 +312,7 @@ SHADE_TREE_REF: "{{ shade_tree_service_pin.commit }}" SHADE_TREE_DIR: "{{ shade_tree_install_dir }}" SHADE_TREE_ADMISSION: "{{ shade_tree_deployment.elder.admission }}" + SHADE_TREE_ELDER_ONLY: "{{ '1' if shade_tree_target_mode == 'elder' else '0' }}" SHADE_TREE_BOOTNODE_ONION: "{{ shade_tree_deployment.elder.onion if shade_tree_target_mode == 'node' else '' }}" SHADE_TREE_BOOTNODE_SIGNER: "{{ shade_tree_deployment.elder.canopySigner if shade_tree_target_mode == 'node' else '' }}" SHADE_TREE_ADMIT: "{{ shade_tree_admit }}" @@ -337,7 +348,9 @@ owner: root group: root mode: "0755" - when: shade_tree_onchain_admission | bool + when: + - shade_tree_runs_node | bool + - shade_tree_onchain_admission | bool - name: Install on-chain slashing authorization ansible.builtin.template: @@ -346,7 +359,9 @@ owner: root group: root mode: "0600" - when: shade_tree_onchain_admission | bool + when: + - shade_tree_runs_node | bool + - shade_tree_onchain_admission | bool no_log: true notify: Restart Shade Tree node @@ -354,7 +369,9 @@ ansible.builtin.file: path: /etc/systemd/system/shade-tree-gateway.service.d/v4-secrets.conf state: absent - when: not shade_tree_onchain_admission | bool + when: + - shade_tree_runs_node | bool + - not shade_tree_onchain_admission | bool notify: Restart Shade Tree node - name: Create Elder and heartbeat drop-in directories @@ -364,9 +381,9 @@ owner: root group: root mode: "0755" - loop: - - shade-tree-bootnode - - shade-tree-heartbeat + loop: >- + {{ (['shade-tree-bootnode'] if shade_tree_runs_elder else []) + + (['shade-tree-heartbeat'] if shade_tree_runs_node else []) }} when: shade_tree_deployment.elder.admission == 'stake' - name: Install stake-gated Elder configuration @@ -377,7 +394,7 @@ group: root mode: "0600" when: - - shade_tree_target_mode == 'elder-and-node' + - shade_tree_runs_elder | bool - shade_tree_deployment.elder.admission == 'stake' no_log: true notify: Restart Elder Tree @@ -389,7 +406,9 @@ owner: root group: root mode: "0600" - when: shade_tree_deployment.elder.admission == 'stake' + when: + - shade_tree_runs_node | bool + - shade_tree_deployment.elder.admission == 'stake' no_log: true notify: Restart Shade Tree heartbeat @@ -397,14 +416,18 @@ ansible.builtin.file: path: /etc/systemd/system/shade-tree-bootnode.service.d/v4-stake.conf state: absent - when: shade_tree_deployment.elder.admission != 'stake' + when: + - shade_tree_runs_elder | bool + - shade_tree_deployment.elder.admission != 'stake' notify: Restart Elder Tree - name: Remove stale heartbeat operator authorization ansible.builtin.file: path: /etc/systemd/system/shade-tree-heartbeat.service.d/v4-operator.conf state: absent - when: shade_tree_deployment.elder.admission != 'stake' + when: + - shade_tree_runs_node | bool + - shade_tree_deployment.elder.admission != 'stake' notify: Restart Shade Tree heartbeat - name: Reconcile the dedicated-host firewall @@ -433,8 +456,8 @@ state: started daemon_reload: true loop: >- - {{ ['shade-tree-gateway', 'shade-tree-heartbeat'] - + (['shade-tree-bootnode'] if shade_tree_target_mode == 'elder-and-node' else []) }} + {{ (['shade-tree-gateway', 'shade-tree-heartbeat'] if shade_tree_runs_node else []) + + (['shade-tree-bootnode'] if shade_tree_runs_elder else []) }} - name: Verify the checked-out commit ansible.builtin.command: @@ -454,19 +477,21 @@ - is-active - "{{ item }}" loop: >- - {{ ['shade-tree-gateway', 'shade-tree-heartbeat'] - + (['shade-tree-bootnode'] if shade_tree_target_mode == 'elder-and-node' else []) }} + {{ (['shade-tree-gateway', 'shade-tree-heartbeat'] if shade_tree_runs_node else []) + + (['shade-tree-bootnode'] if shade_tree_runs_elder else []) }} changed_when: false - name: Verify every service uses JSON logs and the explicit artifact set ansible.builtin.shell: cmd: | set -eu - for unit in shade-tree-gateway shade-tree-heartbeat{% if shade_tree_target_mode == 'elder-and-node' %} shade-tree-bootnode{% endif %}; do + for unit in{% if shade_tree_runs_node %} shade-tree-gateway shade-tree-heartbeat{% endif %}{% if shade_tree_runs_elder %} shade-tree-bootnode{% endif %}; do systemctl show "$unit" --property=Environment --value | grep -q 'SHADE_TREE_LOG_FORMAT=json' done + {% if shade_tree_runs_node %} systemctl show shade-tree-gateway --property=Environment --value | grep -Fq {{ ('SHADE_TREE_ZK_ARTIFACTS=' ~ (shade_tree_artifact_specs | join(','))) | quote }} systemctl show shade-tree-heartbeat --property=Environment --value | grep -Fq {{ ('SHADE_TREE_ZK_ARTIFACTS=' ~ (shade_tree_artifact_specs | join(','))) | quote }} + {% endif %} args: executable: /bin/bash changed_when: false @@ -488,13 +513,13 @@ - name: Build remote onion continuity checks ansible.builtin.set_fact: shade_tree_remote_identity_checks: >- - {{ [ + {{ ([ {'path': shade_tree_install_dir ~ '/deploy-state/gateway-hs/hs_ed25519_secret_key', 'onion': (lookup('ansible.builtin.file', shade_tree_identity_source ~ '/gateway-hs/hostname') | trim)}, {'path': '/var/lib/tor/shade-tree-gateway/hs_ed25519_secret_key', 'onion': (lookup('ansible.builtin.file', shade_tree_identity_source ~ '/gateway-hs/hostname') | trim)} - ] + ([ + ] if shade_tree_runs_node else []) + ([ {'path': shade_tree_install_dir ~ '/deploy-state/bootnode-hs/hs_ed25519_secret_key', 'onion': shade_tree_deployment.elder.onion}, {'path': '/var/lib/tor/shade-tree-bootnode/hs_ed25519_secret_key', 'onion': shade_tree_deployment.elder.onion} - ] if shade_tree_target_mode == 'elder-and-node' else []) }} + ] if shade_tree_runs_elder else []) }} - name: Derive deployed and Tor-active onion identities ansible.builtin.command: @@ -525,7 +550,7 @@ - -e - "console.log(JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8')).pub)" - "{{ shade_tree_install_dir }}/deploy-state/bootnode-signer.key" - when: shade_tree_target_mode == 'elder-and-node' + when: shade_tree_runs_elder | bool register: shade_tree_deployed_signer changed_when: false @@ -533,8 +558,8 @@ ansible.builtin.assert: that: - shade_tree_deployed_head.stdout | trim == shade_tree_service_pin.commit - - shade_tree_target_mode != 'elder-and-node' or (lookup('ansible.builtin.file', shade_tree_identity_source ~ '/bootnode-hs/hostname') | trim == shade_tree_deployment.elder.onion) - - shade_tree_target_mode != 'elder-and-node' or (shade_tree_deployed_signer.stdout | trim == shade_tree_deployment.elder.canopySigner) + - (not shade_tree_runs_elder | bool) or (lookup('ansible.builtin.file', shade_tree_identity_source ~ '/bootnode-hs/hostname') | trim == shade_tree_deployment.elder.onion) + - (not shade_tree_runs_elder | bool) or (shade_tree_deployed_signer.stdout | trim == shade_tree_deployment.elder.canopySigner) fail_msg: "Post-deploy identity or immutable source verification failed." - name: Record successful reconciliation only after every check passes From 27b8c7b61470cf79bcfa879c6563075789f04389 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:46:15 -0400 Subject: [PATCH 02/11] network: record sepolia v4 research fleet --- network/sepolia/deployment.json | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 network/sepolia/deployment.json diff --git a/network/sepolia/deployment.json b/network/sepolia/deployment.json new file mode 100644 index 0000000..69cd539 --- /dev/null +++ b/network/sepolia/deployment.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "network": "sepolia", + "status": "live", + "protocol": { + "min": 4, + "max": 4 + }, + "security": { + "proofArtifacts": "untrusted-testnet", + "scope": "disposable-research", + "decisionRef": "operator-approved disposable v4 research Grove, 2026-08-25" + }, + "services": { + "elder": { + "repository": "https://github.com/dmarzzz/shade-tree-node.git", + "commit": "5e668ab5f10e0ad77a8d851b5d0c0df430c00272" + }, + "node": { + "repository": "https://github.com/dmarzzz/shade-tree-node.git", + "commit": "5e668ab5f10e0ad77a8d851b5d0c0df430c00272" + }, + "heartbeat": { + "repository": "https://github.com/dmarzzz/shade-tree-node.git", + "commit": "5e668ab5f10e0ad77a8d851b5d0c0df430c00272" + } + }, + "elder": { + "onion": "a4xt55gef66pifqebadjobjpy2drsoa63bdok4toisvenmyehslaz4id.onion", + "canopySigner": "e0ce2892955b506c63d746ff61e06c0fdf446536c2b7b3dc68a3ba214608ca8c", + "admission": "open", + "gatewayRegistry": null + }, + "admission": { + "paths": [ + "invited" + ], + "roots": { + "invited": { + "membersSha256": "809d8fc762937c234650046bfdfa6cc6d9641e42762430a1961c4b8c7ab10898" + }, + "staked": null, + "paid": null + }, + "operatorAuthorization": null + }, + "artifacts": { + "accepted": [ + { + "id": "rln-0b25f824a04da3a8", + "verificationKeyPath": "circuits/rln/verification_key.json", + "sha256": "0b25f824a04da3a85f128baa6fdaa33d10baa7dd18c5e6f5935089661c68622c" + } + ], + "legacy": null + }, + "created": "2026-08-25", + "note": "Disposable invited-only Protocol v4 research Grove: one dedicated Elder and three dedicated Shade Tree nodes. Current proof artifacts are explicitly untrusted testnet material." +} From b5114d1d670633f86b34a36f5b1664b24997f663 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:49:16 -0400 Subject: [PATCH 03/11] deploy: accept UFW host-prefix normalization --- .../ansible/roles/shade_tree_v4/tasks/main.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml index fc69168..779037e 100644 --- a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml +++ b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml @@ -438,9 +438,21 @@ ufw default allow outgoing >/dev/null ufw allow proto tcp from {{ shade_tree_admin_cidr | quote }} to any port {{ shade_tree_ssh_port | int }} comment shade-tree-v4-admin >/dev/null ufw --force enable >/dev/null - ufw status | awk -v port='{{ shade_tree_ssh_port | int }}/tcp' -v cidr={{ shade_tree_admin_cidr | quote }} ' - $2 == "ALLOW" && ($1 != port || $4 != cidr) { print "unexpected inbound allow rule: " $0 > "/dev/stderr"; bad=1 } - END { exit bad ? 1 : 0 } + admin_cidr={{ shade_tree_admin_cidr | quote }} + display_cidr="$admin_cidr" + case "$display_cidr" in + */32) display_cidr="${display_cidr%/32}" ;; + */128) display_cidr="${display_cidr%/128}" ;; + esac + ufw status | awk -v port='{{ shade_tree_ssh_port | int }}/tcp' -v cidr="$admin_cidr" -v display_cidr="$display_cidr" ' + $2 == "ALLOW" { + if ($1 == port && ($4 == cidr || $4 == display_cidr)) expected=1 + else { print "unexpected inbound allow rule: " $0 > "/dev/stderr"; bad=1 } + } + END { + if (!expected) print "missing reviewed admin allow rule" > "/dev/stderr" + exit (bad || !expected) ? 1 : 0 + } ' args: executable: /bin/bash From 701e5e8b31171b1f5fb15cc446f9ab0bec9b7bd6 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:49:29 -0400 Subject: [PATCH 04/11] network: advance v4 fleet service pin --- network/sepolia/deployment.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/network/sepolia/deployment.json b/network/sepolia/deployment.json index 69cd539..8951a7d 100644 --- a/network/sepolia/deployment.json +++ b/network/sepolia/deployment.json @@ -14,15 +14,15 @@ "services": { "elder": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "5e668ab5f10e0ad77a8d851b5d0c0df430c00272" + "commit": "b5114d1d670633f86b34a36f5b1664b24997f663" }, "node": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "5e668ab5f10e0ad77a8d851b5d0c0df430c00272" + "commit": "b5114d1d670633f86b34a36f5b1664b24997f663" }, "heartbeat": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "5e668ab5f10e0ad77a8d851b5d0c0df430c00272" + "commit": "b5114d1d670633f86b34a36f5b1664b24997f663" } }, "elder": { From 0e36f9f2ac7ae9e78d885b4404bbcc5937aae456 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:51:02 -0400 Subject: [PATCH 05/11] deploy: parse the UFW source column --- deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml index 779037e..733d294 100644 --- a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml +++ b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml @@ -446,7 +446,7 @@ esac ufw status | awk -v port='{{ shade_tree_ssh_port | int }}/tcp' -v cidr="$admin_cidr" -v display_cidr="$display_cidr" ' $2 == "ALLOW" { - if ($1 == port && ($4 == cidr || $4 == display_cidr)) expected=1 + if ($1 == port && ($3 == cidr || $3 == display_cidr)) expected=1 else { print "unexpected inbound allow rule: " $0 > "/dev/stderr"; bad=1 } } END { From 2d63d17a1cdd344cbb09360fb4a2eb1e83e47b4a Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:51:12 -0400 Subject: [PATCH 06/11] network: repin v4 fleet after firewall fix --- network/sepolia/deployment.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/network/sepolia/deployment.json b/network/sepolia/deployment.json index 8951a7d..e812d46 100644 --- a/network/sepolia/deployment.json +++ b/network/sepolia/deployment.json @@ -14,15 +14,15 @@ "services": { "elder": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "b5114d1d670633f86b34a36f5b1664b24997f663" + "commit": "0e36f9f2ac7ae9e78d885b4404bbcc5937aae456" }, "node": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "b5114d1d670633f86b34a36f5b1664b24997f663" + "commit": "0e36f9f2ac7ae9e78d885b4404bbcc5937aae456" }, "heartbeat": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "b5114d1d670633f86b34a36f5b1664b24997f663" + "commit": "0e36f9f2ac7ae9e78d885b4404bbcc5937aae456" } }, "elder": { From 4b141797cd4d4fd961190c0f9774f6f181e81181 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:54:43 -0400 Subject: [PATCH 07/11] deploy: preserve bootstrap-managed ownership --- deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml index 733d294..2595179 100644 --- a/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml +++ b/deploy/v4/ansible/roles/shade_tree_v4/tasks/main.yml @@ -215,16 +215,12 @@ ansible.builtin.file: path: "{{ shade_tree_install_dir }}/deploy-state" state: directory - owner: root - group: root mode: "0700" - name: Restore the reviewed onion and signer material ansible.builtin.copy: src: "{{ shade_tree_identity_source }}/" dest: "{{ shade_tree_install_dir }}/deploy-state/" - owner: root - group: root mode: preserve directory_mode: "0700" register: shade_tree_identity_copy @@ -241,8 +237,6 @@ ansible.builtin.file: path: /etc/shade-tree state: directory - owner: root - group: root mode: "0750" - name: Install the reviewed public deployment record From 7f35a421e572e5b25a50ae5b59941dd31e53c4e5 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:54:53 -0400 Subject: [PATCH 08/11] network: pin idempotent v4 reconciler --- network/sepolia/deployment.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/network/sepolia/deployment.json b/network/sepolia/deployment.json index e812d46..4698fb3 100644 --- a/network/sepolia/deployment.json +++ b/network/sepolia/deployment.json @@ -14,15 +14,15 @@ "services": { "elder": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "0e36f9f2ac7ae9e78d885b4404bbcc5937aae456" + "commit": "4b141797cd4d4fd961190c0f9774f6f181e81181" }, "node": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "0e36f9f2ac7ae9e78d885b4404bbcc5937aae456" + "commit": "4b141797cd4d4fd961190c0f9774f6f181e81181" }, "heartbeat": { "repository": "https://github.com/dmarzzz/shade-tree-node.git", - "commit": "0e36f9f2ac7ae9e78d885b4404bbcc5937aae456" + "commit": "4b141797cd4d4fd961190c0f9774f6f181e81181" } }, "elder": { From 3a472bacc40db06bcff79e6cf0d9c2c6d4493aee Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:13:23 -0400 Subject: [PATCH 09/11] docs: record v4 Grove go-live --- docs/GO-LIVE-LOG-2026-08-25-v4.md | 74 +++++++++++++++++++++++++++++++ specs/data-api.md | 59 ++++++++++++++++++++---- 2 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 docs/GO-LIVE-LOG-2026-08-25-v4.md diff --git a/docs/GO-LIVE-LOG-2026-08-25-v4.md b/docs/GO-LIVE-LOG-2026-08-25-v4.md new file mode 100644 index 0000000..e76b66d --- /dev/null +++ b/docs/GO-LIVE-LOG-2026-08-25-v4.md @@ -0,0 +1,74 @@ +# Protocol v4 research Grove go-live — 2026-08-25 + +## Scope and security boundary + +- One dedicated Elder Tree and three dedicated Shade Tree nodes on DigitalOcean. +- Ubuntu 24.04, Basic 1-vCPU/2-GiB droplets, spread across New York, San Francisco, + and Amsterdam. Backups are off and provider monitoring is on. +- Estimated steady-state compute cost: **$48/month** before tax and incidental + bandwidth; no load balancer, volume, managed database, or reserved IP. +- Sepolia, invited-only admission, Protocol 4 envelopes, and the explicitly + untrusted `rln-0b25f824a04da3a8` testnet artifact. This is disposable research + infrastructure, not production. +- Public deployment record: [`network/sepolia/deployment.json`](../network/sepolia/deployment.json). + Service pin: `4b141797cd4d4fd961190c0f9774f6f181e81181`. +- The provider addresses, SSH inventory, identity backups, member secret, and + OpenTofu state are not committed to this repository. + +## Deployment evidence + +- The v4 preflight recomputed the proof-artifact hash and accepted the live + deployment record. +- Ansible reconciled all four hosts with zero failures. A second pass reported + `changed=0` for the Elder and every node. +- Only the Elder service is enabled on the Elder host. Only gateway and heartbeat + services are enabled on node hosts. +- Every application and metrics listener is loopback-only. Host UFW and the + DigitalOcean firewall admit SSH only from the reviewed operator `/32`. +- Deployed and Tor-active onion identities were derived independently and matched + the controller backups. The Elder onion and signer also match the public record. +- The live Elder `/health` reported three entries. Its signed `/directory` contained + three fresh, `health: up`, Protocol 4 announcements with the accepted artifact + and the intended NA/NA/EU region buckets. +- The signer-pinned uptime probe passed over Tor with + `bootnodeReachable=true`, `signerOk=true`, `directoryFresh=true`, and + `fleetSize=3`. +- Invited RLN proofs established real HTTPS CONNECT tunnels through all three + node onions; the observed egress addresses matched the three provider hosts. + +## Grove Data API + +The repository observer variables now point at this v4 Elder and signer, with +pre-v4 capability compatibility disabled. GitHub Actions run +[`32908979132`](https://github.com/dmarzzz/shade-tree-node/actions/runs/32908979132) +passed its Tor probe and minimal publisher job, replacing the parentless +`network-state` head with signed v1 and v2 aggregate snapshots. + +The production Vercel endpoint returned `200`, schema +`shade-tree-public-grove-v2`, a fresh signed observation, and +`nodes.announced=3`: + +```text +https://shade-tree-node.vercel.app/api/v2/data/grove/sepolia/head +``` + +The Grove page is live at `https://shade-tree-node.vercel.app/grove/`. It uses +only the signed aggregate count and bounded history; it receives no onion, IP, +location, identity, traffic-path, or stable-position data. Relay-byte windows +remain suppressed because this fleet has fewer than the required five-reporting- +node cohort and relay telemetry is not configured. + +## Operations and teardown + +- The hosted Tor probe runs every 15 minutes and publishes nothing on a failed + observation; the API continues serving the last valid signed head and the UI + labels it stale. +- Reconciliation is the v4 Ansible playbook documented in + [`deploy/v4/README.md`](../deploy/v4/README.md). +- Provider lifecycle and the ignored local state live in the isolated + `agent-devops/tofu/environments/shade-tree-v4` environment. Run an OpenTofu + plan there before either changing or destroying this fleet; do not target the + older shared fleet environment. +- Before teardown, repoint or clear the four `SHADE_TREE_*` Actions variables so + scheduled probes stop targeting a removed Elder. Preserve the encrypted SSH + keys and private identity backup if onion continuity may be needed later. diff --git a/specs/data-api.md b/specs/data-api.md index 83e9698..1bc775e 100644 --- a/specs/data-api.md +++ b/specs/data-api.md @@ -23,14 +23,13 @@ rounded observation time. Its trees are not one-to-one node markers. Public copy calls the bootnode the **Elder Tree** and its signed directory the **Canopy**. These names do not change the observer input or the signed schema. -The Sepolia bootnode currently shown here is the earlier, pre-v4 research -fleet recorded under `network/sepolia/`. Its nodes sign capabilities with the -pre-v4 domain tag. The hosted observer has an explicit, read-only compatibility -switch that verifies those signatures before counting. That switch is off by -default and is not used by client discovery, routing, announcements, or node -admission; Shade Tree proxies and nodes remain v4-only. When a coordinated v4 -fleet is live, the public source will move to that fleet rather than treating -the earlier nodes as v4-compatible. +The Sepolia source now points at the disposable Protocol v4 research Grove +recorded in [`network/sepolia/deployment.json`](../network/sepolia/deployment.json): +one dedicated Elder Tree and three dedicated Shade Tree nodes. The fleet is +invited-only and uses explicitly untrusted testnet proof artifacts; it is not a +production security claim. The hosted observer's earlier read-only pre-v4 +compatibility switch is disabled. Client discovery, routing, announcements, and +node admission remain v4-only. The count means **announced within the bootnode TTL**. It does not necessarily mean the gateway was independently reachable. When optional active probing is @@ -109,6 +108,50 @@ onion service, so visitor demand cannot create bootnode traffic. Combining the Tor read and public API in one process would require a managed container with a Tor sidecar, which adds infrastructure without improving this data contract. +## Launch and update runbook + +The data plane has two independently deployed pieces. A GitHub Actions observer +reads the Elder over Tor and force-updates the generated `network-state` branch; +the already-deployed Vercel Function reads and verifies that branch. A new valid +snapshot therefore does not require a Vercel deployment. + +Before enabling publication, an operator needs the public Elder onion and its +pinned Canopy signer plus a repository secret named +`SHADE_TREE_GROVE_SIGNING_KEY`. That private Ed25519 key must match +[`network/grove-signing-public.pem`](../network/grove-signing-public.pem), which +is pinned by both the API and browser. Configure the public observer inputs as +repository variables, not secrets: + +```bash +gh variable set SHADE_TREE_BOOTNODE_ONION --repo --body +gh variable set SHADE_TREE_DIR_SIGNER --repo --body +gh variable set SHADE_TREE_NETWORK --repo --body sepolia +gh variable set SHADE_TREE_PROBE_ACCEPT_PRE_V4_CAPS --repo --body 0 +gh workflow run uptime-probe.yml --repo --ref +``` + +The run is complete only when both `fleet uptime probe (over Tor)` and +`publish signed aggregate` pass. Verify the generated head and production +consumer independently: + +```bash +gh api 'repos//contents/grove-v2.json?ref=network-state' +curl --fail --show-error \ + https://shade-tree-node.vercel.app/api/v2/data/grove/sepolia/head +``` + +Deploy Vercel only when the function, schema, static site, or pinned publication +key changes. The linked project root is `docs/post`; from that directory, review +the project link and deploy with `vercel --prod`, then repeat the endpoint and +Grove-page checks. Never put the Grove signing private key in Vercel: the +function verifies snapshots and does not sign them. + +The three-node research Grove immediately supplies announced count and bounded +growth history. Relay-byte publication is intentionally still suppressed: the +v2 contract requires at least five reporting node identities, and unavailable +or sub-cohort input is never represented as zero. Enabling private relay +reporting on only these three nodes would not make a public byte total eligible. + Operator Prometheus endpoints are a separate, loopback-only system. The Elder Tree, nodes, heartbeats, registrars, and Proxies do not upload those metrics to the observer. The Grove publication path cannot read or publish them. From 0fdf40e22e2e9daf93bf2fc9aff80b4ea5340c89 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:21:36 -0400 Subject: [PATCH 10/11] docs: record default-branch Grove publisher proof --- docs/GO-LIVE-LOG-2026-08-25-v4.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/GO-LIVE-LOG-2026-08-25-v4.md b/docs/GO-LIVE-LOG-2026-08-25-v4.md index e76b66d..383f696 100644 --- a/docs/GO-LIVE-LOG-2026-08-25-v4.md +++ b/docs/GO-LIVE-LOG-2026-08-25-v4.md @@ -42,7 +42,10 @@ The repository observer variables now point at this v4 Elder and signer, with pre-v4 capability compatibility disabled. GitHub Actions run [`32908979132`](https://github.com/dmarzzz/shade-tree-node/actions/runs/32908979132) passed its Tor probe and minimal publisher job, replacing the parentless -`network-state` head with signed v1 and v2 aggregate snapshots. +`network-state` head with signed v1 and v2 aggregate snapshots. Default-branch +run [`32910049456`](https://github.com/dmarzzz/shade-tree-node/actions/runs/32910049456) +then passed the same probe and publisher path with the repository's ongoing +scheduled-workflow source. The production Vercel endpoint returned `200`, schema `shade-tree-public-grove-v2`, a fresh signed observation, and From 9fa2187e93ad89eddfad6d29c7201a984ddbb304 Mon Sep 17 00:00:00 2001 From: dmarzzz <22778355+dmarzzz@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:13:59 -0400 Subject: [PATCH 11/11] fix: render one Grove tree per announcement --- docs/post/grove/index.html | 4 +- docs/post/grove/network.js | 7 +++- docs/post/grove/scene.js | 56 +++++++++++----------------- docs/post/grove/visual-model.js | 12 ++---- specs/data-api.md | 11 ++++-- test/grove-visual-model.selftest.mjs | 12 ++++-- test/site.selftest.mjs | 6 +-- 7 files changed, 52 insertions(+), 56 deletions(-) diff --git a/docs/post/grove/index.html b/docs/post/grove/index.html index 2963b44..a19a3d8 100644 --- a/docs/post/grove/index.html +++ b/docs/post/grove/index.html @@ -10,7 +10,7 @@ - + @@ -38,7 +38,7 @@

The Grove,
observed.

-

A privacy-preserving view of the Grove, built from signed aggregate reports without publishing node identities or locations.

+

A privacy-preserving view of the Grove, built from signed aggregate reports. One tree appears per announced identity, with no identity or location attached.

diff --git a/docs/post/grove/network.js b/docs/post/grove/network.js index e872a05..17bde5a 100644 --- a/docs/post/grove/network.js +++ b/docs/post/grove/network.js @@ -249,8 +249,9 @@ function randomFrom(seed) { function drawFallback(snapshot) { fallback.querySelectorAll(".fallback-grove").forEach((element) => element.remove()); const count = snapshot.nodes.announced; - const patchCount = Math.min(30, grovePatchCount(count, "high")); + const patchCount = grovePatchCount(count, "high"); const random = randomFrom(hashSeed(`${snapshot.observedAt}:${count}`)); + const trees = document.createDocumentFragment(); for (let index = 0; index < patchCount; index += 1) { const vertical = 1 - (2 * (index + 0.5)) / patchCount; const radius = Math.sqrt(1 - vertical * vertical); @@ -259,14 +260,16 @@ function drawFallback(snapshot) { const depth = Math.sin(angle) * radius; const grove = document.createElement("span"); grove.className = "fallback-grove"; + grove.dataset.announcedIdentity = ""; grove.style.setProperty("--x", `${50 + horizontal * 39}%`); grove.style.setProperty("--y", `${50 - vertical * 39}%`); grove.style.setProperty("--scale", (0.58 + (depth + 1) * 0.32).toFixed(2)); grove.style.setProperty("--alpha", (0.34 + (depth + 1) * 0.31).toFixed(2)); grove.style.setProperty("--depth", String(Math.round(2 + (depth + 1) * 4))); grove.style.setProperty("--turn", `${Math.round((random() - 0.5) * 13)}deg`); - fallback.append(grove); + trees.append(grove); } + fallback.append(trees); } function setText(selector, value) { diff --git a/docs/post/grove/scene.js b/docs/post/grove/scene.js index 8e95979..a39f4d6 100644 --- a/docs/post/grove/scene.js +++ b/docs/post/grove/scene.js @@ -50,25 +50,18 @@ function fibonacciPoint(index, total, phase = 0) { return new THREE.Vector3(Math.cos(turn) * radius, vertical, Math.sin(turn) * radius); } -function tangentBasis(normal) { - const tangent = new THREE.Vector3(-normal.z, 0, normal.x); - if (tangent.lengthSq() < 0.01) tangent.set(1, 0, 0); - tangent.normalize(); - return [tangent, new THREE.Vector3().crossVectors(normal, tangent).normalize()]; -} - function createCanopyField(snapshot, quality) { const lowQuality = quality === "low"; const announced = snapshot.nodes.announced; const patchCount = grovePatchCount(announced, quality); - const treesPerPatch = lowQuality ? 2 : 3; - const treeCount = patchCount * treesPerPatch; + const treeCount = patchCount; const phase = seededRandom(hashSeed(`${snapshot.observedAt}:${announced}`))() * Math.PI * 2; const random = seededRandom(hashSeed(`${announced}:${snapshot.observedAt}:canopy`)); const normals = Array.from({ length: patchCount }, (_, index) => fibonacciPoint(index, patchCount, phase)); const group = new THREE.Group(); - group.name = "aggregate-canopy-field"; + group.name = "announced-identity-trees"; + group.userData.announcedIdentityCount = treeCount; const trunkGeometry = new THREE.CylinderGeometry(1, 1, 1, lowQuality ? 4 : 6, 1, false); const crownGeometry = new THREE.IcosahedronGeometry(1, lowQuality ? 0 : 1); const trunkMaterial = new THREE.MeshStandardMaterial({ color: BARK, flatShading: true, roughness: 1 }); @@ -81,6 +74,8 @@ function createCanopyField(snapshot, quality) { }); const trunks = new THREE.InstancedMesh(trunkGeometry, trunkMaterial, treeCount); const crowns = new THREE.InstancedMesh(crownGeometry, crownMaterial, treeCount); + trunks.name = "announced-identity-trunks"; + crowns.name = "announced-identity-crowns"; trunks.frustumCulled = false; crowns.frustumCulled = false; @@ -91,30 +86,23 @@ function createCanopyField(snapshot, quality) { const color = new THREE.Color(); let treeIndex = 0; - normals.forEach((normal, patchIndex) => { - const [tangent, bitangent] = tangentBasis(normal); - for (let localIndex = 0; localIndex < treesPerPatch; localIndex += 1) { - const spread = localIndex === 0 ? 0 : 0.12 + random() * 0.07; - const turn = localIndex * GOLDEN_ANGLE + random() * 0.25; - const offset = tangent.clone().multiplyScalar(Math.cos(turn) * spread) - .add(bitangent.clone().multiplyScalar(Math.sin(turn) * spread)); - const base = normal.clone().multiplyScalar(SPHERE_RADIUS + 0.015).add(offset); - const height = (lowQuality ? 0.25 : 0.29) + random() * 0.15; - const trunkRadius = 0.025 + random() * 0.012; - const crownSize = (lowQuality ? 0.115 : 0.13) + random() * 0.05; - quaternion.setFromUnitVectors(UP, normal); - - scale.set(trunkRadius, height, trunkRadius); - trunkMatrix.compose(base.clone().addScaledVector(normal, height * 0.5), quaternion, scale); - trunks.setMatrixAt(treeIndex, trunkMatrix); - - scale.set(crownSize, crownSize * (0.86 + random() * 0.2), crownSize); - crownMatrix.compose(base.clone().addScaledVector(normal, height + crownSize * 0.25), quaternion, scale); - crowns.setMatrixAt(treeIndex, crownMatrix); - color.setHex((patchIndex + localIndex) % 3 === 0 ? LEAF_DARK : LEAF).offsetHSL(0, 0, (random() - 0.5) * 0.09); - crowns.setColorAt(treeIndex, color); - treeIndex += 1; - } + normals.forEach((normal, index) => { + const base = normal.clone().multiplyScalar(SPHERE_RADIUS + 0.015); + const height = (lowQuality ? 0.25 : 0.29) + random() * 0.15; + const trunkRadius = 0.025 + random() * 0.012; + const crownSize = (lowQuality ? 0.115 : 0.13) + random() * 0.05; + quaternion.setFromUnitVectors(UP, normal); + + scale.set(trunkRadius, height, trunkRadius); + trunkMatrix.compose(base.clone().addScaledVector(normal, height * 0.5), quaternion, scale); + trunks.setMatrixAt(treeIndex, trunkMatrix); + + scale.set(crownSize, crownSize * (0.86 + random() * 0.2), crownSize); + crownMatrix.compose(base.clone().addScaledVector(normal, height + crownSize * 0.25), quaternion, scale); + crowns.setMatrixAt(treeIndex, crownMatrix); + color.setHex(index % 3 === 0 ? LEAF_DARK : LEAF).offsetHSL(0, 0, (random() - 0.5) * 0.09); + crowns.setColorAt(treeIndex, color); + treeIndex += 1; }); trunks.instanceMatrix.needsUpdate = true; diff --git a/docs/post/grove/visual-model.js b/docs/post/grove/visual-model.js index f892eae..3fbf30c 100644 --- a/docs/post/grove/visual-model.js +++ b/docs/post/grove/visual-model.js @@ -1,13 +1,9 @@ -export function grovePatchCount(announced, quality = "high") { +export function grovePatchCount(announced) { if (!Number.isInteger(announced) || announced <= 0) return 0; - const lowQuality = quality === "low"; - return Math.min( - lowQuality ? 22 : 36, - Math.max(lowQuality ? 13 : 19, 14 + Math.round(Math.log2(announced + 1) * (lowQuality ? 3 : 5))), - ); + return announced; } export function groveArcCount(patchCount, quality = "high") { - if (!Number.isInteger(patchCount) || patchCount <= 0) return 0; - return quality === "low" ? 3 : 6; + if (!Number.isInteger(patchCount) || patchCount < 2) return 0; + return Math.min(patchCount, quality === "low" ? 3 : 6); } diff --git a/specs/data-api.md b/specs/data-api.md index 1bc775e..9899f3c 100644 --- a/specs/data-api.md +++ b/specs/data-api.md @@ -17,8 +17,10 @@ rules that OpenAPI cannot express. The exact number counts gateway identities in the bootnode directory at observation time. The observer fetches `/directory` over Tor and verifies its signature against the pinned directory signer before counting entries. The -visual canopy regenerates aggregate tree clusters from only that count and the -rounded observation time. Its trees are not one-to-one node markers. +visual canopy regenerates one tree for each unit in that aggregate count, using +only the count and rounded observation time. A tree does not reveal or persist +the identity of a particular node; all non-geographic positions regenerate on +each observation. Public copy calls the bootnode the **Elder Tree** and its signed directory the **Canopy**. These names do not change the observer input or the signed schema. @@ -355,8 +357,9 @@ The v1 collector projection must never publish: - the signed directory or any other raw bootnode response. The canopy is regenerated from only the aggregate count and rounded snapshot -time, with density capped for rendering performance. Its roots are an -illustration of announcements, not observed traffic. +time. It renders exactly one non-geographic tree for each announced identity; +positions are not stable across observations. Its roots are an illustration of +announcements, not observed traffic. ## Safe derivations diff --git a/test/grove-visual-model.selftest.mjs b/test/grove-visual-model.selftest.mjs index ce0296c..ebe4c5a 100644 --- a/test/grove-visual-model.selftest.mjs +++ b/test/grove-visual-model.selftest.mjs @@ -4,9 +4,15 @@ import { groveArcCount, grovePatchCount } from "../docs/post/grove/visual-model. for (const quality of ["low", "high"]) { assert.equal(grovePatchCount(0, quality), 0, `zero announcements render no trees at ${quality} quality`); assert.equal(groveArcCount(0, quality), 0, `zero announcements render no arcs at ${quality} quality`); - const counts = [1, 2, 8, 64, 1_024].map((announced) => grovePatchCount(announced, quality)); - assert.ok(counts.every((count) => count > 0), `positive announcements render aggregate groves at ${quality} quality`); - assert.ok(counts.every((count, index) => index === 0 || count >= counts[index - 1]), `aggregate density is monotonic at ${quality} quality`); + for (const announced of [1, 2, 8, 64, 1_024, 100_000]) { + assert.equal( + grovePatchCount(announced, quality), + announced, + `one tree renders for each announced identity at ${quality} quality`, + ); + } + assert.equal(groveArcCount(1, quality), 0, `a lone tree does not draw a self-arc at ${quality} quality`); + assert.ok(groveArcCount(2, quality) <= 2, `arcs never outnumber announced identities at ${quality} quality`); } console.log("PASS: Grove visual-model selftest"); diff --git a/test/site.selftest.mjs b/test/site.selftest.mjs index 8cf59c6..aee5a65 100644 --- a/test/site.selftest.mjs +++ b/test/site.selftest.mjs @@ -141,7 +141,7 @@ check("landing sections use restrained full-width grove clearing bands", /--clea check("Tor boundary copy is precise and qualified", /Tor exit addresses are public/.test(landing) && /publishes no egress-IP list/.test(landing) && /Destinations still see and can block a node IP/.test(landing) && /Destinations still see and can block a node IP/.test(readme)); check("public vocabulary stays paired with protocol names", /Elder Tree/.test(readme) && /bootnode/.test(readme) && /Canopy/.test(readme) && /controls discovery/.test(readme) && /\| Proxy \| client \|/.test(protocol)); -check("Grove links only the aggregate-report phrase to the canonical Data API", /

A privacy-preserving view of the Grove, built from signed aggregate reports<\/a> without publishing node identities or locations\.<\/p>/.test(grovePage) && (grovePage.match(/specs\/data-api\.md/g) || []).length === 1 && !/specs\/protocol\.md/.test(grovePage)); +check("Grove links only the aggregate-report phrase to the canonical Data API", /

A privacy-preserving view of the Grove, built from signed aggregate reports<\/a>\. One tree appears per announced identity, with no identity or location attached\.<\/p>/.test(grovePage) && (grovePage.match(/specs\/data-api\.md/g) || []).length === 1 && !/specs\/protocol\.md/.test(grovePage)); check("Grove keeps the intro as one inline sentence with natural wrapping", !/

A privacy-preserving view[\s\S]*?]*>[^<]+<\/title>/.test(pathGraphic) && /]*>[^<]+<\/desc>/.test(pathGraphic) && /Discovery plane/i.test(pathGraphic) && /Traffic path/i.test(pathGraphic) && /target-bound RLN proof \+ nullifier/.test(pathGraphic) && /stays out of this path/i.test(pathGraphic) && !/ /function stopFrames\(\)/.test(scene) && /function scheduleFrame\(\)/.test(scene) && /if \(event\.persisted\) \{\s*stopFrames\(\)/.test(scene) && /addEventListener\("pageshow", onPageShow\)/.test(scene) && /addEventListener\("visibilitychange", onVisibilityChange\)/.test(scene) && (scene.match(/requestAnimationFrame\(tick\)/g) || []).length === 1)); check("home scene uses the supported shadow filter without console fallback", /renderer\.shadowMap\.type = THREE\.PCFShadowMap/.test(landingScene) && !/PCFSoftShadowMap/.test(landingScene)); -check("network scene builds an abstract canopy sphere from small tree groves", /non-geographic-canopy-sphere/.test(groveScene) && /IcosahedronGeometry\(SPHERE_RADIUS/.test(groveScene) && /CylinderGeometry/.test(groveScene) && /InstancedMesh/.test(groveScene) && /aggregate-canopy-field/.test(groveScene)); +check("network scene builds a non-geographic canopy with announced identity trees", /non-geographic-canopy-sphere/.test(groveScene) && /IcosahedronGeometry\(SPHERE_RADIUS/.test(groveScene) && /CylinderGeometry/.test(groveScene) && /InstancedMesh/.test(groveScene) && /announced-identity-trees/.test(groveScene)); check("network scene crosses a nonempty canopy in several census directions", /groveArcCount\(field\.patchCount, quality\)/.test(groveScene) && /quality === "low" \? 3 : 6/.test(groveVisualModel) && /QuadraticBezierCurve3/.test(groveScene) && /TubeGeometry/.test(groveScene) && /aggregate-observation-signal/.test(groveScene) && /elder-discovery-satellite/.test(groveScene)); -check("abstract canopy density is deterministic, aggregate, and empty at zero", /grovePatchCount\(announced, quality\)/.test(groveScene) && /hashSeed\(`\$\{snapshot\.observedAt\}:\$\{announced\}`\)/.test(groveScene) && /announced <= 0\) return 0/.test(groveVisualModel) && /Math\.log2\(announced \+ 1\)/.test(groveVisualModel)); +check("abstract canopy renders exactly one tree per announced identity", /grovePatchCount\(announced, quality\)/.test(groveScene) && /hashSeed\(`\$\{snapshot\.observedAt\}:\$\{announced\}`\)/.test(groveScene) && /announced <= 0\) return 0/.test(groveVisualModel) && /return announced/.test(groveVisualModel) && /const treeCount = patchCount/.test(groveScene)); check("scene controller exposes the complete query lifecycle", /return \{[\s\S]*beginQuery,[\s\S]*failQuery,[\s\S]*finishQuery,[\s\S]*updateSnapshot: replaceSnapshot/.test(groveScene)); check("loader emits soft checks and strong new-census pulses", /sceneController\?\.beginQuery\(\)/.test(groveLoader) && /sceneController\?\.finishQuery\(snapshot, \{ freshCensus \}\)/.test(groveLoader) && /sceneController\?\.failQuery\(\)/.test(groveLoader) && /lastLiveObservedAt !== snapshot\.observedAt/.test(groveLoader)); check("Grove resumes signed-view polling after BFCache restore", /function onPageHide\(\)\s*\{\s*window\.clearTimeout\(pollTimer\);\s*window\.clearTimeout\(ageTimer\)/.test(groveLoader) && /function onPageShow\(event\)\s*\{\s*if \(!event\.persisted\) return;\s*updateFreshness\(\);\s*load\(\)/.test(groveLoader) && /addEventListener\("pageshow", onPageShow\)/.test(groveLoader));