Skip to content

Update engine hardening, wrapper-aware config edits, keygen secrets hygiene - #7

Merged
blu3chipsx merged 7 commits into
mainfrom
ops-hardening
Jul 24, 2026
Merged

Update engine hardening, wrapper-aware config edits, keygen secrets hygiene#7
blu3chipsx merged 7 commits into
mainfrom
ops-hardening

Conversation

@grantkee

Copy link
Copy Markdown
Contributor
  • update-node.sh (v1.1.60): mutating operations now take a cross-invocation lock so a UI-triggered apply and a CLI apply can't run concurrently; post-apply verify now checks artifact identity (installed binary hash for source, running container image ID for docker) instead of health alone, so a no-op swap or stale unit no longer reports success; restored the script's intended set -uo pipefail-only semantics (lib/common.sh was silently re-enabling set -e) and made every apply/rollback step check its own result, including failed pending-state writes and failed rollback restores.
  • edit-config.sh (v1.2.5): detection and every edit path (listeners, p2p ports, metrics, verbosity, docker image, menu and --json set) now resolve the file the service actually reads, matching update-node.sh's resolution. Docker installs launched through the start wrapper were previously misdetected as binary installs, so every field showed unknown and edits silently landed in a unit file the service never reads. Also fixes a verbosity-edit bug where the first -v on a docker launch line (the volume flag) could be replaced instead of the verbosity flag. RPC editing on wrapper installs is refused with manual instructions rather than appending flags to the wrong file.
  • setup-node.sh (v1.0.1): the docker keygen no longer passes the BLS passphrase as -e TN_BLS_PASSPHRASE="<value>", which was visible in ps//proc/*/cmdline for the life of the keygen; it now passes the name only and lets docker read the value from the environment. The passphrase file is created 0600 from the first byte via a umask subshell instead of write-then-chmod.
  • update-scripts.sh and ui/server.py version bumps to redeploy the updater and web UI copies in /opt/telcoin-ui-update/, plus refreshed .sha256 sidecars and changelog entries for all of the above.

grantkee and others added 7 commits July 24, 2026 12:41
…steps

update-node.sh declares `set -uo pipefail` (no errexit -- the apply flows do
manual error handling with health-verify + rollback), but sourcing
lib/common.sh silently re-enabled -e. Any unguarded failure after the service
was stopped (perl edit, daemon-reload, a restore cp) aborted the script
mid-apply: node left down, no rollback offered, pending state stranded.

- set +e right after the common.sh source, with a comment explaining why.
- write_pending_state now returns non-zero on write failure and all four
  call sites check it (a silent failure previously became a false
  "pending update saved" under +e).
- daemon-reload failures warn (main paths) or are explicit best-effort
  (restore paths) instead of relying on errexit.
- Rollback restore-cp failures now hard-stop with manual recovery
  instructions instead of restarting on the NEW artifact and letting a
  passing re-verify misreport "rolled back".
- perl edit stays unguarded by design: the existing hash + grep checks
  catch every failure mode; now commented as such.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing prevented a UI-triggered `--json --apply` and a CLI run from
updating the same node concurrently: double service stop, racing
truncate-writes to .pending-update, and interleaved binary/image swaps.

lib/common.sh v1.3.8 adds tn_acquire_update_lock():
- flock -n on /var/lock/telcoin-update.lock (fd 9 held for process
  lifetime, so the kernel releases the lock on any exit -- no stale
  locks possible). Lock file records the holder PID for diagnostics.
- Atomic mkdir fallback with PID-staleness takeover where flock(1) or
  /var/lock is absent (stock macOS).
- On contention, TN_UPDATE_LOCK_HOLDER carries the holder PID so JSON
  mode can report it.

update-node.sh v1.1.59 takes the lock for every mutating mode --
interactive flow, --json prepare/apply/discard -- and exits with a clear
"another update is running (PID N)" message when contended. Read-only
--check/--json check stays lock-free so dashboard polls never block a
real update.

Fixture-tested (mkdir path, the one macOS uses): contention reports the
holder PID, dead-PID locks are taken over, EXIT trap releases cleanly.
flock path to be exercised on the staging node (Linux-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s live

verify_health_after_restart only checked unit-active + RPC "result", so a
no-op update (cp to the wrong place, restart on a stale unit definition
after a failed daemon-reload) passed verification and reported success
while the node kept running the old version.

update-node.sh v1.1.60:
- Source applies: after restart, the installed binary must sha256-match
  BUILT_HASH from the pending state; mismatch = verify failure = the
  existing rollback path. If the interactive operator explicitly approves
  applying a binary that changed since prepare, the check follows what
  they approved.
- Docker applies: prepare now records PULLED_IMAGE_ID (docker image
  inspect .Id) in the pending state; after restart the running
  container's .Image must match it. Container name comes from --name in
  the launch wrapper/unit, falling back to SERVICE_NAME.
- Rollback verifies stay on the plain health probe -- the old artifact is
  exactly what should be running there.
- Both checks skip with a warning when the pending state predates this
  version (no BUILT_HASH / PULLED_IMAGE_ID), so in-flight prepares still
  apply.

Fixture-tested via PATH-shim harness: tamper -> rejected, match -> pass,
old pending state -> skip, docker-inspect hiccup -> no false rollback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two exposure windows during validator keygen:

- The docker keygen passed `-e TN_BLS_PASSPHRASE="$bls_passphrase"`,
  putting the passphrase in the docker client's argv -- world-readable
  via ps / /proc/<pid>/cmdline for the life of the keygen run. Switched
  to name-only pass-through (`-e TN_BLS_PASSPHRASE`), the same convention
  the runtime start wrapper already uses; the value now travels only via
  the environment (already exported for the binary branch).

- The passphrase file was echo-written first and chmod 600 second,
  leaving it readable under the default umask (0644) for the gap between
  the two commands. Now created 0600 from the first byte via a
  umask-077 subshell (the lib/observability.sh token-file pattern), with
  the chmod kept for re-runs since umask cannot tighten a pre-existing
  file. echo -> printf '%s\n' while here, so a passphrase starting with
  "-e"/"-n" cannot be mangled.

setup-node.sh v1.0.1. Verified with a docker PATH shim: argv carries no
passphrase, the container env still receives it, and the file is 600 at
creation under a 022 umask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y reads

Config detection grepped the unit's ExecStart for a literal "docker run",
but current installs launch via the start wrapper
(/opt/telcoin/start-<svc>.sh) -- the unit's ExecStart is just the wrapper
path. Docker installs were misdetected as binary, every flag showed
"unknown", edits silently rewrote unit lines the service never reads, and
edit_docker_image refused to run at all.

Ports the update-node.sh 7b10e97 pattern (edit-config.sh v1.2.5):
- TARGET_LAUNCH_FILE resolved via tn_node_launch_target (wrapper when
  present, unit for legacy inline installs; unit fallback if the wrapper
  is missing) in both the menu flow and --json set.
- read_launch_line joins the wrapper's backslash-continued command so all
  flag/image inspection sees one logical line; is_docker_install and the
  show-config display now detect wrapper docker installs correctly.
- Listener edits write wherever the service reads: the -e lines in the
  docker launch file (quote-preserving pattern), or the unit Environment=
  PLUS the wrapper export for binary/source (the export is what the
  process sees; the unit line stays in sync). Menu, p2p-ports, and
  --json set all share the one writer.
- Metrics, verbosity, and image edits target TARGET_LAUNCH_FILE; backups
  and the --json rollback now cover both files when they differ.
- set_verbosity rewritten: the old `sed s| -v\+ |` matched the first
  " -v " -- on any docker launch line that is the -v VOLUME flag (a
  latent corruption bug on legacy inline units too) and on wrapper files
  the line-start token never matched. Now replaces -v{1,5} only when
  followed by a continuation, another --flag, or end of line, so volume
  -v and `command -v` can never match.
- edit_rpc refuses loudly on wrapper installs (its ExecStart rewrite is
  legacy-only) with the manual instruction, instead of appending junk
  args to the unit. edit_bls_passphrase is intentionally unchanged: only
  legacy inline units embed the passphrase, and its unit grep is the
  correct discriminator for exactly those.

Fixture-tested against all three real layouts (docker wrapper incl. TPM
lines, binary wrapper, legacy inline unit): detection, listener/metrics/
verbosity/image edits, quote integrity, wrapper still parses, and the
volume--v regression case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-cut

- update-scripts.sh v1.1.65 release marker so operators re-fetch the five
  changed files (update-node 1.1.60, lib/common 1.3.8, edit-config 1.2.5,
  setup-node 1.0.1, ui/server.py).
- ui/server.py UI_VERSION 1.8.6 (no UI change): triggers install-ui.sh
  --update so the root-owned engine copies in /opt/telcoin-ui-update/
  pick up the lock, identity-verify, and wrapper-aware edit fixes.
- Regenerated the six affected .sha256 sidecars via tools/gen-checksums.sh.
- README changelog: one entry per changed script version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…estore

Self-review caught two errexit landmines in the wrapper-aware port
(edit-config.sh runs under set -e from common.sh, unlike update-node.sh):

- edit_p2p_ports used `[[ -z x ]] && x=$(...)` for the launch-file
  fallback -- rc 1 whenever the unit already provided the value, which
  killed the script mid-menu. Now if/fi.
- run_json_set's _json_restore_backups ended with the same && shape when
  no wrapper backup exists, killing the script before the terminal
  done-event reached the UI. Now if/fi with a guarded cp that reports a
  failed wrapper restore instead of dying silently.

Sidecar refreshed; edit-config stays v1.2.5 (same unreleased cut).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@grantkee
grantkee requested a review from blu3chipsx July 24, 2026 17:55
@grantkee grantkee self-assigned this Jul 24, 2026
@blu3chipsx
blu3chipsx merged commit 558e0fc into main Jul 24, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants