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) ──