From 0bf8b6910ed6287913a845bea059f5e5341e79f6 Mon Sep 17 00:00:00 2001 From: aperod-network Date: Sat, 10 Oct 2026 18:05:40 +0500 Subject: [PATCH] fix: pin node source retrieval and secure decoy sampling Co-authored-by: aperod-network --- README.md | 4 + core/decoy_sampling.go | 58 +++++ core/decoy_sampling_test.go | 218 +++++++++++++++++ core/tx_builder.go | 10 +- core/tx_builder_multi.go | 10 +- core/utxo.go | 112 ++++----- deploy/NODE-SOURCE-UPDATE.md | 42 ++++ deploy/install-node.sh | 92 ++------ deploy/install-validator.sh | 55 +++-- deploy/node-source-release.sh | 136 +++++++++++ deploy/test-node-source-release.py | 289 +++++++++++++++++++++++ deploy/test-source-safe-guard.sh | 21 +- deploy/test-update-node-key-preflight.sh | 29 ++- deploy/update-node.sh | 37 ++- deploy/upgrade-node.sh | 3 + 15 files changed, 909 insertions(+), 207 deletions(-) create mode 100644 core/decoy_sampling.go create mode 100644 core/decoy_sampling_test.go create mode 100644 deploy/NODE-SOURCE-UPDATE.md create mode 100644 deploy/node-source-release.sh create mode 100644 deploy/test-node-source-release.py mode change 100644 => 100755 deploy/test-update-node-key-preflight.sh mode change 100644 => 100755 deploy/update-node.sh mode change 100644 => 100755 deploy/upgrade-node.sh diff --git a/README.md b/README.md index d7ee23b..d8ddacc 100644 --- a/README.md +++ b/README.md @@ -653,3 +653,7 @@ under their own terms. Email + +## Reviewed source updates + +After the history transition, use the complete deployment-tools package and an explicitly reviewed revision. Do not force-reset old node clones. See [installation and update requirements](deploy/NODE-SOURCE-UPDATE.md). Clean source provenance is not proof of live chain compatibility. diff --git a/core/decoy_sampling.go b/core/decoy_sampling.go new file mode 100644 index 0000000..ef795ca --- /dev/null +++ b/core/decoy_sampling.go @@ -0,0 +1,58 @@ +package core + +import ( + "crypto/rand" + "errors" + "io" + "math/big" + + "github.com/aperod/aperod/crypto" +) + +// Builders distinguish an entropy failure from an ordinary candidate shortfall: +// legacy shortfalls may use Phase 1, but a failure must stop both builder paths. +func (b *TxBuilder) sampleChainDecoys(count int, exclude map[crypto.Point32]bool, clsag bool) ([]DecoyUTXO, error) { + entropy := b.decoyEntropy + if entropy == nil { + entropy = rand.Reader + } + if clsag { + return b.utxoSet.sampleCLSAGDecoys(count, exclude, entropy) + } + return b.utxoSet.sampleDecoys(count, exclude, entropy) +} + +// sampleDecoyCandidates partially shuffles a call-owned candidate slice. +// rand.Int rejection sampling makes every remaining index equally likely; +// partial Fisher-Yates chooses distinct entries without changing UTXO records. +// Entropy failure discards the entire selection, including already drawn items. +func sampleDecoyCandidates(candidates []*UTXO, count int, entropy io.Reader) ([]DecoyUTXO, error) { + if count <= 0 || len(candidates) == 0 { + return nil, nil + } + if entropy == nil { + return nil, errors.New("decoy sampling requires cryptographic entropy") + } + want := count + if want > len(candidates) { + want = len(candidates) + } + var bound big.Int + for i := 0; i < want; i++ { + bound.SetInt64(int64(len(candidates) - i)) + offset, err := rand.Int(entropy, &bound) + if err != nil { + return nil, err + } + j := i + int(offset.Int64()) + candidates[i], candidates[j] = candidates[j], candidates[i] + } + out := make([]DecoyUTXO, want) + for i := range out { + out[i] = DecoyUTXO{ + OneTimePub: candidates[i].OneTimePub, + AmountCommit: candidates[i].AmountCommit, + } + } + return out, nil +} diff --git a/core/decoy_sampling_test.go b/core/decoy_sampling_test.go new file mode 100644 index 0000000..97787fb --- /dev/null +++ b/core/decoy_sampling_test.go @@ -0,0 +1,218 @@ +package core + +import ( + "bytes" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + "github.com/aperod/aperod/crypto" +) + +func samplingFixture(id byte) *UTXO { + return &UTXO{ + TxHash: crypto.Hash32{id}, OneTimePub: crypto.Point32{id}, + AmountCommit: crypto.Commitment{id, 0xa5}, + } +} + +func TestDecoySamplingUniformOrderedSelections(t *testing.T) { + // Enumerate all equiprobable draws for bounds 4, 3, 2, rather than a + // probabilistic frequency assertion. Each of the 24 ordered triples + // must occur exactly once and retain the key/commitment pairing. + outcomes := make(map[string]int) + for a := byte(0); a < 4; a++ { + for b := byte(0); b < 3; b++ { + for c := byte(0); c < 2; c++ { + pool := []*UTXO{samplingFixture(1), samplingFixture(2), samplingFixture(3), samplingFixture(4)} + got, err := sampleDecoyCandidates(pool, 3, bytes.NewReader([]byte{a, b, c})) + if err != nil || len(got) != 3 { + t.Fatalf("selection: %v %v", got, err) + } + seen := map[crypto.Point32]bool{} + for _, d := range got { + if seen[d.OneTimePub] || d.AmountCommit != samplingFixture(d.OneTimePub[0]).AmountCommit { + t.Fatal("repeated key or altered commitment") + } + seen[d.OneTimePub] = true + } + outcomes[fmt.Sprint(got[0].OneTimePub[0], got[1].OneTimePub[0], got[2].OneTimePub[0])]++ + } + } + } + if len(outcomes) != 24 { + t.Fatalf("only %d ordered outcomes", len(outcomes)) + } + for _, count := range outcomes { + if count != 1 { + t.Fatal("unequal outcome multiplicity") + } + } +} + +func TestDecoySamplingRejectsOutOfRangeEntropy(t *testing.T) { + reader := bytes.NewReader([]byte{0xff, 2}) + got, err := sampleDecoyCandidates([]*UTXO{samplingFixture(1), samplingFixture(2), samplingFixture(3)}, 1, reader) + if err != nil || len(got) != 1 || got[0].OneTimePub[0] != 3 || reader.Len() != 0 { + t.Fatalf("rejection sampling changed: %v %v", got, err) + } +} + +func TestDecoySamplingEntropyFailureDiscardsPartialSelection(t *testing.T) { + for _, reader := range []io.Reader{nil, bytes.NewReader(nil), bytes.NewReader([]byte{1})} { + pool := []*UTXO{samplingFixture(1), samplingFixture(2), samplingFixture(3)} + before := map[crypto.Point32]UTXO{} + for _, u := range pool { + before[u.OneTimePub] = *u + } + got, err := sampleDecoyCandidates(pool, 2, reader) + if err == nil || got != nil { + t.Fatalf("entropy failure returned partial output: %v %v", got, err) + } + for _, u := range pool { + if *u != before[u.OneTimePub] { + t.Fatal("sampling modified an underlying UTXO") + } + } + } +} + +func TestDecoySelectorsFiltersCountsAndFailures(t *testing.T) { + for _, clsag := range []bool{false, true} { + t.Run(fmt.Sprintf("clsag=%t", clsag), func(t *testing.T) { + set := NewUTXOSet() + for id := byte(1); id <= 5; id++ { + set.spentPubKeys[crypto.Point32{id}] = samplingFixture(id) + } + set.spentPubKeys[crypto.Point32{2}].ProtocolLocked = true + set.byPubKey[crypto.Point32{6}] = samplingFixture(6) + set.byPubKey[crypto.Point32{7}] = samplingFixture(7) + set.byPubKey[crypto.Point32{7}].ProtocolLocked = true + // Same key with a stale spent commitment: active lookup must win. + set.byPubKey[crypto.Point32{3}] = samplingFixture(3) + set.byPubKey[crypto.Point32{3}].AmountCommit[1] = 0xb6 + exclude := map[crypto.Point32]bool{{1}: true, {6}: true} + sample := set.sampleDecoys + if clsag { + sample = set.sampleCLSAGDecoys + } + for _, count := range []int{-1, 0, 1, 3, 99} { + got, err := sample(count, exclude, bytes.NewReader(make([]byte, 100))) + if err != nil { + t.Fatal(err) + } + want := count + if want < 0 { + want = 0 + } + if want > 3 { + want = 3 + } + if len(got) != want { + t.Fatalf("count=%d got=%d want=%d", count, len(got), want) + } + seen := map[crypto.Point32]bool{} + for _, d := range got { + if seen[d.OneTimePub] || exclude[d.OneTimePub] || d.OneTimePub[0] == 2 || d.OneTimePub[0] == 7 { + t.Fatal("repeated, excluded, active-only or locked candidate") + } + seen[d.OneTimePub] = true + expected := set.spentPubKeys[d.OneTimePub] + if clsag && set.byPubKey[d.OneTimePub] != nil { + expected = set.byPubKey[d.OneTimePub] + } + if d.AmountCommit != expected.AmountCommit { + t.Fatal("canonical pair changed") + } + } + } + got, err := sample(2, exclude, bytes.NewReader([]byte{0})) + if !errors.Is(err, io.EOF) || got != nil { + t.Fatalf("selector did not fail closed: %v %v", got, err) + } + empty, err := sample(5, map[crypto.Point32]bool{{1}: true, {3}: true, {4}: true, {5}: true, {6}: true}, nil) + if err != nil || empty != nil { + t.Fatalf("empty pool should not consume entropy: %v %v", empty, err) + } + }) + } +} + +type samplingStore struct { + result []DecoyUTXO + err error + calls int +} + +func (*samplingStore) LookupRingMember(crypto.Point32) (*UTXO, error) { return nil, nil } +func (s *samplingStore) SampleRingMembers(_ int, _ map[crypto.Point32]bool) ([]DecoyUTXO, error) { + s.calls++ + return s.result, s.err +} + +func TestDecoySamplingPersistentStoreNoMemoryFallback(t *testing.T) { + set := NewUTXOSet() + set.spentPubKeys[crypto.Point32{1}] = samplingFixture(1) + backend := &samplingStore{err: errors.New("synthetic store entropy failure")} + set.ringMembers = backend + if got := set.SampleCLSAGDecoys(2, nil); got != nil { + t.Fatal("store error used a memory fallback") + } + if got := set.SampleCLSAGDecoys(-1, nil); got != nil || backend.calls != 1 { + t.Fatal("nonpositive request reached persistent store") + } + backend.err = nil + backend.result = []DecoyUTXO{{OneTimePub: crypto.Point32{8}, AmountCommit: crypto.Commitment{9}}} + if got := set.SampleCLSAGDecoys(2, nil); !reflect.DeepEqual(got, backend.result) { + t.Fatal("persistent canonical pairs changed") + } +} + +func TestDecoySamplingBuildersAbortOnEntropyError(t *testing.T) { + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addr := crypto.AddressFromKeys(crypto.TestnetByte, keys) + const amount = uint64(10_000_000_000) + mint, err := BuildMintTx(addr, amount, 1) + if err != nil { + t.Fatal(err) + } + blind, err := crypto.DeterministicMintBlindV2(keys.Spend.Public, amount, 1) + if err != nil { + t.Fatal(err) + } + owned := []OwnedUTXO{{ + UTXO: UTXO{TxHash: mint.Hash(), OneTimePub: mint.Outputs[0].OneTimePub, + TxPubKey: mint.Outputs[0].TxPubKey, AmountCommit: mint.Outputs[0].AmountCommit}, + HsScalar: crypto.ScalarFromUint64(1), Amount: amount, Blind: blind, + }} + for _, version := range []TxVersion{TxVersionBase, TxVersionCLSAG} { + for _, multi := range []bool{false, true} { + t.Run(fmt.Sprintf("version=%d/multi=%t", version, multi), func(t *testing.T) { + set := NewUTXOSet() + for id := byte(1); id <= 20; id++ { + set.spentPubKeys[crypto.Point32{id}] = samplingFixture(id) + } + builder := NewTxBuilder(keys.Spend.Private, keys.View.Private, keys.Spend.Public, owned, 1). + WithVersion(version).WithDecoySet(set) + builder.decoyEntropy = bytes.NewReader([]byte{0}) + if multi { + result, err := builder.BuildMulti([]BatchRecipient{{Address: addr, AmountNAPR: 1_000_000}}, addr) + if result != nil || err == nil || !strings.Contains(err.Error(), "sample chain decoys") { + t.Fatalf("multi builder did not abort: %v %v", result, err) + } + } else { + result, err := builder.Build(1_000_000, addr, addr) + if result != nil || err == nil || !strings.Contains(err.Error(), "sample chain decoys") { + t.Fatalf("single builder did not abort: %v %v", result, err) + } + } + }) + } + } +} diff --git a/core/tx_builder.go b/core/tx_builder.go index 418ad76..0474874 100644 --- a/core/tx_builder.go +++ b/core/tx_builder.go @@ -7,6 +7,7 @@ package core import ( "fmt" + "io" "sort" "github.com/aperod/aperod/crypto" @@ -21,6 +22,7 @@ type TxBuilder struct { feePerByte uint64 // base-fee + optional tip per byte in nAPRO utxoSet *UTXOSet // optional; if set, real chain UTXOs are used as ring decoys (Phase 2) decoys []DecoyUTXO // optional caller-supplied public chain decoys + decoyEntropy io.Reader // nil uses crypto/rand.Reader; per-builder test injection txVersion TxVersion avmPayload *AVMPayload // optional signed AVM payload; forces TxVersionAVM paymentRecipientProof bool @@ -447,10 +449,10 @@ func (b *TxBuilder) Build(amount uint64, recipient, changeAddr crypto.Address) ( excludePubs[u.OneTimePub] = true } need := len(selected) * (crypto.RingSize - 1) - if txVersionUsesCLSAG(b.txVersion) { - allDecoys = b.utxoSet.SampleCLSAGDecoys(need, excludePubs) - } else { - allDecoys = b.utxoSet.SampleDecoys(need, excludePubs) + var err error + allDecoys, err = b.sampleChainDecoys(need, excludePubs, txVersionUsesCLSAG(b.txVersion)) + if err != nil { + return nil, fmt.Errorf("sample chain decoys: %w", err) } } diff --git a/core/tx_builder_multi.go b/core/tx_builder_multi.go index 3d3d117..924a331 100644 --- a/core/tx_builder_multi.go +++ b/core/tx_builder_multi.go @@ -228,11 +228,11 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad excludePubs[u.OneTimePub] = true } need := len(selected) * (crypto.RingSize - 1) - if b.txVersion == TxVersionCLSAG { - allDecoys = b.utxoSet.SampleCLSAGDecoys(need, excludePubs) - } else { - allDecoys = b.utxoSet.SampleDecoys(need, excludePubs) - } + var err error + allDecoys, err = b.sampleChainDecoys(need, excludePubs, b.txVersion == TxVersionCLSAG) + if err != nil { + return nil, fmt.Errorf("sample chain decoys: %w", err) + } } // ── Build ring inputs ───────────────────────────────────────────────────── diff --git a/core/utxo.go b/core/utxo.go index 3c52287..7c186b1 100644 --- a/core/utxo.go +++ b/core/utxo.go @@ -1,11 +1,11 @@ package core import ( + cryptorand "crypto/rand" "fmt" - "math/rand" + "io" "sort" "sync" - "time" "github.com/aperod/aperod/crypto" ) @@ -698,13 +698,13 @@ func (s *UTXOSet) applyTxsLocked(block *Block) { for i, out := range tx.Outputs { key := UTXOKey{TxHash: txHash, OutputIndex: uint32(i)} u := &UTXO{ - TxHash: txHash, - OutputIndex: uint32(i), - OneTimePub: out.OneTimePub, - TxPubKey: out.TxPubKey, - AmountCommit: out.AmountCommit, - EncAmount: out.EncAmount, - BlockHeight: block.Header.Height, + TxHash: txHash, + OutputIndex: uint32(i), + OneTimePub: out.OneTimePub, + TxPubKey: out.TxPubKey, + AmountCommit: out.AmountCommit, + EncAmount: out.EncAmount, + BlockHeight: block.Header.Height, ProtocolLocked: tx.IsGuardianFund(), } s.utxos[key] = u @@ -908,11 +908,20 @@ func (s *UTXOSet) IsStaked(txHash crypto.Hash32, outIdx uint32) bool { // Any UTXO whose OneTimePub appears in exclude is omitted; callers use this to // prevent the real input from appearing as its own decoy. // -// Selection is randomised with a time-seeded PRNG (decoys are public knowledge; -// randomness serves privacy, not security). If fewer than count candidates +// Selection uses cryptographic entropy to protect the anonymity set. +// If fewer than count candidates // remain after exclusions, all available candidates are returned — txBuildRing // fills the remaining ring slots with Phase 1 random keys. func (s *UTXOSet) SampleDecoys(count int, exclude map[crypto.Point32]bool) []DecoyUTXO { + out, _ := s.sampleDecoys(count, exclude, cryptorand.Reader) + return out +} + +// The reader is scoped to one call so tests never replace process-wide entropy. +func (s *UTXOSet) sampleDecoys(count int, exclude map[crypto.Point32]bool, entropy io.Reader) ([]DecoyUTXO, error) { + if count <= 0 { + return nil, nil + } s.mu.RLock() defer s.mu.RUnlock() @@ -923,30 +932,7 @@ func (s *UTXOSet) SampleDecoys(count int, exclude map[crypto.Point32]bool) []Dec } } - n := len(candidates) - want := count - if want > n { - want = n - } - if want == 0 { - return nil - } - - // Fisher-Yates partial shuffle to pick `want` items in random order. - rng := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec // privacy, not security - for i := 0; i < want; i++ { - j := i + rng.Intn(n-i) - candidates[i], candidates[j] = candidates[j], candidates[i] - } - - out := make([]DecoyUTXO, want) - for i := 0; i < want; i++ { - out[i] = DecoyUTXO{ - OneTimePub: candidates[i].OneTimePub, - AmountCommit: candidates[i].AmountCommit, - } - } - return out + return sampleDecoyCandidates(candidates, count, entropy) } // SampleCLSAGDecoys returns real on-chain decoys for v5. Unlike historical @@ -954,23 +940,34 @@ func (s *UTXOSet) SampleDecoys(count int, exclude map[crypto.Point32]bool) []Dec // CLSAG validates the commitment paired with every ring key and does not reveal // which member is being spent. func (s *UTXOSet) SampleCLSAGDecoys(count int, exclude map[crypto.Point32]bool) []DecoyUTXO { + out, err := s.sampleCLSAGDecoys(count, exclude, cryptorand.Reader) + if err != nil { + return nil + } + return out +} + +func (s *UTXOSet) sampleCLSAGDecoys(count int, exclude map[crypto.Point32]bool, entropy io.Reader) ([]DecoyUTXO, error) { + if count <= 0 { + return nil, nil + } s.mu.RLock() store := s.ringMembers s.mu.RUnlock() if store != nil { - decoys, err := store.SampleRingMembers(count, exclude) - if err == nil { - return decoys - } - // Fail closed for v5 builders: returning too few members makes Build - // reject the transaction rather than silently using fabricated decoys. - return nil + // The persistent index owns its entropy; do not fall back to RAM on error. + return store.SampleRingMembers(count, exclude) } s.mu.RLock() defer s.mu.RUnlock() candidates := make([]*UTXO, 0, len(s.spentPubKeys)+len(s.byPubKey)) for pub, u := range s.spentPubKeys { + // Match in-memory ring lookup precedence if historical pools overlap. + // Never return two entries for one public key or its stale commitment. + if _, active := s.byPubKey[pub]; active { + continue + } if !exclude[pub] && !isProtocolLockedUTXO(u) { candidates = append(candidates, u) } @@ -981,30 +978,7 @@ func (s *UTXOSet) SampleCLSAGDecoys(count int, exclude map[crypto.Point32]bool) } } - n := len(candidates) - want := count - if want > n { - want = n - } - if want == 0 { - return nil - } - - // Fisher-Yates partial shuffle to pick `want` items in random order. - rng := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec // privacy, not security - for i := 0; i < want; i++ { - j := i + rng.Intn(n-i) - candidates[i], candidates[j] = candidates[j], candidates[i] - } - - out := make([]DecoyUTXO, want) - for i := 0; i < want; i++ { - out[i] = DecoyUTXO{ - OneTimePub: candidates[i].OneTimePub, - AmountCommit: candidates[i].AmountCommit, - } - } - return out + return sampleDecoyCandidates(candidates, count, entropy) } // ApplyBlockForSpentDecoys rebuilds the spentPubKeys pool during a node restart. @@ -1047,8 +1021,10 @@ func (s *UTXOSet) ApplyBlockForSpentDecoys(block *Block) { } func stateTransitionRingMembers(tx Transaction, inp RingInput) []crypto.RingMember { - if tx.IsLPoDPosition(){ - if len(inp.Ring)==0{return nil} + if tx.IsLPoDPosition() { + if len(inp.Ring) == 0 { + return nil + } return inp.Ring[:1] // native proof authenticates exactly source index zero } if tx.Version != TxVersionCommitmentBinding { diff --git a/deploy/NODE-SOURCE-UPDATE.md b/deploy/NODE-SOURCE-UPDATE.md new file mode 100644 index 0000000..e3aa63d --- /dev/null +++ b/deploy/NODE-SOURCE-UPDATE.md @@ -0,0 +1,42 @@ +# Installing and updating after the source-history transition + +The public repository uses standalone Go paths at its root. Old clones have an +unrelated history; do not force-reset, clean, or remove their node state. + +Download the **complete deployment-tools package** for the reviewed source +revision and verify its published SHA-256 before extraction. Keep its `deploy/` +directory and all sibling helpers together, outside live source/data directories. +Do not run a single downloaded installer without its helpers. + +Select the exact reviewed public commit explicitly: + +```sh +export APEROD_NODE_SOURCE_COMMIT= +``` + +For a fresh installation, run `deploy/install-node.sh` or +`deploy/install-validator.sh` from that package. Installers reject an existing +binary, configuration, or nonempty state directory; an existing node is not a +fresh install. Validator setup also needs the operator's reward address. + +Updating an existing node additionally requires: + +```sh +export APEROD_BASELINE_APPROVED_SOURCE= +export APEROD_BASELINE_APPROVED_BINARY_SHA256= +``` + +These values are operator approvals, not automatic proof of chain compatibility. +Before setting them, independently rehearse activation, replay, snapshots, +configuration and network behavior against that node's actual baseline. Use +`deploy/update-node.sh` only after that review. A source commit being clean or +passing build/tests does not establish compatibility with a running chain. + +Source is acquired into an owned isolated temporary directory. Existing clones, +keys, configuration, databases and snapshots are not source-cleanup targets. +Candidate builds must have clean revision metadata, CGO disabled and a static +ELF before replacement; missing verification tools or mismatched approvals abort. + +The existing `v0.1.1-p2p` binary release is unchanged. Deployment-tools packages +are separate from node binaries and must not be mistaken for an automatic +production upgrade or a comprehensive security audit. diff --git a/deploy/install-node.sh b/deploy/install-node.sh index 8533201..87522ad 100755 --- a/deploy/install-node.sh +++ b/deploy/install-node.sh @@ -19,7 +19,7 @@ INSTALL_DIR="/opt/aperod" DATA_DIR="/var/lib/aperod" CONFIG_DIR="/etc/aperod" WALLET_DIR="/etc/aperod/wallet" -GO_VERSION="1.23.4" +GO_VERSION="1.26.9" REPO_URL="https://github.com/aperod-network/aperod-node.git" P2P_PORT=30303 RPC_PORT=8545 @@ -27,9 +27,12 @@ RPC_PORT=8545 # Resolve script directory early — referenced in step 8b (bootnode) and later # steps (watchdog, backup, etc.). Must be set before any ${SCRIPT_DIR} use. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/source-safe-guard.sh" -source_checkout_guard "$INSTALL_DIR" "$DATA_DIR" "${APEROD_CONFIG_FILE:-${CONFIG_DIR}/node.yaml}" || - die "Установка остановлена защитой исходников; существующие данные и файлы не изменены" +source "${SCRIPT_DIR}/node-source-release.sh" +node_source_pin_guard || die "Нужен явно выбранный публичный коммит" +[[ ! -e /usr/local/bin/aperod-node && ! -e "${CONFIG_DIR}/node.yaml" ]] || + die "Узел уже установлен: используйте update-node.sh с проверенным baseline" +node_source_fresh_state_guard "$DATA_DIR" "$CONFIG_DIR" || + die "Существующие данные/конфигурацию нельзя переустанавливать как новый узел" # ── Аргументы командной строки ──────────────────────────── # --primary-ip Публичный IP основного (primary) узла. @@ -142,73 +145,21 @@ else fi # ── 3. Клонирование репозитория ─────────────────────────── -# We use `git clone` (not a tarball) so the install directory is a proper -# git checkout. This is required for two reasons: -# -# a) `update-node.sh` later runs `git pull` to receive updates. -# b) The post-merge hook (step 12b) is installed into `.git/hooks/`. -# Without a git repo the hook directory does not exist and the hook -# cannot fire after bare `git pull` calls — which is the exact failure -# mode this task exists to prevent. -# -# Three cases handled explicitly: -# 1. .git exists → pull (idempotent re-run or update). -# 2. Directory is non-empty but has no .git → prior tarball install detected; -# print clear migration instructions and abort so the operator decides. -# 3. Directory is absent or empty → fresh clone. -info "Получаем исходный код Aperod (git clone)…" -REPO_URL_GIT="https://github.com/aperod-network/aperod-node.git" +# Old clones and runtime files in INSTALL_DIR are never checkout targets. +info "Получаем проверенный публичный коммит в отдельном каталоге…" mkdir -p "${INSTALL_DIR}" - -if [[ -d "${INSTALL_DIR}/.git" ]]; then - # ── Case 1: existing git repo — pull latest ────────────────────────────── - info " Репозиторий уже существует — обновляем через git pull…" - chown -R aperod:aperod "${INSTALL_DIR}" - sudo -u aperod git -C "${INSTALL_DIR}" pull --ff-only \ - || die "git pull не удался. Проверьте подключение к GitHub." - ok "Репозиторий обновлён в ${INSTALL_DIR}" - -elif [[ -n "$(ls -A "${INSTALL_DIR}" 2>/dev/null)" ]]; then - # ── Case 2: non-empty directory without .git → prior tarball install ───── - # git clone would fail on a non-empty target. Require the operator to - # convert explicitly so we never silently overwrite production data. - echo "" - echo -e "${RED}═══════════════════════════════════════════════════${NC}" - echo -e "${RED} Обнаружена существующая установка (не git-репо) ${NC}" - echo -e "${RED}═══════════════════════════════════════════════════${NC}" - echo "" - echo " ${INSTALL_DIR} содержит файлы, но не является git-репозиторием." - echo " Вероятно, это старая установка через тарбол." - echo "" - echo " ── Как конвертировать в git-репозиторий ──────────────" - echo " cd ${INSTALL_DIR}" - echo " git init" - echo " git remote add origin ${REPO_URL_GIT}" - echo " git fetch --depth=1 origin main" - echo " git reset --hard origin/main" - echo " sudo bash deploy/install-node.sh # повторный запуск" - echo " ──────────────────────────────────────────────────────" - echo "" - die "Прервано. Выполните конвертацию выше, затем повторите запуск." - -else - # ── Case 3: fresh install — clone as aperod ────────────────────────────── - # Clone as the aperod user so the working tree and .git directory are owned - # by the pull user from the start. The aperod account was created at step - # 2b; `sudo -u aperod` is now safe to use. - chown aperod:aperod "${INSTALL_DIR}" - sudo -u aperod git clone --depth=1 "${REPO_URL_GIT}" "${INSTALL_DIR}" \ - || die "git clone не удался. Проверьте подключение к GitHub." - ok "Репозиторий клонирован в ${INSTALL_DIR}" -fi +node_source_prepare || die "Не удалось получить и проверить выбранный публичный коммит" +chown -R aperod:aperod "$NODE_SOURCE_JOB" # ── 4. Сборка бинарников ────────────────────────────────── info "Компилируем aperod-node и aperod CLI (1–3 минуты)…" -cd "${INSTALL_DIR}" +cd "${NODE_SOURCE_DIR}" export GOPATH="/root/go" -make deps 2>&1 | tail -3 -make build 2>&1 | tail -8 +env CGO_ENABLED=0 GOWORK=off GOFLAGS= GOENV=off make deps 2>&1 | tail -3 +env CGO_ENABLED=0 GOWORK=off GOFLAGS= GOENV=off GOAMD64=v1 make CGO_ENABLED=0 build 2>&1 | tail -8 +node_source_candidate_guard "$NODE_SOURCE_DIR" "$NODE_SOURCE_DIR/build/aperod-node" || + die "Бинарник не соответствует чистому выбранному коммиту или не является portable" if [[ ! -f "build/aperod-node" || ! -f "build/aperod" ]]; then die "Сборка не удалась — проверьте вывод выше" @@ -739,16 +690,7 @@ fi # The actual privileged copy remains an explicit operator action (update-node.sh). # # Tarball installs have no .git directory — the step is a silent no-op. -info "Устанавливаем git post-merge hook (оповещение о расхождении aperod_backup.sh)…" -HOOK_SRC="${SCRIPT_DIR}/post-merge" -GIT_HOOKS_DIR="${INSTALL_DIR}/.git/hooks" -if [[ -d "${GIT_HOOKS_DIR}" && -f "${HOOK_SRC}" ]]; then - cp "${HOOK_SRC}" "${GIT_HOOKS_DIR}/post-merge" - chmod +x "${GIT_HOOKS_DIR}/post-merge" - ok "post-merge hook установлен: ${GIT_HOOKS_DIR}/post-merge" -else - info " .git/hooks не найден — пропускаем (установка через тарбол, без git)." -fi +info "Старый Git-клон и его hooks не изменяются. Обновления выполняются через проверенный update-node.sh." # Проверяем что стартовал (только если --primary-ip был передан) if [[ -n "${PRIMARY_NODE_IP}" ]]; then diff --git a/deploy/install-validator.sh b/deploy/install-validator.sh index 43641f3..4a525b7 100755 --- a/deploy/install-validator.sh +++ b/deploy/install-validator.sh @@ -19,13 +19,23 @@ APEROD_USER="aperod" INSTALL_DIR="/opt/aperod" DATA_DIR="/var/lib/aperod" CONFIG_DIR="/etc/aperod" -GO_VERSION="1.23.4" +GO_VERSION="1.26.9" P2P_PORT=30303 RPC_PORT=8545 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/source-safe-guard.sh" -source_checkout_guard "$INSTALL_DIR" "$DATA_DIR" "${APEROD_CONFIG_FILE:-${CONFIG_DIR}/node.yaml}" || - die "Установка остановлена защитой исходников; существующие данные и файлы не изменены" +source "${SCRIPT_DIR}/node-source-release.sh" +node_source_pin_guard || die "Нужен явно выбранный публичный коммит" +[[ ! -e /usr/local/bin/aperod-node && ! -e "${CONFIG_DIR}/node.yaml" ]] || + die "Узел уже установлен: используйте update-node.sh с проверенным baseline" +node_source_fresh_state_guard "$DATA_DIR" "$CONFIG_DIR" || + die "Существующие данные/конфигурацию нельзя переустанавливать как новый узел" +# A bootstrap package is independent of INSTALL_DIR and must be complete before +# installing binaries or creating a validator identity/configuration. +for bootstrap_file in ensure-dropin.sh gomemlimit-policy.sh aperod-node-watchdog.sh \ + aperod-node-watchdog.service aperod-node-watchdog.timer; do + [[ -f "${SCRIPT_DIR}/${bootstrap_file}" && ! -L "${SCRIPT_DIR}/${bootstrap_file}" ]] || + die "Неполный bootstrap package: отсутствует ${bootstrap_file}" +done echo -e " ${BOLD}╔════════════════════════════════════════════════════════════╗ @@ -135,24 +145,23 @@ fi # ── 4. Клонирование репозитория ─────────────────────────── info "Получаем исходный код Aperod…" -TARBALL_URL="https://github.com/aperod-network/aperod-node/archive/refs/heads/main.tar.gz" -rm -rf "${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}" -info "Скачиваем архив исходного кода…" -wget -q "${TARBALL_URL}" -O /tmp/aperod-src.tar.gz \ - || die "Не удалось скачать ${TARBALL_URL}" -tar -xzf /tmp/aperod-src.tar.gz -C "${INSTALL_DIR}" --strip-components=1 -rm -f /tmp/aperod-src.tar.gz -ok "Исходный код получен в ${INSTALL_DIR}" +node_source_prepare || die "Не удалось получить и проверить выбранный публичный коммит" +chown -R "${APEROD_USER}:${APEROD_USER}" "$NODE_SOURCE_JOB" +GENESIS_SOURCE="${NODE_SOURCE_DIR}/config/genesis-testnet.yaml" +[[ -f "$GENESIS_SOURCE" && ! -L "$GENESIS_SOURCE" ]] || + die "В выбранном публичном коммите отсутствует genesis конфиг; установка остановлена до записи ключей и бинарников" # ── 5. Сборка бинарников ────────────────────────────────── info "Компилируем aperod-node (может занять 1–3 минуты)…" -cd "${INSTALL_DIR}" +cd "${NODE_SOURCE_DIR}" export GOPATH="/root/go" export PATH="$PATH:/usr/local/go/bin" -make deps 2>&1 | tail -5 -make build 2>&1 | tail -10 +env CGO_ENABLED=0 GOWORK=off GOFLAGS= GOENV=off make deps 2>&1 | tail -5 +env CGO_ENABLED=0 GOWORK=off GOFLAGS= GOENV=off GOAMD64=v1 make CGO_ENABLED=0 build 2>&1 | tail -10 +node_source_candidate_guard "$NODE_SOURCE_DIR" "$NODE_SOURCE_DIR/build/aperod-node" || + die "Бинарник не соответствует чистому выбранному коммиту или не является portable" if [[ ! -f "build/aperod-node" ]]; then die "Сборка не удалась — файл build/aperod-node не найден. Проверьте вывод выше." @@ -265,12 +274,8 @@ info "Внешний IP: ${MY_IP}" # ── 9. Копируем genesis конфиг ──────────────────────────── mkdir -p "${CONFIG_DIR}" -if [[ -f "${INSTALL_DIR}/config/genesis-testnet.yaml" ]]; then - cp "${INSTALL_DIR}/config/genesis-testnet.yaml" "${CONFIG_DIR}/genesis-testnet.yaml" - ok "Genesis конфиг скопирован: ${CONFIG_DIR}/genesis-testnet.yaml" -else - warn "Файл genesis не найден. Нода не запустится без него." -fi +cp "$GENESIS_SOURCE" "${CONFIG_DIR}/genesis-testnet.yaml" +ok "Genesis конфиг скопирован из проверенного коммита: ${CONFIG_DIR}/genesis-testnet.yaml" # ── 10. Конфигурация ноды ───────────────────────────────── command -v openssl >/dev/null 2>&1 || die "openssl необходим для генерации API-ключа" @@ -363,7 +368,7 @@ chown -R "${APEROD_USER}:${APEROD_USER}" "${DATA_DIR}" # Write / verify memory-protection drop-ins (timeout.conf + gomemlimit.conf) # and call daemon-reload so the drop-ins are active before the service starts. -bash "${INSTALL_DIR}/deploy/ensure-dropin.sh" +bash "${SCRIPT_DIR}/ensure-dropin.sh" systemctl enable aperod-node systemctl start aperod-node ok "Сервис aperod-node запущен" @@ -373,10 +378,10 @@ ok "Сервис aperod-node запущен" # GET /api/v1/status every 60 s and calls `systemctl restart aperod-node` # if the probe does not return HTTP 200 within 5 s. info "Устанавливаем watchdog для aperod-node…" -cp "${INSTALL_DIR}/deploy/aperod-node-watchdog.sh" /usr/local/bin/aperod-node-watchdog.sh +cp "${SCRIPT_DIR}/aperod-node-watchdog.sh" /usr/local/bin/aperod-node-watchdog.sh chmod +x /usr/local/bin/aperod-node-watchdog.sh -cp "${INSTALL_DIR}/deploy/aperod-node-watchdog.service" /etc/systemd/system/ -cp "${INSTALL_DIR}/deploy/aperod-node-watchdog.timer" /etc/systemd/system/ +cp "${SCRIPT_DIR}/aperod-node-watchdog.service" /etc/systemd/system/ +cp "${SCRIPT_DIR}/aperod-node-watchdog.timer" /etc/systemd/system/ # Create optional env file for Telegram alerts (operator fills in credentials) mkdir -p /etc/aperod if [[ ! -f /etc/aperod/watchdog.env ]]; then diff --git a/deploy/node-source-release.sh b/deploy/node-source-release.sh new file mode 100644 index 0000000..1607ef9 --- /dev/null +++ b/deploy/node-source-release.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Node-only source acquisition. Never refresh, reset or clean a live checkout. +APEROD_PUBLIC_NODE_ORIGIN="https://github.com/aperod-network/aperod-node.git" +APEROD_PUBLIC_NODE_ROOT="4bfab534469dc61296910d968ab13cab27554a97" +_NODE_SOURCE_HELPERS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${_NODE_SOURCE_HELPERS}/source-safe-guard.sh" + +_node_source_git_env() { + local name + while IFS= read -r name; do unset "$name" || return 1; done < <(compgen -A variable GIT_) + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1 + export GIT_TERMINAL_PROMPT=0 +} + +node_source_pin_guard() { + [[ "${APEROD_NODE_SOURCE_COMMIT:-}" =~ ^[0-9a-f]{40}$ ]] || { + echo "[node-source] REFUSED: set APEROD_NODE_SOURCE_COMMIT to an explicitly reviewed full public commit." >&2 + return 1 + } +} + +node_source_fresh_state_guard() { + local path entries + for path in "$@"; do + _source_guard_has_symlink_component "$path" && { + echo "[node-source] REFUSED: fresh-install state path has a symlink component." >&2; return 1; + } + if [[ -e "$path" ]]; then + [[ -d "$path" ]] || return 1 + entries="$(find "$path" -mindepth 1 -maxdepth 1 -print -quit)" || return 1 + [[ -z "$entries" ]] || { + echo "[node-source] REFUSED: state/configuration already exists; use a baseline-approved update or isolated recovery." >&2 + return 1 + } + fi + done +} + +# This is a scoped operator approval, not an automatic consensus rehearsal. +# A blanket 'compatible=true' is deliberately insufficient. +node_source_baseline_guard() { + local binary="$1" actual + node_source_pin_guard || return 1 + [[ -f "$binary" && ! -L "$binary" ]] || return 1 + actual="$(sha256sum -- "$binary")" || return 1 + actual="${actual%% *}" + [[ "${APEROD_BASELINE_APPROVED_SOURCE:-}" == "$APEROD_NODE_SOURCE_COMMIT" && + "${APEROD_BASELINE_APPROVED_BINARY_SHA256:-}" =~ ^[0-9a-f]{64}$ && + "${APEROD_BASELINE_APPROVED_BINARY_SHA256}" == "$actual" ]] || { + echo "[node-source] REFUSED: baseline approval must bind this running binary SHA256 to the requested public source commit; rehearse compatibility first." >&2 + return 1 + } +} + +# expected_origin/root are explicit trusted inputs. Entry points below hardcode +# the public identity; local tests supply independent real Git fixture identities. +node_source_fetch_verified() ( + set -euo pipefail + local origin="$1" expected_origin="$2" commit="$3" root="$4" destination="$5" + [[ "$origin" == "$expected_origin" && "$commit" =~ ^[0-9a-f]{40}$ && + "$root" =~ ^[0-9a-f]{40}$ && "$destination" == /* && + ! -e "$destination" && ! -L "$destination" ]] || { + echo "[node-source] REFUSED: wrong origin/commit or destination already exists." >&2 + exit 1 + } + _source_guard_has_symlink_component "$destination" && { + echo "[node-source] REFUSED: symlink destination component." >&2; exit 1; + } + _node_source_git_env || exit 1 + mkdir -m 700 -- "$destination" || exit 1 + git -c core.hooksPath=/dev/null -c init.templateDir= init -q "$destination" || exit 1 + [[ -d "$destination/.git" && ! -L "$destination/.git" ]] || exit 1 + git -C "$destination" remote add origin "$origin" || exit 1 + git -c core.hooksPath=/dev/null -C "$destination" fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' || exit 1 + [[ "$(git -C "$destination" config --get remote.origin.url)" == "$expected_origin" ]] || exit 1 + [[ "$(git -C "$destination" rev-parse --verify "$commit^{commit}")" == "$commit" ]] || exit 1 + git -C "$destination" merge-base --is-ancestor "$commit" refs/remotes/origin/main || exit 1 + [[ "$(git -C "$destination" rev-list --max-parents=0 "$commit")" == "$root" ]] || { + echo "[node-source] REFUSED: commit is not descended from the reviewed public root." >&2 + exit 1 + } + git -c core.hooksPath=/dev/null -C "$destination" checkout --detach -q "$commit" || exit 1 + [[ -f "$destination/go.mod" && ! -L "$destination/go.mod" && + -f "$destination/Makefile" && ! -L "$destination/Makefile" && + -d "$destination/cmd/node" && ! -d "$destination/blockchain" ]] || exit 1 + source_checkout_guard "$destination" "" "/dev/null" || exit 1 + local status + status="$(git -C "$destination" status --porcelain --untracked-files=all)" || exit 1 + [[ -z "$status" ]] || exit 1 + printf '[node-source] verified public commit %s\n' "$commit" +) + +node_source_prepare() { + node_source_pin_guard || return 1 + NODE_SOURCE_JOB="$(mktemp -d /var/tmp/aperod-node-source.XXXXXXXX)" || return 1 + NODE_SOURCE_DIR="$NODE_SOURCE_JOB/repo" + trap 'node_source_cleanup' EXIT + node_source_fetch_verified "$APEROD_PUBLIC_NODE_ORIGIN" "$APEROD_PUBLIC_NODE_ORIGIN" \ + "$APEROD_NODE_SOURCE_COMMIT" "$APEROD_PUBLIC_NODE_ROOT" "$NODE_SOURCE_DIR" || return 1 +} + +node_source_candidate_guard() ( + local checkout="$1" binary="$2" revision status info segments + _node_source_git_env || return 1 + node_source_pin_guard || return 1 + [[ -d "$checkout/.git" && ! -L "$checkout/.git" && + -f "$binary" && ! -L "$binary" ]] || return 1 + revision="$(git -c safe.directory="$checkout" -C "$checkout" rev-parse --verify HEAD)" || return 1 + status="$(git -c safe.directory="$checkout" -C "$checkout" status --porcelain --untracked-files=no)" || return 1 + [[ "$revision" == "$APEROD_NODE_SOURCE_COMMIT" && -z "$status" ]] || { + echo "[node-source] REFUSED: selected source changed during the build." >&2; return 1; + } + command -v go >/dev/null && command -v readelf >/dev/null || { + echo "[node-source] REFUSED: go and readelf are required to verify the candidate." >&2; return 1; + } + info="$(go version -m "$binary")" || return 1 + grep -Eq '^[[:space:]]*build[[:space:]]+CGO_ENABLED=0$' <<< "$info" && + grep -Eq "^[[:space:]]*build[[:space:]]+vcs.revision=${APEROD_NODE_SOURCE_COMMIT}$" <<< "$info" && + grep -Eq '^[[:space:]]*build[[:space:]]+vcs.modified=false$' <<< "$info" || { + echo "[node-source] REFUSED: candidate lacks clean, portable build provenance for the selected commit." >&2 + return 1 + } + segments="$(readelf -l "$binary")" || return 1 + [[ "$segments" != *INTERP* ]] || { + echo "[node-source] REFUSED: candidate has a dynamic ELF interpreter." >&2; return 1; + } +) + +node_source_cleanup() { + # Only a mktemp-owned job is disposable. Never a configurable live path. + [[ "${NODE_SOURCE_JOB:-}" == /var/tmp/aperod-node-source.* && + "${NODE_SOURCE_JOB##*/}" =~ ^aperod-node-source\.[A-Za-z0-9]{8}$ ]] || return 0 + cd / || return 1 + rm -rf -- "$NODE_SOURCE_JOB" +} diff --git a/deploy/test-node-source-release.py b/deploy/test-node-source-release.py new file mode 100644 index 0000000..ff90970 --- /dev/null +++ b/deploy/test-node-source-release.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Real independent Git histories; no network, credentials or system services.""" +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +HELPER = Path(__file__).with_name("node-source-release.sh").resolve() + + +class NodeSourceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="aperod-git-history-") + self.root = Path(self.temp.name) + self.env = {"PATH": os.environ["PATH"], "HOME": str(self.root), + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null"} + self.old = self.repo("old", "old independent root") + (self.old / "data" / "chain.db").mkdir(parents=True) + for name in ["data/chain.db/CURRENT", "data/chain.db/MANIFEST-000001", + "data/chain.db/000001.ldb", "validator.key", + "snapshot.snapshot", "node.yaml", "p2p_bans.json"]: + path = self.old / name + path.write_text("synthetic runtime fixture: " + name) + path.chmod(0o600) + (self.old / "README").write_text("operator tracked local edit") + self.before = self.manifest(self.old) + self.new = self.repo("new", "new independent public root") + self.new_root = self.git(self.new, "rev-parse", "HEAD").strip() + (self.new / "README").write_text("new reviewed public revision") + self.git(self.new, "add", ".") + self.git(self.new, "commit", "-qm", "second new-public commit") + self.pin = self.git(self.new, "rev-parse", "HEAD").strip() + self.destination = self.root / "isolated" + + def tearDown(self): + self.assertEqual(self.before, self.manifest(self.old), "old checkout/state changed") + self.temp.cleanup() + + def git(self, repo, *args): + return subprocess.check_output( + ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-C", str(repo), *args], text=True, env=self.env, stderr=subprocess.DEVNULL) + + def repo(self, name, readme): + path = self.root / name + path.mkdir() + subprocess.run(["git", "init", "-qb", "main", str(path)], env=self.env, check=True) + (path / "README").write_text(readme) + (path / "go.mod").write_text("module fixture.invalid/node\n\ngo 1.21\n") + (path / "Makefile").write_text("build:\n\tCGO_ENABLED=0 go build -o build/aperod-node ./cmd/node\n") + (path / ".gitignore").write_text("/build/\n") + (path / "cmd/node").mkdir(parents=True) + (path / "cmd/node/main.go").write_text( + 'package main\nimport "fmt"\nfunc main(){fmt.Println("independent-public-node")}\n') + self.git(path, "add", ".") + self.git(path, "commit", "-qm", readme) + return path + + @staticmethod + def manifest(path): + return {str(p.relative_to(path)): (hashlib.sha256(p.read_bytes()).hexdigest(), + p.stat().st_mode, p.stat().st_uid, p.stat().st_gid) + for p in path.rglob("*") if p.is_file()} + + def shell(self, body, *args, env=None): + return subprocess.run(["bash", "-c", 'source "$1"; shift; ' + body, + "fixture", str(HELPER), *map(str, args)], + env=env or self.env, capture_output=True, text=True) + + def fetch(self, origin=None, expected=None, pin=None, root=None, destination=None): + return self.shell('if node_source_fetch_verified "$@"; then exit 0; else exit 41; fi', + origin or self.new, expected or self.new, pin or self.pin, + root or self.new_root, destination or self.destination) + + def test_new_history_fetch_preserves_old_clone_and_every_runtime_byte(self): + self.assertNotEqual(self.git(self.old, "rev-list", "--max-parents=0", "HEAD").strip(), + self.new_root) + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.git(self.destination, "rev-parse", "HEAD").strip(), self.pin) + self.assertEqual((self.destination / "README").read_text(), "new reviewed public revision") + self.assertEqual(self.git(self.destination, "status", "--porcelain"), "") + # Full SHA pin, not merely the latest HEAD: reviewed ancestor remains valid. + result = self.fetch(pin=self.new_root, destination=self.root / "pinned-ancestor") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.git(self.root / "pinned-ancestor", "rev-parse", "HEAD").strip(), + self.new_root) + + def test_wrong_origin_rejected_before_destination_creation(self): + self.assertNotEqual(self.fetch(origin=self.old).returncode, 0) + self.assertFalse(self.destination.exists()) + + def test_missing_commit_rejected_in_conditional_shell_context(self): + self.assertNotEqual(self.fetch(pin="f" * 40).returncode, 0) + + def test_wrong_history_root_rejected(self): + old_root = self.git(self.old, "rev-parse", "HEAD").strip() + self.assertNotEqual(self.fetch(root=old_root).returncode, 0) + + def test_ambient_git_config_cannot_mutate_the_old_checkout(self): + env = {**self.env, "GIT_CONFIG": str(self.old / ".git/config"), + "GIT_COMMON_DIR": str(self.old / ".git"), "GIT_DIR": str(self.old / ".git"), + "GIT_WORK_TREE": str(self.old), "GIT_SHALLOW_FILE": str(self.old / ".git/shallow")} + result = self.shell('if node_source_fetch_verified "$@"; then exit 0; else exit 41; fi', + self.new, self.new, self.pin, self.new_root, self.destination, env=env) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_git_init_failure_does_not_fall_back_to_parent_repository(self): + shim = self.root / "shim" + shim.mkdir() + real_git = shutil.which("git") + wrapper = shim / "git" + wrapper.write_text(f'#!/bin/bash\nfor a in "$@"; do [[ "$a" != init ]] || exit 71; done\nexec "{real_git}" "$@"\n') + wrapper.chmod(0o755) + env = {**self.env, "PATH": str(shim) + ":" + self.env["PATH"]} + parent_config = (self.new / ".git/config").read_bytes() + result = self.shell('if node_source_fetch_verified "$@"; then exit 0; else exit 41; fi', + self.new, self.new, self.pin, self.new_root, self.new / "child", env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(parent_config, (self.new / ".git/config").read_bytes()) + + def test_side_branch_commit_is_not_an_approved_main_ancestor(self): + self.git(self.new, "checkout", "-qb", "unreviewed") + (self.new / "README").write_text("unreviewed") + self.git(self.new, "commit", "-qam", "side branch") + pin = self.git(self.new, "rev-parse", "HEAD").strip() + self.git(self.new, "checkout", "-q", "main") + self.assertNotEqual(self.fetch(pin=pin).returncode, 0) + + def test_existing_checkout_and_symlink_ancestor_are_never_reused(self): + self.assertNotEqual(self.fetch(destination=self.old).returncode, 0) + linked = self.root / "linked" + linked.symlink_to(self.old, target_is_directory=True) + self.assertNotEqual(self.fetch(destination=linked / "new").returncode, 0) + self.assertFalse((self.old / "new").exists()) + + def test_partial_existing_state_is_not_treated_as_fresh_install(self): + self.assertNotEqual(self.shell('node_source_fresh_state_guard "$1"', + self.old / "data").returncode, 0) + self.assertEqual(self.shell('node_source_fresh_state_guard "$1"', + self.root / "absent-data").returncode, 0) + empty = self.root / "empty-data" + empty.mkdir() + self.assertEqual(self.shell('node_source_fresh_state_guard "$1"', empty).returncode, 0) + + def test_baseline_approval_is_bound_to_both_artifact_identities(self): + binary = self.root / "running-node" + binary.write_text("synthetic baseline binary") + env = {**self.env, "APEROD_NODE_SOURCE_COMMIT": self.pin, + "APEROD_BASELINE_APPROVED_SOURCE": self.pin, + "APEROD_BASELINE_APPROVED_BINARY_SHA256": hashlib.sha256(binary.read_bytes()).hexdigest()} + self.assertEqual(self.shell('node_source_baseline_guard "$1"', binary, env=env).returncode, 0) + env["APEROD_BASELINE_APPROVED_SOURCE"] = self.new_root + self.assertNotEqual(self.shell('node_source_baseline_guard "$1"', binary, env=env).returncode, 0) + env["APEROD_BASELINE_APPROVED_SOURCE"] = self.pin + binary.write_text("changed live baseline") + self.assertNotEqual(self.shell('node_source_baseline_guard "$1"', binary, env=env).returncode, 0) + + def test_real_static_fixture_build_after_verified_fetch(self): + self.assertIsNotNone(shutil.which("go"), "Go fixture build is required, not silently skipped") + self.assertEqual(self.fetch().returncode, 0) + env = {**self.env, "CGO_ENABLED": "0", "GOTOOLCHAIN": "local", "GOTELEMETRY": "off"} + subprocess.run(["make", "CGO_ENABLED=0", "build"], cwd=self.destination, env=env, + check=True, capture_output=True) + binary = self.destination / "build/aperod-node" + info = subprocess.check_output(["go", "version", "-m", str(binary)], env=env, text=True) + self.assertIn("CGO_ENABLED=0", info) + self.assertIn("vcs.revision=" + self.pin, info) + self.assertIn("vcs.modified=false", info) + self.assertEqual(subprocess.check_output([str(binary)], text=True).strip(), + "independent-public-node") + self.assertIsNotNone(shutil.which("readelf")) + self.assertNotIn("INTERP", subprocess.check_output(["readelf", "-l", str(binary)], text=True)) + env["APEROD_NODE_SOURCE_COMMIT"] = self.pin + checked = self.shell('node_source_candidate_guard "$1" "$2"', self.destination, binary, env=env) + self.assertEqual(checked.returncode, 0, checked.stderr) + (self.destination / "README").write_text("unapproved build overlay") + self.assertNotEqual(self.shell('node_source_candidate_guard "$1" "$2"', + self.destination, binary, env=env).returncode, 0) + + def test_fresh_validator_install_resolves_genesis_and_bootstrap_without_old_checkout(self): + # Real pinned local source and real builds; privileged host commands are + # explicit stubs. Every filesystem target is redirected under this job. + (self.new / "config").mkdir() + genesis = "network: synthetic-install-fixture\n" + (self.new / "config/genesis-testnet.yaml").write_text(genesis) + (self.new / "cmd/aperod").mkdir() + (self.new / "cmd/aperod/main.go").write_text( + 'package main\nimport("fmt";"strings")\n' + 'func main(){fmt.Println("Private: "+strings.Repeat("1",64));' + 'fmt.Println("Public: "+strings.Repeat("2",64))}\n') + (self.new / "Makefile").write_text( + "deps:\n\ttrue\nbuild:\n\tgo build -o build/aperod-node ./cmd/node\n" + "\tgo build -o build/aperod ./cmd/aperod\n") + self.git(self.new, "add", ".") + self.git(self.new, "commit", "-qm", "synthetic fresh-validator source") + pin = self.git(self.new, "rev-parse", "HEAD").strip() + deploy = HELPER.parent + + def sandbox(name, missing=None): + base = self.root / name + host = base / "host" + bootstrap = base / "bootstrap" + mocks = base / "mocks" + for path in [bootstrap, mocks, host / "etc/systemd/system", + host / "usr/local/bin", host / "var/tmp", host / "opt/aperod"]: + path.mkdir(parents=True, exist_ok=True) + (host / "etc/os-release").write_text('NAME="Ubuntu"\n') + files = ["install-validator.sh", "node-source-release.sh", "source-safe-guard.sh", + "ensure-dropin.sh", "gomemlimit-policy.sh", "aperod-node-watchdog.sh", + "aperod-node-watchdog.service", "aperod-node-watchdog.timer"] + for name in files: + if name == missing: + continue + text = (deploy / name).read_text() + for absolute in ["/usr/local", "/var/lib/aperod", "/opt/aperod", "/var/tmp", "/etc"]: + text = text.replace(absolute, str(host) + absolute) + if name == "node-source-release.sh": + text = text.replace("https://github.com/aperod-network/aperod-node.git", + str(self.new)) + text = text.replace("4bfab534469dc61296910d968ab13cab27554a97", self.new_root) + (bootstrap / name).write_text(text) + stubs = { + "id": 'printf "0\\n"\n', + "apt-get": 'echo apt >> "$HOST/commands"\n', + "useradd": "exit 0\n", + "chown": "exit 0\n", + "ufw": "exit 0\n", + "curl": 'printf "203.0.113.10"\n', + "sleep": "exit 0\n", + "xxd": '''[[ "$*" == "-r -p" ]] || exit 81 +python3 -c 'import sys; sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read()))' +''', + "systemctl": '''echo "$*" >> "$HOST/systemctl.calls" +if [[ "$*" == "start aperod-node" ]]; then + test -s "$HOST/etc/aperod/genesis-testnet.yaml" || exit 31 + test -s "$HOST/etc/aperod/node.yaml" || exit 32 + test -x "$HOST/usr/local/bin/aperod-node" || exit 33 + test -s "$HOST/etc/systemd/system/aperod-node.service" || exit 34 + test -s "$HOST/etc/systemd/system/aperod-node.service.d/timeout.conf" || exit 35 + test -s "$HOST/etc/systemd/system/aperod-node.service.d/gomemlimit.conf" || exit 36 +fi +exit 0 +''' + } + for name, body in stubs.items(): + file = mocks / name + file.write_text("#!/bin/bash\nset -e\n" + body) + file.chmod(0o755) + env = {**self.env, "PATH": str(mocks) + ":" + str(host / "usr/local/bin") + ":" + self.env["PATH"], + "HOST": str(host), "APEROD_NODE_SOURCE_COMMIT": pin, + "APEROD_REWARD_ADDRESS": "synthetic-fixture-address-" * 5, + "GOTOOLCHAIN": "local", "GOTELEMETRY": "off"} + result = subprocess.run(["bash", str(bootstrap / "install-validator.sh")], env=env, + text=True, capture_output=True, stdin=subprocess.DEVNULL, + start_new_session=True, timeout=120) + return host, result + + host, result = sandbox("fresh-success") + self.assertEqual(result.returncode, 0, result.stdout[-2500:] + result.stderr[-2500:]) + self.assertEqual((host / "etc/aperod/genesis-testnet.yaml").read_text(), genesis) + self.assertEqual(list((host / "opt/aperod").iterdir()), []) + for name in ["aperod-node-watchdog.service", "aperod-node-watchdog.timer"]: + self.assertTrue((host / "etc/systemd/system" / name).is_file()) + self.assertTrue((host / "usr/local/bin/aperod-node-watchdog.sh").is_file()) + calls = (host / "systemctl.calls").read_text() + self.assertIn("start aperod-node", calls) + self.assertIn("enable --now aperod-node-watchdog.timer", calls) + self.assertEqual(list((host / "var/tmp").iterdir()), [], "owned source job leaked") + broken, failed = sandbox("fresh-missing-bootstrap", missing="ensure-dropin.sh") + self.assertNotEqual(failed.returncode, 0) + self.assertFalse((broken / "commands").exists(), "missing bootstrap reached apt") + self.assertFalse((broken / "etc/aperod/validator.key").exists()) + self.assertFalse((broken / "usr/local/bin/aperod-node").exists()) + (self.new / "config/genesis-testnet.yaml").unlink() + self.git(self.new, "commit", "-qam", "synthetic candidate without genesis") + pin = self.git(self.new, "rev-parse", "HEAD").strip() + no_genesis, refused = sandbox("fresh-missing-genesis") + self.assertNotEqual(refused.returncode, 0) + self.assertFalse((no_genesis / "etc/aperod/validator.key").exists()) + self.assertFalse((no_genesis / "usr/local/bin/aperod-node").exists()) + self.assertEqual(list((no_genesis / "var/tmp").iterdir()), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/deploy/test-source-safe-guard.sh b/deploy/test-source-safe-guard.sh index dfd374e..34e94c8 100644 --- a/deploy/test-source-safe-guard.sh +++ b/deploy/test-source-safe-guard.sh @@ -32,23 +32,23 @@ section_guard() { printf '\n== %s ==\n' "$*"; } section_guard "destructive entrypoints call the shared guard before mutation" for pair in \ - "blockchain/deploy/install-node.sh:source_checkout_guard \"\$INSTALL_DIR\"" \ - "blockchain/deploy/uninstall-validator.sh:source_checkout_guard \"\$INSTALL_DIR\""; do + "install-node.sh:node_source_pin_guard" \ + "uninstall-validator.sh:source_checkout_guard \"\$INSTALL_DIR\""; do script="${pair%%:*}" expected="${pair#*:}" - if grep -Fq "$expected" "$SCRIPT_DIR/../../$script"; then + if grep -Fq "$expected" "$SCRIPT_DIR/$script"; then pass "$script invokes the shared source guard" else fail "$script invokes the shared source guard" fi done -INSTALL_GUARD_LINE=$(grep -nF 'source_checkout_guard "$INSTALL_DIR"' "$SCRIPT_DIR/install-node.sh" | head -1 | cut -d: -f1) -INSTALL_PULL_LINE=$(grep -nF 'git -C "${INSTALL_DIR}" pull --ff-only' "$SCRIPT_DIR/install-node.sh" | head -1 | cut -d: -f1) +INSTALL_GUARD_LINE=$(grep -nF 'node_source_pin_guard' "$SCRIPT_DIR/install-node.sh" | head -1 | cut -d: -f1) +INSTALL_PULL_LINE=$(grep -nF 'node_source_prepare' "$SCRIPT_DIR/install-node.sh" | head -1 | cut -d: -f1) if [[ -n "$INSTALL_GUARD_LINE" && -n "$INSTALL_PULL_LINE" ]] && (( INSTALL_GUARD_LINE < INSTALL_PULL_LINE )); then - pass "install-node guard runs before its git pull" + pass "install-node pin guard runs before isolated source acquisition" else - fail "install-node guard runs before its git pull" + fail "install-node pin guard runs before isolated source acquisition" fi UNINSTALL_GUARD_LINE=$(grep -nF 'source_checkout_guard "$INSTALL_DIR"' "$SCRIPT_DIR/uninstall-validator.sh" | head -1 | cut -d: -f1) UNINSTALL_DATA_GUARD_LINE=$(grep -nF 'uninstall_runtime_data_guard || exit 1' "$SCRIPT_DIR/uninstall-validator.sh" | head -1 | cut -d: -f1) @@ -144,6 +144,7 @@ printf 'data_dir: %s\n data_dir: %s\n' "$EXTERNAL" "$EXTERNAL" > "$CONFIG" assert_rejected "ambiguous duplicate data_dir entries are rejected" \ source_checkout_guard "$CLEAN" "" "$CONFIG" +if [[ -f "$SCRIPT_DIR/update-source-staging.sh" ]]; then section_guard "source-only sparse staging excludes both chain-data trees and fast-forwards" ORIGIN="$TMP/origin" git init -q --bare "$ORIGIN" @@ -277,6 +278,12 @@ APEROD_SOURCE_STAGING_TEST_MODE=1 \ -z "$(git -C "$STAGING" status --porcelain)" ]] && pass "updated staging remains clean and excludes both chain-data trees" || fail "updated staging remains clean and excludes both chain-data trees" +elif [[ -f "$SCRIPT_DIR/../../blockchain/go.mod" ]]; then + fail "workspace source-staging helper is required" +else + section_guard "private source staging is not part of the standalone node distribution" + printf 'NOT_APPLICABLE source-staging-only fixtures; all shared source/data guards ran\n' +fi printf '\nResult: %d passed, %d failed\n' "$PASS" "$FAIL" (( FAIL == 0 )) \ No newline at end of file diff --git a/deploy/test-update-node-key-preflight.sh b/deploy/test-update-node-key-preflight.sh old mode 100644 new mode 100755 index bf4ed2f..d3e4f5a --- a/deploy/test-update-node-key-preflight.sh +++ b/deploy/test-update-node-key-preflight.sh @@ -174,8 +174,8 @@ SCEN # ── K1 / K7: path resolution from node.yaml and prod-layout fallback ────────── run_resolve() { - local node_yaml="$1" blockchain_dir="$2" - RESOLVE_SRC="$RESOLVE_SRC" BLOCKCHAIN_DIR="$blockchain_dir" \ + local node_yaml="$1" live_dir="$2" build_dir="${3:-$2}" + RESOLVE_SRC="$RESOLVE_SRC" LIVE_BLOCKCHAIN_DIR="$live_dir" BLOCKCHAIN_DIR="$build_dir" \ bash -s "$node_yaml" <<'SCEN' set -uo pipefail eval "$RESOLVE_SRC" @@ -217,18 +217,39 @@ YAML fi } -# K7: no node.yaml → falls back to the standard prod layout under BLOCKCHAIN_DIR +# K7: no node.yaml → falls back to the standard live runtime layout { rt="$WORKDIR/k7"; mkdir -p "$rt/data/testnet" : > "$rt/data/testnet/validator.key" got="$(run_resolve "$rt/does-not-exist.yaml" "$rt")" if [[ "$got" == "$rt/data/testnet/validator.key" ]]; then - pass_test "K7: falls back to prod layout (BLOCKCHAIN_DIR/data/testnet/validator.key)" + pass_test "K7: falls back to prod layout (LIVE_BLOCKCHAIN_DIR/data/testnet/validator.key)" else fail_test "K7: expected prod-layout fallback, got '$got'" fi } +# K7b/c: an isolated source checkout must never become a runtime-key authority. +{ + live="$WORKDIR/k7b/live"; build="$WORKDIR/k7b/build" + mkdir -p "$live/data/testnet" "$build/data/testnet" + printf 'synthetic-live-key\n' > "$live/data/testnet/validator.key" + printf 'synthetic-build-key\n' > "$build/data/testnet/validator.key" + got="$(run_resolve "$live/missing.yaml" "$live" "$build")" + if [[ "$got" == "$live/data/testnet/validator.key" ]]; then + pass_test "K7b: runtime fallback ignores a key in isolated build source" + else + fail_test "K7b: isolated build source replaced runtime key authority" + fi + rm "$live/data/testnet/validator.key" + got="$(run_resolve "$live/missing.yaml" "$live" "$build")" + if [[ -z "$got" && "$(cat "$build/data/testnet/validator.key")" == "synthetic-build-key" ]]; then + pass_test "K7c: source-only key is neither selected nor changed" + else + fail_test "K7c: source-only key must not be selected" + fi +} + # ── K4b: read-only 400/aperod key → no-op, owner bits never loosened ───────── { out="$(run_preflight k4b 400 aperod ok)"; log="$(ops_log k4b)" diff --git a/deploy/update-node.sh b/deploy/update-node.sh old mode 100644 new mode 100755 index 609a6c9..0190d34 --- a/deploy/update-node.sh +++ b/deploy/update-node.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# update-node.sh — Pull latest source, rebuild the Go node binary, and restart +# update-node.sh — Build an approved isolated public commit and restart # the aperod-node systemd service. # # Run this script instead of manually building and copying the binary after @@ -62,9 +62,14 @@ BINARY_SRC="${BLOCKCHAIN_DIR}/build/aperod-node" HEALTH_URL="http://localhost:8545/api/v1/status" STATS_URL="http://localhost:8545/api/v1/network/stats" DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${DEPLOY_DIR}/source-safe-guard.sh" -source_checkout_guard "$APEROD_DIR" "" "${APEROD_CONFIG_FILE:-/etc/aperod/node.yaml}" || - { echo "✗ Source safety guard refused the live checkout; no deploy changes were made." >&2; exit 1; } +source "${DEPLOY_DIR}/node-source-release.sh" +node_source_baseline_guard "$BINARY_DST" || + { echo "✗ Public source/baseline approval refused; no deploy changes were made." >&2; exit 1; } +node_source_prepare || exit 1 +chown -R aperod:aperod "$NODE_SOURCE_JOB" +LIVE_BLOCKCHAIN_DIR="$BLOCKCHAIN_DIR" +BLOCKCHAIN_DIR="$NODE_SOURCE_DIR" +BINARY_SRC="${BLOCKCHAIN_DIR}/build/aperod-node" # Health-check tunables (override via environment) HEALTH_MAX_ATTEMPTS="${HEALTH_MAX_ATTEMPTS:-15}" @@ -378,8 +383,8 @@ fi # --------------------------------------------------------------------------- # Step 1: Pull latest source # --------------------------------------------------------------------------- -echo "==> [1/5] Pulling latest source as aperod..." -sudo -u aperod git -C "$APEROD_DIR" pull +echo "==> [1/5] Fetching the approved public commit in a clean independent directory..." +echo " Verified detached public source: ${APEROD_NODE_SOURCE_COMMIT}" # --------------------------------------------------------------------------- # Step 1a: Warn if node.yaml still carries dangerous ban-threshold defaults. @@ -472,23 +477,14 @@ _sync_backup_script /usr/local/bin/aperod-deploy "${APEROD_DIR}/deploy/aperod-ap # # If .git/hooks does not exist (tarball install) the step is a no-op. # --------------------------------------------------------------------------- -echo "==> [1c] Installing/refreshing git post-merge hook..." -HOOK_SRC="${DEPLOY_DIR}/post-merge" -GIT_HOOKS_DIR="${APEROD_DIR}/.git/hooks" -if [[ -d "${GIT_HOOKS_DIR}" && -f "${HOOK_SRC}" ]]; then - cp "${HOOK_SRC}" "${GIT_HOOKS_DIR}/post-merge" - chmod +x "${GIT_HOOKS_DIR}/post-merge" - echo " [hook] post-merge hook installed: ${GIT_HOOKS_DIR}/post-merge" -else - echo " [hook] ${GIT_HOOKS_DIR} not found — skipping post-merge hook install (tarball install)." -fi +echo "==> [1c] Old checkout/hooks retained unchanged; isolated releases do not use post-merge." # --------------------------------------------------------------------------- # Step 2: Rebuild Go binary — if this fails, abort before touching the service. # The old binary keeps running untouched. # --------------------------------------------------------------------------- echo "==> [2/5] Building aperod-node (Go)..." -if ! sudo -u aperod bash -c "export PATH=\$PATH:/usr/local/go/bin; cd '${BLOCKCHAIN_DIR}' && make build"; then +if ! sudo -u aperod env CGO_ENABLED=0 GOWORK=off GOFLAGS= GOENV=off GOAMD64=v1 bash -c "export PATH=\$PATH:/usr/local/go/bin; cd '${BLOCKCHAIN_DIR}' && make CGO_ENABLED=0 build"; then echo "" echo "✗ Build failed — service NOT stopped. The old binary is still running." >&2 @@ -504,6 +500,9 @@ if [[ ! -f "${BINARY_SRC}" ]]; then echo "✗ Build succeeded but binary not found at ${BINARY_SRC}" >&2 exit 1 fi +node_source_candidate_guard "$NODE_SOURCE_DIR" "$BINARY_SRC" || { + echo "✗ Candidate provenance verification failed; running binary not replaced." >&2; exit 1; +} # --------------------------------------------------------------------------- # Step 2b: Guard — abort if the fresh binary is dynamically linked. @@ -630,7 +629,7 @@ _resolve_validator_key_path() { fi # Fallback 1: the standard production on-disk layout. - local prod_key="${BLOCKCHAIN_DIR}/data/testnet/validator.key" + local prod_key="${LIVE_BLOCKCHAIN_DIR}/data/testnet/validator.key" if [[ -f "${prod_key}" ]]; then printf '%s\n' "${prod_key}" return 0 @@ -638,7 +637,7 @@ _resolve_validator_key_path() { # Fallback 2: glob under the blockchain data tree (first match wins). local globbed - globbed=$(find "${BLOCKCHAIN_DIR}" -maxdepth 4 -name validator.key -type f 2>/dev/null | head -1 || true) + globbed=$(find "${LIVE_BLOCKCHAIN_DIR}" -maxdepth 4 -name validator.key -type f 2>/dev/null | head -1 || true) if [[ -n "${globbed}" ]]; then printf '%s\n' "${globbed}" return 0 diff --git a/deploy/upgrade-node.sh b/deploy/upgrade-node.sh old mode 100644 new mode 100755 index 3faa97e..889df34 --- a/deploy/upgrade-node.sh +++ b/deploy/upgrade-node.sh @@ -74,6 +74,9 @@ fi || die "ensure-dropin.sh not found at ${ENSURE_DROPIN_SH}" [[ -f "${UPDATE_NODE_SH}" ]] \ || die "update-node.sh not found at ${UPDATE_NODE_SH}" +source "${UPGRADE_DIR}/node-source-release.sh" +node_source_baseline_guard /usr/local/bin/aperod-node || + die "Explicit public source and running-binary baseline approval are required before changing drop-ins." # ── Guard: abort on fresh servers (update-node.sh checks too, # but an early message here is more helpful for operators) ──