From 80afee41cfa9f7d568e47d1888a2d3740d6586af Mon Sep 17 00:00:00 2001 From: ibeezhan Date: Tue, 11 Aug 2026 14:01:29 +0100 Subject: [PATCH 1/2] =?UTF-8?q?0.6.0=20=E2=80=94=20support=20Logos=20node?= =?UTF-8?q?=200.2.1=20(breaking=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 0.2.1 (published 2026-08-05) is another genesis reset: the live testnet chain restarted at ~09:01 UTC that day (verified empirically — a synced node's slot count × 1s slot duration lands exactly on the release publish time), and the chainsync protocol ID is versioned per release (/logos-blockchain-testnet-0.2.1/chainsync/1.0.0), so 0.2.0 nodes cannot serve or validate the new chain. The actual 0.2.0 → 0.2.1 delta for logosup: - releases.sh: add 0.2.1 to LOGOS_BREAKING_VERSIONS so `logosup update` diverts to the migration flow. - docker.sh: drop `--ibd` from init-config / migrate-from-0.1.2 — 0.2.1 replaced the opt-in `--ibd` flag with opt-out `--skip-ibd` (IBD from -p peers is now the default); passing `--ibd` hard-fails. - docker.sh: new docker_update_config() — 0.2.1's init-config refuses to run when keystore.yaml exists ("Keystore file exists. Use `update` command."), so the old wipe-and-init reset flow dies for every 0.2.0 operator. Use the node's `update-config` to regenerate the config from the existing keystore, which also keeps wallet key identities stable across the reset (funds still don't carry over). - cmd_reset.sh: three-way strategy — migrate-from-0.1.2 for 0.1.x configs, update-config when a keystore exists (0.2.0+ installs), fresh init-config otherwise; back up + clear a stray keystore in the fresh path so init-config can't hard-fail. - cmd_status.sh / cmd_start.sh / monitoring exporter: 0.2.1 renamed the consensus field — /cryptarchia/info now returns a scalar "state":"Bootstrapping" plus a top-level "phase" (e.g. InitialBlockDownload) instead of 0.2.0's "mode":{"Started":...}. Parse the new shape first, keep the old fallbacks; status shows the phase while syncing. - network.yml: bootstrap peers verified unchanged from 0.2.0 (same fleet multiaddrs in the 0.2.1 release notes; a fresh node connects and syncs against them). Not changed on purpose: asset naming (still logos-blockchain-node-linux-{arch}-{version}.tar.gz with bundled circuits), ports (blend's udp/3400 existed in 0.2.0 too), faucet and dashboard URLs, /network/info parsing (n_peers/n_connections still present in 0.2.1). Co-Authored-By: Claude Fable 5 --- VERSION | 2 +- docs/migrations.md | 2 +- lib/cmd_reset.sh | 69 ++++++++++++++++-------- lib/cmd_start.sh | 12 +++-- lib/docker.sh | 94 +++++++++++++++++++++++++++++---- lib/releases.sh | 2 +- monitoring/exporter/exporter.py | 9 ++-- network.yml | 3 +- settings.env.example | 2 +- 9 files changed, 151 insertions(+), 44 deletions(-) diff --git a/VERSION b/VERSION index 4b9fcbe..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.6.0 diff --git a/docs/migrations.md b/docs/migrations.md index 062f3aa..acc60bd 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -13,7 +13,7 @@ Some Logos Blockchain releases reset the genesis block or otherwise make existin 2. Backs up `~/.logos-node/user_config.yaml` to `user_config.yaml.pre-migration-` 3. Deletes `~/.logos-node/data/` (chain DB + logs) 4. Rebuilds the Docker image -5. Regenerates `user_config.yaml` with fresh wallet keys +5. Regenerates `user_config.yaml`. If a `keystore.yaml` exists (any 0.2.0+ install), your wallet key identities are preserved via the node's `update-config` command; 0.1.2-era configs are converted with `migrate-from-0.1.2`, also preserving keys. Only a fully fresh install generates new keys. 6. Restarts the node (and monitoring, if it was running) ## After migration diff --git a/lib/cmd_reset.sh b/lib/cmd_reset.sh index 2cac408..5d97b7e 100644 --- a/lib/cmd_reset.sh +++ b/lib/cmd_reset.sh @@ -49,18 +49,30 @@ _perform_migration() { log_warn "${BOLD}This will wipe local node data${RESET}" fi - # Detect whether the current on-disk config predates 0.2.0 — if so, we - # can preserve wallet identities across the genesis reset by running - # `migrate-from-0.1.2` before wiping data. The stable discriminator is - # `tip_poll:` under cryptarchia.network.sync (0.2.0-only marker); its - # absence means the config is 0.1.x shape. Both 0.1.2 and 0.2.0 have - # top-level `wallet:` and `known_keys:` under it, so those don't work - # as version markers. Funds still don't carry over — only identities. - local preserve_keys=false + # Pick the config-regeneration strategy. Three cases: + # + # "migrate-012" — on-disk config predates 0.2.0 (no `tip_poll:` marker + # under cryptarchia.network.sync, the stable 0.2.x-only discriminator; + # top-level `wallet:` / `known_keys:` exist in both shapes so they + # don't work as markers). Run `migrate-from-0.1.2` to convert the + # config shape while preserving wallet identities. + # "update-config" — config is already 0.2.x shape and keystore.yaml + # exists (any 0.2.0+ install). Run `update-config` to regenerate the + # config from the existing keystore, keeping key identities stable. + # Also required mechanically: since 0.2.1 `init-config` refuses to + # run when keystore.yaml exists. + # "init-config" — no usable config/keystore. Generate everything fresh. + # + # Funds never carry over a genesis reset — only key identities do. + local strategy="init-config" local config_path config_path="$(get_user_config_path)" + local keystore_path + keystore_path="$(get_keystore_path)" if [[ -f "$config_path" ]] && ! grep -qE '^[[:space:]]+tip_poll:[[:space:]]*$' "$config_path"; then - preserve_keys=true + strategy="migrate-012" + elif [[ -f "$keystore_path" ]]; then + strategy="update-config" fi log_info "Steps that will run:" @@ -68,11 +80,11 @@ _perform_migration() { log_info " 2. Back up ${BOLD}user_config.yaml${RESET} → ${BOLD}user_config.yaml.pre-migration-${RESET}" log_info " 3. Delete ${BOLD}${LOGOS_NODE_DIR}/data/${RESET} (chain DB + logs)" log_info " 4. Rebuild Docker image for the current node version" - if [[ "$preserve_keys" == "true" ]]; then - log_info " 5. Migrate 0.1.2 → 0.2.0 config ${BOLD}(preserves wallet keys)${RESET}" - else - log_info " 5. Regenerate fresh ${BOLD}user_config.yaml${RESET} (new wallet keys)" - fi + case "$strategy" in + migrate-012) log_info " 5. Migrate 0.1.2 → 0.2.x config ${BOLD}(preserves wallet keys)${RESET}" ;; + update-config) log_info " 5. Regenerate ${BOLD}user_config.yaml${RESET} from keystore ${BOLD}(preserves wallet keys)${RESET}" ;; + *) log_info " 5. Regenerate fresh ${BOLD}user_config.yaml${RESET} (new wallet keys)" ;; + esac log_info " 6. Restart node (and monitoring, if it was running)" echo "" log_dim "After migration you must request faucet funds again — the new chain starts from zero." @@ -101,20 +113,31 @@ _perform_migration() { # cmd_start auto-restarts monitoring at the end if the compose file exists. # ── Step 2: back up user_config.yaml ────────────────────────────── - # (config_path already resolved above during preserve_keys detection) + # (config_path already resolved above during strategy detection) if [[ -f "$config_path" ]]; then local backup_path="${config_path}.pre-migration-$(date +%Y%m%d-%H%M%S)" cp "$config_path" "$backup_path" chmod 600 "$backup_path" log_success "Backed up config to ${BOLD}${backup_path}${RESET}" # In the fresh-keys path we drop the old config so init-config generates - # cleanly. In the preserve-keys path we keep it in place because - # migrate-from-0.1.2 needs to read it. - if [[ "$preserve_keys" != "true" ]]; then + # cleanly. migrate-from-0.1.2 needs the old config in place to read it; + # update-config overwrites it in place (-y), so both preserve paths + # leave it where it is. + if [[ "$strategy" == "init-config" ]]; then rm -f "$config_path" fi fi + # In the fresh-keys path a leftover keystore.yaml would make init-config + # hard-fail ("Keystore file exists" since 0.2.1) — back it up and clear it. + if [[ "$strategy" == "init-config" ]] && [[ -f "$keystore_path" ]]; then + local ks_backup="${keystore_path}.pre-migration-$(date +%Y%m%d-%H%M%S)" + cp "$keystore_path" "$ks_backup" + chmod 600 "$ks_backup" + rm -f "$keystore_path" + log_success "Backed up keystore to ${BOLD}${ks_backup}${RESET}" + fi + # ── Step 3: wipe data dir ───────────────────────────────────────── log_step "Wiping ${LOGOS_NODE_DIR}/data/ ..." rm -rf "${LOGOS_NODE_DIR}/data" @@ -141,11 +164,11 @@ _perform_migration() { fi # ── Step 5: regenerate config ───────────────────────────────────── - if [[ "$preserve_keys" == "true" ]]; then - docker_migrate_from_012 || die "Failed to migrate 0.1.2 config to 0.2.0" - else - docker_init_config || die "Failed to regenerate node configuration" - fi + case "$strategy" in + migrate-012) docker_migrate_from_012 || die "Failed to migrate 0.1.2 config to 0.2.x" ;; + update-config) docker_update_config || die "Failed to regenerate config from keystore" ;; + *) docker_init_config || die "Failed to regenerate node configuration" ;; + esac # ── Show new keys + faucet ──────────────────────────────────────── echo "" diff --git a/lib/cmd_start.sh b/lib/cmd_start.sh index 55dab36..bc2e9db 100644 --- a/lib/cmd_start.sh +++ b/lib/cmd_start.sh @@ -112,10 +112,14 @@ _show_brief_status() { if [[ -n "$consensus" ]]; then local mode slot height - # 0.2.0 wraps mode in an enum object: "mode":{"Started":"Bootstrapping"}. - # 0.1.x returned a scalar: "mode":"Bootstrapping". Try wrapped first, - # fall back to scalar, else leave empty. - mode="$(echo "$consensus" | sed -nE 's/.*"mode":[[:space:]]*\{"[^"]+":[[:space:]]*"([^"]+)"\}.*/\1/p')" + # 0.2.1 renamed the field: "state":"Bootstrapping" (scalar). 0.2.0 + # wrapped it in an enum object: "mode":{"Started":"Bootstrapping"}. + # 0.1.x was a scalar "mode":"Bootstrapping". Try newest shape first, + # fall back through the older ones, else leave empty. + mode="$(echo "$consensus" | sed -nE 's/.*"state":[[:space:]]*"([^"]+)".*/\1/p')" + if [[ -z "$mode" ]]; then + mode="$(echo "$consensus" | sed -nE 's/.*"mode":[[:space:]]*\{"[^"]+":[[:space:]]*"([^"]+)"\}.*/\1/p')" + fi if [[ -z "$mode" ]]; then mode="$(echo "$consensus" | sed -nE 's/.*"mode":[[:space:]]*"([^"]+)".*/\1/p')" fi diff --git a/lib/docker.sh b/lib/docker.sh index 533c076..1c73177 100644 --- a/lib/docker.sh +++ b/lib/docker.sh @@ -179,16 +179,15 @@ docker_init_config() { # Build init-config args. --http-host takes a full SocketAddr (host:port), # not just a host; port 8080 matches the container-internal API port that - # the compose file forwards. --ibd populates - # cryptarchia.network.bootstrap.ibd.peers from the -p peer IDs so the - # node actually downloads historical blocks from bootstrap peers instead - # of waiting on gossip forever (gossip only carries new blocks; without - # IBD a fresh node hovers at height 0 with peers connected). + # the compose file forwards. Since 0.2.1 IBD is on by default: the -p peer + # IDs auto-populate cryptarchia.network.bootstrap.ibd.peers so the node + # downloads historical blocks from bootstrap peers instead of waiting on + # gossip forever (the old --ibd opt-in flag was replaced by --skip-ibd, + # which we never pass). local init_args=( --output /app/user_config.yaml --keystore /app/keystore.yaml --http-host 0.0.0.0:8080 - --ibd ) # Bootstrap peers as -p /ip4/.../p2p/... (comma-separated in LOGOS_BOOTSTRAP_PEERS) @@ -303,15 +302,15 @@ docker_migrate_from_012() { # here (the release notes' quick-start relies on this too). Without # this, migrated nodes come up with zero fleet contacts and only find # peers through leftover DHT cache, which almost never surfaces the - # 0.2.0 fleet. Reuse the same LOGOS_BOOTSTRAP_PEERS the fresh-install - # path uses. + # current fleet. Reuse the same LOGOS_BOOTSTRAP_PEERS the fresh-install + # path uses. IBD from those peers is on by default since 0.2.1 (the old + # --ibd opt-in flag was replaced by --skip-ibd, which we never pass). local migrate_args=( migrate-from-0.1.2 --old-config /app/user_config.yaml --new-config /app/user_config.migrated.yaml --keystore /app/keystore.yaml --http-host 0.0.0.0:8080 - --ibd ) local _p IFS=',' read -ra _peers <<< "$LOGOS_BOOTSTRAP_PEERS" @@ -355,6 +354,83 @@ docker_migrate_from_012() { return 0 } +# Regenerate user_config.yaml from an EXISTING keystore.yaml via the node's +# `update-config` subcommand. This is the migration path for 0.2.x → 0.2.1+ +# breaking releases: the config shape is already modern, but the chain was +# reset, so we want a freshly generated config (new defaults, new fields) +# while keeping the operator's key identities stable. Funds never survive a +# genesis reset, but stable public keys mean dashboards, faucet history and +# any allowlists keep pointing at the same operator. +# +# Also load-bearing for a subtler reason: since 0.2.1, `init-config` hard-fails +# when keystore.yaml already exists ("Keystore file exists. Use `update` +# command."), so the old wipe-and-init flow cannot work for operators coming +# from 0.2.0 — this is the path the node itself directs us to. +docker_update_config() { + local config_path + config_path="$(get_user_config_path)" + local keystore_path + keystore_path="$(get_keystore_path)" + + [[ -f "$keystore_path" ]] || { + log_error "No keystore.yaml found — cannot run update-config" + return 1 + } + + local update_args=( + update-config + -y + --user-config /app/user_config.yaml + --keystore /app/keystore.yaml + --http-host 0.0.0.0:8080 + ) + + local _p + IFS=',' read -ra _peers <<< "$LOGOS_BOOTSTRAP_PEERS" + for _p in "${_peers[@]}"; do + update_args+=("-p" "$_p") + done + if [[ -n "${LOGOS_EXTERNAL_IP:-}" ]]; then + update_args+=("--external-address" "/ip4/${LOGOS_EXTERNAL_IP}/udp/${LOGOS_UDP_PORT}/quic-v1") + log_dim "Advertising external address: /ip4/${LOGOS_EXTERNAL_IP}/udp/${LOGOS_UDP_PORT}/quic-v1" + fi + + log_step "Regenerating node configuration from existing keystore..." + log_dim "Running logos-blockchain-node update-config (wallet keys preserved)" + + local host_uid + host_uid="$(id -u)" + local host_gid + host_gid="$(id -g)" + + mkdir -p "${LOGOS_NODE_DIR}/data" + + $DOCKER_CMD run --rm \ + --user "${host_uid}:${host_gid}" \ + -v "${LOGOS_NODE_DIR}:/app" \ + -w /app \ + "${LOGOS_DOCKER_IMAGE}:${LOGOS_NODE_VERSION}" \ + "${update_args[@]}" 2>&1 | while IFS= read -r line; do + echo -e " ${DIM}${line}${RESET}" + done + + if [[ ! -f "$config_path" ]]; then + log_error "update-config did not produce a new config" + return 1 + fi + + chmod 600 "$config_path" + chmod 600 "$keystore_path" + + # Same compose-friendly patches as the init-config path (all idempotent). + patch_user_config_for_http_bind "$config_path" + patch_user_config_for_otlp "$config_path" + patch_user_config_for_log_files "$config_path" + + log_success "Regenerated $config_path; wallet keys preserved in $keystore_path" + return 0 +} + # Rewrite api.backend.listen_address so the node binds 0.0.0.0 inside the # container. Docker's port map forwards host:8080 to container_ip:8080 — if # the node listens on 127.0.0.1:8080 inside the container, the forward can't diff --git a/lib/releases.sh b/lib/releases.sh index a041b56..13b179e 100644 --- a/lib/releases.sh +++ b/lib/releases.sh @@ -4,7 +4,7 @@ # Versions that introduce breaking on-chain changes (genesis reset, incompatible # state). Updating across one of these requires wiping local data and # regenerating user_config.yaml. Add new entries here as the chain evolves. -LOGOS_BREAKING_VERSIONS=("0.1.2" "0.2.0") +LOGOS_BREAKING_VERSIONS=("0.1.2" "0.2.0" "0.2.1") is_breaking_version() { local v="${1#v}" diff --git a/monitoring/exporter/exporter.py b/monitoring/exporter/exporter.py index 035f5b5..276372e 100644 --- a/monitoring/exporter/exporter.py +++ b/monitoring/exporter/exporter.py @@ -125,9 +125,12 @@ def poll_node_api(): node_up.set(1) - # 0.2.0: mode is an enum object like {"Started": "Bootstrapping"} - # 0.1.x: mode was a scalar string like "Bootstrapping" - mode_field = data.get("mode", "Unknown") + # 0.2.1: scalar "state" inside "cryptarchia_info" (e.g. "Bootstrapping") + # 0.2.0: "mode" is an enum object like {"Started": "Bootstrapping"} + # 0.1.x: "mode" was a scalar string like "Bootstrapping" + mode_field = data.get("cryptarchia_info", {}).get("state") or data.get( + "mode", "Unknown" + ) if isinstance(mode_field, dict) and mode_field: mode = next(iter(mode_field.values()), "Unknown") else: diff --git a/network.yml b/network.yml index 20898cc..11e1cd9 100644 --- a/network.yml +++ b/network.yml @@ -7,7 +7,8 @@ network: testnet # Once connected, the node uses Kademlia to find additional peers. bootstrap_peers: # Updated for 0.2.0 release (genesis reset rotated peer IDs). - # Source: https://github.com/logos-blockchain/logos-blockchain/releases/tag/0.2.0 + # Verified unchanged for 0.2.1 (genesis reset again, but same fleet peers). + # Source: https://github.com/logos-blockchain/logos-blockchain/releases/tag/0.2.1 - /ip4/65.109.51.37/udp/3000/quic-v1/p2p/12D3KooWFrouXfmrR4nsLMtE7wu15DoMJ6VtoUtHinREZCvbWHar - /ip4/65.109.51.37/udp/3001/quic-v1/p2p/12D3KooWJRGau8M1rjT7R5e4YYsgdFhsMX35nRDtMwCDjxQkXAHz - /ip4/65.109.51.37/udp/3002/quic-v1/p2p/12D3KooWQXJavMDTRscjauFSgVAB1VLB6Rzpy2uY5SU9Tk7927tb diff --git a/settings.env.example b/settings.env.example index 981fb7a..3e9b889 100644 --- a/settings.env.example +++ b/settings.env.example @@ -22,7 +22,7 @@ # ── Optional: public IP hint ──────────────────────────────────────────── # Set to your node's public IPv4 to advertise it directly to peers and skip -# NAT traversal. Applied when init-config / migrate-from-0.1.2 runs. +# NAT traversal. Applied when init-config / update-config / migrate-from-0.1.2 runs. # LOGOS_EXTERNAL_IP=203.0.113.42 # ── Bootstrap peers ───────────────────────────────────────────────────── From 1c2f789843efc130880e12371c799941c95f87c7 Mon Sep 17 00:00:00 2001 From: ibeezhan Date: Tue, 11 Aug 2026 14:01:29 +0100 Subject: [PATCH 2/2] status: fix diagnostics count parsing (0\n0 arithmetic errors) grep -c prints the count (including "0") and merely exits 1 when it is zero, so the `|| echo 0` fallback produced a two-line "0\n0" value and every (( count == 0 )) check spewed "syntax error in expression" into the status output whenever any single diagnostic fired. Found while testing 0.2.1: stale 0.2.0 peers trigger the protocol-mismatch warning, which made the corruption visible on every status call. Use `|| true`. Also parse 0.2.1's "state"/"phase" consensus fields (see previous commit) in the status Consensus section. Co-Authored-By: Claude Fable 5 --- lib/cmd_status.sh | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/lib/cmd_status.sh b/lib/cmd_status.sh index c920bd5..995aa9d 100644 --- a/lib/cmd_status.sh +++ b/lib/cmd_status.sh @@ -42,21 +42,25 @@ cmd_status() { consensus="$(curl -sf "${api_url}/cryptarchia/info" 2>/dev/null)" || true if [[ -n "$consensus" ]]; then - local mode slot height lib tip - # 0.2.0 wraps the mode in an enum object: "mode":{"Started":"Bootstrapping"}. - # 0.1.x returned a scalar: "mode":"Bootstrapping". Try the wrapped shape - # first (extracts the inner variant), fall back to the scalar. On no - # match the substitution leaves the whole body — detect and blank out. - mode="$(echo "$consensus" | sed -nE 's/.*"mode":[[:space:]]*\{"[^"]+":[[:space:]]*"([^"]+)"\}.*/\1/p')" + local mode phase slot height lib tip + # 0.2.1 renamed the field: "state":"Bootstrapping" (scalar) plus a + # top-level "phase" (e.g. "InitialBlockDownload"). 0.2.0 wrapped it in + # an enum object: "mode":{"Started":"Bootstrapping"}. 0.1.x was a + # scalar "mode":"Bootstrapping". Try newest shape first. + mode="$(echo "$consensus" | sed -nE 's/.*"state":[[:space:]]*"([^"]+)".*/\1/p')" + if [[ -z "$mode" ]]; then + mode="$(echo "$consensus" | sed -nE 's/.*"mode":[[:space:]]*\{"[^"]+":[[:space:]]*"([^"]+)"\}.*/\1/p')" + fi if [[ -z "$mode" ]]; then mode="$(echo "$consensus" | sed -nE 's/.*"mode":[[:space:]]*"([^"]+)".*/\1/p')" fi + phase="$(echo "$consensus" | sed -nE 's/.*"phase":[[:space:]]*"([^"]+)".*/\1/p')" slot="$(echo "$consensus" | sed -E 's/.*"slot":([0-9]+).*/\1/')" height="$(echo "$consensus" | sed -E 's/.*"height":([0-9]+).*/\1/')" case "$mode" in Online) log_success "Mode: ${GREEN}${mode}${RESET}" ;; - Bootstrapping) log_info "Mode: ${YELLOW}${mode}${RESET} (syncing...)" ;; + Bootstrapping) log_info "Mode: ${YELLOW}${mode}${RESET} (syncing...${phase:+ phase: ${phase}})" ;; "") log_info "Mode: ${DIM}(unknown)${RESET}" ;; *) log_info "Mode: ${mode}" ;; esac @@ -197,12 +201,15 @@ _scan_recent_node_logs() { window="$($DOCKER_CMD logs --since 60s "$LOGOS_CONTAINER_NAME" 2>&1)" || return 0 [[ -z "$window" ]] && return 0 + # grep -c prints the count (including "0") on its own — it just also + # exits 1 when the count is 0, so the fallback must NOT echo a second + # "0" (that yields "0\n0" and breaks the (( )) checks below). local proto_mismatch ibd_failed ntp_failed net_unreach no_gateway - proto_mismatch="$(echo "$window" | grep -cE 'does not support /logos-blockchain-[a-z]+-[0-9]+\.[0-9]+\.[0-9]+/chainsync' 2>/dev/null || echo 0)" - ibd_failed="$(echo "$window" | grep -cE 'Initial Block Download failed: AllPeersFailed' 2>/dev/null || echo 0)" - ntp_failed="$(echo "$window" | grep -cE 'NTP sync failed' 2>/dev/null || echo 0)" - net_unreach="$(echo "$window" | grep -cE 'Network is unreachable|Temporary failure in name resolution' 2>/dev/null || echo 0)" - no_gateway="$(echo "$window" | grep -cE 'Failed to detect gateway|Failed to get default gateway' 2>/dev/null || echo 0)" + proto_mismatch="$(echo "$window" | grep -cE 'does not support /logos-blockchain-[a-z]+-[0-9]+\.[0-9]+\.[0-9]+/chainsync' 2>/dev/null || true)" + ibd_failed="$(echo "$window" | grep -cE 'Initial Block Download failed: AllPeersFailed' 2>/dev/null || true)" + ntp_failed="$(echo "$window" | grep -cE 'NTP sync failed' 2>/dev/null || true)" + net_unreach="$(echo "$window" | grep -cE 'Network is unreachable|Temporary failure in name resolution' 2>/dev/null || true)" + no_gateway="$(echo "$window" | grep -cE 'Failed to detect gateway|Failed to get default gateway' 2>/dev/null || true)" # Nothing to say → don't print the section header at all. if (( proto_mismatch == 0 && ibd_failed == 0 && ntp_failed == 0 && net_unreach == 0 && no_gateway == 0 )); then