diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..f724e29 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,31 @@ +name: Validate node templates + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Install static-analysis tools + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Validate role profiles and scripts + run: scripts/validate.sh + - name: Install verified TelosZero Core package + run: | + curl -fLO https://github.com/telosnetwork/teloszero-core/releases/download/teloszero-v1.2.2/teloszero-core_1.2.2_amd64.deb + echo '285fdfc1abde5104892d94b1f380c6d79aba35eac3413f139119ea88574c5007 teloszero-core_1.2.2_amd64.deb' | sha256sum --check --strict + sudo apt-get install -y ./teloszero-core_1.2.2_amd64.deb + - name: Check shell and systemd files + run: | + shellcheck -x start.sh stop.sh tests/test-runner.sh scripts/render-config.sh scripts/validate.sh scripts/smoke-nodeos.sh + sudo useradd --system --home-dir /var/lib/telos --shell /usr/sbin/nologin telos 2>/dev/null || true + systemd-analyze verify systemd/telos-nodeos@.service + - name: Parse every profile with TelosZero + run: scripts/smoke-nodeos.sh diff --git a/.gitignore b/.gitignore index 1559848..6224dae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ -data -protocol_features +data/ +protocol_features/ genesis.json node-template.log +nodeos.log nodeos.pid nohup.out -.idea +.idea/ + +# Never commit local producer/finalizer secrets or safety state. +*.private.ini +*.secrets +*.key +*.keys +finalizer-*.txt +finalizers/ +safety.dat +.nodeos-start.lock/ diff --git a/DEV_SETUP.md b/DEV_SETUP.md index 9c8cf7b..85c4c9d 100644 --- a/DEV_SETUP.md +++ b/DEV_SETUP.md @@ -1,122 +1,21 @@ -# Local Dev Network Tutorial - - -__Clone the node template repo at the root of this directory and follow along to learn how to create a local AntelopeIO developer network.__ - - -Set LOCALIZE_LOG=true in node_config and set the nodeos and cleos binaries to be the name of the binary without a specific path: -``` -BUILD_ROOT="/path/to/binaries/2.0.4" -CLIENT_BIN="cleos" -NODEOS_BIN="nodeos" -CPU="0" -LOCALIZE_LOG=true -``` -Don't bother with the directories setup, with the log localized, the whole node will be contained within this `node-template` repo's directory - - -To reset the node, either remove or rename the `data` directory - -First, generate a key pair to use for this local network's `eosio` super-user account. **NOTE** SAVE THESE KEYS for creating system accounts at bottom -```bash -cleos create key --to-console -``` - -Create a dev wallet, save this password: -```bash -cleos wallet create -n dev --to-console -``` - -Import the key into cleos, paste the private key when prompted: -```bash -cleos wallet import -n dev -``` - -Then generate a new `genesis.json` file -```bash -nodeos --extract-genesis-json genesis.json -``` - -Edit the genesis.json and set the `initial_key` to the key that you generated above for `zzzz`: -```bash -vi genesis.json -``` - -Edit the config.ini to configure this node to produce blocks as `eosio` -```bash -vi config.ini -``` -and change these two, seting the key to be the one you generated above -``` -producer-name = eosio -signature-provider = EOS6nrS...=KEY:5HxQ... -``` -Also, uncomment the producer API plugin -``` -plugin = eosio::producer_api_plugin -``` - -Start the node for the first time -```bash -./start.sh --enable-stale-production --genesis-json genesis.json -``` - -Observe it is now producing blocks, change the name of the log file depending if you renamed this repo directory -```bash -tail -f node-template.log -``` - -Stop the node -```bash -./stop.sh -``` - -Start the node in dev mode, this is how you will start the node from now on: -```bash -./start.sh --enable-stale-production -``` -## Create system accounts -Now make sure that you have your key pair that we initialized in the beginning of tutorial. - -If you want to double check your using the right key in your wallet simply run the command: -```bash -cleos wallet private_keys -n -``` - - -```bash -Instructions for Cleos create account(Usage: cleos create account [OPTIONS] creator name OwnerKey [ActiveKey]) - - -cleos create account eosio eosio.token -cleos create account eosio eosio.bpay -cleos create account eosio eosio.vpay -cleos create account eosio eosio.msig -cleos create account eosio eosio.names -cleos create account eosio eosio.ram -cleos create account eosio eosio.ramfee -cleos create account eosio eosio.rex -cleos create account eosio eosio.saving -cleos create account eosio eosio.stake -``` - -## Deploy the token contract - -NOTE: Can only create accounts using this command before the system contract is deployed, once it's deployed you must use `cleos system newaccount ...` -```bash -cleos create account eosio eosio.token -cd contracts/eosio.token -cleos set contract eosio.token . ./eosio.token.wasm ./eosio.token.abi -cleos push action eosio.token create '["eosio","100000000.0000 TLOS"]' -p eosio.token@active -cleos push action eosio.token issue '["eosio","100000000.0000 TLOS","Issue max supply to eosio"]' -p eosio@active -``` - - - -## Deploy the system contract -```bash -curl -X POST http://127.0.0.1:8888/v1/producer/schedule_protocol_feature_activations -d '{"protocol_features_to_activate": ["0ec7e080177b2c02b278d5088611686b49d739925a92d9bfcacd7fc6b74053bd"]}' | jq -cd contracts/eosio.system -cleos set contract eosio . ./eosio.system.wasm ./eosio.system.abi -cleos push action eosio init '[0,"4,TLOS"]' -p eosio@active -``` +# Local development + +The production profiles in this repository target existing Telos networks where +Savanna instant finality is already active. They are not a network-bootstrap +tutorial. + +For local protocol, system-contract, or finalizer development, use the test +harness and bootstrap procedures from the exact tagged +[TelosZero Core source](https://github.com/telosnetwork/teloszero-core). Those +tests establish the required protocol features, system state, finalizer policy, +and BLS voting conditions; a single `--enable-stale-production` command does not. + +`--enable-stale-production` is acceptable only on an isolated local chain that +cannot connect to mainnet or testnet. `start.sh` refuses it by default. To use it +for that isolated purpose, set `ALLOW_STALE_PRODUCTION=true` in `node_config` and +bind all HTTP/P2P listeners to loopback. + +The compiled contract artifacts under `contracts/` are retained only for +historical compatibility. They predate the current Telos finalizer actions and +must not be deployed to Telos mainnet, testnet, or a current Savanna test network. +Build identified contract sources reproducibly instead. diff --git a/FINALIZER.md b/FINALIZER.md new file mode 100644 index 0000000..75adf98 --- /dev/null +++ b/FINALIZER.md @@ -0,0 +1,252 @@ +# Savanna finalizer operations + +A Telos BP's producer `nodeos` has two signing responsibilities after Savanna: + +- its K1 key signs scheduled blocks; +- its BLS key signs finalizer votes that form quorum certificates. + +The finalizer is part of `nodeos`, not a separate daemon. + +## Non-negotiable safety rules + +1. Generate a unique BLS key for every producer instance. +2. Never share a BLS private key across hosts, networks, or instances. +3. Keep `finalizers/safety.dat` on persistent local storage outside chain data. +4. Never copy `safety.dat` to another host or restore an older version. +5. If safety state is missing, corrupt, stale, or uncertain for a used key, + retire that key and activate a never-used key. +6. Configure and sync a replacement host/key before activating it on-chain. +7. Never delete an active key. Rotate and verify the replacement first. + +These rules prevent a finalizer from signing conflicting branches without its +complete voting history. + +## Generate and configure the first key + +On the producer host: + +```bash +sudo install -d -o telos -g telos -m 0700 /var/lib/telos/keys +sudo install -d -o telos -g telos -m 0700 /var/lib/telos/finalizers/mainnet-primary + +sudo -u telos sh -c ' + umask 077 + key_file=/var/lib/telos/keys/finalizer-mainnet-primary.txt + test ! -e "$key_file" || { echo "Refusing to overwrite $key_file" >&2; exit 1; } + spring-util bls create key --file "$key_file" +' +``` + +The output file contains: + +```text +Private key: PVT_BLS_... +Public key: PUB_BLS_... +Proof of Possession: SIG_BLS_... +``` + +Add the BLS provider alongside the K1 provider: + +```ini +producer-name = +signature-provider = =KEY: +signature-provider = =KEY: +vote-threads = 4 +finalizers-dir = /var/lib/telos/finalizers/mainnet-primary +production-pause-vote-timeout-ms = 6000 +``` + +BLS supports the direct `KEY` provider in TelosZero 1.2.x; it cannot be loaded +through `keosd`. Install `config.ini` as `root:telos` with mode `0640` so the +service can read but cannot rewrite the key-bearing configuration. + +Start and fully sync the producer before registration. A missing `safety.dat` +is expected only on the first start of a never-used key. Once the key votes, +the key and its continuously updated safety history must remain together. + +## Register the first finalizer key + +The BP must already be a registered producer. Run these transactions from the +separate control/signing host: + +```bash +export RPC=https://mainnet.telos.net +export BP_ACCOUNT= +export FINALIZER_PUBLIC_KEY= +export FINALIZER_POP= + +cleos -u "$RPC" push action eosio regfinkey \ + "$(jq -nc \ + --arg name "$BP_ACCOUNT" \ + --arg key "$FINALIZER_PUBLIC_KEY" \ + --arg pop "$FINALIZER_POP" \ + '{finalizer_name:$name,finalizer_key:$key,proof_of_possession:$pop}')" \ + -p "$BP_ACCOUNT@active" +``` + +The first registered key automatically becomes active in the contract table. +Do not call `actfinkey` for the initial key; it is for switching to a later, +already registered key. + +## Verify the table, native policy, and votes + +Join the BP's active key ID to the registered key: + +```bash +ACTIVE_ID="$( + cleos -u "$RPC" get table eosio eosio finalizers --limit 1000 \ + | jq -r --arg bp "$BP_ACCOUNT" \ + '.rows[] | select(.finalizer_name == $bp) | .active_key_id' +)" + +cleos -u "$RPC" get table eosio eosio finkeys --limit 1000 \ + | jq --arg bp "$BP_ACCOUNT" --arg id "$ACTIVE_ID" ' + .rows[] + | select(.finalizer_name == $bp and (.id | tostring) == $id) + ' +``` + +The returned `finalizer_key` must match the configured BLS public key. A row in +`finkeys` alone proves registration, not active-policy membership or voting. + +```bash +cleos -u "$RPC" get finalizer_info \ + | jq --arg bp "$BP_ACCOUNT" '{ + active_generation: .active_finalizer_policy.generation, + threshold: .active_finalizer_policy.threshold, + active: [ + .active_finalizer_policy.finalizers[]? + | select(.description == $bp) + ], + pending: [ + .pending_finalizer_policy.finalizers[]? + | select(.description == $bp) + ], + last_vote: [ + .last_tracked_votes[]? + | select(.description == $bp) + ] + }' +``` + +For a BP in the active producer schedule, verify that: + +- the expected key is in the active native policy; +- a pending policy appears only during a schedule/key transition; +- the tracked vote uses the expected key; +- vote block number and timestamp advance on repeated checks; +- the vote generation converges to the active policy generation; +- head and irreversible blocks keep advancing. + +A standby BP does not enter the native active policy until it enters the active +producer schedule. + +```bash +cleos -u "$RPC" get info | jq '{ + head_block_num, + last_irreversible_block_num, + lib_lag: (.head_block_num - .last_irreversible_block_num) +}' +``` + +Alert on a non-advancing LIB, stale votes, or sustained deviation from the +network baseline rather than one universal lag threshold. + +## Carry votes through relays + +Every `nodeos` hop between the private producer and peer finalizers must have: + +```ini +plugin = eosio::chain_plugin +plugin = eosio::net_plugin +vote-threads = 4 +``` + +Relays have no `producer-name`, K1 key, BLS key, or finalizer directory. A TCP +P2P connection alone does not prove votes are propagating; verify advancing +tracked votes across both independent relay paths. + +## Pre-stage and fail over to a backup + +The backup profile contains `pause-on-startup = true`. This pauses K1 block +production only; it does not make sharing a BLS key safe. + +1. Generate a different, never-used BLS key on the backup host. +2. Configure only the backup with that key and its own local finalizer directory. +3. Start and sync the backup with production paused. +4. Register its key with `regfinkey`; because another key is active, the new + key remains inactive. +5. Confirm it exists in `finkeys` without replacing `active_key_id`. +6. Test the complete handoff on testnet. + +For failover, activate the backup BLS key and wait until it is in the active +native policy with advancing tracked votes. Then stop the primary and positively +confirm it is down. Remove `pause-on-startup = true` from the backup, restart it, +and verify the exclusive K1 handoff. Never run the same K1 key on two unpaused +producers and never copy the primary's `safety.dat` to the backup. + +## Rotate a key + +For a same-host rotation, generate a never-used key on that host, load both old +and new BLS providers there, restart, sync, and register the new key. Then +activate it. For a host migration, never copy the old key or safety state; use +the pre-staged backup/new-key procedure above instead. + +```bash +export NEW_FINALIZER_PUBLIC_KEY= + +cleos -u "$RPC" push action eosio actfinkey \ + "$(jq -nc \ + --arg name "$BP_ACCOUNT" \ + --arg key "$NEW_FINALIZER_PUBLIC_KEY" \ + '{finalizer_name:$name,finalizer_key:$key}')" \ + -p "$BP_ACCOUNT@active" +``` + +Do not stop at transaction inclusion or the `finalizers` table update. Re-run +`get finalizer_info` until the new key moves through any pending policy into the +active policy and its tracked votes advance. + +Only then delete the old inactive registration: + +```bash +export OLD_FINALIZER_PUBLIC_KEY= + +cleos -u "$RPC" push action eosio delfinkey \ + "$(jq -nc \ + --arg name "$BP_ACCOUNT" \ + --arg key "$OLD_FINALIZER_PUBLIC_KEY" \ + '{finalizer_name:$name,finalizer_key:$key}')" \ + -p "$BP_ACCOUNT@active" +``` + +Remove the retired private provider when operationally safe. Retain/quarantine +its logs and safety state for incident review; never reuse the old key. + +## Recover from missing or uncertain safety state + +If `safety.dat` is missing, corrupt, rolled back, or uncertain for a BLS key +that may have voted: + +1. Stop the affected `nodeos` immediately. +2. Do not restart that BLS key. +3. Quarantine its directory; do not copy it elsewhere. +4. Configure a never-used BLS key with a fresh local finalizer directory. +5. Register if necessary, activate it, and wait for native-policy membership + and advancing tracked votes. +6. Restore chain state from a trusted snapshot if needed, without restoring old + finalizer safety state. +7. Delete the retired registration only after the replacement is active. + +A private-key backup alone is not sufficient recovery state. + +## Monitoring and decommissioning + +Alert on process restarts, unexpected versions, stale head/LIB, missing policy +membership, non-advancing votes, production pauses, relay loss, missed blocks, +clock drift, finalizer-directory errors, and unexpected finalizer-key actions. + +To decommission, continue voting until the BP is absent from both the producer +schedule and active native policy. Confirm with `get finalizer_info`, delete only +inactive registered keys, and securely retire local providers and safety state. +Do not unregister a scheduled producer and immediately delete its current key. diff --git a/README.md b/README.md index 75ed6ec..0cf37b2 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,271 @@ -# Telos node template files -Examples to simplify running a Telos node. These examples assume you're running Ubuntu 22.04 +# TelosZero node templates -For a local dev node, check out the [Dev setup](DEV_SETUP.md) +Security-focused templates for Telos block producers, vote relays, APIs, and +SHiP nodes. These instructions assume Savanna instant finality is already live. +They do **not** include the one-time Lightspeed/Savanna activation or +`switchtosvnn` choreography. -## Setup directories -Create a directory to store nodes and binaries in, setup a log directory and set their permissions. Let's assume your user is named `telos` and you want to store everything in a directory named `/telos` +Start with the full +[Telos BP setup guide](https://docs.telos.net/nodes/bp-nodes/setting-up-telos-validator-nodes/) +and read [FINALIZER.md](FINALIZER.md) before configuring any producer-capable +host. -Note, if you wish to have the node keep it's logs in it's own node directory, you can skip the creation and chown of the /var/log/nodeos directory -```shell -sudo mkdir /telos -sudo mkdir /var/log/nodeos -sudo chown telos: /telos -sudo chown telos: /var/log/nodeos -``` +## Supported software + +The current public production artifact is +[TelosZero Core 1.2.2](https://github.com/telosnetwork/teloszero-core/releases/tag/teloszero-v1.2.2): -## Install packages +```text +Package: teloszero-core_1.2.2_amd64.deb +SHA-256: 285fdfc1abde5104892d94b1f380c6d79aba35eac3413f139119ea88574c5007 +Host validated by the release: Ubuntu 22.04, x86-64 ``` -sudo apt install schedtool + +Use the exact tagged artifact approved for the target network. Do not replace +it with Leap, a random Spring build, or the `main` branch. + +```bash +cd /tmp +curl -fLO https://github.com/telosnetwork/teloszero-core/releases/download/teloszero-v1.2.2/teloszero-core_1.2.2_amd64.deb +echo '285fdfc1abde5104892d94b1f380c6d79aba35eac3413f139119ea88574c5007 teloszero-core_1.2.2_amd64.deb' \ + | sha256sum --check --strict +sudo apt-get update +sudo apt-get install -y ./teloszero-core_1.2.2_amd64.deb jq + +nodeos --full-version +cleos version client +spring-util version full ``` -## Download this template -To create a new node at /telos/nodes/testnet1: -```shell -mkdir -p /telos/nodes/testnet1 -cd /telos/nodes/testnet1 -curl -L https://api.github.com/repos/telosnetwork/node-template/tarball/master | tar -xvz --strip=1 +Installing the package may replace historical `leap`, `spring`, or +`antelope-spring` packages that provide the same commands. + +## Choose one role per instance + +| Profile | Purpose | Signing keys | Vote threads | +|---|---|---|---| +| `producer` | Private BP producer and finalizer | Dedicated K1 plus unique BLS | `4` | +| `backup-producer` | Synced failover, with K1 production paused | Same dedicated K1 plus a different BLS | `4` | +| `vote-relay` | Public/private sentry that carries blocks and finalizer votes | None | `4` | +| `api` | Read-only RPC behind a TLS proxy | None | `0` | +| `ship` | State History endpoint restricted to indexers | None | `0` | + +A relay without positive `vote-threads` drops Savanna votes instead of +forwarding them. API and SHiP nodes outside the vote path explicitly use zero. +Do not combine public API, SHiP, or indexing workloads with a private producer. + +## Production topology + +Use a private primary producer, a private backup producer, and at least two +independent relay paths. The producer HTTP API binds to loopback, and its P2P +listener accepts only BP-controlled relays. Public peers belong on relays, APIs, +and SHiP nodes—not directly on the producer. + +Every producer-capable host needs: + +- the BP's dedicated K1 block-signing key; +- its own BLS finalizer key, never shared with another host; +- a local, persistent `finalizers-dir` outside disposable chain data; +- a complete, never-rolled-back `safety.dat` for that BLS key. + +See [REQUIREMENTS.md](REQUIREMENTS.md) for the infrastructure baseline. + +## Create the service account and directories + +The supplied service and examples use these paths: + +```text +/etc/telos/mainnet-primary/ configuration +/var/lib/telos/mainnet-primary/ chain data +/var/lib/telos/finalizers/mainnet-primary/ finalizer safety state +/var/lib/telos/keys/ protected key-generation output ``` -## Setup peers +```bash +id -u telos >/dev/null 2>&1 \ + || sudo useradd --system --home-dir /var/lib/telos --shell /usr/sbin/nologin telos -Copy the contents of the peers.ini from mainnet/testnet directory into the config.ini from the template, adding/removing peers to suit your region/needs -```shell -echo >> config.ini -echo "#TESTNET PEERS:" >> config.ini -cat testnet/peers.ini >> config.ini +sudo install -d -o root -g telos -m 0750 /etc/telos/mainnet-primary +sudo install -d -o telos -g telos -m 0750 /var/lib/telos/mainnet-primary +sudo install -d -o telos -g telos -m 0700 /var/lib/telos/finalizers/mainnet-primary +sudo install -d -o telos -g telos -m 0700 /var/lib/telos/keys ``` -Pick a version to download from https://github.com/EOSIO/eos/releases -You can find more up to date peers via the [EOS Nation](https://eosnation.io/) BP validator tool for either [testnet](https://validate.eosnation.io/telostest/) or [mainnet](https://validate.eosnation.io/telos/) +Never place a finalizer directory on `tmpfs`, NFS, a snapshot-restored volume, +or storage shared by two `nodeos` instances. Give every network/instance its own +directory. + +The role profiles put the writable `protocol-features-dir` under the instance's +data directory. This allows `/etc/telos/` and its key-bearing +configuration to remain root-owned and read-only to the `telos` service account. + +## Generate the BLS key before rendering -## Install latest Leap AntelopeIO binaries (if needed) -Pick a version to download from https://github.com/AntelopeIO/leap/releases +Generate the BLS key on the producer host under the `telos` account: -Select a stable release, likely the `latest` tagged one (not RC/Release Candidate) which is built for your OS Version -```shell -wget https://github.com/AntelopeIO/leap/releases/download/v5.0.2/leap_5.0.2_amd64.deb -sudo apt install ./leap_5.0.2_amd64.deb +```bash +sudo -u telos sh -c ' + umask 077 + key_file=/var/lib/telos/keys/finalizer-mainnet-primary.txt + test ! -e "$key_file" || { echo "Refusing to overwrite $key_file" >&2; exit 1; } + spring-util bls create key --file "$key_file" +' ``` -## Move binaries -Now find where the binaries installed and move the nodeos binary to somewhere that won't be changed when you install the next version, it's likely they are in `/usr/bin`, this will allow you to run a different version on this same machine +The file contains three labeled values: `Private key`, `Public key`, and +`Proof of Possession`. Use its public and private values in the producer profile, +then follow [FINALIZER.md](FINALIZER.md) for registration and verification. + +Do not pass that three-line output file to `spring-util bls create pop`; that +command expects a file containing only a raw private key. + +## Render and install a role profile -The strategy here is that in the `/telos/leap` directory you have a directory for each version you may want to run on this machine, inside each directory is the `nodeos` binary matching that version. To update nodes you'll just have to change the version in the specific node's `node_config` file and restart it, as well as take any other measures needed for that version upgrade (reindex, etc..) +Render a network/role combination to a new file. The renderer annotates the +expected chain ID for post-start verification and, for non-producer roles, +appends the current verified public peer list. `chain-id` is not a valid +`nodeos` configuration option; the chain is selected by its genesis/snapshot +state and verified through `get_info`. -Note, installing the `.deb` file will put all the binaries (`nodeos`,`cleos`,`keosd`) in your path, if you simply use the binary name (e.g. `nodeos` without a path) it will use the most recently installed version. For the purposes of running a node, the only binary which needs to be copied and versioned in this way would be `nodeos` -```shell -mkdir -p /telos/leap/5.0.2 -cp -a /usr/bin/nodeos /telos/leap/5.0.2/ +```bash +./scripts/render-config.sh mainnet producer mainnet-primary /tmp/telos-producer.ini ``` -## Setup node_config -### For each of these settings, you'll make changes in the `node_config` file -```shell -vi /telos/nodes/testnet1/node_config +Replace every `` in the rendered file. A producer must retain both +signature providers: + +```ini +producer-name = +signature-provider = =KEY: +signature-provider = =KEY: +vote-threads = 4 +finalizers-dir = /var/lib/telos/finalizers/mainnet-primary +production-pause-vote-timeout-ms = 6000 +chain-state-db-size-mb = 32768 ``` -### Set node version -Now you know the path to the binaries, change that in the `node_config` file, set the `BUILD_ROOT` variable +Do not use the BP account active key as the K1 block-signing key. Do not set +`production-pause-vote-timeout-ms = 0`; the default guard pauses production +when finalizer votes disappear. TelosZero's 1024 MiB chain-state default is too +small for current Telos state, so every profile uses 32768 MiB; keep it below +usable physical RAM and raise it deliberately as measured chain state grows. + +Install the reviewed files: + +```bash +sudo install -o root -g telos -m 0640 /tmp/telos-producer.ini \ + /etc/telos/mainnet-primary/config.ini +sudo install -o root -g telos -m 0640 logging.json \ + /etc/telos/mainnet-primary/logging.json ``` -BUILD_ROOT="/telos/leap/5.0.2" + +The tracked root `config.ini` is the same fail-closed mainnet producer example +for existing users of this repository. It must not start until its placeholders +are replaced. Keep completed configurations and all key material out of Git. + +## Bootstrap and run with systemd + +Use a recent snapshot from a trusted provider and verify any published +checksum. Import it with the same user and paths as the service: + +> **Warning — replacing an already scheduled producer:** Before starting either +> the snapshot import or the service on a replacement host, add +> `pause-on-startup = true` (or render the `backup-producer` role). This pauses +> K1 block production while still allowing finalizer voting. Prove the old K1 +> signer is stopped and fenced, and use a new BLS key with its own safety +> history, before removing the pause and restarting. Never enable the same K1 +> key on two hosts at once, and never share or reuse a BLS key between hosts. + +```bash +sudo -u telos /usr/bin/nodeos \ + --config-dir /etc/telos/mainnet-primary \ + --data-dir /var/lib/telos/mainnet-primary \ + --snapshot /var/lib/telos/mainnet-primary/snapshots/.bin ``` -### Distribute CPU load -The `start.sh` script will pin the `nodeos` process to a specific CPU core, this optimizes performance. If you are running multiple nodes on the same host and do not change this setting you will have all nodes fighting over the same CPU core so you should make sure each node has a different value set. +Run this in the foreground only for the first import, confirm it loaded the +snapshot into the intended data directory, then stop it cleanly with `Ctrl-C`. +Savanna does not change the existing mainnet or testnet genesis files, but a +snapshot is the normal BP bootstrap path. Start the service without `--snapshot`: -To determine how many cores you have, run `cat /proc/cpuinfo | grep processor` and then pick one that's not already configured on other nodes and set it in the `node_config` file: +```bash +sudo install -o root -g root -m 0644 systemd/telos-nodeos@.service \ + /etc/systemd/system/telos-nodeos@.service +sudo systemctl daemon-reload +sudo systemctl enable --now telos-nodeos@mainnet-primary +sudo systemctl status telos-nodeos@mainnet-primary +sudo journalctl -u telos-nodeos@mainnet-primary -f ``` -CPU="0" + +Verify the local chain before any on-chain key registration: + +```bash +curl -s http://127.0.0.1:8888/v1/chain/get_info | jq '{ + chain_id, + server_full_version_string, + head_block_num, + last_irreversible_block_num, + head_block_time, + head_block_producer +}' ``` -### Optional: Configure to write logs locally to the node's directory if you prefer instead of the system-wide `/var/log/nodeos` path as mentioned above -Set the `LOCALIZE_LOG` flag to true in the `node_config` file +Required checks: + +- the chain ID exactly matches `mainnet/chain-id.txt` or `testnet/chain-id.txt`; +- the approved full version is running; +- head and irreversible blocks advance and the node reaches network head; +- both relay paths stay connected; +- logs contain no key parsing, safety-file, fork, or persistent vote errors. + +Then register/update the producer account from a separate control/signing host, +not from the producer: + +```bash +cleos -u "$RPC" system regproducer \ + "$BP_ACCOUNT" "$BLOCK_SIGNING_PUBLIC_KEY" "$BP_URL" "$BP_LOCATION" \ + -p "$BP_ACCOUNT@active" ``` -LOCALIZE_LOG=true + +`BP_LOCATION` is a numeric `uint16`, not a city name. Complete finalizer +registration and active-policy verification in [FINALIZER.md](FINALIZER.md) +before accepting an active schedule slot. + +## Manual compatibility scripts + +`start.sh` and `stop.sh` remain for development and existing clone-per-node +deployments. Configure `node_config`, replace every placeholder in `config.ini`, +and run `./start.sh`. The script refuses duplicate starts and production use of +`--enable-stale-production`; `stop.sh` validates the PID and waits for a clean +SIGINT shutdown. Systemd is the production recommendation. + +## Refresh public peers + +The checked-in peer lists are generated from endpoints currently marked +verified by [validators.telos.net](https://validators.telos.net/), deduplicated, +and validated for syntax: + +```bash +./scripts/update-peers.py ``` -## Setup config.ini -### Review -Review the config.ini file to get familiar with it, adjust as needed +Validator verification is a point-in-time signal, not an availability or trust +guarantee. Curate peers for operator and geographic diversity, monitor them, and +keep private producers behind BP-controlled vote relays. -### Set the ports -Make sure you set the ports to ones that are not already in use on this server by other nodes +## Validate a change -## Start the node from genesis to begin the sync -```shell -cd /telos/nodes/testnet1 -./start.sh --genesis-json ./testnet/genesis.json +```bash +./scripts/validate.sh ``` -## Configure public access -This assumes the node operator has reasonable system administration skills, which should be expected of a Telos block producer. -### P2P -Point DNS at the server and expose the p2p port (`p2p-listen-endpoint`), this is your seed/p2p endpoint and is only tcp, it does not require anything in front of it. If you wish you can use a tcp load balancer such as haproxy in front of the p2p, then you'll point the DNS at the load balancer. -### API -Install something like nginx or haproxy and point DNS at it for your API endpoint, configure it for SSL. +The validation checks JSON and shell syntax, canonical chain IDs, peer syntax, +role separation, private producer HTTP, BLS/K1 providers, vote-thread settings, +SHiP finality history, and finalizer-safety paths. CI runs the same command. -A popular option is to put nginx in front of it using auto-renewing and free SSL certs from Let's Encrypt - https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-18-04 +## Steady-state readiness gate -You'll want to configure nginx in this case for a reverse proxy, and point it at your `http-server-address` +This repository presumes instant finality is live. Before enabling production, +`cleos get finalizer_info` must return a non-null active policy and advancing +tracked votes for active finalizers. If the target chain has no active native +policy, stop: it has not reached the steady state assumed by these templates. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 1f9508d..51cc6e4 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,29 +1,91 @@ -# Node requirements +# Production requirements -## Node capabilities/ports -- P2P: The peer-to-peer protocol used for nodes communicating blocks with each other. -- API: Serving the http API, should be put behind nginx/haproxy terminating SSL and available on a public facing DNS record. If the node is started from a snapshot, it will not be able to satisfy /v1/chain/get_block requests from prior to the snapshot. -- State history: This provides a websocket stream used for getting all chain history in binary form, it is typically combined with other tools that are ABI aware and can do the decoding. Similar to http api, if started from a snapshot, the node will not be able to provide any history from prior to that snapshot block. +A post-Savanna BP continuously signs finalizer votes as well as producing its +scheduled blocks. Size and secure the complete producer-to-relay path. -## Producer nodes -A producer node should not be exposed to the public internet and for this reason can be started from a snapshot without any significant downsides. It should be connected via P2P with other nodes for the validator and have a curated list of other nearby/reliable validator's public P2P endpoints. +## Required topology -## Multiple nodes per host -It is not uncommon to run more than one node on a single physical host. Take care to not create port conflicts or the nodes will not start. Consider the resource utilization numbers below when deciding how best to organize nodes, leaving plenty of room for growth. +| Role | Exposure | Finalizer behavior | +|---|---|---| +| Primary producer | Private | Unique BLS key, `vote-threads = 4`, persistent safety state | +| Backup producer | Private and paused until handoff | Different BLS key and independent safety state | +| Two or more relay/sentry paths | P2P as required | No keys; `vote-threads = 4` | +| Control/signing host | Restricted administration | Holds BP account authority; no finalizer | +| API/SHiP nodes | Public only through hardened proxies | No keys; `vote-threads = 0` unless deliberately a relay | -## Resource utilization -Node usage can grow over time. Here are the current numbers for mainnet usage as of 1/16/2023: +Avoid a single provider, firewall, switch, region, or transit link that can +isolate the producer. Do not place primary and backup producers in one fault +domain, and do not run public API/indexing workloads on a private producer. -### CPU -Nodeos is generally a single-threaded process, with multithreading only for signature verification. It's best to run nodeos on a high-frequency CPU (over 4GHz turbo boost, with optimizations as described [in this article](https://github.com/poplexity/bp-performance)) +## Planning baseline -### Disk -It is critical that state is stored on fast IO hardware (SSD/NVMe) but blocks and state-history are acceptable to run on slower HDD drives. There is a default setting that will shut the node down gracefully when it's storage reaches 90% capacity. One of the most common contributors to disk usage is the log file, so without logrotate configured it's best to keep an eye on the log file and `echo "" > nodeos.log` when disk capacity becomes a concern. -```text -State: 3.4G (all nodes will have this) -Blocks: 262G (full blocks since genesis, this would start at 0 if you start from a snapshot) -State history: 750G (full state-history since genesis, this would start at 0 if you start from a snapshot) -``` +These are operational starting points, not consensus constants. Benchmark the +approved TelosZero artifact under replay and peak load, then leave substantial +headroom. -### Ram -A single node uses 2.4G of RAM currently this can at any time jump up to the on-chain max which is 36G however that would mean that all RAM would have been bought AND used by accounts which is highly unlikely as the price of RAM approaches infinity as the supply approaches 0 (bancor algorithm). +| Resource | Producer baseline | +|---|---| +| CPU | Modern high-clock x86-64 with at least 8 dedicated cores | +| Memory | 64 GB ECC recommended; never size chain state above usable RAM | +| Storage | Enterprise NVMe with power-loss protection and growth headroom | +| Network | Redundant, low-latency 1 Gbit/s or better paths | +| Clock | Multiple NTP sources with drift and clock-jump alerts | +| Power | Redundant power where available and tested graceful shutdown | +| OS | The supported Ubuntu release for the approved package | + +TelosZero uses chain, network, and vote worker pools. Do not pin the entire +process to one CPU. Size API, SHiP, archive, and indexer storage from measured +retention requirements rather than stale repository figures. The supplied +profiles set `chain-state-db-size-mb = 32768` because the 1024 MiB software +default is insufficient; keep that capacity below usable RAM and monitor growth. + +## Persistent finalizer storage + +`finalizers-dir` contains consensus-critical `safety.dat`. It must be: + +- on persistent local storage outside the disposable chain data directory; +- owned by the `nodeos` service user and mode `0700`; +- monitored for write, checksum, permission, latency, and capacity failures; +- excluded from cross-host replication and generic restore automation; +- preserved in place through normal restarts, replays, and snapshot recovery. + +Never copy or roll back `safety.dat`, share it over NFS, or reuse its BLS key on +another host. A chain snapshot does not recover finalizer voting history. + +## Key custody + +- Keep BP owner/active authorities off producer hosts. +- Use a dedicated K1 block-signing key, not the account active key. +- Generate one BLS key per producer-capable host and never export it to another. +- Protect completed key-bearing configs as `root:telos` with mode `0640`; keep + writable protocol-feature files under the instance data directory. +- Pre-register a distinct backup BLS key before an emergency. + +TelosZero 1.2.x BLS keys use the direct `KEY` provider, so host and file +protection are part of the signing boundary. + +## Network and service security + +- Bind producer HTTP to loopback and allow producer P2P only from controlled relays. +- Set `vote-threads = 4` on every relay hop carrying finalizer traffic. +- Maintain two independent inbound/outbound vote paths. +- Put public HTTP behind TLS, rate limiting, and request filtering. +- Never expose `producer_api_plugin` or `net_api_plugin` directly to the internet. +- Restrict SHiP endpoints to trusted indexers. +- Curate public peers for operator and geographic diversity; monitor continuously. + +## Monitoring and retention + +Monitor process/version/checksum, head and LIB, native policy generation, +tracked-vote freshness, production pauses, missed blocks, both relay paths, +resource saturation, disk/inodes, finalizer writes, clock drift, configuration +changes, and unexpected `regfinkey`/`actfinkey`/`delfinkey` transactions. + +Use journald retention or managed log rotation. Never reclaim space by truncating +a live log manually. Alert an operator who can execute the tested rotation and +failover runbook whenever the BP is scheduled. + +Exercise on testnet at least quarterly and before a major release: artifact +upgrade, vote propagation, activation of the backup BLS key, positive shutdown +of the primary, exclusive K1 handoff, and recovery with a new key when safety +history is unavailable. diff --git a/config.ini b/config.ini index a4da9a3..1d006b0 100644 --- a/config.ini +++ b/config.ini @@ -1,59 +1,48 @@ -# THIS IS YOUR API SERVER HTTP PORT, PUT IT BEHIND NGINX OR HAPROXY WITH SSL -http-server-address = 0.0.0.0:8888 - -# THIS IS YOUR P2P PORT AND IS ONLY TCP, DO NOT TRY TO PUT SSL IN FRONT OR USE AN HTTP PROXY WITH IT -p2p-listen-endpoint = 0.0.0.0:9876 - -# SET A LOGICAL NAME FOR THIS NODE -agent-name = "Name that peers will see this node as" - -# Directory configuration if you are splitting state/blocks/ship/trace... -# note that state currently is not configurable and will be relative to the data-dir -#blocks-dir= -#state-history-dir= -#trace-dir= - -wasm-runtime = eos-vm-jit - -# DO NOT ENABLE THESE ON A PRODUCER, they may be handy for making replays faster though! -#eos-vm-oc-compile-threads = 4 -#eos-vm-oc-enable = 1 - -# This can be set as low as the configured RAM on the network, -# but should not be higher than the configured RAM on your server -chain-state-db-size-mb = 65536 -contracts-console = true -access-control-allow-origin = * -access-control-allow-headers = * -verbose-http-errors = true +# Telos mainnet private producer/finalizer template. +# +# This file intentionally contains invalid placeholders. Replace every value in +# angle brackets before starting nodeos. Keep the completed file out of Git and +# install it as root:telos with mode 0640 because it contains private signing +# keys and must be readable, but not writable, by the service account. + +# Expected mainnet chain ID (verify after startup; this is not a nodeos option): +# 4667b205c6838ef70ff7988f6e8257e8be0e1284a2f59699054a018f743b1d11 + +# Private management API only. Do not expose a producer API publicly. +http-server-address = 127.0.0.1:8888 http-validate-host = false -abi-serializer-max-time-ms = 100 -http-max-response-time-ms = 50 -#this must be a high number behind a proxy, as all connections appear to come from the proxy host -p2p-max-nodes-per-host = 100 +# Bind to a private interface reachable only by operator-controlled relays. +p2p-listen-endpoint = :9876 +agent-name = "-producer" -# UNCOMMENT AND SET PROPER VALUES FOR PRODUCER NODE -#producer-name = validator123 -#signature-provider = EOS5zTR......=KEY:5....... +# TelosZero defaults to 1024 MiB, which is too small for current Telos state. +# Keep this below usable physical RAM and re-evaluate as chain state grows. +chain-state-db-size-mb = 32768 +protocol-features-dir = /var/lib/telos/mainnet-primary/protocol_features -# PLUGINS plugin = eosio::http_plugin plugin = eosio::chain_plugin plugin = eosio::chain_api_plugin - -# DO NOT ENABLE NET OR PRODUCER API PLUGINS ON THE PUBLIC INTERNET -# ONLY RUN IF YOU'RE BEHIND A PROXY THAT IS FILTERING THESE PATHS: /v1/producer/ /v1/net/ -#plugin = eosio::net_api_plugin -#plugin = eosio::producer_api_plugin - plugin = eosio::net_plugin plugin = eosio::producer_plugin -#IF YOU ARE RUNNING STATE HISTORY FOR HYPERION, ENABLE AND CONFIGURE THE BELOW -#plugin = eosio::state_history_plugin -#state-history-endpoint = 0.0.0.0:18999 -#trace-history = true -#chain-state-history = true -#trace-history-debug-mode = true -#state-history-dir = state-history +# K1 block-signing key. Do not use the BP account active key here. +producer-name = +signature-provider = =KEY: + +# Savanna finalizer key. Every producer instance needs a unique BLS key. +signature-provider = =KEY: +vote-threads = 4 + +# Keep safety.dat outside the disposable chain data directory, on persistent +# local storage. Never copy or restore an older safety.dat. +finalizers-dir = /var/lib/telos/finalizers/mainnet-primary + +# Retain the TelosZero default protection that pauses block production if +# finalizer votes are not observed. Never set this to 0 in production. +production-pause-vote-timeout-ms = 6000 + +# A producer connects only to private, operator-controlled vote relays. +p2p-peer-address = :9876 +p2p-peer-address = :9876 diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..cf364b1 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,8 @@ +# Historical artifacts only + +The compiled contracts in this directory predate the current Telos Savanna +finalizer lifecycle (`regfinkey`, `actfinkey`, and `delfinkey`). They are not +supported deployment artifacts for Telos mainnet, testnet, or a current local +Savanna network. + +Use reproducible builds from an explicitly identified current contract source. diff --git a/logging.json b/logging.json index 7cf1d9a..a7db3c9 100644 --- a/logging.json +++ b/logging.json @@ -1,143 +1,64 @@ { "includes": [], - "appenders": [{ + "appenders": [ + { "name": "stderr", "type": "console", "args": { - "format": "${timestamp} ${thread_name} ${context} ${file}:${line} ${method} ${level}] ${message}", + "format": "${timestamp} ${thread_name} ${context} ${file}:${line} ${method} ${level}] ${message}", "stream": "std_error", - "level_colors": [{ - "level": "debug", - "color": "green" - },{ - "level": "warn", - "color": "brown" - },{ - "level": "error", - "color": "red" - } + "level_colors": [ + { "level": "debug", "color": "green" }, + { "level": "warn", "color": "brown" }, + { "level": "error", "color": "red" } ], "flush": true }, "enabled": true - },{ - "name": "stdout", - "type": "console", - "args": { - "stream": "std_out", - "level_colors": [{ - "level": "debug", - "color": "green" - },{ - "level": "warn", - "color": "brown" - },{ - "level": "error", - "color": "red" - } - ], - "flush": true - }, - "enabled": true - },{ - "name": "net", - "type": "gelf", - "args": { - "endpoint": "10.10.10.10:12201", - "host": "host_name", - "_network": "mainnet" - }, - "enabled": false } ], - "loggers": [{ + "loggers": [ + { "name": "default", - "level": "debug", + "level": "info", "enabled": true, "additivity": false, - "appenders": [ - "stderr" - ] - },{ + "appenders": ["stderr"] + }, + { "name": "net_plugin_impl", "level": "info", "enabled": true, "additivity": false, - "appenders": [ - "stderr" - ] - },{ + "appenders": ["stderr"] + }, + { "name": "http_plugin", - "level": "info", + "level": "warn", "enabled": true, "additivity": false, - "appenders": [ - "stderr" - ] - },{ + "appenders": ["stderr"] + }, + { "name": "producer_plugin", "level": "info", "enabled": true, "additivity": false, - "appenders": [ - "stderr" - ] - },{ - "name": "transaction_success_tracing", - "level": "info", - "enabled": true, - "additivity": false, - "appenders": [ - "stderr" - ] - },{ - "name": "transaction_failure_tracing", - "level": "info", - "enabled": true, - "additivity": false, - "appenders": [ - "stderr" - ] - },{ - "name": "trace_api", - "level": "info", - "enabled": true, - "additivity": false, - "appenders": [ - "stderr" - ] - },{ - "name": "transaction_trace_success", - "level": "info", - "enabled": true, - "additivity": false, - "appenders": [ - "stderr", - ] - },{ - "name": "transaction_trace_failure", + "appenders": ["stderr"] + }, + { + "name": "vote", "level": "info", "enabled": true, "additivity": false, - "appenders": [ - "stderr", - ] - },{ - "name": "state_history", - "level": "info", - "enabled": true, - "additivity": false, - "appenders": [ - "stderr", - ] - },{ - "name": "transaction", + "appenders": ["stderr"] + }, + { + "name": "state_history", "level": "info", "enabled": true, "additivity": false, - "appenders": [ - "stderr", - ] + "appenders": ["stderr"] } ] } diff --git a/mainnet/peers.ini b/mainnet/peers.ini index 2079d49..06ea023 100644 --- a/mainnet/peers.ini +++ b/mainnet/peers.ini @@ -1,38 +1,37 @@ -#Peers List Updated June 10, 2024 by JTB. +# Mainnet peers generated from a locally supplied validator JSON file +# Validator data generated at: 2026-07-16T20:29:54.522253+00:00 +# Only endpoints marked verified by the validator are included. +# Curate for geographic/operator diversity and monitor continuously. +# Private producers should peer with operator-controlled vote relays, +# not directly with this public list. -# amsterdam: NL, Amsterdam +# amsterdam p2p-peer-address = telos.eu.eosamsterdam.net:9120 -# bp.boid: ES, Tenerife, Canary Islands, Spain -p2p-peer-address = telos.p2p.boid.animus.is:5151 -# bp.boid: ES, Tenerife, Canary Islands, Spain -p2p-peer-address = telos.p2p.boid.animus.is:5252 -# eosauthority: DE, Falkenstein -p2p-peer-address = node-telos.eosauthority.com:10311 -# eosphereiobp: AU, Sydney +# bigironbptex +p2p-peer-address = p2p.bigironbp.io:9876 +# bp.adex +p2p-peer-address = p2p-telos.a-dex.xyz:9976 +# caleosblocks +p2p-peer-address = telos.caleos.io:9877 +# eosiodetroit +p2p-peer-address = p2p.telos.detroitledger.tech:1337 +# eosphereiobp p2p-peer-address = peer1-telos.eosphere.io:9876 -# eosphereiobp: AU, Sydney +# eosphereiobp p2p-peer-address = peer2-telos.eosphere.io:9876 -# guild.nefty: FI, Finland -p2p-peer-address = telos-public-seed.neftyblocks.com:9877 -# kandaweather: ZA, Cape Town -p2p-peer-address = api.kandaweather.net:9876 -# nation.tlos: CA, Canada -p2p-peer-address = telos.seed.eosnation.io:9876 -# omnidexblock: US, Johnstown -p2p-peer-address = omnidexbp.com:9876 -# southafrica1: ZA, Pretoria -p2p-peer-address = p2p.telos.africa:9876 -# telosgermany: DE, Frankfurt -p2p-peer-address = p2p.telosgermany.genereos.io:9876 -# telosglobal1: US, Sacramento,CA -p2p-peer-address = node1.us-west.telosglobal.io:9876 -# telosglobal1: US, Herndon,VA -p2p-peer-address = node1.us-east.telosglobal.io:9876 -# teloskiwinzz: AU, Melbourne -p2p-peer-address = telosmainnet.bp.kiwi:9786 -# telosmadrid1: DE, SEED -p2p-peer-address = seed.telosmadrid.io:9878 -# telosunlimit: US, St.Louis -p2p-peer-address = p2p.telosunlimited.io:9876 -# totaltelosbp: ES, Spain -p2p-peer-address = p2p.totaltelos.tech:9820 +# eosriobrazil +p2p-peer-address = telos.eosrio.io:8092 +# infinitybloc +p2p-peer-address = peer-telos-mainnet.infinitybloc.io:9876 +# ledgerwisete +p2p-peer-address = telosp2p.ledgerwise.io:29876 +# orbitalblock +p2p-peer-address = p2p-telos-mainnet.orbitalblock.com:9876 +# telosarabia1 +p2p-peer-address = p2p.telosarabia.net:9876 +# telosgreenbp +p2p-peer-address = p2p2.telos.telosgreen.com:9877 +# telosunlimit +p2p-peer-address = p2p.telosunlimited.com:9876 +# telosusa1111, telosusa2222, telosusa3333, telosusa4444, votetelosusa +p2p-peer-address = telos.p2p.eosusa.io:9876 diff --git a/node_config b/node_config index 60164dd..b9d2b3c 100644 --- a/node_config +++ b/node_config @@ -1,10 +1,15 @@ -BUILD_ROOT="/path/to/leap/binaries/5.0.2" -CLIENT_BIN="$BUILD_ROOT/cleos" -NODEOS_BIN="$BUILD_ROOT/nodeos" +# Manual start.sh compatibility settings. Production deployments should use the +# systemd unit in systemd/telos-nodeos@.service instead. +NODEOS_BIN="/usr/bin/nodeos" -#DATA_DIR="" +# Empty values resolve to this checkout's config and data directories. +CONFIG_DIR="" +DATA_DIR="" -LOCALIZE_LOG=false +LOCALIZE_LOG=true +LOG_DIR="/var/log/nodeos" +SHUTDOWN_TIMEOUT=300 -PIN_CPU=false -CPU="0" +# Production scripts refuse --enable-stale-production. Set this only for an +# isolated local development chain that cannot connect to a production network. +ALLOW_STALE_PRODUCTION=false diff --git a/profiles/api.ini.example b/profiles/api.ini.example new file mode 100644 index 0000000..b3f097f --- /dev/null +++ b/profiles/api.ini.example @@ -0,0 +1,16 @@ +# Read-only API node behind a TLS reverse proxy. It is not a vote relay. + +http-server-address = 127.0.0.1:8888 +http-validate-host = false +p2p-listen-endpoint = 0.0.0.0:9876 +agent-name = "@INSTANCE@" +chain-state-db-size-mb = 32768 +protocol-features-dir = /var/lib/telos/@INSTANCE@/protocol_features + +plugin = eosio::http_plugin +plugin = eosio::chain_plugin +plugin = eosio::chain_api_plugin +plugin = eosio::net_plugin + +vote-threads = 0 +api-accept-transactions = false diff --git a/profiles/backup-producer.ini.example b/profiles/backup-producer.ini.example new file mode 100644 index 0000000..7857b70 --- /dev/null +++ b/profiles/backup-producer.ini.example @@ -0,0 +1,28 @@ +# Pre-staged private backup producer/finalizer. This host must have a BLS key +# that has never been used on another host. + +http-server-address = 127.0.0.1:8888 +http-validate-host = false +p2p-listen-endpoint = :9876 +agent-name = "-backup-producer" +chain-state-db-size-mb = 32768 +protocol-features-dir = /var/lib/telos/@INSTANCE@/protocol_features + +plugin = eosio::http_plugin +plugin = eosio::chain_plugin +plugin = eosio::chain_api_plugin +plugin = eosio::net_plugin +plugin = eosio::producer_plugin + +producer-name = +signature-provider = =KEY: +signature-provider = =KEY: +vote-threads = 4 +finalizers-dir = /var/lib/telos/finalizers/@INSTANCE@ +production-pause-vote-timeout-ms = 6000 + +# This pauses K1 block production only. Finalizer voting is independent. +pause-on-startup = true + +p2p-peer-address = :9876 +p2p-peer-address = :9876 diff --git a/profiles/producer.ini.example b/profiles/producer.ini.example new file mode 100644 index 0000000..769b1a9 --- /dev/null +++ b/profiles/producer.ini.example @@ -0,0 +1,25 @@ +# Private producer/finalizer. Render with scripts/render-config.sh, then replace +# every value in angle brackets. + +http-server-address = 127.0.0.1:8888 +http-validate-host = false +p2p-listen-endpoint = :9876 +agent-name = "-producer" +chain-state-db-size-mb = 32768 +protocol-features-dir = /var/lib/telos/@INSTANCE@/protocol_features + +plugin = eosio::http_plugin +plugin = eosio::chain_plugin +plugin = eosio::chain_api_plugin +plugin = eosio::net_plugin +plugin = eosio::producer_plugin + +producer-name = +signature-provider = =KEY: +signature-provider = =KEY: +vote-threads = 4 +finalizers-dir = /var/lib/telos/finalizers/@INSTANCE@ +production-pause-vote-timeout-ms = 6000 + +p2p-peer-address = :9876 +p2p-peer-address = :9876 diff --git a/profiles/ship.ini.example b/profiles/ship.ini.example new file mode 100644 index 0000000..de5be85 --- /dev/null +++ b/profiles/ship.ini.example @@ -0,0 +1,23 @@ +# State History (SHiP) node restricted to trusted indexers. It is not a vote +# relay. + +http-server-address = 127.0.0.1:8888 +http-validate-host = false +p2p-listen-endpoint = 0.0.0.0:9876 +agent-name = "@INSTANCE@" +chain-state-db-size-mb = 32768 +protocol-features-dir = /var/lib/telos/@INSTANCE@/protocol_features + +plugin = eosio::http_plugin +plugin = eosio::chain_plugin +plugin = eosio::chain_api_plugin +plugin = eosio::net_plugin +plugin = eosio::state_history_plugin + +vote-threads = 0 +api-accept-transactions = false +state-history-endpoint = 127.0.0.1:8080 +trace-history = true +chain-state-history = true +finality-data-history = true +state-history-dir = state-history diff --git a/profiles/vote-relay.ini.example b/profiles/vote-relay.ini.example new file mode 100644 index 0000000..f51bb34 --- /dev/null +++ b/profiles/vote-relay.ini.example @@ -0,0 +1,20 @@ +# Public P2P relay/sentry in the path between a private producer and the Telos +# network. It has no signing keys. + +http-server-address = 127.0.0.1:8888 +http-validate-host = false +p2p-listen-endpoint = 0.0.0.0:9876 +agent-name = "@INSTANCE@" +chain-state-db-size-mb = 32768 +protocol-features-dir = /var/lib/telos/@INSTANCE@/protocol_features + +plugin = eosio::http_plugin +plugin = eosio::chain_plugin +plugin = eosio::chain_api_plugin +plugin = eosio::net_plugin + +# Mandatory on every relay hop. Zero or omitted drops Savanna votes. +vote-threads = 4 + +# Private producer plus public peers appended by scripts/render-config.sh. +p2p-peer-address = :9876 diff --git a/scripts/peer_utils.py b/scripts/peer_utils.py new file mode 100644 index 0000000..7386611 --- /dev/null +++ b/scripts/peer_utils.py @@ -0,0 +1,20 @@ +"""Shared validation for P2P endpoint and producer-account input.""" + +import re + + +ENDPOINT_RE = re.compile( + r"^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|" + r"\[[0-9A-Fa-f:]+\]|(?:\d{1,3}\.){3}\d{1,3}):(\d{1,5})$" +) +OWNER_RE = re.compile(r"^[a-z1-5.]{1,12}$") + + +def validate_endpoint(endpoint: str) -> int: + match = ENDPOINT_RE.fullmatch(endpoint) + if not match: + raise ValueError(f"invalid P2P endpoint: {endpoint!r}") + port = int(match.group(1)) + if not 1 <= port <= 65535: + raise ValueError(f"invalid P2P port in endpoint: {endpoint!r}") + return port diff --git a/scripts/render-config.sh b/scripts/render-config.sh new file mode 100755 index 0000000..44c1d2a --- /dev/null +++ b/scripts/render-config.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 " >&2 +} + +if [[ $# -ne 4 ]]; then + usage + exit 2 +fi + +NETWORK=$1 +ROLE=$2 +INSTANCE=$3 +OUTPUT=$4 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +case "$NETWORK" in + mainnet|testnet) ;; + *) + echo "ERROR: unsupported network: $NETWORK" >&2 + usage + exit 2 + ;; +esac + +if [[ ! "$INSTANCE" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then + echo "ERROR: instance must contain only lowercase letters, digits, and hyphens" >&2 + exit 2 +fi +if [[ "$INSTANCE" != "$NETWORK"-* ]]; then + echo "ERROR: instance must start with '$NETWORK-' to keep runtime paths separated" >&2 + exit 2 +fi + +case "$ROLE" in + producer|backup-producer|vote-relay|api|ship) ;; + *) + echo "ERROR: unsupported role: $ROLE" >&2 + usage + exit 2 + ;; +esac + +PROFILE="$ROOT/profiles/$ROLE.ini.example" +CHAIN_ID_FILE="$ROOT/$NETWORK/chain-id.txt" +PEERS_FILE="$ROOT/$NETWORK/peers.ini" + +if [[ -e "$OUTPUT" ]]; then + echo "ERROR: refusing to overwrite existing file: $OUTPUT" >&2 + exit 1 +fi + +OUTPUT_DIR="$(dirname "$OUTPUT")" +if [[ ! -d "$OUTPUT_DIR" ]]; then + echo "ERROR: output directory does not exist: $OUTPUT_DIR" >&2 + exit 1 +fi + +chain_id="$(tr -d '[:space:]' < "$CHAIN_ID_FILE")" +if [[ ! "$chain_id" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: invalid chain ID in $CHAIN_ID_FILE" >&2 + exit 1 +fi + +tmp="$(mktemp "$OUTPUT.tmp.XXXXXX")" +trap 'rm -f "$tmp"' EXIT + +{ + printf '# Network: %s\n' "$NETWORK" + printf '# Expected chain ID (verify with get_info; not a nodeos option):\n' + printf '# %s\n\n' "$chain_id" + sed -e "s/@NETWORK@/$NETWORK/g" -e "s/@INSTANCE@/$INSTANCE/g" "$PROFILE" +} > "$tmp" + +case "$ROLE" in + producer|backup-producer) + ;; + *) + { + printf '\n# Verified public peers for %s. Curate for region and diversity.\n' "$NETWORK" + cat "$PEERS_FILE" + } >> "$tmp" + ;; +esac + +chmod 0600 "$tmp" +mv "$tmp" "$OUTPUT" +trap - EXIT + +echo "Rendered $NETWORK $ROLE template to $OUTPUT" +echo "Replace every , review the role, and keep private keys out of Git." diff --git a/scripts/smoke-nodeos.sh b/scripts/smoke-nodeos.sh new file mode 100755 index 0000000..a9d49d0 --- /dev/null +++ b/scripts/smoke-nodeos.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +for command_name in nodeos cleos spring-util timeout; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "ERROR: required command is missing: $command_name" >&2 + exit 1 + fi +done + +temporary="$(mktemp -d)" +trap 'rm -rf "$temporary"' EXIT + +k1_output="$(cleos create key --to-console)" +k1_private="$(printf '%s\n' "$k1_output" | sed -n 's/^Private key: //p')" +k1_public="$(printf '%s\n' "$k1_output" | sed -n 's/^Public key: //p')" + +bls_file="$temporary/bls.txt" +spring-util bls create key --file "$bls_file" >/dev/null +bls_private="$(sed -n 's/^Private key: //p' "$bls_file")" +bls_public="$(sed -n 's/^Public key: //p' "$bls_file")" + +replace_placeholders() { + local config=$1 + local finalizers_dir=$2 + local protocol_features_dir=$3 + sed -i \ + -e 's||127.0.0.1|g' \ + -e 's||127.0.0.1|g' \ + -e 's||127.0.0.1|g' \ + -e 's||127.0.0.1|g' \ + -e 's||eosio|g' \ + -e "s||$k1_public|g" \ + -e "s||$k1_private|g" \ + -e "s||$bls_public|g" \ + -e "s||$bls_private|g" \ + -e "s||$bls_public|g" \ + -e "s||$bls_private|g" \ + -e "s|/var/lib/telos/finalizers/[^[:space:]]*|$finalizers_dir|g" \ + -e "s|/var/lib/telos/[^[:space:]]*/protocol_features|$protocol_features_dir|g" \ + -e 's|chain-state-db-size-mb = 32768|chain-state-db-size-mb = 1024|g' \ + "$config" + + if grep -Eq '<[A-Z0-9_]+>|@[A-Z0-9_]+@' "$config"; then + echo "ERROR: unresolved placeholder in $config" >&2 + exit 1 + fi +} + +smoke_one() { + local network=$1 + local role=$2 + local instance="$network-$role" + local config_dir="$temporary/config-$instance" + local data_dir="$temporary/data-$instance" + local protocol_features_dir="$temporary/protocol-features-$instance" + local log="$temporary/$instance.log" + mkdir -p "$config_dir" "$data_dir" "$temporary/finalizers-$instance" "$protocol_features_dir" + + "$ROOT/scripts/render-config.sh" \ + "$network" "$role" "$instance" "$config_dir/config.ini" >/dev/null + replace_placeholders \ + "$config_dir/config.ini" \ + "$temporary/finalizers-$instance" \ + "$protocol_features_dir" + cp "$ROOT/logging.json" "$config_dir/logging.json" + + set +e + timeout --kill-after=5s --signal=INT 5s nodeos \ + --config-dir "$config_dir" \ + --data-dir "$data_dir" \ + --genesis-json "$ROOT/$network/genesis.json" \ + >"$log" 2>&1 + exit_code=$? + set -e + + if [[ $exit_code -ne 0 && $exit_code -ne 124 && $exit_code -ne 130 ]]; then + echo "ERROR: nodeos smoke failed for $network/$role (exit $exit_code)" >&2 + cat "$log" >&2 + exit 1 + fi + if ! grep -Eq 'Blockchain started|nodeos using configuration file' "$log"; then + echo "ERROR: nodeos did not finish parsing $network/$role" >&2 + cat "$log" >&2 + exit 1 + fi + echo "nodeos accepted $network/$role" +} + +for network in mainnet testnet; do + for role in producer backup-producer vote-relay api ship; do + smoke_one "$network" "$role" + done +done diff --git a/scripts/update-peers.py b/scripts/update-peers.py new file mode 100755 index 0000000..1bad89d --- /dev/null +++ b/scripts/update-peers.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Refresh peer files from the Telos validator's verified endpoint data.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import tempfile +import urllib.request +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Mapping, MutableMapping, Set + +from peer_utils import OWNER_RE, validate_endpoint + + +DEFAULT_SOURCE = "https://validators.telos.net/validation/latest.json" + + +def load_document(source: str) -> Mapping[str, object]: + if source.startswith("https://"): + request = urllib.request.Request( + source, headers={"User-Agent": "telos-node-template-peer-updater/1"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + if "://" in source: + raise ValueError("peer data URLs must use HTTPS") + with open(source, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def collect_verified( + document: Mapping[str, object], field: str +) -> MutableMapping[str, Set[str]]: + endpoints: MutableMapping[str, Set[str]] = defaultdict(set) + producers = document.get("producers") + if not isinstance(producers, list): + raise ValueError("validator document has no producers array") + + for producer in producers: + if not isinstance(producer, dict): + continue + owner = producer.get("owner") + if not isinstance(owner, str) or not OWNER_RE.fullmatch(owner): + raise ValueError(f"invalid producer account name: {owner!r}") + checks = producer.get(field, []) + if not isinstance(checks, list): + continue + for check in checks: + if not isinstance(check, dict) or check.get("verified") is not True: + continue + endpoint = check.get("endpoint") + if not isinstance(endpoint, str): + continue + endpoint = endpoint.strip() + validate_endpoint(endpoint) + endpoints[endpoint].add(owner) + + if not endpoints: + raise ValueError(f"validator document returned no verified endpoints for {field}") + return endpoints + + +def render( + network: str, + generated_at: str, + endpoints: Mapping[str, Set[str]], + source_label: str, +) -> str: + lines = [ + f"# {network.capitalize()} peers generated from {source_label}", + f"# Validator data generated at: {generated_at}", + "# Only endpoints marked verified by the validator are included.", + "# Curate for geographic/operator diversity and monitor continuously.", + "# Private producers should peer with operator-controlled vote relays,", + "# not directly with this public list.", + "", + ] + for endpoint in sorted(endpoints, key=lambda item: (sorted(endpoints[item]), item)): + owners = ", ".join(sorted(endpoints[endpoint])) + lines.append(f"# {owners}") + lines.append(f"p2p-peer-address = {endpoint}") + return "\n".join(lines) + "\n" + + +def atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent)) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + os.replace(temporary, path) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +def validate_generated_at(value: object, max_age_hours: float, allow_stale: bool) -> str: + if not isinstance(value, str) or not value or "\n" in value or "\r" in value: + raise ValueError("validator document has an invalid generatedAt timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("validator document has an invalid generatedAt timestamp") from error + if parsed.tzinfo is None: + raise ValueError("validator generatedAt timestamp has no timezone") + age_hours = (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds() / 3600 + if age_hours < -1: + raise ValueError("validator generatedAt timestamp is unexpectedly in the future") + if age_hours > max_age_hours and not allow_stale: + raise ValueError( + f"validator data is {age_hours:.1f} hours old; use --allow-stale only after review" + ) + return value + + +def endpoint_count(content: str) -> int: + return sum(1 for line in content.splitlines() if line.startswith("p2p-peer-address = ")) + + +def guard_peer_count( + network: str, old_count: int, new_count: int, allow_large_change: bool +) -> None: + minimum = max(3, math.ceil(old_count / 2)) if old_count else 3 + if new_count < minimum and not allow_large_change: + raise ValueError( + f"refusing large {network} peer drop ({old_count} -> {new_count}); " + "review and pass --allow-large-change if intentional" + ) + + +def source_label(source: str) -> str: + if source == DEFAULT_SOURCE: + return DEFAULT_SOURCE + if source.startswith("https://"): + if "\n" in source or "\r" in source: + raise ValueError("peer data source contains a line break") + return source + return "a locally supplied validator JSON file" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source", + default=DEFAULT_SOURCE, + help="validator JSON URL or local file (default: %(default)s)", + ) + parser.add_argument( + "--output-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root containing mainnet/ and testnet/", + ) + parser.add_argument( + "--check", + action="store_true", + help="report whether generated files differ without writing them", + ) + parser.add_argument( + "--max-age-hours", + type=float, + default=48.0, + help="maximum validator-data age (default: %(default)s)", + ) + parser.add_argument( + "--allow-stale", + action="store_true", + help="accept validator data older than --max-age-hours after manual review", + ) + parser.add_argument( + "--allow-large-change", + action="store_true", + help="allow a generated peer list to shrink by more than 50%%", + ) + args = parser.parse_args() + + if not math.isfinite(args.max_age_hours) or args.max_age_hours <= 0: + raise ValueError("--max-age-hours must be a positive finite number") + + document = load_document(args.source) + generated_at = validate_generated_at( + document.get("generatedAt"), args.max_age_hours, args.allow_stale + ) + + network_fields = { + "mainnet": "p2pEndpointChecks", + "testnet": "p2pEndpointChecksTestNet", + } + rendered = {} + for network, field in network_fields.items(): + content = render( + network, + generated_at, + collect_verified(document, field), + source_label(args.source), + ) + destination = args.output_root / network / "peers.ini" + old_content = destination.read_text(encoding="utf-8") if destination.exists() else "" + old_count = endpoint_count(old_content) + new_count = endpoint_count(content) + guard_peer_count(network, old_count, new_count, args.allow_large_change) + rendered[network] = (destination, old_content, content) + + changed = False + for network, (destination, old_content, content) in rendered.items(): + if old_content == content: + print(f"unchanged: {destination}") + continue + changed = True + if args.check: + print(f"out of date: {destination}") + else: + atomic_write(destination, content) + print(f"updated: {destination}") + + return 1 if args.check and changed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..0db0880 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +bash -n start.sh stop.sh scripts/render-config.sh scripts/validate.sh +tests/test-runner.sh +python3 -m py_compile scripts/peer_utils.py scripts/update-peers.py scripts/validate_templates.py +python3 scripts/validate_templates.py +PYTHONOPTIMIZE=1 python3 scripts/validate_templates.py +python3 -m unittest discover -s tests -p 'test_*.py' + +temporary="$(mktemp -d)" +trap 'rm -rf "$temporary"' EXIT + +for network in mainnet testnet; do + expected_chain_id="$(tr -d '[:space:]' < "$network/chain-id.txt")" + for role in producer backup-producer vote-relay api ship; do + instance="$network-$role" + output="$temporary/$network-$role.ini" + scripts/render-config.sh "$network" "$role" "$instance" "$output" >/dev/null + grep -q "^# $expected_chain_id$" "$output" + if grep -q '^chain-id[[:space:]]*=' "$output"; then + echo "ERROR: unsupported chain-id option in $output" >&2 + exit 1 + fi + if [[ "$role" == producer || "$role" == backup-producer ]]; then + grep -q "^finalizers-dir = /var/lib/telos/finalizers/$instance$" "$output" + fi + done +done + +echo "all validations passed" diff --git a/scripts/validate_templates.py b/scripts/validate_templates.py new file mode 100644 index 0000000..95c6d13 --- /dev/null +++ b/scripts/validate_templates.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Validate security and Savanna invariants in node-template profiles.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, List, Set + +from peer_utils import validate_endpoint + + +ROOT = Path(__file__).resolve().parents[1] +CHAIN_IDS = { + "mainnet": "4667b205c6838ef70ff7988f6e8257e8be0e1284a2f59699054a018f743b1d11", + "testnet": "1eaa0824707c8c16bd25145493bf062aecddfeb56c736f6ba6397f3195f33c9f", +} + + +class ValidationError(RuntimeError): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValidationError(message) + + +def settings(path: Path) -> Dict[str, List[str]]: + result: Dict[str, List[str]] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + require("=" in line, f"{path}: invalid setting line: {raw_line!r}") + key, value = (part.strip() for part in line.split("=", 1)) + result.setdefault(key, []).append(value) + return result + + +def one(path: Path, config: Dict[str, List[str]], key: str) -> str: + values = config.get(key, []) + require(len(values) == 1, f"{path}: expected one {key}, got {values}") + return values[0] + + +def absent(path: Path, config: Dict[str, List[str]], *keys: str) -> None: + for key in keys: + require(key not in config, f"{path}: unsafe/unexpected setting present: {key}") + + +def require_plugins(path: Path, config: Dict[str, List[str]], expected: Set[str]) -> None: + plugins = set(config.get("plugin", [])) + missing = expected - plugins + require(not missing, f"{path}: missing required plugins: {sorted(missing)}") + + +def require_loopback(path: Path, config: Dict[str, List[str]], key: str) -> None: + endpoint = one(path, config, key) + require( + endpoint.startswith("127.0.0.1:") or endpoint.startswith("[::1]:"), + f"{path}: {key} must bind to loopback, got {endpoint}", + ) + + +def common_security(path: Path, config: Dict[str, List[str]]) -> None: + text = path.read_text(encoding="utf-8") + for forbidden in ( + "access-control-allow-origin", + "access-control-allow-headers", + "chain-id", + "contracts-console", + "enable-stale-production", + "p2p-max-nodes-per-host", + "trace-history-debug-mode", + "verbose-http-errors", + "wasm-runtime", + ): + require(forbidden not in config, f"{path}: forbidden production default: {forbidden}") + require("EOS" not in text, f"{path}: legacy EOS-format placeholder found") + require_loopback(path, config, "http-server-address") + require( + one(path, config, "chain-state-db-size-mb") == "32768", + f"{path}: chain-state database must use the reviewed 32768 MiB baseline", + ) + protocol_features = one(path, config, "protocol-features-dir") + prefix = "/var/lib/telos/" + suffix = "/protocol_features" + require( + protocol_features.startswith(prefix) and protocol_features.endswith(suffix), + f"{path}: protocol-features-dir must be writable instance data storage", + ) + instance = protocol_features[len(prefix) : -len(suffix)] + require(bool(instance) and "/" not in instance, f"{path}: protocol-features-dir must be instance-specific") + + +def validate_producer(path: Path, backup: bool = False) -> None: + config = settings(path) + common_security(path, config) + require(one(path, config, "vote-threads") == "4", f"{path}: producer must use 4 vote threads") + require( + one(path, config, "production-pause-vote-timeout-ms") == "6000", + f"{path}: production vote-timeout guard must remain 6000ms", + ) + p2p = one(path, config, "p2p-listen-endpoint") + require(not p2p.startswith(("0.0.0.0:", "[::]:")), f"{path}: producer P2P must be private") + + finalizers_dir = one(path, config, "finalizers-dir") + require( + finalizers_dir.startswith("/var/lib/telos/finalizers/"), + f"{path}: finalizers-dir must use persistent instance-specific storage", + ) + require( + finalizers_dir != "/var/lib/telos/finalizers/", + f"{path}: finalizers-dir must be instance-specific", + ) + require("/tmp/" not in finalizers_dir, f"{path}: finalizers-dir cannot be temporary") + require("producer-name" in config, f"{path}: missing producer-name") + + providers = config.get("signature-provider", []) + require(len(providers) == 2, f"{path}: expected exactly one K1 and one BLS provider") + require(any("PUB_K1" in provider for provider in providers), f"{path}: missing K1 provider") + require( + any("PUB_BLS" in provider and "=KEY: None: + config = settings(path) + common_security(path, config) + require( + one(path, config, "vote-threads") == vote_threads, + f"{path}: unexpected vote-threads for {role}", + ) + absent(path, config, "producer-name", "signature-provider", "finalizers-dir", "pause-on-startup") + require( + "eosio::producer_plugin" not in set(config.get("plugin", [])), + f"{path}: nonproducer profile loads producer_plugin", + ) + + required = {"eosio::chain_plugin", "eosio::net_plugin"} + if role in {"api", "ship"}: + required |= {"eosio::http_plugin", "eosio::chain_api_plugin"} + if role == "ship": + required.add("eosio::state_history_plugin") + require_plugins(path, config, required) + + if role in {"api", "ship"}: + require( + one(path, config, "api-accept-transactions") == "false", + f"{path}: read-only service must reject submitted transactions", + ) + if role == "ship": + require(one(path, config, "trace-history") == "true", f"{path}: trace history disabled") + require(one(path, config, "chain-state-history") == "true", f"{path}: chain history disabled") + require(one(path, config, "finality-data-history") == "true", f"{path}: finality history disabled") + require_loopback(path, config, "state-history-endpoint") + + +def validate_peers(path: Path) -> None: + endpoints: List[str] = [] + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + require(line.startswith("p2p-peer-address = "), f"{path}: invalid line: {line}") + endpoint = line.split("=", 1)[1].strip() + try: + validate_endpoint(endpoint) + except ValueError as error: + raise ValidationError(f"{path}: {error}") from error + endpoints.append(endpoint) + require(bool(endpoints), f"{path}: no peers") + require(len(endpoints) == len(set(endpoints)), f"{path}: duplicate endpoints") + + +def validate_logging() -> None: + path = ROOT / "logging.json" + with path.open(encoding="utf-8") as handle: + logging = json.load(handle) + appenders = {item["name"]: item for item in logging.get("appenders", [])} + loggers = {item["name"]: item for item in logging.get("loggers", [])} + for name in {"default", "net_plugin_impl", "producer_plugin", "vote"}: + require(name in loggers, f"{path}: missing {name} logger") + vote = loggers["vote"] + require(vote.get("enabled") is True, f"{path}: vote logger is disabled") + require(bool(vote.get("appenders")), f"{path}: vote logger has no appender") + for name in vote["appenders"]: + require(name in appenders, f"{path}: vote logger references unknown appender {name}") + require(appenders[name].get("enabled") is True, f"{path}: vote appender {name} is disabled") + + +def main() -> None: + for network, expected in CHAIN_IDS.items(): + actual = (ROOT / network / "chain-id.txt").read_text(encoding="utf-8").strip() + require(actual == expected, f"{network}: unexpected chain ID {actual}") + with (ROOT / network / "genesis.json").open(encoding="utf-8") as handle: + json.load(handle) + validate_peers(ROOT / network / "peers.ini") + + validate_logging() + validate_producer(ROOT / "config.ini") + validate_producer(ROOT / "profiles/producer.ini.example") + validate_producer(ROOT / "profiles/backup-producer.ini.example", backup=True) + validate_nonproducer(ROOT / "profiles/vote-relay.ini.example", "vote-relay", "4") + validate_nonproducer(ROOT / "profiles/api.ini.example", "api", "0") + validate_nonproducer(ROOT / "profiles/ship.ini.example", "ship", "0") + + service = (ROOT / "systemd/telos-nodeos@.service").read_text(encoding="utf-8") + require("--config-dir=/etc/telos/%i" in service, "systemd unit lacks instance config path") + require("--data-dir=/var/lib/telos/%i" in service, "systemd unit lacks instance data path") + + print("template validation passed") + + +if __name__ == "__main__": + main() diff --git a/start.sh b/start.sh index 06bf2ff..40ca39b 100755 --- a/start.sh +++ b/start.sh @@ -1,27 +1,130 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -INSTALL_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -. $INSTALL_ROOT/node_config +INSTALL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=node_config +source "$INSTALL_ROOT/node_config" -LOG_NAME="$(basename $INSTALL_ROOT)" +CONFIG_DIR_PATH="${CONFIG_DIR:-$INSTALL_ROOT}" +DATA_DIR_PATH="${DATA_DIR:-$INSTALL_ROOT/data}" +PID_FILE="$INSTALL_ROOT/nodeos.pid" +CONFIG_FILE="$CONFIG_DIR_PATH/config.ini" +LOCK_DIR="$INSTALL_ROOT/.nodeos-start.lock" + +process_is_live() { + local candidate_pid=$1 + if ! kill -0 "$candidate_pid" 2>/dev/null; then + return 1 + fi + if [[ -r "/proc/$candidate_pid/stat" ]]; then + local state + state="$(awk '{print $3}' "/proc/$candidate_pid/stat")" + [[ "$state" != "Z" && "$state" != "X" ]] + return + fi + return 0 +} + +umask 027 +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "ERROR: another start is in progress (or stale lock: $LOCK_DIR)" >&2 + exit 1 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +if [[ ! -x "$NODEOS_BIN" ]]; then + echo "ERROR: nodeos is not executable at $NODEOS_BIN" >&2 + exit 1 +fi + +if [[ ! -r "$CONFIG_FILE" ]]; then + echo "ERROR: config file is not readable: $CONFIG_FILE" >&2 + exit 1 +fi + +if grep -Eq '<[A-Z0-9_]+>|@[A-Z0-9_]+@' "$CONFIG_FILE"; then + echo "ERROR: $CONFIG_FILE still contains template placeholders" >&2 + exit 1 +fi + +if grep -Eq '^[[:space:]]*enable-stale-production[[:space:]]*=' "$CONFIG_FILE" \ + && [[ "$ALLOW_STALE_PRODUCTION" != "true" ]]; then + echo "ERROR: enable-stale-production is refused for production networks" >&2 + exit 1 +fi + +if [[ -f "$PID_FILE" ]]; then + existing_pid="$(sed -n '1p' "$PID_FILE" | tr -d '[:space:]')" + if [[ "$existing_pid" =~ ^[0-9]+$ ]] && process_is_live "$existing_pid"; then + echo "ERROR: nodeos already appears to be running with pid $existing_pid" >&2 + exit 1 + fi + rm -f "$PID_FILE" +fi + +extra_args=("$@") +for ((index = 0; index < ${#extra_args[@]}; index++)); do + arg=${extra_args[index]} + case "$arg" in + -e|--enable-stale-production|--enable-stale-production=*) + if [[ "$ALLOW_STALE_PRODUCTION" != "true" ]]; then + echo "ERROR: --enable-stale-production is refused for production networks" >&2 + exit 1 + fi + ;; + --snapshot|--genesis-json|--truncate-at-block|--terminate-at-block|--extract-genesis-json) + index=$((index + 1)) + if ((index >= ${#extra_args[@]})); then + echo "ERROR: $arg requires a value" >&2 + exit 2 + fi + ;; + --snapshot=*|--genesis-json=*|--truncate-at-block=*|--terminate-at-block=*|--extract-genesis-json=*) + ;; + --replay-blockchain|--hard-replay-blockchain|--delete-all-blocks|--fix-reversible-blocks|--force-all-checks|--disable-replay-opts|--print-genesis-json) + ;; + *) + echo "ERROR: unsupported command-line override: $arg; use the reviewed config.ini" >&2 + exit 2 + ;; + esac +done + +mkdir -p "$DATA_DIR_PATH" +LOG_NAME="$(basename "$INSTALL_ROOT")" if [[ "$LOCALIZE_LOG" == "true" ]]; then LOG_PATH="$INSTALL_ROOT/nodeos.log" else - LOG_PATH="/var/log/nodeos/$LOG_NAME.log" + mkdir -p "$LOG_DIR" + LOG_PATH="$LOG_DIR/$LOG_NAME.log" fi -if [[ "$DATA_DIR" != "" ]]; then - DATA_DIR_PATH=$DATA_DIR -else - DATA_DIR_PATH=$INSTALL_ROOT/data +command=( + "$NODEOS_BIN" + --data-dir "$DATA_DIR_PATH" + --config-dir "$CONFIG_DIR_PATH" +) +if ((${#extra_args[@]})); then + command+=("${extra_args[@]}") fi -nohup $NODEOS_BIN --disable-replay-opts --data-dir $DATA_DIR_PATH --config-dir $INSTALL_ROOT "$@" >> "$LOG_PATH" 2>&1 & -PID="$!" -echo "nodeos started with pid $PID" -echo $PID > $INSTALL_ROOT/nodeos.pid +nohup "${command[@]}" >> "$LOG_PATH" 2>&1 & +pid=$! -if [[ "$PIN_CPU" == "true" ]]; then - command -v taskset >/dev/null 2>&1 && taskset -cp $CPU $PID && echo "taskset called..." - command -v schedtool >/dev/null 2>&1 && schedtool -B $PID && echo "schedtool called..." +sleep 1 +if ! process_is_live "$pid"; then + echo "ERROR: nodeos exited during startup; inspect $LOG_PATH" >&2 + exit 1 fi + +if [[ -r "/proc/$pid/stat" ]]; then + process_start="$(awk '{print $22}' "/proc/$pid/stat")" +else + process_start="$(ps -p "$pid" -o lstart= | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" +fi +resolved_nodeos="$(realpath "$NODEOS_BIN" 2>/dev/null || printf '%s' "$NODEOS_BIN")" +resolved_config="$(realpath "$CONFIG_DIR_PATH" 2>/dev/null || printf '%s' "$CONFIG_DIR_PATH")" +printf '%s\n%s\n%s\n%s\n' \ + "$pid" "$process_start" "$resolved_nodeos" "$resolved_config" > "$PID_FILE" + +echo "nodeos started with pid $pid; log: $LOG_PATH" diff --git a/stop.sh b/stop.sh index 3640f91..ff63883 100755 --- a/stop.sh +++ b/stop.sh @@ -1,30 +1,87 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -INSTALL_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -PID_FILE="$INSTALL_ROOT/nodeos.pid" -PID="$( cat $PID_FILE )" +INSTALL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=node_config +source "$INSTALL_ROOT/node_config" -if [ -n "$PID" ]; then - echo "Killing pid " $PID - kill $PID +PID_FILE="$INSTALL_ROOT/nodeos.pid" +CONFIG_DIR_PATH="${CONFIG_DIR:-$INSTALL_ROOT}" - for i in $(seq 1 20); do - IS_RUNNING=`ps $PID | wc -l` +process_is_live() { + local candidate_pid=$1 + if ! kill -0 "$candidate_pid" 2>/dev/null; then + return 1 + fi + if [[ -r "/proc/$candidate_pid/stat" ]]; then + local state + state="$(awk '{print $3}' "/proc/$candidate_pid/stat")" + [[ "$state" != "Z" && "$state" != "X" ]] + return + fi + return 0 +} - if [ $IS_RUNNING = "1" ]; then - echo "$INSTALL_ROOT node has been shutdown" - break; - fi +if [[ ! -r "$PID_FILE" ]]; then + echo "No readable pid file at $PID_FILE" + exit 0 +fi - echo "Waiting..." +pid="$(sed -n '1p' "$PID_FILE" | tr -d '[:space:]')" +if [[ ! "$pid" =~ ^[0-9]+$ ]]; then + echo "ERROR: invalid pid in $PID_FILE" >&2 + exit 1 +fi - sleep 2 - done +if ! process_is_live "$pid"; then + rm -f "$PID_FILE" + echo "Removed stale pid file; process $pid is not running" + exit 0 +fi - if [ $IS_RUNNING = "2" ]; then - echo "ERROR: Unable to shutdown $INSTALL_ROOT node successfully, check log" - fi +recorded_start="$(sed -n '2p' "$PID_FILE")" +recorded_nodeos="$(sed -n '3p' "$PID_FILE")" +recorded_config="$(sed -n '4p' "$PID_FILE")" +if [[ -r "/proc/$pid/stat" ]]; then + current_start="$(awk '{print $22}' "/proc/$pid/stat")" else - echo "No pid found at $PID_FILE" + current_start="$(ps -p "$pid" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" +fi +if [[ -n "$recorded_start" && "$current_start" != "$recorded_start" ]]; then + echo "ERROR: pid $pid was reused by another process; refusing to signal it" >&2 + exit 1 fi + +resolved_nodeos="$(realpath "$NODEOS_BIN" 2>/dev/null || printf '%s' "$NODEOS_BIN")" +resolved_config="$(realpath "$CONFIG_DIR_PATH" 2>/dev/null || printf '%s' "$CONFIG_DIR_PATH")" +if [[ -n "$recorded_nodeos" && "$recorded_nodeos" != "$resolved_nodeos" ]]; then + echo "ERROR: pid file was created for a different nodeos binary" >&2 + exit 1 +fi +if [[ -n "$recorded_config" && "$recorded_config" != "$resolved_config" ]]; then + echo "ERROR: pid file was created for a different config directory" >&2 + exit 1 +fi + +command_line="$(ps -p "$pid" -o args= 2>/dev/null || true)" +if [[ " $command_line " != *" $NODEOS_BIN "* \ + || "$command_line" != *"--config-dir $CONFIG_DIR_PATH"* ]]; then + echo "ERROR: pid $pid does not identify the configured nodeos process" >&2 + exit 1 +fi + +echo "Sending SIGINT to nodeos pid $pid" +kill -INT "$pid" + +for ((elapsed = 0; elapsed < SHUTDOWN_TIMEOUT; elapsed++)); do + if ! process_is_live "$pid"; then + rm -f "$PID_FILE" + echo "nodeos stopped cleanly" + exit 0 + fi + sleep 1 +done + +echo "ERROR: nodeos did not stop within ${SHUTDOWN_TIMEOUT}s; inspect it manually" >&2 +exit 1 diff --git a/systemd/telos-nodeos@.service b/systemd/telos-nodeos@.service new file mode 100644 index 0000000..dc00c05 --- /dev/null +++ b/systemd/telos-nodeos@.service @@ -0,0 +1,20 @@ +[Unit] +Description=TelosZero Core node (%i) +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=telos +Group=telos +UMask=0077 +WorkingDirectory=/var/lib/telos/%i +ExecStart=/usr/bin/nodeos --config-dir=/etc/telos/%i --data-dir=/var/lib/telos/%i +Restart=on-failure +RestartSec=5 +KillSignal=SIGINT +TimeoutStopSec=300 +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target diff --git a/testnet/peers.ini b/testnet/peers.ini index a59e22f..a6243f2 100644 --- a/testnet/peers.ini +++ b/testnet/peers.ini @@ -1,40 +1,47 @@ -# Testnet Peers list updated June 10, 2024 by JTB. +# Testnet peers generated from a locally supplied validator JSON file +# Validator data generated at: 2026-07-16T20:29:54.522253+00:00 +# Only endpoints marked verified by the validator are included. +# Curate for geographic/operator diversity and monitor continuously. +# Private producers should peer with operator-controlled vote relays, +# not directly with this public list. -# amsterdam: DE, Falkenstein +# amsterdam p2p-peer-address = telostest.eu.eosamsterdam.net:9907 -# bp.adex: DE, Falkenstein -p2p-peer-address = p2p-telos-testnet.a-dex.xyz:9876 -# bp.boid: ES, Tenerife, Canary Islands, Spain -p2p-peer-address = telos.testnet.boid.animus.is:3535 -# bp.boid: ES, Tenerife, Canary Islands, Spain -p2p-peer-address = telos.testnet.boid.animus.is:5050 -# cryptolions1: DE, Germany-Finland +# caleosblocks +p2p-peer-address = testnet.telos.caleos.io:9876 +# cryptolions1 p2p-peer-address = telos-testnet.cryptolions.io:9872 -# eosdublinwow: IE, Dublin -p2p-peer-address = telos.testnet.eosdublin.io:9677 -# fortisblocks: ES, Marbella -p2p-peer-address = p2p.testnet.fortisbp.io:9876 -# guild.nefty: FI, Finland -p2p-peer-address = telos-testnet-seed.neftyblocks.com:19877 -# nation.tlos: CA, Canada -p2p-peer-address = telostest.seed.eosnation.io:9876 -# teleologytls: AU, Australia -p2p-peer-address = testnet.teleology.one:9875 -# telosarabia1: DE, Munich +# eosphereiobp +p2p-peer-address = peer1-telos-testnet.eosphere.io:9876 +# eosriobrazil +p2p-peer-address = p2p-test.telos.eosrio.io:28092 +# ledgerwisete +p2p-peer-address = 65.21.162.170:28876 +# telosarabia1 p2p-peer-address = testnet2p2p.telosarabia.net:9876 -# telosculture: CA, Montreal -p2p-peer-address = telos-testnet-p2p.cultureblock.io:9874 -# telosgermany: DE, Frankfurt +# telosgermany p2p-peer-address = telosgermany-testnet.genereos.io:9876 -# teloskiwinzz: NZ, Auckland -p2p-peer-address = telostestnet.bp.kiwi:9786 -# teloskorea11: KR, Korea -p2p-peer-address = t-seed.teloskorea.com:19876 -# telosmadrid1: DE, Falkenstein -p2p-peer-address = seed-testnet.telosmadrid.io:9877 -# telosunlimit: CA, St. Louis -p2p-peer-address = p2p.testnet.telosunlimited.io:9876 -# theteloscrew: DE, Frankfurt -p2p-peer-address = testnet.teloscrew.com:18876 -# votetelosusa: US, Greenville,SC,USA +# telosgreenbp +p2p-peer-address = p2p.testnet.telosgreen.com:9876 +# telosunlimit +p2p-peer-address = p2p.testnet.telosunlimited.com:9876 +# tempbpfill11 +p2p-peer-address = 38.49.217.195:9878 +# tempbpfill22 +p2p-peer-address = 38.49.217.195:9879 +# tempbpfill33 +p2p-peer-address = 38.49.217.195:9880 +# tempbpfill44 +p2p-peer-address = 38.49.217.195:9881 +# tempbpfill55 +p2p-peer-address = 38.49.217.195:9882 +# tempbpfillaa +p2p-peer-address = 38.49.217.195:9883 +# tempbpfillbb +p2p-peer-address = 38.49.217.195:9884 +# tempbpfillcc +p2p-peer-address = 38.49.217.195:9885 +# tempbpfilldd +p2p-peer-address = 38.49.217.195:9886 +# votetelosusa p2p-peer-address = test.telos.p2p.eosusa.io:19876 diff --git a/tests/test-runner.sh b/tests/test-runner.sh new file mode 100755 index 0000000..fbd9e7b --- /dev/null +++ b/tests/test-runner.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +temporary="$(mktemp -d)" + +cleanup() { + if [[ -r "$temporary/nodeos.pid" ]]; then + pid="$(sed -n '1p' "$temporary/nodeos.pid")" + if [[ "$pid" =~ ^[0-9]+$ ]]; then + kill -INT "$pid" 2>/dev/null || true + fi + fi + rm -rf "$temporary" +} +trap cleanup EXIT + +cp "$ROOT/start.sh" "$ROOT/stop.sh" "$temporary/" + +cat > "$temporary/fake-nodeos" <<'SCRIPT' +#!/usr/bin/env python3 +import signal +import time + +signal.signal(signal.SIGINT, lambda _signum, _frame: raise_exit()) +signal.signal(signal.SIGTERM, lambda _signum, _frame: raise_exit()) + + +def raise_exit(): + raise SystemExit(0) + + +while True: + time.sleep(1) +SCRIPT +chmod +x "$temporary/fake-nodeos" + +cat > "$temporary/node_config" < "$temporary/config.ini" +if "$temporary/start.sh" >"$temporary/stale.out" 2>&1; then + echo "ERROR: runner accepted enable-stale-production = yes" >&2 + exit 1 +fi +grep -q 'enable-stale-production is refused' "$temporary/stale.out" + +printf '%s\n' '# safe fake config' > "$temporary/config.ini" +if "$temporary/start.sh" -e >"$temporary/short.out" 2>&1; then + echo "ERROR: runner accepted -e" >&2 + exit 1 +fi +grep -q -- '--enable-stale-production is refused' "$temporary/short.out" + +printf '%s\n' 'enable-stale-production = yes' > "$temporary/unsafe.ini" +if "$temporary/start.sh" --config unsafe.ini >"$temporary/config-override.out" 2>&1; then + echo "ERROR: runner accepted --config override" >&2 + exit 1 +fi +grep -q 'unsupported command-line override' "$temporary/config-override.out" + +"$temporary/start.sh" >/dev/null +[[ "$(wc -l < "$temporary/nodeos.pid" | tr -d '[:space:]')" == "4" ]] + +if "$temporary/start.sh" >"$temporary/duplicate.out" 2>&1; then + echo "ERROR: runner accepted a duplicate start" >&2 + exit 1 +fi +grep -q 'already appears to be running' "$temporary/duplicate.out" + +"$temporary/stop.sh" >/dev/null +[[ ! -e "$temporary/nodeos.pid" ]] + +echo "runner lifecycle tests passed" diff --git a/tests/test_update_peers.py b/tests/test_update_peers.py new file mode 100644 index 0000000..634750b --- /dev/null +++ b/tests/test_update_peers.py @@ -0,0 +1,72 @@ +import importlib.util +import sys +import unittest +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +SPEC = importlib.util.spec_from_file_location( + "update_peers", ROOT / "scripts" / "update-peers.py" +) +assert SPEC is not None and SPEC.loader is not None +update_peers = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(update_peers) + + +class UpdatePeersTests(unittest.TestCase): + def document(self, owner="producer1111", endpoint="peer.example:9876"): + return { + "generatedAt": datetime.now(timezone.utc).isoformat(), + "producers": [ + { + "owner": owner, + "p2pEndpointChecks": [ + {"endpoint": endpoint, "verified": True} + ], + } + ], + } + + def test_collects_verified_endpoint(self): + endpoints = update_peers.collect_verified(self.document(), "p2pEndpointChecks") + self.assertEqual(endpoints, {"peer.example:9876": {"producer1111"}}) + + def test_rejects_owner_comment_injection(self): + with self.assertRaisesRegex(ValueError, "invalid producer account"): + update_peers.collect_verified( + self.document(owner="safeowner\nplugin = eosio::producer_plugin"), + "p2pEndpointChecks", + ) + + def test_rejects_endpoint_injection(self): + with self.assertRaisesRegex(ValueError, "invalid P2P endpoint"): + update_peers.collect_verified( + self.document(endpoint="peer.example:9876\nproducer-name = attacker"), + "p2pEndpointChecks", + ) + + def test_rejects_timestamp_comment_injection(self): + with self.assertRaisesRegex(ValueError, "invalid generatedAt"): + update_peers.validate_generated_at( + "2026-07-16T00:00:00+00:00\nplugin = malicious", 48, False + ) + + def test_rejects_stale_timestamp(self): + with self.assertRaisesRegex(ValueError, "hours old"): + update_peers.validate_generated_at("2020-01-01T00:00:00+00:00", 48, False) + + def test_rejects_large_peer_drop(self): + with self.assertRaisesRegex(ValueError, "refusing large mainnet peer drop"): + update_peers.guard_peer_count("mainnet", 20, 9, False) + + def test_labels_local_source_without_injecting_path(self): + self.assertEqual( + update_peers.source_label("/tmp/operator-input.json"), + "a locally supplied validator JSON file", + ) + + +if __name__ == "__main__": + unittest.main()