diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6a8db5..637c12b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: ["master", "develop", "release/**"] + branches: ["master", "release/**"] concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/nodectl-release.yml b/.github/workflows/nodectl-release.yml new file mode 100644 index 0000000..1b93241 --- /dev/null +++ b/.github/workflows/nodectl-release.yml @@ -0,0 +1,149 @@ +name: Publish nodectl + +on: + push: + tags: + - "nodectl/v*" + +concurrency: + group: ${{ github.workflow }}-${{ github.sha }} + cancel-in-progress: true + +env: + WORKING_DIR: src + +jobs: + build-binaries: + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: nodectl-linux-amd64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + name: nodectl-linux-arm64 + - target: aarch64-apple-darwin + os: macos-latest + name: nodectl-darwin-arm64 + - target: x86_64-pc-windows-msvc + os: windows-latest + name: nodectl-windows-amd64.exe + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev + + - name: Build + working-directory: ${{ env.WORKING_DIR }} + run: cargo build --release --package nodectl --target ${{ matrix.target }} + shell: bash + + - name: Rename binary + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + cp src/target/${{ matrix.target }}/release/nodectl.exe ${{ matrix.name }} + else + cp src/target/${{ matrix.target }}/release/nodectl ${{ matrix.name }} + fi + shell: bash + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: ${{ matrix.name }} + + container: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/rsquad/ton-rust-node/nodectl + tags: | + type=match,pattern=nodectl/(v.*),group=1 + type=raw,value=latest + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./src + file: ./src/Dockerfile.nodectl + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + release: + needs: [build-binaries, container] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - uses: actions/download-artifact@v4 + with: + pattern: nodectl-* + path: artifacts + merge-multiple: true + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF_NAME#nodectl/}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + if echo "$VERSION" | grep -qE '(rc|alpha|beta)'; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: "nodectl/${{ steps.version.outputs.version }}" + body: | + nodectl ${{ steps.version.outputs.version }} + + Image: `ghcr.io/rsquad/ton-rust-node/nodectl:${{ steps.version.outputs.version }}` + files: artifacts/* + prerelease: ${{ steps.version.outputs.prerelease }} + make_latest: false + draft: ${{ steps.version.outputs.prerelease }} + + - name: Publish pre-release + if: steps.version.outputs.prerelease == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false diff --git a/RELEASE.md b/RELEASE.md index 3559723..38a554f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,13 +7,13 @@ This project follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PA Version tags include an artifact prefix and `v`: `node/v1.2.0`, `helm/node/v0.3.0`. A network qualifier may be appended as a pre-release suffix: `node/v0.1.2-mainnet`. -The Helm chart is versioned independently from the node. +Each artifact is versioned independently. | Artifact | Tag format | Examples | |----------------|---------------------------|-------------------------------------------| | Node | `node/v` | `node/v1.2.0`, `node/v0.1.2-mainnet` | +| nodectl | `nodectl/v` | `nodectl/v0.3.0` | | Helm chart | `helm/node/v` | `helm/node/v0.2.2` | -| nodectl | `nodectl/v` | `nodectl/v0.1.0` | | nodectl chart | `helm/nodectl/v` | `helm/nodectl/v0.1.0` | ## Commits @@ -22,119 +22,198 @@ The Helm chart is versioned independently from the node. **Types:** `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `ci` -**Scopes:** `helm`, `nodectl`, `grafana` (more scopes will appear as the node code lands) +**Scopes:** `helm`, `nodectl`, `grafana`, `node` ## Branches | Branch | Purpose | |---------------------|----------------------------------------------------------------------| | `master` | Production-ready code. All final release tags live here. | -| `release/` | Stabilization branch for an upcoming release. Branched off `master`. | +| `release//` | Release branch. Features and changes are merged here, then the branch is merged into `master` for release. | | `hotfix/` | Urgent fix for a previously released version. Branched off the relevant release tag. | -The `` in branch names matches the target tag, e.g. `release/node/v0.2.0-mainnet`, -`hotfix/node/v0.1.3-mainnet`, `release/helm/node/v0.3.0`. +This is a monorepo with independent release cycles for each product (node, nodectl). Release branches are product-scoped: -## Tags +- `release/node/v0.2.0-mainnet` +- `release/nodectl/v0.3.0` +- `release/helm/node/v0.4.0` -### Node Tags +## Release Process -| Tag | Meaning | Placed on | -|-------------------------|--------------------|------------| -| `node/v1.2.0` | Final release | `master` | -| `node/v0.1.2-mainnet` | Final release (network qualifier) | `master` | -| `node/v1.2.0-rc.1` | Release candidate | `release/*` | -| `node/v0.1.3-mainnet` | Hotfix for older version | `hotfix/*` (if not latest) | +### 1. Create release branch -### Helm Chart Tags +Branch off `master`: -| Tag | Meaning | Placed on | -|-----------------------|---------------|-----------| -| `helm/node/v0.2.2` | Final release | `master` | +```bash +git checkout master && git pull +git checkout -b release//v +``` -### nodectl Tags +### 2. Develop on the release branch -| Tag | Meaning | Placed on | -|-------------------------|----------------------|-------------| -| `nodectl/v0.1.0` | Final release | `master` | -| `helm/nodectl/v0.1.0` | Chart final release | `master` | +Merge feature branches and fixes into the release branch. This is where all changes for the release are collected. -### Rules +### 3. Test with RC tags (optional) -- Final release tags live on `master`, except hotfixes for older versions. -- Release candidate tags live on `release/*` branches. -- Node, Helm chart, and nodectl tags are independent and may point to the same or different commits. +Tag release candidates **only from the release branch** to trigger CI: -## Releases and Hotfixes +```bash +git tag /v-rc.1 +git push origin /v-rc.1 +``` -### Standard Release +CI builds and publishes the RC automatically. RC releases are marked as **Pre-release** on GitHub. -Create a branch `release/` from `master`. Stabilize, tagging release -candidates as `node/v-rc.N` or `helm/node/v-rc.N`. Once stable, merge into `master` and tag. +> **Internal/test builds:** If you need to build an image for testing outside of a release branch, use the `alpha` or `beta` pre-release suffix: `/v-alpha.1`. These builds are strictly internal — never distribute or deploy them to production. -### Hotfix (latest version) +### 4. Open PR into master -Create a feature branch from `master`, fix, merge via PR, tag on `master`. +When the release is ready, open a PR from the release branch into `master`: +- **PR title:** `release/:v` (e.g. `release/nodectl:v0.3.0`) +- **PR body:** changelog entry (without the `## [version]` header) -### Hotfix (older version) +### 5. Merge into master -Create `hotfix/` from the relevant release tag. Fix and tag on that -branch. Cherry-pick into `master` if applicable. GitHub Release is published -with `make_latest: false`. +Squash-merge the PR: -## CI Pipeline +```bash +gh pr merge --squash --admin +``` -### On Node Tags (`node/v*`) +### 6. Tag final release -1. Build and push container images to `ghcr.io/rsquad/ton-rust-node/node`. -2. Create a GitHub Release. +```bash +git checkout master && git pull +git tag /v +git push origin /v +``` -| Git Tag | Docker Tags | GitHub Release | -|--------------------------------|----------------------------------|-----------------| -| `node/v1.2.0-mainnet` | `v1.2.0-mainnet`, `latest` | Latest | -| `node/v1.2.0-rc.1` | `v1.2.0-rc.1` | Pre-release | -| `node/v0.1.3-mainnet` (hotfix)| `v0.1.3-mainnet` (no `latest`) | `latest: false` | +### 7. CI publishes automatically -### On Helm Tags (`helm/node/v*`) +CI triggers on the tag and handles everything: +- Builds artifacts (containers, binaries, Helm charts) +- Pushes to registries +- Creates a GitHub Release -1. Package the Helm chart. -2. Push to `oci://ghcr.io/rsquad/ton-rust-node/helm/node`. -3. Create a GitHub Release. +### 8. Finalize GitHub Release + +CI creates the release, but the body may need updating. Edit with the changelog entry: + +```bash +gh release edit -F- <<< '' +``` + +For **node releases only**, Latest is default. All other artifacts: -| Git Tag | OCI Tag | GitHub Release | -|----------------------|----------|-----------------| -| `helm/node/v0.2.2` | `0.2.2` | `latest: false` | +```bash +gh release edit --latest=false +``` + +Verify: + +```bash +gh release list # only node release should be Latest +``` + +### 9. Cleanup + +```bash +git branch -d release//v +git push origin --delete release//v +``` + +## Helm Chart Releases + +Helm charts should be released **together with their parent app** (node or nodectl), bumping `appVersion` and `image.tag` to match. + +Release Helm charts independently **only** for chart-specific bugfixes (template fixes, value changes) that don't involve an app version change. + +When releasing together: +1. Include Helm chart changes in the same release branch as the app. +2. After the app release is tagged, tag the Helm chart separately: `helm/node/v`. +3. CI packages and publishes the chart automatically. + +## CI Pipelines + +### On Node Tags (`node/v*`) + +1. Build container image from `src/Dockerfile`. +2. Push to `ghcr.io/rsquad/ton-rust-node/node`. +3. Create GitHub Release. + +| Git Tag | Docker Tags | GitHub Release | +|-------------------------|----------------------------|----------------| +| `node/v1.2.0-mainnet` | `v1.2.0-mainnet`, `sha-*` | Latest | +| `node/v1.2.0-rc.1` | `v1.2.0-rc.1`, `sha-*` | Pre-release | ### On nodectl Tags (`nodectl/v*`) -1. Build and push container images to `ghcr.io/rsquad/nodectl` (upstream). Mirror to `ghcr.io/rsquad/ton-rust-node/nodectl`. -2. Create a GitHub Release. +1. Build cross-platform binaries (linux/amd64, linux/arm64, darwin/arm64, windows/amd64). +2. Build container image from `src/Dockerfile.nodectl`. +3. Push image to `ghcr.io/rsquad/ton-rust-node/nodectl`. +4. Create GitHub Release with binaries attached. + +| Git Tag | Docker Tags | Binaries | GitHub Release | +|----------------------|--------------------------------|-------------------------|-----------------| +| `nodectl/v0.3.0` | `v0.3.0`, `latest`, `sha-*` | linux, darwin, windows | `latest: false` | +| `nodectl/v0.3.0-rc.1` | `v0.3.0-rc.1`, `sha-*` | linux, darwin, windows | Pre-release | + +### On Helm Tags (`helm/node/v*`, `helm/nodectl/v*`) -| Git Tag | Docker Tags | GitHub Release | -|--------------------|--------------------------|-----------------| -| `nodectl/v0.1.0` | `v0.1.0`, `latest` | `latest: false` | +1. Package the Helm chart. +2. Push to OCI registry (`oci://ghcr.io/rsquad/ton-rust-node/helm/`). +3. Create GitHub Release with chart archive attached. + +| Git Tag | OCI Tag | GitHub Release | +|-----------------------|---------|-----------------| +| `helm/node/v0.2.2` | `0.2.2` | `latest: false` | +| `helm/nodectl/v0.1.0`| `0.1.0` | `latest: false` | + +### On Pull Requests -### On nodectl Helm Tags (`helm/nodectl/v*`) +CI runs on PRs targeting `master` or `release/**`: -1. Package the nodectl Helm chart. -2. Push to `oci://ghcr.io/rsquad/ton-rust-node/helm/nodectl`. -3. Create a GitHub Release. +- `audit` — cargo security audit +- `fmt` — formatting check (nightly rustfmt) +- `check` — clippy + cargo check +- `tests` — cargo tests +- `tests-net` — network integration tests -| Git Tag | OCI Tag | GitHub Release | -|-------------------------|----------|-----------------| -| `helm/nodectl/v0.1.0` | `0.1.0` | `latest: false` | +## Registries + +| Artifact | Registry | +|----------|----------| +| Node image | `ghcr.io/rsquad/ton-rust-node/node:` | +| nodectl image | `ghcr.io/rsquad/ton-rust-node/nodectl:` | +| Node Helm chart | `oci://ghcr.io/rsquad/ton-rust-node/helm/node` | +| nodectl Helm chart | `oci://ghcr.io/rsquad/ton-rust-node/helm/nodectl` | ## GitHub Releases -- Only node releases on `master` are marked as **Latest**. -- Helm chart releases are always published with `make_latest: false`. -- nodectl and nodectl chart releases are always published with `make_latest: false`. +- Only **node** releases on `master` are marked as **Latest**. +- All other releases (Helm charts, nodectl) use `make_latest: false`. - Release candidates are marked as **Pre-release**. -- Hotfixes for older versions are published with `make_latest: false`. +- Hotfixes for older versions use `make_latest: false`. + +## Changelogs -## PR Guidelines +| Changelog | Artifact | Versioned by | +|-----------|----------|--------------| +| `CHANGELOG.md` | Node | `node/v*` tags | +| `nodectl/CHANGELOG.md` | nodectl | `nodectl/v*` tags | +| `helm/ton-rust-node/CHANGELOG.md` | Node Helm chart | `helm/node/v*` tags | +| `helm/nodectl/CHANGELOG.md` | nodectl Helm chart | `helm/nodectl/v*` tags | -- Use meaningful PR titles — they serve as the changelog. -- Apply labels (`enhancement`, `bug`, `infrastructure`, `dependencies`) for grouped release notes. -- Add `skip-changelog` to PRs that should not appear in release notes. +All changelogs use [Keep a Changelog](https://keepachangelog.com/) format. +## Hotfixes + +### Hotfix (latest version) + +Create a feature branch from `master`, fix, merge via PR, tag on `master`. + +### Hotfix (older version) + +Create `hotfix/` from the relevant release tag. Fix and tag on that +branch. Cherry-pick into `master` if applicable. GitHub Release is published +with `make_latest: false`. diff --git a/helm/nodectl/CHANGELOG.md b/helm/nodectl/CHANGELOG.md index ab5b2d2..fead188 100644 --- a/helm/nodectl/CHANGELOG.md +++ b/helm/nodectl/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to the nodectl Helm chart will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). Versions follow the Helm chart release tags (e.g. `helm/nodectl/v0.1.0`). +## [0.2.0] - 2026-03-24 + +appVersion: `v0.3.0` + +### Added + +- `service.nodePort` — fixed node port when `service.type` is `NodePort` +- `service.clusterIP` — explicit ClusterIP (set to `None` for headless) +- `service.loadBalancerIP` — static IP for cloud load balancers +- `service.externalTrafficPolicy` — `Local` or `Cluster` for NodePort/LoadBalancer +- `networkPolicy.allowFrom` — flexible network policy peers (ipBlock, podSelector, namespaceSelector) + +### Changed + +- Default image updated to nodectl `v0.3.0` + +### Removed + +- `networkPolicy.allowCIDRs` — replaced by `networkPolicy.allowFrom` which accepts standard NetworkPolicy peers + ## [0.1.4] - 2026-03-19 appVersion: `v0.2.1` diff --git a/helm/nodectl/Chart.yaml b/helm/nodectl/Chart.yaml index 4392695..6352c86 100644 --- a/helm/nodectl/Chart.yaml +++ b/helm/nodectl/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: nodectl description: TON Node Control Tool — validator elections, voting, and monitoring type: application -version: 0.1.4 -appVersion: "v0.2.1" +version: 0.2.0 +appVersion: "v0.3.0" sources: - https://github.com/rsquad/ton-rust-node diff --git a/helm/nodectl/README.md b/helm/nodectl/README.md index 0f87cdf..374767e 100644 --- a/helm/nodectl/README.md +++ b/helm/nodectl/README.md @@ -299,6 +299,7 @@ On first deploy, the init container prepares the PVC: | Step-by-step validator setup | [docs/setup.md](docs/setup.md) | | Elections and stake policies | [docs/elections.md](docs/elections.md) | | Chart maintainer guide | [docs/maintaining.md](docs/maintaining.md) | +| First elections with Rust node | [docs/first-elections.md](docs/first-elections.md) | ## Useful commands diff --git a/helm/nodectl/docs/first-elections.md b/helm/nodectl/docs/first-elections.md new file mode 100644 index 0000000..e796770 --- /dev/null +++ b/helm/nodectl/docs/first-elections.md @@ -0,0 +1,63 @@ +# First elections with Rust node + +`nodectl` only works with Rust TON Node. If you are currently running a C++ TON Node, you will need to migrate your cluster to Rust TON Node. + +## Migration scenario + +Prerequisites: + +- C++ TON Node is running and validating in the current round, meaning it has a frozen stake. +- Rust TON Node is running and fully synced with the network. + +Goal: + +- Make the Rust TON Node a validator in the next validation period. +- Shut down the C++ node after the current validation period ends. + +Steps: + +1. Deploy the Rust node and let it sync with the network (if not already done). +2. Deploy nodectl and configure it to work with the Rust node: + + - Add the node's control server + - Import the validator wallet key into the vault and add the wallet to the nodectl config (the same wallet used by the C++ node) + - Add the nominator pool to the nodectl config (the same pool used by the C++ node) + - Configure the binding + +3. Disable election participation on the C++ node. +4. Wait for the next election round to begin. + +Nodectl will automatically withdraw the unfrozen stake previously submitted by the C++ node in the prior elections and submit a new stake to the current elections according to the staking policy. + +--- + +### Critical — read before the first Rust elections + +> [!CAUTION] +> If the node's staking policy in `nodectl` is set to `split50` (the default), `nodectl` first calculates the total available funds for staking = frozen stake + pool's free balance + already submitted stake, then splits them in half and submits that amount to the current elections. However, since the Rust node does not have the validator key that the C++ node is currently using to validate in this round, `nodectl` cannot determine the frozen stake and will treat it as 0. **As a result, the stake amount will be half of what it should be.** + +For example: + +```bash +C++ node frozen stake = 1_000_000 TON +Pool free balance = 1_000_000 TON +Elections started and nodectl has not submitted any stakes yet: already submitted stake = 0 TON +# Since Rust node does not have the current validator key: +Rust node frozen stake = 0 TON +nodectl calculates total available funds = 0 TON + 1_000_000 TON + 0 TON = 1_000_000 TON +Splits in half and submits to current elections = 1_000_000 / 2 = 500_000 TON +``` + +This situation only occurs during the first election in which the Rust node participates. After the C++ stake is unfrozen (in the following elections), `nodectl` will return the unfrozen stake to the pool balance and will be able to calculate the correct stake amount. + +--- + +**How to top up the remaining funds into the stake?** + +Use the manual staking command: + +```bash +nodectl config wallet stake -b -a [-m ] +``` + +This command allows you to manually submit a stake to the current elections or top up the remaining funds into an existing stake. \ No newline at end of file diff --git a/helm/nodectl/docs/setup.md b/helm/nodectl/docs/setup.md index 6d7761c..03e5793 100644 --- a/helm/nodectl/docs/setup.md +++ b/helm/nodectl/docs/setup.md @@ -12,6 +12,7 @@ Step-by-step guide for deploying nodectl and configuring it to manage TON valida - [Step 3: Set up keys](#step-3-set-up-keys) - [Step 4: Restart the service](#step-4-restart-the-service) - [Step 5: Fund and verify](#step-5-fund-and-verify) +- [Setup REST API authentication](#setup-rest-api-authentication) - [Migrating an existing deployment](#migrating-an-existing-deployment) - [Troubleshooting](#troubleshooting) @@ -368,6 +369,68 @@ See [elections.md](elections.md) for binding statuses, stake policies, and elect --- +## Setup REST API authentication + +> **Authentication is enabled by default.** A freshly deployed nodectl has the `http.auth` section in its config with an empty user list — all protected endpoints return `401` until at least one user is created. The API is safe to expose only after you create a user and verify authentication works. See [nodectl-security.md](../../../src/node-control/docs/nodectl-security.md) for the full security model. + +### 1. Create a user inside the pod + +```bash +kubectl exec -it deploy/my-nodectl -- nodectl auth add -u -r operator +``` + +The service picks up the new user automatically — no restart required. + +### 2. Verify auth is working + +```bash +kubectl exec deploy/my-nodectl -- nodectl api elections +``` + +Without a token, the command returns `401 Unauthorized`. This confirms the API is protected and safe to expose externally. + +### 3. Log in and use the API + +All `nodectl api` commands resolve the service URL in this order: + +1. Explicit `--url` flag (e.g. `--url http://10.0.0.5:8080`) +2. `http.bind` value from `--config` (or `CONFIG_PATH`) + +When running **inside the pod**, the config file is available and the URL is resolved automatically. When running **outside the pod** (e.g. from your workstation), pass `--url` explicitly — otherwise the command tries to read the local config and fails: + +```bash +# Inside the pod — URL from config +kubectl exec -it deploy/my-nodectl -- nodectl api login + +# Outside the pod — explicit URL required +nodectl api login --url http://:8080 +``` + +```bash +# Get a token (inside the pod) +kubectl exec -it deploy/my-nodectl -- nodectl api login +export NODECTL_API_TOKEN="" + +# Use the API (from outside, with --url) +nodectl api elections --url http://:8080 + +# Or set both token and URL, then run commands without flags +export NODECTL_API_TOKEN="" +nodectl api elections --url http://:8080 +``` + +### 4. Expose the Service REST API externally (Optional) + +The chart creates a Kubernetes Service with configurable `service.type`. Set it to `NodePort` or `LoadBalancer` in your values, or keep the default `ClusterIP` and attach your own Ingress or reverse proxy to the Service by name. See `values.yaml` for all available `service.*` parameters. + +The chart does not terminate TLS — the pod serves plain HTTP on port 8080. TLS should be handled by your load balancer, Ingress controller, or reverse proxy. + +> **Warning:** Without TLS, passwords sent to `/auth/login` and JWT tokens in `Authorization` headers are transmitted in plain text. Always terminate TLS before the traffic leaves your trusted network — at the Ingress controller, load balancer, or reverse proxy. + +> **Rate limiter:** Make sure your reverse proxy forwards the real client IP (e.g. `X-Forwarded-For`). Without it, the login rate limiter keys all requests to the proxy IP instead of the real client. + +--- + ## Migrating an existing deployment nodectl configuration should only be managed through the CLI — do not edit `config.json` by hand. However, if you need to migrate nodectl to a different cluster or namespace, you can transfer the config and vault files from the existing PVC. @@ -436,9 +499,7 @@ All nodes share the same wallet. The SNP address depends on the validator wallet ### Probes failing -The default generated config uses `http.bind: "127.0.0.1:8080"`. Kubernetes probes need to reach the pod from outside localhost. Edit the config inside the pod: - -Change `"bind": "127.0.0.1:8080"` to `"bind": "0.0.0.0:8080"`. +The default `http.bind` is `0.0.0.0:8080`, so probes should work out of the box. If you have overridden it to `127.0.0.1:8080`, change it back to `0.0.0.0:8080` — Kubernetes probes need to reach the pod from outside localhost. ### Debug mode diff --git a/helm/nodectl/templates/networkpolicy.yaml b/helm/nodectl/templates/networkpolicy.yaml index 0e34c59..18e3a1e 100644 --- a/helm/nodectl/templates/networkpolicy.yaml +++ b/helm/nodectl/templates/networkpolicy.yaml @@ -15,12 +15,9 @@ spec: - ports: - port: {{ .Values.port }} protocol: TCP - {{- if .Values.networkPolicy.allowCIDRs }} + {{- with .Values.networkPolicy.allowFrom }} from: - {{- range .Values.networkPolicy.allowCIDRs }} - - ipBlock: - cidr: {{ . }} - {{- end }} + {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.networkPolicy.extraIngress }} {{- toYaml . | nindent 4 }} diff --git a/helm/nodectl/templates/service.yaml b/helm/nodectl/templates/service.yaml index eecce49..a9ba67d 100644 --- a/helm/nodectl/templates/service.yaml +++ b/helm/nodectl/templates/service.yaml @@ -10,9 +10,22 @@ metadata: {{- end }} spec: type: {{ .Values.service.type }} + {{- with .Values.service.clusterIP }} + clusterIP: {{ . }} + {{- end }} + {{- with .Values.service.loadBalancerIP }} + loadBalancerIP: {{ . }} + {{- end }} + {{- with .Values.service.externalTrafficPolicy }} + externalTrafficPolicy: {{ . }} + {{- end }} selector: {{- include "nodectl.selectorLabels" . | nindent 4 }} ports: - name: http port: {{ .Values.port }} + targetPort: {{ .Values.port }} protocol: TCP + {{- if and .Values.service.nodePort (eq .Values.service.type "NodePort") }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} diff --git a/helm/nodectl/values.yaml b/helm/nodectl/values.yaml index 29840a2..e68bed9 100644 --- a/helm/nodectl/values.yaml +++ b/helm/nodectl/values.yaml @@ -12,7 +12,7 @@ replicas: 1 ## image: repository: ghcr.io/rsquad/ton-rust-node/nodectl - tag: "v0.2.1" + tag: "v0.3.0" pullPolicy: IfNotPresent ## @param imagePullSecrets [array] Registry pull secrets for private container images @@ -22,17 +22,27 @@ imagePullSecrets: [] ## @section Port parameters ## @param port HTTP API port. Used for health probes, REST API, and Swagger UI. +## NOTE: This value must match the port in `http.bind` of your nodectl config +## (generated by `nodectl config generate`, default `0.0.0.0:8080`). ## port: 8080 ## @section Service parameters -## @param service.type Service type for the HTTP API -## @param service.annotations [object] Annotations for the Service +## @param service.type Service type (`ClusterIP`, `NodePort`, `LoadBalancer`) +## @param service.annotations [object] Annotations for the Service (e.g. cloud LB TLS settings) +## @param service.nodePort [nullable] Node port number. Only used when `service.type` is `NodePort`. +## @param service.clusterIP [nullable] Explicit ClusterIP. Set to `None` for a headless service. +## @param service.loadBalancerIP [nullable] Static IP for the load balancer. Only used when `service.type` is `LoadBalancer`. +## @param service.externalTrafficPolicy [nullable] External traffic policy (`Cluster` or `Local`). Only used when `service.type` is `NodePort` or `LoadBalancer`. ## service: type: ClusterIP annotations: {} + # nodePort: 30080 + # clusterIP: "" + # loadBalancerIP: "" + # externalTrafficPolicy: Cluster ## @section Storage parameters ## nodectl data directory is a PVC. Config, file vault, and runtime state all live here. @@ -208,13 +218,27 @@ serviceAccount: ## @section NetworkPolicy parameters ## @param networkPolicy.enabled Create a NetworkPolicy. Restricts ingress to the HTTP API port. -## @param networkPolicy.allowCIDRs [array] Source CIDRs allowed to reach the HTTP API. If empty, not restricted by source. +## @param networkPolicy.allowFrom [array] Allowed sources for the HTTP API port (standard NetworkPolicy peers: ipBlock, podSelector, namespaceSelector). If empty, allows all sources. ## @param networkPolicy.extraIngress [array] Additional raw ingress rules appended to the policy. ## networkPolicy: enabled: false - allowCIDRs: [] + allowFrom: [] extraIngress: [] + # Example — allow only from specific CIDRs: + # allowFrom: + # - ipBlock: + # cidr: 10.0.0.0/8 + # Example — allow only from pods with a specific label: + # allowFrom: + # - podSelector: + # matchLabels: + # app: nginx-ingress + # Example — allow only from a specific namespace: + # allowFrom: + # - namespaceSelector: + # matchLabels: + # name: monitoring ## @section PodDisruptionBudget parameters diff --git a/nodectl/CHANGELOG.md b/nodectl/CHANGELOG.md deleted file mode 100644 index 51628ae..0000000 --- a/nodectl/CHANGELOG.md +++ /dev/null @@ -1,58 +0,0 @@ -# Changelog - -All notable changes to nodectl will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/). -Versions follow the nodectl release tags (e.g. `nodectl/v0.1.0`). - -## [v0.2.1] - 2026-03-18 - -Image: `ghcr.io/rsquad/ton-rust-node/nodectl:v0.2.1` - -### Added - -- **Manual election stake command** — new `wallet stake` command for manual election participation via nominator pool - -## [v0.2.0] - 2026-03-05 - -Image: `ghcr.io/rsquad/ton-rust-node/nodectl:v0.2.0` - -### Added -- `config log` CLI commands (`ls`, `set`) for viewing and updating log settings -- Log file rotation and automatic cleanup configuration -- Support for multiple ton-http-api endpoints with failover and per-endpoint API keys -- Support for V4 and V5 wallet contracts -- Control server connection status in `config node ls` output -- Auto-detection of pool addresses and balances in `config pool ls` -- Owner address validation in `config pool add` -- Warning on missing node key in vault during `config node add` / `config pool add` -- Automated single-host test network script - -### Changed -- REST API rewritten using Axum framework -- Vault is now reopened automatically on configuration reload -- `--version` parameter for `wallet add` is now case-insensitive - -### Fixed -- `wallet send`: broken `--bounce` flag and unclear confirmation default -- Stake amount mismatch between calculated and submitted values -- Balance parsing error when using ton-http-api -- Duplicate wallet deployment when a single wallet is shared across nodes - -## [v0.1.1] - 2026-02-27 - -Image: `ghcr.io/rsquad/ton-rust-node/nodectl:v0.1.1` - -### Added -- Support for V1R3 wallet type in `config-wallet` command (`--version V1R3`) -- V1R3 wallet contract: address computation, state init, and external message building - -### Changed -- `subwallet_id` has no effect for V1R3 wallets (V1R3 does not have a subwallet concept) -- Wallet version help text now lists all supported versions (`V1R3`, `V3R2`) - -## [v0.1.0] - 2026-02-22 - -Image: `ghcr.io/rsquad/ton-rust-node/nodectl:v0.1.0` - -Initial release. diff --git a/src/Cargo.lock b/src/Cargo.lock index 01423f0..0b18404 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -864,11 +864,10 @@ dependencies = [ [[package]] name = "commands" -version = "0.2.0" +version = "0.3.0" dependencies = [ "adnl", "anyhow", - "async-trait", "base64 0.22.1", "clap 4.6.0", "colored", @@ -876,7 +875,6 @@ dependencies = [ "contracts", "control-client", "elections", - "hex", "reqwest", "rpassword", "scopeguard", @@ -893,7 +891,7 @@ dependencies = [ [[package]] name = "common" -version = "0.2.0" +version = "0.3.0" dependencies = [ "adnl", "anyhow", @@ -910,14 +908,12 @@ dependencies = [ "serde_json", "serde_yaml2", "tokio", - "tokio-util", "ton_api", "ton_block", "tracing", "tracing-appender", "tracing-subscriber", "utoipa", - "yaml-rust2 0.10.4", ] [[package]] @@ -973,13 +969,12 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "contracts" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", "common", "hex", - "serde", "tokio", "ton-http-api-client", "ton_api", @@ -988,7 +983,7 @@ dependencies = [ [[package]] name = "control-client" -version = "0.2.0" +version = "0.3.0" dependencies = [ "adnl", "anyhow", @@ -997,8 +992,6 @@ dependencies = [ "hex", "serde", "serde_json", - "tokio", - "tokio-util", "ton_api", "ton_block", "tracing", @@ -1584,7 +1577,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "elections" -version = "0.2.0" +version = "0.3.0" dependencies = [ "adnl", "anyhow", @@ -1593,19 +1586,13 @@ dependencies = [ "common", "contracts", "control-client", - "env_logger", "hex", "mockall", - "num", - "rand 0.9.2", - "scopeguard", "secrets-vault", "serde", "serde_json", "tokio", - "tokio-util", "ton-http-api-client", - "ton_api", "ton_block", "tracing", ] @@ -2153,15 +2140,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "headers" version = "0.4.1" @@ -3314,22 +3292,13 @@ dependencies = [ [[package]] name = "nodectl" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", - "async-trait", "clap 4.6.0", "commands", "common", - "control-client", - "scopeguard", - "serde", - "strum 0.27.2", - "strum_macros 0.27.2", - "thiserror 1.0.69", "tokio", - "tokio-util", - "toml", "tracing", ] @@ -4865,15 +4834,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4907,7 +4867,7 @@ checksum = "63a7808e70b0b09116f01a4730d8976bcab457ea4a5e2e638e9b12ed3e01f27c" dependencies = [ "serde", "thiserror 1.0.69", - "yaml-rust2 0.8.1", + "yaml-rust2", ] [[package]] @@ -4938,7 +4898,7 @@ dependencies = [ [[package]] name = "service" -version = "0.2.0" +version = "0.3.0" dependencies = [ "adnl", "anyhow", @@ -4953,7 +4913,6 @@ dependencies = [ "hex", "http-body-util", "jsonwebtoken", - "scopeguard", "secrets-vault", "serde", "serde_json", @@ -5184,8 +5143,8 @@ dependencies = [ "serde_cbor", "serde_derive", "smallvec", - "strum 0.18.0", - "strum_macros 0.18.0", + "strum", + "strum_macros", "thiserror 1.0.69", "tokio", "tokio-util", @@ -5229,12 +5188,6 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57bd81eb48f4c437cadc685403cad539345bf703d78e63707418431cecd4522b" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" - [[package]] name = "strum_macros" version = "0.18.0" @@ -5247,18 +5200,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" @@ -5616,53 +5557,11 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "ton-http-api-client" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", - "async-trait", "base64 0.22.1", "common", "serde", @@ -6777,15 +6676,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winreg" version = "0.50.0" @@ -6934,18 +6824,7 @@ checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.8.4", -] - -[[package]] -name = "yaml-rust2" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink 0.10.0", + "hashlink", ] [[package]] diff --git a/src/node-control/CHANGELOG.md b/src/node-control/CHANGELOG.md index 3d6547b..c1a4212 100644 --- a/src/node-control/CHANGELOG.md +++ b/src/node-control/CHANGELOG.md @@ -1,6 +1,41 @@ # Changelog -All notable changes to the Node Control Tool. +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2026-03-24 + +### Added + +- **JWT-based authentication for REST API** — login, token revocation, auth middleware with login rate limiter, argon2 password hashing, and `NODECTL_API_TOKEN` env support; new `auth` and `api login` CLI commands +- **Election status dashboard** — `/v1/elections` API endpoint and `nodectl api elections` CLI table with participation lifecycle tracking (Idle → Participating → Submitted → Accepted → Elected → Validating), stake sums, and election metadata +- **Validation keys listing** — `/v1/validators` API endpoint and `nodectl api validators` command displays validator information including validator key with election ID, created/expires timestamps, validator status, key ID, and ADNL address +- **Kubernetes internal DNS support** — control server address now accepts DNS names (e.g. `validator-0-control.ton.svc.cluster.local`) in addition to IP addresses +- **JWT authorization in Swagger UI** — added `bearerAuth` security scheme to OpenAPI spec; Swagger UI now shows an "Authorize" button for Bearer token authentication +- **`--filter` for elections and validators API** — `nodectl api elections` and `nodectl api validators` accept `--filter=` to limit output to specific nodes +- **`--format=json|table` flag** — added `--format=json|table` flag to all `config ... ls` subcommands (`config bind ls`, `config elections ls`, `config log ls`, `config node ls`, `config pool ls`, `config wallet ls`, `master-wallet ls`) + +### Changed + +- **Bounceable base64 wallet addresses** — `config wallet ls` now displays addresses in bounceable URL-safe base64 format +- **Improved endpoint round-robin** — lowered retry loop log level to debug, shortened error messages when all endpoints fail, fixed `rr_cursor` initial value starting from 1 instead of 0 +- **Graceful RPC error handling in `wallet ls`** — wallet listing no longer fails when TON API is unreachable; addresses are still displayed with `-` for unavailable state/balance fields; unified warning format +- **Hot reload for auth state** — JWT TTL changes, newly added users, and token revocations take effect on config reload without service restart; JWT signing key is generated on first start even if auth is disabled +- **Extended version command** — `nodectl --version` now prints build artifacts (git hash, build date, features) +- **Updated documentation** — added descriptions for new commands and flags, fixed documentation errors, added document on `nodectl` security model, added documentation for first elections with Rust node, added documentation for REST API authentication + +### Fixed + +- **`State` column alignment in `wallet ls`** — adjusted column width to fix misalignment in `config wallet ls` output +- **Missing OpenAPI schema references** — registered `ElectionsStatus`, `NodeListRequest`, and nested election schemas (`OurElectionParticipant`, `ParticipationStatus`, `StakeSubmission`) in OpenAPI components, fixing Swagger resolver errors + +## [0.2.1] - 2026-03-18 + +### Added + +- **Manual election stake command** — new `config wallet stake` command for manual election participation via nominator pool. ## [0.2.0] - 2026-03-04 @@ -21,6 +56,7 @@ All notable changes to the Node Control Tool. - **Migrated to Axum web framework** — replaced the previous web framework with Axum for the control HTTP server - **Removed `--verbose` and `--log-file` CLI flags** — log level can still be overridden via the `RUST_LOG` environment variable - **Backward-compatible config migration** — old `"url": "…"` field in ton-http-api config is transparently migrated to `urls` on first re-save +- Updated nodectl documentation to reflect current configuration and usage ### Fixed @@ -28,15 +64,11 @@ All notable changes to the Node Control Tool. - **Wallet send `--bounce` flag and confirmation default** — fixed `--bounce` flag handling and clarified the default confirmation prompt - **Wallet version help text case insensitivity** — updated wallet version help text to reflect case-insensitive input -### Docs - -- Updated nodectl documentation to reflect current configuration and usage - ## [0.1.1] - 2026-02-27 ### Added -- **Wallet V1R3 support** - added support for wallet contract v1r3 +- **Wallet V1R3 support** — added support for wallet contract v1r3 ## [0.1.0] - 2026-02-22 diff --git a/src/node-control/README.md b/src/node-control/README.md index c28e328..364d127 100644 --- a/src/node-control/README.md +++ b/src/node-control/README.md @@ -15,6 +15,7 @@ - [Commands](#commands) - [Configuration Commands](#configuration-commands) - [Key Management Commands](#key-management-commands) + - [Authentication Commands](#authentication-commands) - [Deploy Commands](#deploy-commands) - [Service Command](#service-command) - [Service API Commands](#service-api-commands) @@ -181,6 +182,8 @@ List all configured nodes. ```bash nodectl config node ls +# or with json format +nodectl config node ls --format json ``` ##### `config node rm` @@ -229,6 +232,8 @@ List all configured wallets. ```bash nodectl config wallet ls +# or with json format +nodectl config wallet ls --format json ``` ##### `config wallet rm` @@ -303,6 +308,8 @@ List all configured pools. ```bash nodectl config pool ls +# or with json format +nodectl config pool ls --format json ``` ##### `config pool rm` @@ -352,6 +359,8 @@ List all node bindings. ```bash nodectl config bind ls +# or with json format +nodectl config bind ls --format json ``` ##### `config bind rm` @@ -403,6 +412,8 @@ Display information about the configured master wallet (address, version, workch ```bash nodectl config master-wallet info +# or with json format +nodectl config master-wallet info --format json ``` --- @@ -415,15 +426,12 @@ Manage log configuration settings (level, output mode, rotation, file path). Display the current log settings. -| Flag | Description | -|------|-------------| -| `--format ` | Output format: `table` or `json` (default: `table`) | +| Flag | Short form | Description | +|------|------------|-------------| +| `--format ` | | Output format: `table` or `json` (default: `table`) | ```bash -# Show log config as table nodectl config log ls - -# Show log config as JSON nodectl config log ls --format json ``` @@ -461,15 +469,12 @@ Manage elections configuration, including stake policies, tick intervals, and pe Display the current elections configuration. -| Flag | Description | -|------|-------------| -| `--format ` | Output format: `table` or `json` (default: `table`) | +| Flag | Short form | Description | +|------|------------|-------------| +| `--format ` | | Output format: `table` or `json` (default: `table`) | ```bash -# Show elections config as table nodectl config elections show - -# Show elections config as JSON nodectl config elections show --format json ``` @@ -647,6 +652,87 @@ nodectl key rm --name "old-key" --- +### Authentication Commands + +Commands for managing REST API users and tokens. User credentials are stored in the vault. For a detailed description of roles, token lifecycle, revocation, rate limiting, and monitoring, see the **[Security Guide](./docs/nodectl-security.md)**. + +#### `auth add` + +Create a new API user. The password is entered interactively and confirmed. + +| Flag | Description | +|------|-------------| +| `--username ` | Username (alphanumeric, `_`, `-`, max 64 chars) | +| `--role ` | User role: `operator` or `nominator` | + +```bash +# Create an operator user (full operational access) +nodectl auth add --username admin --role operator + +# Create a nominator user (read-only status access) +nodectl auth add --username viewer --role nominator +``` + +--- + +#### `auth ls` + +List all configured users. + +```bash +nodectl auth ls +``` + +--- + +#### `auth rm` + +Remove a user. + +| Argument | Description | +|----------|-------------| +| `` | Username to remove | + +```bash +nodectl auth rm admin +``` + +--- + +#### `auth revoke` + +Revoke all tokens issued to a user. After revocation the user can log in again to obtain a new token. + +| Argument / Flag | Description | +|-----------------|-------------| +| `` | Username whose tokens to revoke | +| `--at ` | Optional unix timestamp cutoff (default: now) | + +```bash +# Revoke all current tokens +nodectl auth revoke admin + +# Revoke tokens issued before a specific time +nodectl auth revoke admin --at 1710000000 +``` + +--- + +#### `auth set ttl` + +Configure token TTL (time-to-live) for each role. + +| Flag | Description | +|------|-------------| +| `--operator ` | Operator token TTL (e.g. `3600`, `30s`, `60m`, `8h`) | +| `--nominator ` | Nominator token TTL | + +```bash +nodectl auth set ttl --operator 8h --nominator 1h +``` + +--- + ### Deploy Commands Commands for deploying contracts to the blockchain. Requires a configuration file with `ton_http_api` and `wallets` sections. @@ -723,7 +809,7 @@ RUST_LOG=debug nodectl service -c config.json ### Service API Commands -Commands for interacting with the nodectl service REST API. The service URL is resolved in this order: explicit `--url`, then `http.bind` from `--config`, then default `http://127.0.0.1:8080`. +Commands for interacting with the nodectl service REST API. The service URL is resolved in this order: explicit `--url`, then `http.bind` from `--config`. If neither is available, the command fails. When connecting from a remote machine, pass `--url` explicitly. #### `api` @@ -731,12 +817,37 @@ Client for the nodectl service REST API. Use this to interact with a running nod | Flag | Short form | Description | |------|------------|-------------| -| `--config ` | `-c` | Path to configuration file (reads `http.bind` for the service URL). Can also be set as an environment variable CONFIG_PATH | -| `--url ` | `-u` | URL to the node control service API (overrides config; default: `http://127.0.0.1:8080`) | -| `--token ` | | JWT token for authentication (optional) | +| `--config ` | `-c` | Path to configuration file (reads `http.bind` for the service URL; default: `nodectl-config.json`). Can also be set via `CONFIG_PATH` env var | +| `--url ` | `-u` | URL to the node control service API. Takes precedence over `--config` when both are provided | +| `--token ` | | JWT token for authentication (env: `NODECTL_API_TOKEN`) | **Subcommands:** +##### `api login` + +Authenticate with the REST API and obtain a JWT token. The password is entered interactively unless `--password-stdin` is used. + +| Argument / Flag | Description | +|-----------------|-------------| +| `` | Username to authenticate with | +| `--password-stdin` | Read password from stdin (for non-interactive use) | + +```bash +# Interactive login +nodectl api login admin + +# Non-interactive (e.g. in scripts) +echo "$PASSWORD" | nodectl api login admin --password-stdin +``` + +The command returns the JWT token, its expiration time, and the user role. Store the token for subsequent API calls: + +```bash +export NODECTL_API_TOKEN="" +``` + +Once the token is exported, all `nodectl api` commands use it automatically. + ##### `api health` Check service health. @@ -842,7 +953,9 @@ nodectl config-param -c config.json 34 ## REST API Endpoints -When running in service mode, nodectl exposes a REST API for monitoring and management. +When running in service mode, nodectl exposes a REST API for monitoring and management. By default, the HTTP server listens on all interfaces (`0.0.0.0:8080`) with authentication enabled and no users — all protected endpoints return `401` until at least one user is created via `nodectl auth add`. Protected endpoints require a JWT token in the `Authorization: Bearer ` header. See the **[Security Guide](./docs/nodectl-security.md)** for full details on roles, rate limiting, and token revocation. + +> **Warning:** nodectl serves plain HTTP. If the API is reachable outside your trusted network, terminate TLS at a reverse proxy or load balancer — otherwise passwords (`/auth/login`) and JWT tokens (`Authorization` header) travel in plain text. ### Configuration @@ -851,7 +964,7 @@ The HTTP server is configured in the `http` section of the config: ```json { "http": { - "bind": "127.0.0.1:8080", + "bind": "0.0.0.0:8080", "enable_swagger": true } } @@ -879,9 +992,69 @@ Health check endpoint. --- +#### `POST /auth/login` + +Authenticate and obtain a JWT token. Rate-limited: 5 failed attempts per 60s window, then blocked for 120s. + +**Request:** + +```json +{ + "username": "admin", + "password": "secret" +} +``` + +**Response:** + +```json +{ + "ok": true, + "token": "", + "expires_in": 2592000, + "role": "operator" +} +``` + +--- + +#### `GET /auth/me` + +Return the identity of the authenticated user. Requires: `nominator` or `operator` role. + +**Response:** + +```json +{ + "ok": true, + "username": "admin", + "role": "operator" +} +``` + +--- + +#### `GET /auth/users` + +List all users. Requires: `operator` role. + +**Response:** + +```json +{ + "ok": true, + "users": [ + { "username": "admin", "role": "operator" }, + { "username": "viewer", "role": "nominator" } + ] +} +``` + +--- + #### `GET /v1/elections` -Get current elections snapshot. +Get current elections snapshot. Requires: `nominator` or `operator` role. **Response:** @@ -1107,7 +1280,7 @@ Configuration is specified in JSON format. "api_key": "" | null }, "http": { - "bind": "127.0.0.1:8080", + "bind": "0.0.0.0:8080", "enable_swagger": true, "api_key": null }, @@ -1199,9 +1372,23 @@ TON HTTP API configuration: HTTP REST API server configuration: -- `bind` — address and port to bind (default: `127.0.0.1:8080`) +- `bind` — address and port to bind (default: `0.0.0.0:8080`) - `enable_swagger` — enable Swagger UI at `/swagger` (default: `true`) -- `api_key` — API key for authentication (optional) +- `auth` — JWT authentication configuration (see below) + +#### `http.auth` + +REST API authentication settings. **Authentication is enabled by default** — a freshly generated config includes the `http.auth` section with an empty user list, so all protected endpoints return `401` until at least one user is created via `nodectl auth add`. To disable authentication and open all endpoints, remove the `http.auth` section from the config (or set it to `null`). + +> **Note:** On first start the service creates a JWT signing key in the vault (secret `auth.jwt-signing-key`). +> +> **No restart required:** The service hot-reloads the configuration, so changes to users or auth settings take effect immediately. + +- `operator_token_ttl` — operator token TTL in seconds (default: `2592000` — 30 days) +- `nominator_token_ttl` — nominator token TTL in seconds (default: `86400` — 1 day) +- `min_password_length` — minimum password length (default: `8`) +- `jwt_secret` — base64-encoded JWT signing key (optional; falls back to vault secret `auth.jwt-signing-key`) +- `users` — list of user entries (managed via `nodectl auth` commands) #### `master_wallet` (optional) @@ -1258,7 +1445,7 @@ Logging configuration: "tick_interval": 40 }, "http": { - "bind": "127.0.0.1:8080", + "bind": "0.0.0.0:8080", "enable_swagger": true }, "master_wallet": { @@ -1437,6 +1624,37 @@ nodectl config elections tick-interval 60 nodectl config elections max-factor 2.5 ``` +### Authentication Setup + +```bash +# Create an operator user +nodectl auth add --username admin --role operator + +# Create a read-only nominator user +nodectl auth add --username viewer --role nominator + +# List users +nodectl auth ls + +# Configure token TTL +nodectl auth set ttl --operator 8h --nominator 1h + +# Log in and obtain a JWT token +nodectl api login admin + +# Non-interactive login (for scripts) +echo "$PASSWORD" | nodectl api login admin --password-stdin + +# Export the token for subsequent commands +export NODECTL_API_TOKEN="" + +# Revoke all tokens for a user +nodectl auth revoke admin + +# Remove a user +nodectl auth rm viewer +``` + ### Key Management ```bash @@ -1490,6 +1708,28 @@ nodectl config wallet send \ --amount 10.0 ``` +### Manual staking + +`nodectl config wallet stake` sends an election stake through a nominator pool. Use it to participate in elections manually. + +```bash +nodectl config wallet stake -b -a [-m ] +``` + +| Flag | Long | Required | Default | Description | +|------|------|----------|---------|-------------| +| `-b` | `--binding` | Yes | — | Binding name (node-wallet-pool triple) | +| `-a` | `--amount` | Yes | — | Stake amount in TON | +| `-m` | `--max-factor` | No | `3.0` | Max factor (`1.0`–`3.0`) | + +Example: + +```bash +nodectl config wallet stake -b node0 -a 50000 -m 2.5 +``` + +The command validates that elections are active, manages validator keys and ADNL addresses automatically, builds and sends the stake transaction, and polls the Elector until the stake is confirmed. + ### Starting the Service Daemon ```bash @@ -1553,33 +1793,44 @@ nodectl api stake-policy --node node0 --fixed 500000000000 ### Using REST API Directly ```bash -# Health check +# Login and obtain a token +TOKEN=$(curl -s -X POST http://127.0.0.1:8080/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "secret"}' | jq -r '.token') + +# Health check (public, no token required) curl http://127.0.0.1:8080/health # Get elections -curl http://127.0.0.1:8080/v1/elections +curl http://127.0.0.1:8080/v1/elections \ + -H "Authorization: Bearer $TOKEN" # Get validators -curl http://127.0.0.1:8080/v1/validators +curl http://127.0.0.1:8080/v1/validators \ + -H "Authorization: Bearer $TOKEN" # Exclude nodes curl -X POST http://127.0.0.1:8080/v1/elections/exclude \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -d '{"nodes": ["node0"]}' # Set default stake policy curl -X POST http://127.0.0.1:8080/v1/stake_strategy \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -d '{"policy": "minimum"}' # Set per-node policy override curl -X POST http://127.0.0.1:8080/v1/stake_strategy \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -d '{"policy": {"fixed": 500000000000}, "node": "node0"}' # Control elections task curl -X POST http://127.0.0.1:8080/v1/task/elections \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -d '{"action": "restart"}' ``` @@ -1589,3 +1840,4 @@ curl -X POST http://127.0.0.1:8080/v1/task/elections \ - [Hashicorp Vault Dedicated Setup](./docs/hcp-vault-setup.md) - [Node Control Service Setup](./docs/nodectl-setup.md) +- [Security Guide](./docs/nodectl-security.md) — roles, token lifecycle, rate limiting, monitoring diff --git a/src/node-control/commands/Cargo.toml b/src/node-control/commands/Cargo.toml index 151f70d..0fb3ba8 100644 --- a/src/node-control/commands/Cargo.toml +++ b/src/node-control/commands/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "commands" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' @@ -8,10 +8,8 @@ license = 'GPL-3.0' serde = { features = ["derive", "rc"], version = "1.0" } serde_json = "1.0" anyhow = "1.0" -async-trait = "0.1" clap = { version = "4.4", features = ["derive", "env"] } tracing = "0.1" -hex = { version = "0.4" } base64 = "0.22.1" colored = "2.0" reqwest = { version = "0.12.24", default-features = false, features = [ diff --git a/src/node-control/commands/src/commands/nodectl/auth_cmd.rs b/src/node-control/commands/src/commands/nodectl/auth_cmd.rs index a7bafa4..52e3ed0 100644 --- a/src/node-control/commands/src/commands/nodectl/auth_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/auth_cmd.rs @@ -138,7 +138,7 @@ impl AddUserCmd { if config.http.auth.is_none() { println!( - "{}: auth is not configured yet, default config is being created", + "{}: auth is not configured yet; enabling with default settings", "Warning".yellow().bold() ); } @@ -316,7 +316,7 @@ fn list_users(config_path: &Path) -> anyhow::Result<()> { let config = AppConfig::load(config_path)?; if config.http.auth.is_none() { - anyhow::bail!("{}", "auth is not configured yet; add users with 'nodectl auth add' to enable authentication".red()); + anyhow::bail!("{}", "auth is disabled (http.auth section removed from config). Use 'nodectl auth add' to enable.".red()); } let users = config.http.auth.as_ref().map(|a| a.users.as_slice()).unwrap_or_default(); diff --git a/src/node-control/commands/src/commands/nodectl/config_bind_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_bind_cmd.rs index fa52239..14673ef 100644 --- a/src/node-control/commands/src/commands/nodectl/config_bind_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_bind_cmd.rs @@ -6,7 +6,7 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::save_config; +use crate::commands::nodectl::{output_format::OutputFormat, utils::save_config}; use colored::Colorize; use common::app_config::{AppConfig, BindingStatus, NodeBinding}; use std::path::Path; @@ -48,7 +48,10 @@ pub struct BindRmCmd { #[derive(clap::Args, Clone)] #[command(about = "List all node bindings")] -pub struct BindLsCmd {} +pub struct BindLsCmd { + #[arg(long = "format", default_value = "table", help = "Output format: table or json")] + format: OutputFormat, +} impl BindCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { @@ -134,41 +137,75 @@ impl BindRmCmd { } } +#[derive(serde::Serialize)] +struct BindingView { + node: String, + wallet: String, + pool: Option, + enable: bool, + status: String, +} + impl BindLsCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { let config = AppConfig::load(path)?; if config.bindings.is_empty() { - println!("\n{}\n", "No bindings configured".yellow()); + match self.format { + OutputFormat::Json => println!("[]"), + OutputFormat::Table => println!("\n{}\n", "No bindings configured".yellow()), + } return Ok(()); } - println!("\n{} {} ({})\n", "OK".green().bold(), "Bindings:".green(), config.bindings.len()); - println!( - " {:<20} {:<20} {:<20} {:<12} {}", - "Node".cyan().bold(), - "Wallet".cyan().bold(), - "Pool".cyan().bold(), - "Enable".cyan().bold(), - "Status".cyan().bold(), - ); - println!(" {}", "─".repeat(90).dimmed()); - - let mut sorted: Vec<_> = config.bindings.iter().collect(); - sorted.sort_by(|(a, _), (b, _)| a.cmp(b)); - for (node_name, binding) in sorted { - let enable_str = - if binding.enable { "yes".green().to_string() } else { "no".red().to_string() }; - println!( - " {:<20} {:<20} {:<20} {:<21} {}", - node_name, - binding.wallet, - binding.pool.as_deref().unwrap_or("-"), - enable_str, - binding.status, - ); + let mut views: Vec = config + .bindings + .into_iter() + .map(|(node, b)| BindingView { + node, + wallet: b.wallet, + pool: b.pool, + enable: b.enable, + status: b.status.to_string(), + }) + .collect(); + views.sort_by(|a, b| a.node.cmp(&b.node)); + + match self.format { + OutputFormat::Json => print_bindings_json(&views)?, + OutputFormat::Table => print_bindings_table(&views), } - println!(); Ok(()) } } + +fn print_bindings_json(views: &[BindingView]) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(views)?); + Ok(()) +} + +fn print_bindings_table(views: &[BindingView]) { + println!("\n{} {} ({})\n", "OK".green().bold(), "Bindings:".green(), views.len()); + println!( + " {:<20} {:<20} {:<20} {:<12} {}", + "Node".cyan().bold(), + "Wallet".cyan().bold(), + "Pool".cyan().bold(), + "Enable".cyan().bold(), + "Status".cyan().bold(), + ); + println!(" {}", "─".repeat(90).dimmed()); + + for v in views { + let enable_str = if v.enable { "yes".green().to_string() } else { "no".red().to_string() }; + println!( + " {:<20} {:<20} {:<20} {:<21} {}", + v.node, + v.wallet, + v.pool.as_deref().unwrap_or("-"), + enable_str, + v.status, + ); + } + println!(); +} diff --git a/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs index be277a8..aed6b0b 100644 --- a/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs @@ -6,7 +6,7 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::save_config; +use crate::commands::nodectl::{output_format::OutputFormat, utils::save_config}; use colored::Colorize; use common::{ app_config::{AppConfig, BindingStatus, ElectionsConfig, StakePolicy}, @@ -37,13 +37,6 @@ pub enum ElectionsAction { Disable(DisableCmd), } -#[derive(clap::ValueEnum, Clone, Default)] -pub enum OutputFormat { - #[default] - Table, - Json, -} - #[derive(clap::Args, Clone)] pub struct ShowCmd { #[arg(long = "format", default_value = "table", help = "Output format: table or json")] diff --git a/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs index da5e5e3..bea09bf 100644 --- a/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs @@ -6,7 +6,7 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::save_config; +use crate::commands::nodectl::{output_format::OutputFormat, utils::save_config}; use colored::Colorize; use common::app_config::{AppConfig, LogConfig, LogOutput, LogRotation}; use std::path::{Path, PathBuf}; @@ -27,13 +27,6 @@ pub enum LogAction { Set(LogSetCmd), } -#[derive(clap::ValueEnum, Clone, Default)] -pub enum OutputFormat { - #[default] - Table, - Json, -} - #[derive(clap::Args, Clone)] pub struct LogLsCmd { #[arg(long = "format", default_value = "table", help = "Output format: table or json")] diff --git a/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs index a5c6f43..ec79224 100644 --- a/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs @@ -6,7 +6,10 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::{save_config, warn_missing_secret}; +use crate::commands::nodectl::{ + output_format::OutputFormat, + utils::{save_config, warn_missing_secret}, +}; use adnl::common::Timeouts; use anyhow::Context; use colored::Colorize; @@ -59,7 +62,10 @@ pub struct NodeAddCmd { #[derive(clap::Args, Clone)] #[command(about = "List all configured nodes")] -pub struct NodeLsCmd {} +pub struct NodeLsCmd { + #[arg(long = "format", default_value = "table", help = "Output format: table or json")] + format: OutputFormat, +} #[derive(clap::Args, Clone)] #[command(about = "Remove a node from the configuration")] @@ -125,12 +131,24 @@ impl NodeAddCmd { } } +#[derive(serde::Serialize)] +struct NodeView { + name: String, + control_server_endpoint: String, + control_server_pubkey: String, + control_client_secret: String, + status: String, +} + impl NodeLsCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { let config = AppConfig::load(path)?; if config.nodes.is_empty() { - println!("\n{}\n", "No nodes configured".yellow()); + match self.format { + OutputFormat::Json => println!("[]"), + OutputFormat::Table => println!("\n{}\n", "No nodes configured".yellow()), + } return Ok(()); } @@ -150,50 +168,74 @@ impl NodeLsCmd { } } - println!("\n{} {} ({})\n", "OK".green().bold(), "Nodes:".green(), config.nodes.len()); - println!( - " {:<20} {:<25} {:<48} {:<30} {}", - "Name".cyan().bold(), - "Control Server Endpoint".cyan().bold(), - "Control Server Pubkey".cyan().bold(), - "Control Client Secret".cyan().bold(), - "Status".cyan().bold(), - ); - println!(" {}", "─".repeat(150).dimmed()); - - let mut sorted_nodes: Vec<_> = config.nodes.iter().collect(); - sorted_nodes.sort_by(|(a, _), (b, _)| a.cmp(b)); - - for (name, adnl) in sorted_nodes { - let control_server_pubkey = match &adnl.server_key { - KeyConfig::PublicKey { pub_key, .. } => { - base64::Engine::encode(&base64::engine::general_purpose::STANDARD, pub_key) - } - _ => "-".to_string(), - }; - let control_client_secret_name = match &adnl.client_key { - KeyConfig::VaultKey { name } => name.clone(), - _ => "-".to_string(), - }; - let status_display = match statuses.get(name) { - Some(Ok(())) => "OK".green().to_string(), - Some(Err(msg)) => msg.red().to_string(), - None => "unknown".dimmed().to_string(), - }; - println!( - " {:<20} {:<25} {:<48} {:<30} {}", + let mut views: Vec = config + .nodes + .into_iter() + .map(|(name, adnl)| NodeView { + control_server_pubkey: match &adnl.server_key { + KeyConfig::PublicKey { pub_key, .. } => { + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, pub_key) + } + _ => "-".to_string(), + }, + control_client_secret: match &adnl.client_key { + KeyConfig::VaultKey { name } => name.clone(), + _ => "-".to_string(), + }, + status: match statuses.get(&name) { + Some(Ok(())) => "ok".to_string(), + Some(Err(msg)) => msg.clone(), + None => "unknown".to_string(), + }, + control_server_endpoint: adnl.server_address, name, - adnl.server_address, - control_server_pubkey, - control_client_secret_name, - status_display, - ); + }) + .collect(); + views.sort_by(|a, b| a.name.cmp(&b.name)); + + match self.format { + OutputFormat::Json => print_nodes_json(&views)?, + OutputFormat::Table => print_nodes_table(&views), } - println!(); Ok(()) } } +fn print_nodes_json(views: &[NodeView]) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(views)?); + Ok(()) +} + +fn print_nodes_table(views: &[NodeView]) { + println!("\n{} {} ({})\n", "OK".green().bold(), "Nodes:".green(), views.len()); + println!( + " {:<20} {:<25} {:<48} {:<30} {}", + "Name".cyan().bold(), + "Control Server Endpoint".cyan().bold(), + "Control Server Pubkey".cyan().bold(), + "Control Client Secret".cyan().bold(), + "Status".cyan().bold(), + ); + println!(" {}", "─".repeat(150).dimmed()); + + for v in views { + let status_display = match v.status.as_str() { + "ok" => "OK".green().to_string(), + "unknown" => "unknown".dimmed().to_string(), + msg => msg.red().to_string(), + }; + println!( + " {:<20} {:<25} {:<48} {:<30} {}", + v.name, + v.control_server_endpoint, + v.control_server_pubkey, + v.control_client_secret, + status_display, + ); + } + println!(); +} + const STATUS_CHECK_TIMEOUT_SECS: u64 = 5; async fn check_node_status( diff --git a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs index ba13142..3e50df8 100644 --- a/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs @@ -6,13 +6,16 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::{ - calculate_wallet_address, save_config, try_create_rpc_client, +use crate::commands::nodectl::{ + output_format::OutputFormat, + utils::{ + calculate_wallet_address, save_config, try_create_rpc_client, warn_ton_api_unavailable, + }, }; use colored::Colorize; use common::{ app_config::{AppConfig, PoolConfig}, - ton_utils::nanotons_to_tons_f64, + ton_utils::display_tons, }; use contracts::{NOMINATOR_POOL_WORKCHAIN, NominatorWrapperImpl}; use secrets_vault::{vault::SecretVault, vault_builder::SecretVaultBuilder}; @@ -58,7 +61,10 @@ pub struct PoolAddCmd { #[derive(clap::Args, Clone)] #[command(about = "List all configured pools")] -pub struct PoolLsCmd {} +pub struct PoolLsCmd { + #[arg(long = "format", default_value = "table", help = "Output format: table or json")] + format: OutputFormat, +} #[derive(clap::Args, Clone)] #[command(about = "Remove a pool from the configuration")] @@ -118,106 +124,167 @@ impl PoolAddCmd { } } +#[derive(serde::Serialize)] +struct PoolView { + name: String, + kind: String, + balance: Option, + address: Option, + owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + addresses: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + validator_share: Option, +} + impl PoolLsCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { let config = AppConfig::load(path)?; if config.pools.is_empty() { - println!("\n{}\n", "No pools configured".yellow()); + match self.format { + OutputFormat::Json => println!("[]"), + OutputFormat::Table => println!("\n{}\n", "No pools configured".yellow()), + } return Ok(()); } let rpc_client = match try_create_rpc_client(&config).await { Ok(c) => Some(c), Err(e) => { - println!( - "{}: {}", - "Warning: failed to connect to ton api".yellow(), - e.to_string().yellow() - ); + if matches!(self.format, OutputFormat::Table) { + warn_ton_api_unavailable(&e, "Balances will not be available"); + } None } }; - // The vault is needed for the address calculation, but it will be loaded lazily. - // The vault is not necessary so it is wrapped in an inner Option. - let mut vault: Option>> = None; - - println!("\n{} {} ({})\n", "OK".green().bold(), "Pools:".green(), config.pools.len()); - println!( - " {:<15} {:<6} {:<14} {:<50} {}", - "Name".cyan().bold(), - "Kind".cyan().bold(), - "Balance".cyan().bold(), - "Address".cyan().bold(), - "Owner".cyan().bold(), - ); - println!(" {}", "─".repeat(137).dimmed()); - - for (name, pool) in &config.pools { - match pool { - PoolConfig::SNP { address, owner } => { - let needs_vault = address.is_none() && owner.is_some(); - if needs_vault && vault.is_none() { - vault = Some(match SecretVaultBuilder::from_env().await { - Ok(v) => Some(v), - Err(e) => { + let views = + collect_pool_views(&config, &rpc_client, self.format == OutputFormat::Table).await; + + match self.format { + OutputFormat::Json => print_pools_json(&views)?, + OutputFormat::Table => print_pools_table(&views), + } + Ok(()) + } +} + +async fn collect_pool_views( + config: &AppConfig, + rpc_client: &Option>, + warn_on_error: bool, +) -> Vec { + let mut vault: Option>> = None; + let mut views = Vec::new(); + + for (name, pool) in &config.pools { + match pool { + PoolConfig::SNP { address, owner } => { + let needs_vault = address.is_none() && owner.is_some(); + if needs_vault && vault.is_none() { + vault = Some(match SecretVaultBuilder::from_env().await { + Ok(v) => Some(v), + Err(e) => { + if warn_on_error { println!( "{}: {}", "Warning: failed to initialize secret vault".yellow(), e.to_string().yellow() ); - None } - }); - } - let vault_ref = vault.as_ref().and_then(|v| v.clone()); - - let addr_result = get_pool_display_result( - name, - address.as_ref(), - owner.as_ref(), - &config, - vault_ref, - ) - .await; - - let balance_result = resolve_pool_balance(&addr_result, &rpc_client).await; - - let display_addr = match addr_result { - Ok(addr) => addr.white(), - Err(msg) => msg.red(), - }; - - let display_balance = match balance_result { - Ok(balance) => balance.white(), - Err(msg) => msg.red(), - }; - - println!( - " {:<15} {:<6} {:<14} {:<50} {}", - name, - "SNP", - display_balance, - display_addr, - owner.as_deref().unwrap_or("-") - ); - } - PoolConfig::TONCore { addresses, validator_share } => { - println!( - " {:<15} {:<6} {:<14} {:<50} share={}", - name, - "Core", - "-", - addresses.join(", "), - validator_share - ); + None + } + }); } + let vault_ref = vault.as_ref().and_then(|v| v.clone()); + + let addr_result = get_pool_display_result( + name, + address.as_ref(), + owner.as_ref(), + config, + vault_ref, + ) + .await; + + let balance_result = resolve_pool_balance(&addr_result, rpc_client).await; + + let display_owner = + owner.as_ref().and_then(|o| MsgAddressInt::from_str(o).ok()).and_then(|o| { + o.to_string_custom(ADDR_FORMAT_BOUNCE | ADDR_FORMAT_URL_SAFE).ok() + }); + + views.push(PoolView { + name: name.clone(), + kind: "SNP".to_string(), + balance: balance_result.ok(), + address: addr_result.ok(), + owner: display_owner, + addresses: None, + validator_share: None, + }); + } + PoolConfig::TONCore { addresses, validator_share } => { + views.push(PoolView { + name: name.clone(), + kind: "Core".to_string(), + balance: None, + address: None, + owner: None, + addresses: Some(addresses.to_vec()), + validator_share: Some(*validator_share), + }); } } - println!(); - Ok(()) } + views +} + +fn print_pools_json(views: &[PoolView]) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(views)?); + Ok(()) +} + +fn print_pools_table(views: &[PoolView]) { + println!("\n{} {} ({})\n", "OK".green().bold(), "Pools:".green(), views.len()); + println!( + " {:<15} {:<6} {:<14} {:<50} {}", + "Name".cyan().bold(), + "Kind".cyan().bold(), + "Balance".cyan().bold(), + "Address".cyan().bold(), + "Owner".cyan().bold(), + ); + println!(" {}", "─".repeat(137).dimmed()); + + for v in views { + match v.kind.as_str() { + "SNP" => { + let display_addr = + v.address.as_deref().map(|s| s.white()).unwrap_or_else(|| "-".red()); + let display_owner = + v.owner.as_deref().map(|s| s.white()).unwrap_or_else(|| "-".red()); + let display_balance = + v.balance.as_deref().map(|s| s.white()).unwrap_or_else(|| "-".red()); + + println!( + " {:<15} {:<6} {:<14} {:<50} {}", + v.name, "SNP", display_balance, display_addr, display_owner, + ); + } + "Core" => { + let addrs = v.addresses.as_deref().map(|a| a.join(", ")).unwrap_or_default(); + let share = v.validator_share.map(|s| s.to_string()).unwrap_or_default(); + println!( + " {:<15} {:<6} {:<14} {:<50} share={}", + v.name, "Core", "-", addrs, share, + ); + } + _ => {} + } + } + println!(); } async fn get_pool_display_result( @@ -245,7 +312,7 @@ async fn resolve_pool_balance( (Ok(addr_str), Some(client)) => { let addr = MsgAddressInt::from_str(addr_str).map_err(|_| "invalid address")?; match client.get_address_information(&addr).await { - Ok(info) => Ok(format!("{:.4}", nanotons_to_tons_f64(info.balance))), + Ok(info) => Ok(display_tons(info.balance)), Err(e) => Err(format!("ton api failed: {e}")), } } diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index 3adb0b2..2d57bc2 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -6,25 +6,40 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::{ - SEND_TIMEOUT, get_wallet_config, load_config_vault, load_config_vault_rpc_client, make_wallet, - save_config, wait_for_seqno_change, wallet_info, warn_missing_secret, +use crate::commands::nodectl::{ + output_format::OutputFormat, + utils::{ + SEND_TIMEOUT, check_ton_api_connection, get_wallet_config, load_config_vault, + load_config_vault_rpc_client, make_wallet, save_config, wait_for_seqno_change, + wallet_address, wallet_info, warn_missing_secret, warn_ton_api_unavailable, + }, }; use anyhow::Context; use colored::Colorize; use common::{ TonWalletVersion, - app_config::{AppConfig, KeyConfig, WalletConfig}, + app_config::{AppConfig, KeyConfig, PoolConfig, WalletConfig}, task_cancellation::CancellationCtx, - ton_utils::{nanotons_to_tons_f64, tons_f64_to_nanotons}, + time_format, + ton_utils::{display_tons, tons_f64_to_nanotons}, }; -use contracts::TonWallet; -use secrets_vault::errors::error::VaultError; -use std::{io::Write, path::Path}; -use ton_block::{Cell, MsgAddressInt, write_boc}; -use ton_http_api_client::v2::data_models::AccountState; +use contracts::{ + ElectorWrapper, ElectorWrapperImpl, NominatorWrapperImpl, TonWallet, contract_provider, + nominator, +}; +use elections::providers::{DefaultElectionsProvider, ElectionsProvider}; +use secrets_vault::{errors::error::VaultError, vault::SecretVault}; +use std::{borrow::Cow, io::Write, path::Path, sync::Arc}; +use ton_block::{ADDR_FORMAT_BOUNCE, ADDR_FORMAT_URL_SAFE, Cell, MsgAddressInt, write_boc}; +use ton_http_api_client::v2::{client_json_rpc::ClientJsonRpc, data_models::AccountState}; const WALLET_SEND_GAS: u64 = 1_000_000; // 0.001 TON +/// Value in nanotons required by elector to execute stake operations. +const ELECTOR_STAKE_FEE: u64 = 1_000_000_000; +/// Gas fee consumed by nominator pool. +const NPOOL_COMPUTE_FEE: u64 = 200_000_000; +/// Gas fee consumed by wallet to send message to nominator pool. +const WALLET_COMPUTE_FEE: u64 = 100_000_000; #[derive(clap::Args, Clone)] #[command(about = "Manage wallets in the configuration")] @@ -43,6 +58,8 @@ pub enum WalletAction { Rm(WalletRmCmd), /// Send TONs Send(WalletSendCmd), + /// Send election stake via nominator pool + Stake(WalletStakeCmd), } #[derive(clap::Args, Clone)] @@ -67,7 +84,10 @@ pub struct WalletAddCmd { #[derive(clap::Args, Clone)] #[command(about = "List all configured wallets")] -pub struct WalletLsCmd {} +pub struct WalletLsCmd { + #[arg(long = "format", default_value = "table", help = "Output format: table or json")] + format: OutputFormat, +} #[derive(clap::Args, Clone)] #[command(about = "Remove a wallet from the configuration")] @@ -93,6 +113,17 @@ pub struct WalletSendCmd { bounce: bool, } +#[derive(clap::Args, Clone)] +#[command(about = "Send election stake via nominator pool")] +pub struct WalletStakeCmd { + #[arg(short = 'b', long = "binding", help = "Binding name")] + binding: String, + #[arg(short = 'a', long = "amount", help = "Stake amount in TONs")] + amount: f64, + #[arg(short = 'm', long = "max-factor", default_value = "3.0", help = "Max factor (1.0..3.0)")] + max_factor: f32, +} + impl WalletCmd { pub async fn run(&self, path: &Path, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { match &self.action { @@ -100,6 +131,7 @@ impl WalletCmd { WalletAction::Ls(cmd) => cmd.run(path).await, WalletAction::Rm(cmd) => cmd.run(path).await, WalletAction::Send(cmd) => cmd.run(path, cancellation_ctx).await, + WalletAction::Stake(cmd) => cmd.run(path, cancellation_ctx).await, } } } @@ -141,10 +173,26 @@ impl WalletAddCmd { } } +#[derive(serde::Serialize)] +struct WalletView { + name: String, + secret: String, + version: String, + state: Option, + balance: Option, + address: Option, +} + impl WalletLsCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; + if let Err(e) = check_ton_api_connection(&rpc_client).await { + if matches!(self.format, OutputFormat::Table) { + warn_ton_api_unavailable(&e, "State and balances will not be available"); + } + } + let mut all_wallets: Vec<(&str, &WalletConfig)> = config.wallets.iter().map(|(k, v)| (k.as_str(), v)).collect(); if let Some(mw) = config.master_wallet.as_ref() { @@ -152,53 +200,128 @@ impl WalletLsCmd { } if all_wallets.is_empty() { - println!("\n{}\n", "No wallets configured".yellow()); + match self.format { + OutputFormat::Json => println!("[]"), + OutputFormat::Table => println!("\n{}\n", "No wallets configured".yellow()), + } return Ok(()); } - println!("\n{} {} ({})\n", "OK".green().bold(), "Wallets:".green(), all_wallets.len()); - println!( - " {:<20} {:<22} {:<8} {:<12} {:<12} {}", - "Name".cyan().bold(), - "Secret".cyan().bold(), - "Version".cyan().bold(), - "State".cyan().bold(), - "Balance".cyan().bold(), - "Address".cyan().bold(), - ); - println!(" {}", "─".repeat(120).dimmed()); - - for (name, wallet_cfg) in &all_wallets { - let (address, account_state, balance) = - match wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await { - Ok((address, wallet_info, _)) => ( - address.to_string(), - wallet_info.account_state.to_string(), - format!("{:.4}", nanotons_to_tons_f64(wallet_info.balance)), + match self.format { + OutputFormat::Json => { + print_wallets_json(all_wallets, vault, rpc_client).await?; + } + OutputFormat::Table => { + print_wallets_table(all_wallets, vault, rpc_client).await; + } + } + Ok(()) + } +} + +async fn print_wallets_json( + wallets: Vec<(&str, &WalletConfig)>, + vault: Arc, + rpc_client: Arc, +) -> anyhow::Result<()> { + let mut views = Vec::new(); + for (name, wallet_cfg) in wallets { + let secret = match &wallet_cfg.key { + KeyConfig::VaultKey { name } => name.clone(), + _ => "-".to_string(), + }; + let (address, state, balance) = match wallet_address(wallet_cfg, vault.clone()).await { + Ok((address, _)) => { + let address_str = address + .to_string_custom(ADDR_FORMAT_BOUNCE | ADDR_FORMAT_URL_SAFE) + .unwrap_or_else(|_| address.to_string()); + match rpc_client.get_wallet_information(&address).await { + Ok(info) => ( + Some(address_str), + Some(info.account_state.to_string()), + Some(display_tons(info.balance)), ), - Err(e) => { - if e.downcast_ref::() - .is_some_and(|e| e.code() == VaultError::NOT_FOUND) - { - ("not found in the vault".to_string(), "".to_string(), "".to_string()) - } else { - (e.to_string(), "".to_string(), "".to_string()) - } - } - }; + Err(_) => (Some(address_str), None, None), + } + } + Err(_) => (None, None, None), + }; + views.push(WalletView { + name: name.to_string(), + secret, + version: wallet_cfg.version.to_string(), + state, + balance, + address, + }); + } + println!("{}", serde_json::to_string_pretty(&views)?); + Ok(()) +} - let secret_name = match &wallet_cfg.key { - KeyConfig::VaultKey { name } => name.clone(), - _ => "-".to_string(), +async fn print_wallets_table( + wallets: Vec<(&str, &WalletConfig)>, + vault: Arc, + rpc_client: Arc, +) { + println!("\n{} {} ({})\n", "OK".green().bold(), "Wallets:".green(), wallets.len()); + println!( + " {:<20} {:<22} {:<8} {:<9} {:<14} {}", + "Name".cyan().bold(), + "Secret".cyan().bold(), + "Version".cyan().bold(), + "State".cyan().bold(), + "Balance".cyan().bold(), + "Address".cyan().bold(), + ); + println!(" {}", "─".repeat(125).dimmed()); + + let red_dash = Cow::Borrowed(&"-".red()); + for (name, wallet_cfg) in wallets { + let (address, account_state, balance) = + match wallet_address(wallet_cfg, vault.clone()).await { + Ok((address, _)) => { + let address_str = address + .to_string_custom(ADDR_FORMAT_BOUNCE | ADDR_FORMAT_URL_SAFE) + .unwrap_or_else(|_| address.to_string()); + + match rpc_client.get_wallet_information(&address).await { + Ok(info) => ( + address_str.white(), + Cow::Owned(info.account_state.to_string().white()), + Cow::Owned(display_tons(info.balance).white()), + ), + Err(_) => (address_str.white(), red_dash.clone(), red_dash.clone()), + } + } + Err(e) => { + let error_message = if e + .downcast_ref::() + .is_some_and(|e| e.code() == VaultError::NOT_FOUND) + { + "not found in the vault".red() + } else { + e.root_cause().to_string().red() + }; + (error_message, red_dash.clone(), red_dash.clone()) + } }; - println!( - " {:<20} {:<22} {:<8} {:<12} {:<12} {}", - name, secret_name, wallet_cfg.version, account_state, balance, address, - ); - } - println!(); - Ok(()) + + let secret_name = match &wallet_cfg.key { + KeyConfig::VaultKey { name } => Cow::Owned(name.white()), + _ => red_dash.clone(), + }; + println!( + " {:<20} {:<22} {:<8} {:<9} {:<14} {}", + name, + secret_name, + wallet_cfg.version.to_string(), + account_state, + balance, + address, + ); } + println!(); } impl WalletRmCmd { @@ -241,9 +364,9 @@ impl WalletSendCmd { .contains(&tons_f64_to_nanotons(self.amount)) { anyhow::bail!( - "Wrong amount value {}TON. Wallet balance is {}TON", + "Wrong amount value {} TON. Wallet balance is {} TON", self.amount, - nanotons_to_tons_f64(from_wallet_info.balance) + display_tons(from_wallet_info.balance) ) } @@ -271,11 +394,7 @@ impl WalletSendCmd { self.bounce, ); - print!("Confirm transfer? [y/N]: "); - std::io::stdout().flush()?; - let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; - if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { + if !confirm("Confirm transfer?")? { println!("{}", "Transfer cancelled".yellow()); return Ok(()); } @@ -308,3 +427,284 @@ impl WalletSendCmd { Ok(()) } } + +impl WalletStakeCmd { + pub async fn run(&self, path: &Path, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { + if !(1.0..=3.0).contains(&self.max_factor) { + anyhow::bail!("max-factor must be between 1.0 and 3.0"); + } + + let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; + + // Resolve binding → wallet, pool, node + let binding = config + .bindings + .get(&self.binding) + .ok_or_else(|| anyhow::anyhow!("Binding '{}' not found", self.binding))?; + + let wallet_cfg = + get_wallet_config(&binding.wallet, &config.wallets, config.master_wallet.as_ref())?; + + let pool_name = binding + .pool + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Binding '{}' has no pool configured", self.binding))?; + let pool_cfg = config + .pools + .get(pool_name) + .ok_or_else(|| anyhow::anyhow!("Pool '{}' not found", pool_name))?; + + let adnl_cfg = config + .nodes + .get(&self.binding) + .ok_or_else(|| anyhow::anyhow!("Node '{}' not found", self.binding))?; + + // Wallet and pool addresses + let (wallet_address, wallet_info_res, wallet_secret) = + wallet_info(rpc_client.clone(), wallet_cfg, vault.clone()).await?; + if wallet_info_res.account_state != AccountState::Active { + anyhow::bail!("Wallet '{}' is {}", binding.wallet, wallet_info_res.account_state); + } + let pool_address = resolve_pool_address(pool_cfg, &wallet_address)?; + let pool_addr_bytes = pool_address.address().clone().storage().to_vec(); + + // Connect to validator node via control protocol + let adnl_client_cfg = adnl_cfg + .to_node_adnl_config(Some(vault.clone())) + .await + .context("ADNL client config")?; + let mut provider = DefaultElectionsProvider::new(adnl_client_cfg); + + // Get active election ID from elector via RPC + let elector = ElectorWrapperImpl::new(contract_provider!(rpc_client.clone())); + let election_id = + elector.get_active_election_id().await.context("get_active_election_id")?; + if election_id == 0 { + anyhow::bail!("No active elections"); + } + let elections_info = elector.elections_info().await.context("elections_info")?; + if elections_info.finished { + anyhow::bail!("Elections are already finished"); + } + + let stake_nanotons = tons_f64_to_nanotons(self.amount); + if stake_nanotons < elections_info.min_stake { + anyhow::bail!( + "Stake {:.4} TON is below minimum {:.4} TON", + self.amount, + elections_info.min_stake as f64 / 1_000_000_000.0 + ); + } + + // Get election parameters for key expiration + let cfg15 = provider.election_parameters().await.context("election_parameters")?; + const KEY_EXPIRED_LAG: u64 = 300; + let key_expired_at = election_id + cfg15.validators_elected_for as u64 + KEY_EXPIRED_LAG; + + // Find or generate validator key + let validator_config = provider.validator_config().await.context("validator_config")?; + let existing_key = validator_config.find(election_id); + + let (key_id, pub_key, adnl_addr) = match existing_key { + Some(entry) => { + let pub_key = + provider.export_public_key(&entry.key_id).await.context("export_public_key")?; + let adnl_addr = entry + .adnl_addr() + .ok_or_else(|| anyhow::anyhow!("Validator key has no ADNL address"))?; + println!( + "{} Reusing existing validator key for election {}: {}", + "Info:".cyan().bold(), + election_id, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pub_key) + ); + (entry.key_id, pub_key, adnl_addr) + } + None => { + println!( + "\n{} No validator key found for election {}", + "Warning:".yellow().bold(), + election_id + ); + if !confirm("Generate new validator key?")? { + anyhow::bail!("Aborted: no validator key for this election"); + } + let (key_id, pub_key) = provider + .new_validator_key(election_id, key_expired_at) + .await + .context("new_validator_key")?; + let adnl_addr = provider + .new_adnl_addr(key_id.clone(), key_expired_at) + .await + .context("new_adnl_addr")?; + println!( + "{} Generated validator key: {}\n{} Generated ADNL address: {}", + "Info:".cyan().bold(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pub_key), + "Info:".cyan().bold(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &adnl_addr), + ); + (key_id, pub_key, adnl_addr) + } + }; + + // Check if already participating + if let Some(p) = elections_info.participants.iter().find(|p| p.pub_key == pub_key) { + println!( + "\n{} Already participating with stake {:.4} TON", + "Warning:".yellow().bold(), + p.stake as f64 / 1_000_000_000.0 + ); + } + + // Build election bid: sign data with validator key + let max_factor_raw = (self.max_factor * 65536.0) as u32; + + let mut sign_data = 0x654C5074u32.to_be_bytes().to_vec(); + sign_data.extend_from_slice(&(election_id as u32).to_be_bytes()); + sign_data.extend_from_slice(&max_factor_raw.to_be_bytes()); + sign_data.extend_from_slice(&pool_addr_bytes); + sign_data.extend_from_slice(&adnl_addr); + + let signature = provider.sign(key_id, sign_data).await.context("sign election bid")?; + + // Build NEW_STAKE payload for nominator pool + let payload = nominator::new_stake(&nominator::NewStakeParams { + query_id: time_format::now(), + stake_amount: stake_nanotons, + validator_pubkey: &pub_key, + stake_at: election_id as u32, + max_factor: max_factor_raw, + adnl_addr: &adnl_addr, + signature: &signature, + })?; + + // Build wallet message to nominator pool (wallet sends only gas, pool has the stake) + let wallet = + make_wallet(rpc_client.clone(), wallet_cfg, wallet_secret, &binding.wallet).await?; + let fee = ELECTOR_STAKE_FEE + NPOOL_COMPUTE_FEE; + if wallet_info_res.balance < fee + WALLET_COMPUTE_FEE { + anyhow::bail!( + "Insufficient wallet balance: required {:.4} TON, available {:.4} TON", + (fee + WALLET_COMPUTE_FEE) as f64 / 1_000_000_000.0, + display_tons(wallet_info_res.balance) + ); + } + let msg = wallet.message(pool_address.clone(), fee, payload).await?; + let msg_boc = write_boc(&msg)?; + + // Confirmation + println!( + "\n{}\n Binding: {}\n Wallet: {} ({})\n Pool: {}\n Election ID: {} ({})\n Stake: {:.9} TON\n Max Factor: {:.2}\n Min Stake: {:.4} TON\n", + "Stake summary:".cyan().bold(), + self.binding, + binding.wallet, + wallet_address, + pool_address, + election_id, + time_format::format_ts(election_id), + self.amount, + self.max_factor, + elections_info.min_stake as f64 / 1_000_000_000.0, + ); + + if !confirm("Confirm stake?")? { + println!("{}", "Stake cancelled".yellow()); + return Ok(()); + } + + println!("{} Sending message to wallet...", "DOING".blue().bold()); + // Send via control protocol + provider.send_boc(&msg_boc).await.context("send stake message")?; + + wait_for_seqno_change( + rpc_client.clone(), + &wallet_address, + wallet_info_res.seqno, + &cancellation_ctx, + SEND_TIMEOUT, + ) + .await?; + + println!( + "{} Message delivered, waiting for stake to appear in elector...", + "OK ".green().bold() + ); + + let previous_stake = elections_info + .participants + .iter() + .find(|p| p.pub_key == pub_key) + .map(|p| p.stake) + .unwrap_or(0); + let expected_stake = previous_stake + stake_nanotons; + + let stake_timeout = tokio::time::Duration::from_secs(60); + wait_for_stake_accepted( + &elector, + &pub_key, + expected_stake, + &cancellation_ctx, + stake_timeout, + ) + .await?; + + println!("{} Stake accepted by elector", "OK ".green().bold()); + let _ = provider.shutdown().await; + Ok(()) + } +} + +const STAKE_POLL_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_secs(3); + +async fn wait_for_stake_accepted( + elector: &ElectorWrapperImpl, + pub_key: &[u8], + expected_stake: u64, + cancellation_ctx: &CancellationCtx, + max_wait: tokio::time::Duration, +) -> anyhow::Result<()> { + let poll = async { + loop { + if cancellation_ctx.is_cancelled() { + anyhow::bail!("Task cancelled"); + } + tokio::time::sleep(STAKE_POLL_INTERVAL).await; + let info = elector.elections_info().await.context("elections_info")?; + if let Some(p) = info.participants.iter().find(|p| p.pub_key == pub_key) { + if p.stake >= expected_stake { + return Ok(()); + } + } + } + }; + tokio::time::timeout(max_wait, poll) + .await + .map_err(|_| anyhow::anyhow!("Timeout waiting for stake to appear in elector"))? +} + +fn confirm(prompt: &str) -> anyhow::Result { + print!("{prompt} [y/N]: "); + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "Yes")) +} + +fn resolve_pool_address( + pool_cfg: &PoolConfig, + validator_addr: &MsgAddressInt, +) -> anyhow::Result { + match pool_cfg { + PoolConfig::SNP { address, owner } => match (address, owner) { + (Some(addr), _) => addr.parse::().context("invalid pool address"), + (None, Some(owner)) => { + let owner_addr = + owner.parse::().context("invalid pool owner address")?; + NominatorWrapperImpl::calculate_address(-1, &owner_addr, validator_addr) + } + (None, None) => anyhow::bail!("Pool has neither address nor owner configured"), + }, + _ => anyhow::bail!("Unsupported pool kind for manual stake"), + } +} diff --git a/src/node-control/commands/src/commands/nodectl/master_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/master_wallet_cmd.rs index 2373d2b..37f4bc6 100644 --- a/src/node-control/commands/src/commands/nodectl/master_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/master_wallet_cmd.rs @@ -6,11 +6,18 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::{load_config_vault_rpc_client, wallet_info}; +use crate::commands::nodectl::{ + output_format::OutputFormat, + utils::{load_config_vault_rpc_client, wallet_info}, +}; use colored::Colorize; -use common::{app_config::KeyConfig, ton_utils::nanotons_to_tons_f64}; -use secrets_vault::types::secret::Secret; -use std::path::Path; +use common::{ + app_config::{KeyConfig, WalletConfig}, + ton_utils::display_tons, +}; +use secrets_vault::{types::secret::Secret, vault::SecretVault}; +use std::{path::Path, sync::Arc}; +use ton_http_api_client::v2::client_json_rpc::ClientJsonRpc; #[derive(clap::Args, Clone)] #[command(about = "Master wallet info")] @@ -27,7 +34,10 @@ pub enum MasterWalletAction { #[derive(clap::Args, Clone)] #[command(about = "Show master wallet info")] -pub struct MasterWalletInfoCmd {} +pub struct MasterWalletInfoCmd { + #[arg(long = "format", default_value = "table", help = "Output format: table or json")] + format: OutputFormat, +} impl MasterWalletCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { @@ -37,6 +47,17 @@ impl MasterWalletCmd { } } +#[derive(serde::Serialize)] +struct MasterWalletView { + address: Option, + balance: Option, + state: Option, + version: String, + subwallet_id: u32, + secret: String, + public_key: Option, +} + impl MasterWalletInfoCmd { pub async fn run(&self, path: &Path) -> anyhow::Result<()> { let (config, vault, rpc_client) = load_config_vault_rpc_client(path).await?; @@ -46,44 +67,103 @@ impl MasterWalletInfoCmd { .as_ref() .ok_or_else(|| anyhow::anyhow!("master_wallet is not configured"))?; - let (address, account_state, balance, public_key) = - match wallet_info(rpc_client, master_wallet, vault).await { - Ok((address, info, secret)) => ( - address.to_string().white(), - info.account_state.to_string().white(), - format!("{:.4}", nanotons_to_tons_f64(info.balance)).white(), - if let Secret::KeyPair { keypair } = secret { - let public_key = keypair - .public_key() - .await? - .ok_or_else(|| anyhow::anyhow!("no public key"))?; - base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - public_key.as_slice(), - ) - .white() - } else { - "unknown".red() - }, - ), - Err(_) => ("unknown".red(), "unknown".red(), "unknown".red(), "unknown".red()), - }; - let secret_name = match &master_wallet.key { KeyConfig::VaultKey { name } => name.clone(), _ => "-".to_string(), }; - println!("\n{} {}\n", "OK".green().bold(), "Master Wallet".green()); - println!(" {:<16} {}", "Address:".cyan().bold(), address); - println!(" {:<16} {}", "Balance:".cyan().bold(), balance); - println!(" {:<16} {}", "State:".cyan().bold(), account_state); - println!(" {:<16} {}", "Version:".cyan().bold(), master_wallet.version); - println!(" {:<16} {}", "Subwallet ID:".cyan().bold(), master_wallet.subwallet_id); - println!(" {:<16} {}", "Secret:".cyan().bold(), secret_name); - println!(" {:<16} {}", "Public Key:".cyan().bold(), public_key); - println!(); + match self.format { + OutputFormat::Json => { + print_master_wallet_json(master_wallet, &secret_name, rpc_client, vault).await? + } + OutputFormat::Table => { + print_master_wallet_table(master_wallet, &secret_name, rpc_client, vault).await? + } + } Ok(()) } } + +async fn print_master_wallet_json( + master_wallet: &WalletConfig, + secret_name: &str, + rpc_client: Arc, + vault: Arc, +) -> anyhow::Result<()> { + let (address, state, balance, public_key) = + match wallet_info(rpc_client, master_wallet, vault).await { + Ok((address, info, secret)) => { + let pk = if let Secret::KeyPair { keypair } = secret { + keypair.public_key().await.ok().flatten().map(|pk| { + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + pk.as_slice(), + ) + }) + } else { + None + }; + ( + Some(address.to_string()), + Some(info.account_state.to_string()), + Some(display_tons(info.balance)), + pk, + ) + } + Err(_) => (None, None, None, None), + }; + + let view = MasterWalletView { + address, + balance, + state, + version: master_wallet.version.to_string(), + subwallet_id: master_wallet.subwallet_id, + secret: secret_name.to_string(), + public_key, + }; + println!("{}", serde_json::to_string_pretty(&view)?); + Ok(()) +} + +async fn print_master_wallet_table( + master_wallet: &WalletConfig, + secret_name: &str, + rpc_client: Arc, + vault: Arc, +) -> anyhow::Result<()> { + let (address, account_state, balance, public_key) = + match wallet_info(rpc_client, master_wallet, vault).await { + Ok((address, info, secret)) => ( + address.to_string().white(), + info.account_state.to_string().white(), + display_tons(info.balance).white(), + if let Secret::KeyPair { keypair } = secret { + let public_key = keypair + .public_key() + .await? + .ok_or_else(|| anyhow::anyhow!("no public key"))?; + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + public_key.as_slice(), + ) + .white() + } else { + "unknown".red() + }, + ), + Err(_) => ("unknown".red(), "unknown".red(), "unknown".red(), "unknown".red()), + }; + + println!("\n{} {}\n", "OK".green().bold(), "Master Wallet".green()); + println!(" {:<16} {}", "Address:".cyan().bold(), address); + println!(" {:<16} {}", "Balance:".cyan().bold(), balance); + println!(" {:<16} {}", "State:".cyan().bold(), account_state); + println!(" {:<16} {}", "Version:".cyan().bold(), master_wallet.version); + println!(" {:<16} {}", "Subwallet ID:".cyan().bold(), master_wallet.subwallet_id); + println!(" {:<16} {}", "Secret:".cyan().bold(), secret_name); + println!(" {:<16} {}", "Public Key:".cyan().bold(), public_key); + println!(); + Ok(()) +} diff --git a/src/node-control/commands/src/commands/nodectl/mod.rs b/src/node-control/commands/src/commands/nodectl/mod.rs index ca8bb28..2afb520 100644 --- a/src/node-control/commands/src/commands/nodectl/mod.rs +++ b/src/node-control/commands/src/commands/nodectl/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod config_wallet_cmd; pub(crate) mod deploy_cmd; pub(crate) mod key_cmd; pub(crate) mod master_wallet_cmd; +pub(crate) mod output_format; pub(crate) mod service_api_cmd; pub(crate) mod service_cmd; mod utils; diff --git a/src/node-control/commands/src/commands/nodectl/output_format.rs b/src/node-control/commands/src/commands/nodectl/output_format.rs new file mode 100644 index 0000000..c970339 --- /dev/null +++ b/src/node-control/commands/src/commands/nodectl/output_format.rs @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2025-2026 RSquad Blockchain Lab. + * + * Licensed under the GNU General Public License v3.0. + * See the LICENSE file in the root of this repository. + * + * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +#[derive(clap::ValueEnum, Clone, Default, PartialEq, Eq)] +pub enum OutputFormat { + #[default] + Table, + Json, +} diff --git a/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs b/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs index 1d07311..6a9b44b 100644 --- a/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs @@ -7,9 +7,14 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use anyhow::Context; -use common::app_config::{AppConfig, StakePolicy}; +use colored::Colorize; +use common::{ + app_config::{AppConfig, StakePolicy}, + ton_utils::display_tons_from_str, +}; use std::{ borrow::Cow, + collections::HashSet, io::{self, Read}, path::Path, }; @@ -17,18 +22,14 @@ use std::{ #[derive(clap::Args, Clone)] #[command(about = "Node control service REST API")] pub struct ApiCmd { + // URL to the service API #[arg( - short = 'c', - long = "config", - help = "Path to the configuration file", - default_value = "nodectl-config.json", - env = "CONFIG_PATH", - global = true + short = 'u', + long = "url", + value_hint = clap::ValueHint::Url, + help = "URL to the node control service API (takes precedence over --config)", + global = true, )] - config: String, - - // URL to the service API - #[arg(short = 'u', long = "url", value_hint = clap::ValueHint::Url, help = "URL to the node control service API (default: http://127.0.0.1:8080)")] pub url: Option, // JWT token to authenticate with the service API @@ -36,10 +37,21 @@ pub struct ApiCmd { long = "token", env = "NODECTL_API_TOKEN", value_name = "TOKEN", - help = "JWT token to authenticate with the service API (or NODECTL_API_TOKEN)" + help = "JWT token to authenticate with the service API (or NODECTL_API_TOKEN)", + global = true )] pub token: Option, + #[arg( + short = 'c', + long = "config", + help = "Path to the configuration file (default: nodectl-config.json)", + default_value = "nodectl-config.json", + env = "CONFIG_PATH", + global = true + )] + config: String, + #[command(subcommand)] action: ServiceAction, } @@ -48,13 +60,39 @@ pub struct ApiCmd { pub enum ServiceAction { Health, Elections(ElectionsCmd), - Validators, + Validators(ValidatorsCmd), Task(TaskCmd), StakePolicy(StakePolicyCmd), /// Authenticate and print the JWT token Login(LoginCmd), } +#[derive(clap::Args, Clone)] +pub struct ValidatorsCmd { + #[arg( + long = "format", + default_value = "table", + help = "Output format for validators view: table or json" + )] + pub format: ValidatorsOutputFormat, + #[arg( + short = 'f', + long = "filter", + value_delimiter = ',', + action = clap::ArgAction::Append, + value_parser = parse_node_name, + help = "Filter output by controlled node name (repeatable, or comma-separated)" + )] + pub filter: Vec, +} + +#[derive(clap::ValueEnum, Clone, Default, PartialEq, Eq)] +pub enum ValidatorsOutputFormat { + #[default] + Table, + Json, +} + #[derive(clap::Args, Clone)] pub struct LoginCmd { #[arg(required = true, help = "Username to authenticate with")] @@ -100,6 +138,33 @@ pub struct ElectionsCmd { help = "List of controlled nodes to be included in elections (repeatable, or comma-separated)" )] pub include: Vec, + #[arg( + long = "all-participants", + help = "Include full elections participants list (disabled by default)" + )] + pub all_participants: bool, + #[arg( + long = "format", + default_value = "table", + help = "Output format for elections view: table or json" + )] + pub format: ElectionsOutputFormat, + #[arg( + short = 'f', + long = "filter", + value_delimiter = ',', + action = clap::ArgAction::Append, + value_parser = parse_node_name, + help = "Filter output by controlled node name (repeatable, or comma-separated)" + )] + pub filter: Vec, +} + +#[derive(clap::ValueEnum, Clone, Default, PartialEq, Eq)] +pub enum ElectionsOutputFormat { + #[default] + Table, + Json, } #[derive(clap::Args, Clone)] @@ -156,13 +221,30 @@ impl ApiCmd { .await?; } if cmd.exclude.is_empty() && cmd.include.is_empty() { - let url = join_url(&base_url, "/v1/elections"); - send_get(&client, &url, token).await?; + let mut url = join_url(&base_url, "/v1/elections"); + if cmd.all_participants { + url.push_str("?include_participants=true"); + } + let body = send_get_raw(&client, &url, token).await?; + let body = + filter_response_by_nodes(&body, &cmd.filter, NodeFilterTarget::Elections)?; + if cmd.format == ElectionsOutputFormat::Json { + print_json(&body); + } else { + print_elections_table(&body)?; + } } } - ServiceAction::Validators => { + ServiceAction::Validators(cmd) => { let url = join_url(&base_url, "/v1/validators"); - send_get(&client, &url, token).await?; + let body = send_get_raw(&client, &url, token).await?; + let body = + filter_response_by_nodes(&body, &cmd.filter, NodeFilterTarget::Validators)?; + if cmd.format == ValidatorsOutputFormat::Json { + print_json(&body); + } else { + print_validators_table(&body)?; + } } ServiceAction::Task(cmd) => { let url = join_url(&base_url, &format!("/v1/task/{}", cmd.name)); @@ -210,6 +292,62 @@ impl LoginCmd { } } +#[derive(Clone, Copy)] +enum NodeFilterTarget { + Elections, + Validators, +} + +fn filter_response_by_nodes( + body: &str, + filters: &[String], + target: NodeFilterTarget, +) -> anyhow::Result { + if filters.is_empty() { + return Ok(body.to_owned()); + } + + let wanted: HashSet<&str> = filters.iter().map(String::as_str).collect(); + let mut value: serde_json::Value = serde_json::from_str(body).with_context(|| { + let what = match target { + NodeFilterTarget::Elections => "elections", + NodeFilterTarget::Validators => "validators", + }; + format!("failed to parse {what} response JSON while applying --filter") + })?; + + match target { + NodeFilterTarget::Elections => { + if let Some(items) = + value.get_mut("our_participants").and_then(serde_json::Value::as_array_mut) + { + items.retain(|item| { + item.get("node_id") + .and_then(serde_json::Value::as_str) + .map(|node_id| wanted.contains(node_id)) + .unwrap_or(false) + }); + } + } + NodeFilterTarget::Validators => { + if let Some(items) = value + .get_mut("result") + .and_then(|v| v.get_mut("controlled_nodes")) + .and_then(serde_json::Value::as_array_mut) + { + items.retain(|item| { + item.get("node_id") + .and_then(serde_json::Value::as_str) + .map(|node_id| wanted.contains(node_id)) + .unwrap_or(false) + }); + } + } + } + + Ok(serde_json::to_string(&value).context("failed to re-serialize filtered response to JSON")?) +} + #[derive(Clone, serde::Serialize)] struct StakePolicyRequest { policy: StakePolicy, @@ -260,37 +398,49 @@ fn join_url(base: &str, path: &str) -> String { } async fn send_get(client: &reqwest::Client, url: &str, token: Option<&str>) -> anyhow::Result<()> { - let mut req = client.get(url); + let body = send_get_raw(client, url, token).await?; + print_json(&body); + Ok(()) +} + +async fn send_post( + client: &reqwest::Client, + url: &str, + payload: &T, + token: Option<&str>, +) -> anyhow::Result<()> { + let mut req = client.post(url).json(payload); if let Some(t) = token { req = req.header("Authorization", format!("Bearer {t}")); } let response = req.send().await?; let status = response.status(); let body = response.text().await?; - handle_response(status, &body) + ensure_success(status, &body)?; + print_json(&body); + Ok(()) } -async fn send_post( +async fn send_get_raw( client: &reqwest::Client, url: &str, - payload: &T, token: Option<&str>, -) -> anyhow::Result<()> { - let mut req = client.post(url).json(payload); +) -> anyhow::Result { + let mut req = client.get(url); if let Some(t) = token { req = req.header("Authorization", format!("Bearer {t}")); } let response = req.send().await?; let status = response.status(); let body = response.text().await?; - handle_response(status, &body) + ensure_success(status, &body)?; + Ok(body) } -fn handle_response(status: reqwest::StatusCode, body: &str) -> anyhow::Result<()> { +fn ensure_success(status: reqwest::StatusCode, body: &str) -> anyhow::Result<()> { if !status.is_success() { anyhow::bail!("request failed: status={}, body={}", status, body); } - print_json(body); Ok(()) } @@ -307,6 +457,311 @@ fn print_json(body: &str) { } } +fn print_validators_table(body: &str) -> anyhow::Result<()> { + let value: serde_json::Value = serde_json::from_str(body)?; + let ok = value.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false); + let result = value.get("result"); + + let nodes = + result.and_then(|r| r.get("controlled_nodes")).and_then(serde_json::Value::as_array); + + let default_policy = result + .and_then(|r| r.get("default_stake_policy")) + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Object(obj) => { + obj.iter() + .next() + .map(|(k, v)| { + if let Some(n) = v.as_u64() { + format!("{}({})", k, n) + } else { + k.to_string() + } + }) + .unwrap_or_else(|| "-".to_string()) + } + _ => "-".to_string(), + }) + .unwrap_or_else(|| "-".to_string()); + + let election_id = nodes + .and_then(|arr| { + arr.iter().filter_map(|n| n.get("key_election_id").and_then(|v| v.as_u64())).next() + }) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()); + + let validation_range = result.and_then(|r| r.get("validation_range")); + let validation_start = validation_range + .and_then(|r| r.get("start_utc")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let validation_end = validation_range + .and_then(|r| r.get("end_utc")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + + println!( + "\n{} Validators (default policy: {})\n", + if ok { "OK".green().bold() } else { "ERR".red().bold() }, + default_policy + ); + println!(" {} {}", format!("{:<26}", "Election ID:").bold(), election_id); + println!(" {} {}", format!("{:<26}", "Validation start:").bold(), validation_start); + println!(" {} {}", format!("{:<26}", "Validation end:").bold(), validation_end); + + let Some(nodes) = nodes else { + println!(" {}\n", "No nodes in response".yellow()); + return Ok(()); + }; + + if nodes.is_empty() { + println!(" {}\n", "No controlled nodes".yellow()); + return Ok(()); + } + + let total_weight: u64 = + nodes.iter().filter_map(|n| n.get("weight").and_then(|v| v.as_u64())).sum(); + + println!( + "\n {} {} {} {} {} {} {} {}", + format!("{:<14}", "Node").cyan().bold(), + format!("{:<13}", "Status").cyan().bold(), + format!("{:<6}", "Index").cyan().bold(), + format!("{:<10}", "Weight %").cyan().bold(), + format!("{:<15}", "Stake TON").cyan().bold(), + format!("{:<10}", "Key").cyan().bold(), + format!("{:<44}", "Pubkey").cyan().bold(), + "ADNL".cyan().bold(), + ); + println!(" {}", "-".repeat(125).dimmed()); + + for node in nodes { + let node_id = binding_str(node, "node_id"); + + let binding_status = node.get("binding_status").and_then(|v| v.as_str()).unwrap_or("idle"); + let status = match binding_status { + "validating" => format!("{:<13}", "validating").green().bold().to_string(), + "participating" => format!("{:<13}", "participating").blue().to_string(), + "draining" => format!("{:<13}", "draining").yellow().to_string(), + "idle" => format!("{:<13}", "idle").dimmed().to_string(), + other => format!("{:<13}", other), + }; + + let validator_index = node + .get("validator_index") + .and_then(|v| v.as_u64()) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()); + + let weight_pct = node + .get("weight") + .and_then(|v| v.as_u64()) + .filter(|_| total_weight > 0) + .map(|w| format!("{:.2}%", (w as f64 / total_weight as f64) * 100.0)) + .unwrap_or_else(|| "-".to_string()); + + let stake = node.get("stake").and_then(|v| v.as_str()).unwrap_or("-"); + let stake_display = display_tons_from_str(stake); + + let is_key_active = node.get("is_key_active").and_then(|v| v.as_bool()); + let key_status = match is_key_active { + Some(true) => format!("{:<10}", "active").green().to_string(), + Some(false) => format!("{:<10}", "expired").yellow().to_string(), + None => format!("{:<10}", "-"), + }; + + let pubkey = binding_str(node, "pubkey"); + let adnl = binding_str(node, "adnl"); + + println!( + " {:<14} {} {:<6} {:<10} {:<15} {} {:<44} {}", + node_id, status, validator_index, weight_pct, stake_display, key_status, pubkey, adnl, + ); + + if let Some(err) = node.get("last_error").and_then(|v| v.as_str()) { + if !err.is_empty() { + println!(" {} {}: {}", " ".repeat(14), "Error".red().bold(), err); + } + } + } + println!(); + Ok(()) +} + +fn print_elections_table(body: &str) -> anyhow::Result<()> { + let value: serde_json::Value = serde_json::from_str(body)?; + let ok = value.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false); + let status = value.get("status").and_then(serde_json::Value::as_str).unwrap_or("-").to_string(); + let result = value.get("result"); + + let participants_count = result + .and_then(|r| r.get("participants_count")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + + let election_id = result + .and_then(|r| r.get("election_id")) + .and_then(serde_json::Value::as_u64) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()); + + // Elections time range + let elections_range = result.and_then(|r| r.get("elections_range")); + let elections_start = elections_range + .and_then(|r| r.get("start_utc")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let elections_end = elections_range + .and_then(|r| r.get("end_utc")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + + let min_stake = + result.and_then(|r| r.get("min_stake")).and_then(serde_json::Value::as_str).unwrap_or("-"); + let participant_min_stake = result + .and_then(|r| r.get("participant_min_stake")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let participant_max_stake = result + .and_then(|r| r.get("participant_max_stake")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let total_stake = result + .and_then(|r| r.get("total_stake")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + + println!("\n{} Elections\n", if ok { "OK".green().bold() } else { "ERR".red().bold() }); + println!(" {} {}", format!("{:<26}", "Status:").bold(), status); + println!(" {} {}", format!("{:<26}", "Election ID:").bold(), election_id); + println!(" {} {}", format!("{:<26}", "Elections start:").bold(), elections_start); + println!(" {} {}", format!("{:<26}", "Elections end:").bold(), elections_end); + println!(" {} {}", format!("{:<26}", "Participants count:").bold(), participants_count); + println!(" {} {}", format!("{:<26}", "Min stake:").bold(), display_tons_from_str(min_stake)); + println!( + " {} {}", + format!("{:<26}", "Participant min stake:").bold(), + display_tons_from_str(participant_min_stake) + ); + println!( + " {} {}", + format!("{:<26}", "Participant max stake:").bold(), + display_tons_from_str(participant_max_stake) + ); + println!( + " {} {}", + format!("{:<26}", "Total stake:").bold(), + display_tons_from_str(total_stake) + ); + + let Some(participants) = value.get("our_participants").and_then(serde_json::Value::as_array) + else { + println!("\n {}\n", "No participants in response".yellow()); + return Ok(()); + }; + + if participants.is_empty() { + println!("\n {}\n", "No controlled participants".yellow()); + return Ok(()); + } + + println!("\n {} ({})\n", "Our Participants".cyan().bold(), participants.len()); + println!( + " {} {} {} {} {} {} {} {} {}", + format!("{:<14}", "Node").cyan().bold(), + format!("{:<13}", "Status").cyan().bold(), + format!("{:<5}", "Pos").cyan().bold(), + format!("{:<15}", "Submitted TON").cyan().bold(), + format!("{:<15}", "Accepted TON").cyan().bold(), + format!("{:<24}", "Submitted At").cyan().bold(), + format!("{:<6}", "MaxF").cyan().bold(), + format!("{:<44}", "Pubkey").cyan().bold(), + "ADNL".cyan().bold(), + ); + println!(" {}", "-".repeat(148).dimmed()); + + for p in participants { + let node = binding_str(p, "node_id"); + + // Get status with color coding (pad BEFORE coloring to fix alignment) + // Flow: Idle → Participating → Submitted → Accepted → Elected → Validating + let status_raw = p.get("status").and_then(serde_json::Value::as_str).unwrap_or("idle"); + let status = match status_raw { + "validating" => format!("{:<13}", "validating").green().bold().to_string(), + "elected" => format!("{:<13}", "elected").green().to_string(), + "accepted" => format!("{:<13}", "accepted").cyan().to_string(), + "submitted" => format!("{:<13}", "submitted").yellow().to_string(), + "participating" => format!("{:<13}", "participating").blue().to_string(), + _ => format!("{:<13}", "idle"), + }; + + // Get position + let position = p + .get("position") + .and_then(serde_json::Value::as_u64) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()); + + // Get stake submissions info + let submissions = p.get("stake_submissions").and_then(serde_json::Value::as_array); + + // Sum all submitted stakes + let total_submitted: u64 = submissions + .map(|arr| { + arr.iter() + .filter_map(|s| s.get("stake").and_then(serde_json::Value::as_str)) + .filter_map(|s| s.parse::().ok()) + .sum() + }) + .unwrap_or(0); + let submitted_stake = + if total_submitted > 0 { total_submitted.to_string() } else { "-".to_string() }; + + // Get last submission time and max_factor + let last_submission = submissions.and_then(|arr| arr.last()); + let submitted_at = last_submission + .and_then(|s| s.get("submission_time_utc")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let max_factor = last_submission + .and_then(|s| s.get("max_factor")) + .and_then(serde_json::Value::as_f64) + .map(|v| format!("{:.1}", v)) + .unwrap_or_else(|| "-".to_string()); + + let accepted_stake = binding_str(p, "accepted_stake"); + let pubkey = binding_str(p, "pubkey"); + let adnl = binding_str(p, "adnl"); + + println!( + " {:<14} {} {:<5} {:<15} {:<15} {:<24} {:<6} {:<44} {}", + node, + status, + position, + display_tons_from_str(&submitted_stake), + display_tons_from_str(&accepted_stake), + submitted_at, + max_factor, + pubkey, + adnl, + ); + } + println!(); + Ok(()) +} + +fn binding_str(value: &serde_json::Value, key: &str) -> String { + value + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| "-".to_string()) +} + fn parse_node_name(s: &str) -> Result { let v = s.trim(); if v.is_empty() { @@ -314,3 +769,212 @@ fn parse_node_name(s: &str) -> Result { } Ok(v.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_print_elections_table_formatting() { + let mock_json = r#"{ + "ok": true, + "status": "active", + "result": { + "election_id": 1742300000, + "elections_range": { + "start": 1742300000, + "start_utc": "2026-03-18 08:00:00", + "end": 1742386400, + "end_utc": "2026-03-19 08:00:00" + }, + "participants_count": 5, + "min_stake": "10000000000000", + "participant_min_stake": "10000000000000", + "participant_max_stake": "50000000000000", + "total_stake": "100000000000000" + }, + "our_participants": [ + { + "node_id": "node1", + "status": "validating", + "elected": true, + "position": 1, + "stake_accepted": true, + "accepted_stake": "25000000000000", + "pubkey": "obss1OX2obss1OX2obss1OX2obss1OX2obss1OX2obs=", + "adnl": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "stake_submissions": [ + {"stake": "15000000000000", "max_factor": 3.0, "submission_time_utc": "2026-03-18 10:00:00"}, + {"stake": "10000000000000", "max_factor": 3.0, "submission_time_utc": "2026-03-18 12:30:00"} + ] + }, + { + "node_id": "node2", + "status": "elected", + "elected": true, + "position": 2, + "stake_accepted": true, + "accepted_stake": "20000000000000", + "pubkey": "ssPE5fahr7LD5OX2oa+yw+Tl9qGvssPk5fahrssPk5c=", + "adnl": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", + "stake_submissions": [ + {"stake": "20000000000000", "max_factor": 2.5, "submission_time_utc": "2026-03-18 11:00:00"} + ] + }, + { + "node_id": "node3", + "status": "submitted", + "elected": false, + "position": 5, + "stake_accepted": false, + "accepted_stake": null, + "pubkey": "w9Tl9qGvssLk5fahrsPU5fahtcPU5fahrsPS5fahr8M=", + "adnl": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=", + "stake_submissions": [ + {"stake": "10000000000000", "max_factor": 2.0, "submission_time_utc": "2026-03-18 12:00:00"} + ] + }, + { + "node_id": "node4", + "status": "accepted", + "elected": false, + "position": 10, + "stake_accepted": true, + "accepted_stake": "12000000000000", + "pubkey": "1OX2oa+yw+Tl9qGuw9Tl9qG1w9Tl9qGuw9Ll9qGvw9Q=", + "adnl": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=", + "stake_submissions": [ + {"stake": "12000000000000", "max_factor": 2.2, "submission_time_utc": "2026-03-18 13:00:00"} + ] + }, + { + "node_id": "node5", + "status": "participating", + "elected": false, + "position": null, + "stake_accepted": false, + "accepted_stake": null, + "pubkey": "5fahrsPU5fahrsPS5fahrsPU5fahrcPU5fahrsPU5fY=", + "adnl": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=", + "stake_submissions": [] + }, + { + "node_id": "node6", + "status": "idle", + "elected": false, + "position": null, + "stake_accepted": false, + "accepted_stake": null, + "pubkey": "", + "adnl": "", + "stake_submissions": [] + } + ] + }"#; + + // This test just verifies the function runs without panic + // Run with `cargo test -p commands test_print_elections -- --nocapture` to see output + let result = print_elections_table(mock_json); + assert!(result.is_ok()); + } + + #[test] + fn test_print_validators_table_formatting() { + let mock_json = r#"{ + "ok": true, + "result": { + "default_stake_policy": "split50", + "validation_range": { + "start": 1742300000, + "start_utc": "2026-03-18 08:00:00", + "end": 1742386400, + "end_utc": "2026-03-19 08:00:00" + }, + "controlled_nodes": [ + { + "node_id": "node1", + "is_validator": true, + "validator_index": 0, + "weight": 10000, + "wallet_addr": "-1:aabbccdd", + "stake": "25000000000000", + "stake_accepted": true, + "key_election_id": 1742300000, + "key_expires_at_utc": "2026-03-19 08:00:00", + "is_key_active": true, + "pubkey": "obss1OX2obss1OX2obss1OX2obss1OX2obss1OX2obs=", + "adnl": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "binding_status": "validating" + }, + { + "node_id": "node2", + "is_validator": false, + "wallet_addr": "-1:ddeeff00", + "stake": "15000000000000", + "stake_accepted": false, + "key_election_id": 1742300000, + "key_expires_at_utc": "2026-03-19 08:00:00", + "is_key_active": true, + "pubkey": "ssPE5fahr7LD5OX2oa+yw+Tl9qGvssPk5fahrssPk5c=", + "adnl": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", + "binding_status": "participating" + }, + { + "node_id": "node3", + "is_validator": false, + "wallet_addr": "-1:11223344", + "stake_accepted": false, + "binding_status": "idle" + } + ] + } + }"#; + + let result = print_validators_table(mock_json); + assert!(result.is_ok()); + } + + #[test] + fn test_filter_response_by_nodes_for_elections() { + let body = r#"{ + "ok": true, + "status": "active", + "result": {"election_id": 1}, + "our_participants": [ + {"node_id": "node1", "status": "idle"}, + {"node_id": "node2", "status": "submitted"} + ] + }"#; + + let filtered = + filter_response_by_nodes(body, &["node2".to_string()], NodeFilterTarget::Elections) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&filtered).unwrap(); + let participants = value["our_participants"].as_array().unwrap(); + + assert_eq!(participants.len(), 1); + assert_eq!(participants[0]["node_id"], "node2"); + } + + #[test] + fn test_filter_response_by_nodes_for_validators() { + let body = r#"{ + "ok": true, + "result": { + "controlled_nodes": [ + {"node_id": "node1", "is_validator": true}, + {"node_id": "node2", "is_validator": false} + ] + } + }"#; + + let filtered = + filter_response_by_nodes(body, &["node1".to_string()], NodeFilterTarget::Validators) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&filtered).unwrap(); + let nodes = value["result"]["controlled_nodes"].as_array().unwrap(); + + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0]["node_id"], "node1"); + } +} diff --git a/src/node-control/commands/src/commands/nodectl/utils.rs b/src/node-control/commands/src/commands/nodectl/utils.rs index 8930162..6601a5b 100644 --- a/src/node-control/commands/src/commands/nodectl/utils.rs +++ b/src/node-control/commands/src/commands/nodectl/utils.rs @@ -30,16 +30,25 @@ pub const SEND_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs pub const DEPLOY_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(60); pub fn warn_missing_secret(secret_name: &str) { + println!("\n{} {}", "[WARNING]".yellow().bold(), "Vault secret is missing".yellow(),); println!( - "{}", - format!( - "Warning: Secret '{}' does not exist in vault. Create it with: nodectl key add --name {}", - secret_name, secret_name - ) - .yellow() + " {} Secret '{}' does not exist in vault", + "Reason:".yellow().bold(), + secret_name.yellow() + ); + println!( + " {} {}", + "Note:".yellow().bold(), + format!("Create it with `nodectl key add --name {secret_name}`").yellow().italic() ); } +pub fn warn_ton_api_unavailable(error: &anyhow::Error, note: &str) { + println!("\n{} {}", "[WARNING]".yellow().bold(), "Failed to connect to TON API".yellow(),); + println!(" {} {}", "Reason:".yellow().bold(), error.root_cause().to_string()); + println!(" {} {}", "Note:".yellow().bold(), note.yellow().italic()); +} + pub fn save_config(config: &AppConfig, path: &Path) -> anyhow::Result<()> { let json = serde_json::to_string_pretty(config)?; fs::write(path, json)?; @@ -55,7 +64,7 @@ pub async fn load_config_vault( Ok((config, vault)) } -async fn check_ton_api_connection(rpc_client: &ClientJsonRpc) -> anyhow::Result<()> { +pub async fn check_ton_api_connection(rpc_client: &ClientJsonRpc) -> anyhow::Result<()> { rpc_client.get_config_param(1).await.map(|_| ()) } @@ -82,11 +91,10 @@ pub async fn load_config_vault_rpc_client( Ok((config, vault, rpc_client)) } -pub async fn wallet_info( - rpc_client: Arc, +pub async fn wallet_address( wallet_cfg: &WalletConfig, vault: Arc, -) -> anyhow::Result<(MsgAddressInt, GetWalletInformationRes, Secret)> { +) -> anyhow::Result<(MsgAddressInt, Secret)> { let secret = wallet_cfg.key.read_secret(Some(vault)).await?; let keypair = secret.as_keypair()?; @@ -95,9 +103,17 @@ pub async fn wallet_info( .await? .ok_or_else(|| anyhow::anyhow!(VaultError::empty_public_key("Empty public key")))?; - let wallet_address = - calculate_wallet_address(wallet_cfg, &pub_key).context("calculate_address")?; + let address = calculate_wallet_address(wallet_cfg, &pub_key).context("calculate_address")?; + Ok((address, secret)) +} + +pub async fn wallet_info( + rpc_client: Arc, + wallet_cfg: &WalletConfig, + vault: Arc, +) -> anyhow::Result<(MsgAddressInt, GetWalletInformationRes, Secret)> { + let (wallet_address, secret) = wallet_address(wallet_cfg, vault).await?; let wallet_info = rpc_client.get_wallet_information(&wallet_address).await?; Ok((wallet_address, wallet_info, secret)) diff --git a/src/node-control/common/Cargo.toml b/src/node-control/common/Cargo.toml index a86df60..1de397b 100644 --- a/src/node-control/common/Cargo.toml +++ b/src/node-control/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' @@ -12,10 +12,8 @@ utoipa = { version = "4", optional = true } hex = { version = "0.4" } base64 = "0.22" tokio = { version = "1.40", features = ["signal"] } -tokio-util = "0.7" tracing = "0.1" libc = "0.2" -yaml-rust2 = "0.10" serde_yaml2 = "0.1" async-trait = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/node-control/common/src/app_config.rs b/src/node-control/common/src/app_config.rs index 3a378ad..4fe7c2e 100644 --- a/src/node-control/common/src/app_config.rs +++ b/src/node-control/common/src/app_config.rs @@ -188,7 +188,7 @@ impl KeyConfig { } fn default_http_bind() -> String { - "127.0.0.1:8080".to_owned() + "0.0.0.0:8080".to_owned() } fn default_http_enable_swagger() -> bool { @@ -299,7 +299,10 @@ pub struct HttpConfig { pub enable_swagger: bool, /// Authentication and authorization configuration. - /// When `None`, all routes are open (no auth). + /// When `Some`, all protected routes require a valid JWT token. + /// When `None`, all routes are open (auth explicitly disabled). + /// Default: enabled with no users — all protected endpoints return 401 + /// until at least one user is created via `nodectl auth add`. #[serde(skip_serializing_if = "Option::is_none")] pub auth: Option, } @@ -309,7 +312,7 @@ impl Default for HttpConfig { Self { bind: default_http_bind(), enable_swagger: default_http_enable_swagger(), - auth: None, + auth: Some(AuthConfig::default()), } } } diff --git a/src/node-control/common/src/snapshot.rs b/src/node-control/common/src/snapshot.rs index d03b73a..578bdfd 100644 --- a/src/node-control/common/src/snapshot.rs +++ b/src/node-control/common/src/snapshot.rs @@ -26,6 +26,9 @@ pub struct Snapshot { /// Next elections range. pub next_elections_range: Option, + /// Election participation status for all controlled nodes. + pub our_participants: Vec, + /// Validators snapshot. pub validators: ValidatorsSnapshot, } @@ -72,9 +75,17 @@ pub struct ElectionsSnapshot { /// Participants list pub participants: Vec, - /// Minimum stake (nanotons, decimal string). + /// Minimum stake required by elections config/params (nanotons, decimal string). pub min_stake: String, + /// Minimum stake among current participants (nanotons, decimal string). + #[serde(skip_serializing_if = "Option::is_none")] + pub participant_min_stake: Option, + + /// Maximum stake among current participants (nanotons, decimal string). + #[serde(skip_serializing_if = "Option::is_none")] + pub participant_max_stake: Option, + /// Total stake (nanotons, decimal string). pub total_stake: String, @@ -101,7 +112,7 @@ pub struct TimeRange { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] pub struct ElectionsParticipantSnapshot { - /// Validator public key (hex). + /// Validator public key (base64). pub pubkey: String, /// ADNL address (base64). @@ -123,6 +134,110 @@ pub struct ElectionsParticipantSnapshot { pub election_id: u64, } +/// Single stake submission record. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct StakeSubmission { + /// Stake sent to elector (nanotons, decimal string). + pub stake: String, + + /// Max factor used for this submission. + pub max_factor: f32, + + /// Time when stake was submitted (unix seconds). + pub submission_time: u64, + + /// Time when stake was submitted (UTC string). + pub submission_time_utc: String, +} + +/// Participation status enum for election flow. +/// Flow: Idle → Participating → Submitted → Accepted → Elected → Validating +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ParticipationStatus { + /// Node is not participating in elections. + #[default] + Idle, + /// Node has generated election key, preparing to submit stake. + Participating, + /// Stake has been submitted to the elector. + Submitted, + /// Stake was accepted by the elector. + Accepted, + /// Node is elected in next validator set (p36) but not yet validating. + Elected, + /// Node is actively validating (in current validator set p34). + Validating, +} + +impl std::fmt::Display for ParticipationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParticipationStatus::Idle => write!(f, "idle"), + ParticipationStatus::Participating => write!(f, "participating"), + ParticipationStatus::Submitted => write!(f, "submitted"), + ParticipationStatus::Accepted => write!(f, "accepted"), + ParticipationStatus::Elected => write!(f, "elected"), + ParticipationStatus::Validating => write!(f, "validating"), + } + } +} + +/// Election participation status for a controlled node. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +pub struct OurElectionParticipant { + /// Node id from config. + pub node_id: String, + + /// Current participation status. + pub status: ParticipationStatus, + + /// Validator public key (base64). + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option, + + /// Key id (base64). + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option, + + /// ADNL address (base64). + #[serde(skip_serializing_if = "Option::is_none")] + pub adnl: Option, + + /// History of stake submissions for this election cycle. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub stake_submissions: Vec, + + /// Stake accepted by elector (nanotons, decimal string). + #[serde(skip_serializing_if = "Option::is_none")] + pub accepted_stake: Option, + + /// Whether the stake was accepted by the elector. + pub stake_accepted: bool, + + /// Whether the node was elected. + pub elected: bool, + + /// Position in the ranked participant list (1-based, by stake descending). + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, + + /// Wallet address. + #[serde(skip_serializing_if = "Option::is_none")] + pub wallet_addr: Option, + + /// Pool address (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub pool_addr: Option, + + /// Last error (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + /// Validators snapshot. #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Clone, serde::Serialize, serde::Deserialize, Default)] @@ -130,9 +245,12 @@ pub struct ValidatorsSnapshot { /// Only nodectl-controlled nodes from config pub controlled_nodes: Vec, pub default_stake_policy: StakePolicy, + /// Current validation time range (from p34 validator set). + #[serde(skip_serializing_if = "Option::is_none")] + pub validation_range: Option, } -/// Per-node status. +/// Per-node validator status. #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[derive(Clone, serde::Serialize, serde::Deserialize, Default)] pub struct ValidatorNodeSnapshot { @@ -142,44 +260,74 @@ pub struct ValidatorNodeSnapshot { /// In current validator set (if known). pub is_validator: bool, - /// Index in validator set (if known). + /// Index in validator set (0-based). + #[serde(skip_serializing_if = "Option::is_none")] pub validator_index: Option, - /// Wallet address (if known). + /// Validator weight in the set. + #[serde(skip_serializing_if = "Option::is_none")] + pub weight: Option, + + /// Wallet address. + #[serde(skip_serializing_if = "Option::is_none")] pub wallet_addr: Option, /// Pool address (if any). + #[serde(skip_serializing_if = "Option::is_none")] pub pool_addr: Option, - /// Max factor. - pub max_factor: Option, - - /// Public key (hex). + /// Public key (base64). + #[serde(skip_serializing_if = "Option::is_none")] pub pubkey: Option, - /// ADNL (base64). + /// ADNL address (base64). + #[serde(skip_serializing_if = "Option::is_none")] pub adnl: Option, - /// Stake sent to elector (nanotons, decimal string). - pub stake: Option, - /// Key id (base64). + #[serde(skip_serializing_if = "Option::is_none")] pub key_id: Option, - /// Stake accepted. + /// Election ID for which the key was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_election_id: Option, + + /// Key expiration timestamp (unix seconds). + #[serde(skip_serializing_if = "Option::is_none")] + pub key_expires_at: Option, + + /// Key expiration timestamp in UTC. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_expires_at_utc: Option, + + /// Whether the key is still active (not expired). + #[serde(skip_serializing_if = "Option::is_none")] + pub is_key_active: Option, + + /// Stake submitted to elections (nanotons as decimal string). + #[serde(skip_serializing_if = "Option::is_none")] + pub stake: Option, + + /// Stake accepted by the elector. pub stake_accepted: bool, /// Last error (if any). + #[serde(skip_serializing_if = "Option::is_none")] pub last_error: Option, - /// Effective stake policy for this node (override or default). - pub stake_policy: StakePolicy, - /// Binding lifecycle status. #[serde(default)] pub binding_status: BindingStatus, } +/// View for `/v1/elections` endpoint. +pub struct ElectionsView { + pub status: ElectionsStatus, + pub elections: Option, + pub next_elections: Option, + pub our_participants: Vec, +} + /// In-memory snapshot store. pub struct SnapshotStore { inner: RwLock, @@ -194,6 +342,40 @@ impl SnapshotStore { self.inner.read().expect("SnapshotStore poisoned (read)").clone() } + /// Read a view optimized for `/v1/elections`. + /// When `include_participants` is false, participants are not cloned (empty list returned). + pub fn get_elections_view(&self, include_participants: bool) -> ElectionsView { + let guard = self.inner.read().expect("SnapshotStore poisoned (read)"); + let elections = guard.elections.as_ref().map(|src| { + if include_participants { + src.clone() + } else { + // Clone only metadata, skip participants to avoid large allocation + ElectionsSnapshot { + election_id: src.election_id, + elect_close: src.elect_close, + elect_close_utc: src.elect_close_utc.clone(), + finished: src.finished, + failed: src.failed, + participants_count: src.participants_count, + participants: Vec::new(), + min_stake: src.min_stake.clone(), + participant_min_stake: src.participant_min_stake.clone(), + participant_max_stake: src.participant_max_stake.clone(), + total_stake: src.total_stake.clone(), + next_validation_range: src.next_validation_range.clone(), + elections_range: src.elections_range.clone(), + } + } + }); + ElectionsView { + status: guard.elections_status.clone(), + elections, + next_elections: guard.next_elections_range.clone(), + our_participants: guard.our_participants.clone(), + } + } + /// Update snapshot in-place and auto-update `generated_at`. pub fn update_with(&self, f: F) where diff --git a/src/node-control/common/src/ton_utils.rs b/src/node-control/common/src/ton_utils.rs index 485d0ef..8837072 100644 --- a/src/node-control/common/src/ton_utils.rs +++ b/src/node-control/common/src/ton_utils.rs @@ -17,3 +17,37 @@ pub fn tons_f64_to_nanotons(tons: f64) -> u64 { pub fn nanotons_to_tons_f64(nanotons: u64) -> f64 { nanotons as f64 / 1_000_000_000.0 } + +pub fn display_tons(nanotons: u64) -> String { + format!("{:.4}", nanotons_to_tons_f64(nanotons)) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + +/// Parse a nanotons decimal string and format as TON (4 decimal places). +/// Returns the original string if it cannot be parsed. +pub fn display_tons_from_str(nanotons_str: &str) -> String { + nanotons_str + .trim() + .parse::() + .map(display_tons) + .unwrap_or_else(|_| nanotons_str.to_string()) +} + +#[cfg(test)] +mod tests { + use super::display_tons; + #[test] + fn test_display_tons() { + assert_eq!(display_tons(0_100_000_000), "0.1"); + assert_eq!(display_tons(1_000_000_000), "1"); + assert_eq!(display_tons(1_100_000_000), "1.1"); + assert_eq!(display_tons(1_100_100_000), "1.1001"); + assert_eq!(display_tons(1_100_010_000), "1.1"); + assert_eq!(display_tons(123_000_000_000), "123"); + assert_eq!(display_tons(123_450_000_000), "123.45"); + assert_eq!(display_tons(123_000_100_000), "123.0001"); + assert_eq!(display_tons(123_000_180_000), "123.0002"); + } +} diff --git a/src/node-control/contracts/Cargo.toml b/src/node-control/contracts/Cargo.toml index 86431ed..30b5ff2 100644 --- a/src/node-control/contracts/Cargo.toml +++ b/src/node-control/contracts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "contracts" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' @@ -10,7 +10,6 @@ async-trait = "0.1.89" common = { path = "../common" } ton_block = { path = "../../block" } hex = "0.4.3" -serde = { version = "1.0.228", features = ["derive"] } ton_api = { version = "0.4.32", path = "../../tl/ton_api" } ton-http-api-client = { path = "../ton-http-api-client" } diff --git a/src/node-control/control-client/Cargo.toml b/src/node-control/control-client/Cargo.toml index 5ad35f9..b26169f 100644 --- a/src/node-control/control-client/Cargo.toml +++ b/src/node-control/control-client/Cargo.toml @@ -1,14 +1,12 @@ [package] name = "control-client" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' [dependencies] serde = { version = "1.0.228", features = ["derive", "rc"] } serde_json = "1.0.145" -tokio = { version = "1.48.0", features = ["full"] } -tokio-util = "0.7" anyhow = "1.0" async-trait = "0.1" tracing = "0.1" diff --git a/src/node-control/control-client/src/config_params.rs b/src/node-control/control-client/src/config_params.rs index 6efcbdc..3c79f48 100644 --- a/src/node-control/control-client/src/config_params.rs +++ b/src/node-control/control-client/src/config_params.rs @@ -37,13 +37,21 @@ pub fn parse_config_param_15(bytes: &[u8]) -> anyhow::Result { } pub fn parse_config_param_34(bytes: &[u8]) -> anyhow::Result { + parse_validator_set(bytes, "p34") +} + +pub fn parse_config_param_36(bytes: &[u8]) -> anyhow::Result { + parse_validator_set(bytes, "p36") +} + +fn parse_validator_set(bytes: &[u8], key: &str) -> anyhow::Result { let param: serde_json::Value = serde_json::from_slice(bytes)?; let map = param .as_object() .ok_or_else(|| anyhow::anyhow!("invalid config param"))? - .get("p34") + .get(key) .and_then(|v| v.as_object()) - .ok_or_else(|| anyhow::anyhow!("p34 entry not found"))?; + .ok_or_else(|| anyhow::anyhow!("{} entry not found", key))?; let utime_since = map .get("utime_since") .and_then(|value| value.as_u64()) diff --git a/src/node-control/docs/nodectl-security.md b/src/node-control/docs/nodectl-security.md index a7679ce..6217ee4 100644 --- a/src/node-control/docs/nodectl-security.md +++ b/src/node-control/docs/nodectl-security.md @@ -1,17 +1,82 @@ # Nodectl Security Guide (for operators) This document explains how `nodectl` REST API security works in day-to-day operations. -It is written for trained system operators, not software developers. ## 1) Quick overview +| State | `http.auth` section | Users | Result | +|-------|---------------------|-------|--------| +| **Locked (default)** | present | empty | All protected endpoints return `401`. No one can log in. | +| **Protected** | present | ≥ 1 | Endpoints require a valid JWT. Users log in with username/password. | +| **Open access** | removed (`null` or absent) | — | All endpoints are accessible without a token. | + +- **Authentication is enabled by default.** A freshly generated config (`nodectl config generate`) includes the `http.auth` section with an empty user list — all protected endpoints return `401` until at least one user is created via `nodectl auth add`. +- On first start the service creates a JWT signing key in the vault (secret `auth.jwt-signing-key`). +- **No service restart is required** to enable or disable authentication — the service hot-reloads the configuration. - A user logs in to the REST API with `username/password`. - The API returns a JWT token with a limited lifetime. - The token is sent in `Authorization: Bearer `. - Allowed actions are determined by user role. - Token revocation is done via CLI (`nodectl auth ...`), not via REST API. -## 2) Roles and permissions +## 2) Three operational states + +### Locked (default for new installations) + +The generated config contains the `http.auth` section but no users: + +```json +{ + "http": { + "bind": "0.0.0.0:8080", + "enable_swagger": true, + "auth": { + "operator_token_ttl": 2592000, + "nominator_token_ttl": 86400, + "min_password_length": 8 + } + } +} +``` + +In this state every protected endpoint returns `401 Unauthorized`. The `/health` endpoint remains accessible. To unlock the API, create at least one user (see [Step-by-step](#step-by-step-enable-api-access)). + +### Protected + +After adding one or more users the API requires a valid JWT on every protected request. See [How to log in and use a token](#4-how-to-log-in-and-use-a-token). + +### Open access (auth disabled) + +To disable authentication and make all endpoints accessible without a token, explicitly remove the `http.auth` section from `config.json` (or set it to `null`): + +```json +{ + "http": { + "bind": "0.0.0.0:8080", + "enable_swagger": true + } +} +``` + +> **Warning:** Before disabling auth, make sure the API is not exposed externally. If `http.bind` is `0.0.0.0:…` and the service port is reachable from outside the pod/host, anyone can call any endpoint. + +The service picks up the change automatically — no restart required. + +### Step-by-step: enable API access + +```bash +# 1. Create a user (inside the pod or on the host) +nodectl auth add -u -r operator + +# 2. Log in +nodectl api login + +# 3. Use the token +export NODECTL_API_TOKEN="" +nodectl api elections +``` + +## 3) Roles and permissions ### `nominator` @@ -48,7 +113,7 @@ Cannot: ### `nodectl admin` (infrastructure/admin host role) -This is **not** a REST role and not a JWT claim. +This is **not** a REST role and not a JWT claim. It is a person/process with host/Pod access where `nodectl` can be executed. Can: @@ -63,7 +128,48 @@ Important: - this path works directly via config/CLI; - REST auth middleware is bypassed because access is at infrastructure level. -## 3) How to log in and use a token +## 4) Token TTL (time-to-live) + +Each role has an independent token lifetime. The TTL determines how long a JWT remains valid after login. + +| Role | Default TTL | Seconds | +|------|-------------|---------| +| `operator` | 30 days | 2 592 000 | +| `nominator` | 1 day | 86 400 | + +### View current TTLs + +The values are stored in `http.auth` inside `config.json`: + +```json +{ + "http": { + "auth": { + "operator_token_ttl": 2592000, + "nominator_token_ttl": 86400 + } + } +} +``` + +### Change TTLs + +Use `nodectl auth set ttl`. Values can be plain seconds or human-friendly suffixes (`s`, `m`, `h`): + +```bash +# Set operator TTL to 8 hours, nominator to 1 hour +nodectl auth set ttl --operator 8h --nominator 1h + +# Set only operator TTL (nominator stays unchanged) +nodectl auth set ttl --operator 3600 + +# Set nominator TTL to 30 minutes +nodectl auth set ttl --nominator 30m +``` + +Changes take effect immediately — the service hot-reloads the config. Existing tokens keep their original expiration; new tokens issued after the change use the updated TTL. + +## 5) How to log in and use a token ### Get a token @@ -81,7 +187,7 @@ Interactive: - `nodectl api validators` - `nodectl api task elections disable` -## 4) How to revoke tokens (CLI only) +## 6) How to revoke tokens (CLI only) Who can revoke tokens: @@ -102,20 +208,21 @@ Effect: - When you revoke a user, any token for that user with an issued-at time (`iat`) less than or equal to the set `revoked_after` timestamp will no longer be accepted. This means all previously issued tokens before or at the revocation cutoff are immediately invalidated, and only tokens created after the `revoked_after` time will be valid. -## 5) What the API validates on each protected request +## 7) What the API validates on each protected request For each Bearer token, checks are applied in this order: -1. header format is valid; -2. JWT signature and expiration (`exp`) are valid; -3. user exists in current config; -4. token role matches current user role; -5. revocation condition passes (`iat > revoked_after`, otherwise rejected); -6. role is sufficient for the requested endpoint. +1. `http.auth` section exists in config — if missing, all routes pass through (open access); +2. header format is valid; +3. JWT signature and expiration (`exp`) are valid; +4. user exists in current config; +5. token role matches current user role; +6. revocation condition passes (`iat > revoked_after`, otherwise rejected); +7. role is sufficient for the requested endpoint. If validation fails, response is `401` or `403`. -## 6) Login brute-force protection +## 8) Login brute-force protection `POST /auth/login` is protected by rate limiting: @@ -135,17 +242,28 @@ Response codes: - `401` for invalid credentials (before threshold); - `429` when attempt threshold is exceeded. -## 7) Secure Kubernetes profile (single instance) +## 9) TLS requirement for external access + +nodectl serves plain HTTP — it does **not** terminate TLS. If the API is reachable from outside the pod or host, you **must** terminate TLS at the Ingress controller, load balancer, or reverse proxy in front of it. + +Without TLS: + +- Passwords sent to `POST /auth/login` travel in plain text and can be intercepted. +- JWT tokens in `Authorization: Bearer` headers travel in plain text. A captured token grants API access until it expires or is revoked. + +> **Rule of thumb:** If traffic crosses a network boundary you do not fully control, encrypt it with TLS. + +## 10) Secure Kubernetes profile (single instance) - run a single service replica; - expose externally only through Ingress/LB; - block direct access to Pod IP/NodePort; -- enforce TLS at Ingress; +- enforce TLS at Ingress (see [TLS requirement](#9-tls-requirement-for-external-access)); - trust `x-forwarded-for` only when traffic is forced through trusted Ingress; - store JWT key and password hashes in Vault; - do not use `jwt_secret` fallback in production. -## 8) Logs and monitoring +## 11) Logs and monitoring Auth logs use `target="auth"` and structured fields for machine parsing. diff --git a/src/node-control/docs/nodectl-setup.md b/src/node-control/docs/nodectl-setup.md index bc9425c..803e1b9 100644 --- a/src/node-control/docs/nodectl-setup.md +++ b/src/node-control/docs/nodectl-setup.md @@ -9,17 +9,18 @@ This guide provides step-by-step instructions for deploying and configuring **no - [Step 2: Configure SecretsVault](#step-2-configure-secretsvault) - [Step 3: Create nodectl Configuration](#step-3-create-nodectl-configuration) - [Step 4: Configure TON HTTP API](#step-4-configure-ton-http-api) -- [Step 5: Master Wallet](#step-5-master-wallet) -- [Step 6: Add Nodes](#step-6-add-nodes) -- [Step 7: Add Wallets](#step-7-add-wallets) -- [Step 8: Add Pools](#step-8-add-pools) -- [Step 9: Create Secrets in Vault](#step-9-create-secrets-in-vault) +- [Step 5: Create Secrets in Vault](#step-5-create-secrets-in-vault) +- [Step 6: Master Wallet](#step-6-master-wallet) +- [Step 7: Add Nodes](#step-7-add-nodes) +- [Step 8: Add Wallets](#step-8-add-wallets) +- [Step 9: Add Pools](#step-9-add-pools) - [Step 10: Configure Control Server Client Keys](#step-10-configure-control-server-client-keys) - [Step 11: Create Bindings](#step-11-create-bindings) - [Step 12: Configure Logging](#step-12-configure-logging) - [Step 13: Increase non-swap memory limits](#step-13-increase-non-swap-memory-limits) - [Step 14: Run the Service](#step-14-run-the-service) - [Step 15: Enable Elections](#step-15-enable-elections) +- [Step 16: Configure REST API Authentication](#step-16-configure-rest-api-authentication) - [Security Recommendations](#security-recommendations) - [Troubleshooting](#troubleshooting) @@ -190,14 +191,60 @@ Make sure the TON node's `config.json` has the RPC server enabled: --- -## Step 5: Master Wallet +## Step 5: Create Secrets in Vault + +Before configuring the master wallet, nodes, wallets, and pools, create all required cryptographic keys in the vault. These keys will be referenced by name in subsequent configuration steps. + +Make sure the `VAULT_URL` environment variable is set (see [Step 2](#step-2-configure-secretsvault)). + +```bash +# Create the master wallet key (the default config references "master-wallet-secret") +nodectl key add -n "master-wallet-secret" + +# Create wallet keys (one per validator node you plan to add) +nodectl key add -n "wallet1-secret" +nodectl key add -n "wallet2-secret" +nodectl key add -n "wallet3-secret" + +# Create the control client key (shared by all nodes) +# Must be --extractable because nodectl reads the private key for ADNL connections +nodectl key add -n "control-client-secret" -e +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `-n, --name` | Secret name (will be referenced in config steps below) | +| `-a, --algorithm` | Key algorithm (default: `ed25519`) | +| `-e, --extractable` | Allow private key extraction (required for ADNL client keys) | + +> **Note:** Wallet and master wallet keys should **not** be extractable. Only control client keys need the `-e` flag. + +Import an existing private key instead of generating a new one: + +```bash +nodectl key import -n "my-key" -k "" -e +``` + +List all secrets in the vault: + +```bash +nodectl key ls +``` + +The output shows the name, algorithm, extractable flag, creation date, and **public key** for each secret. + +--- + +## Step 6: Master Wallet The **master wallet** is a central funding source that the service uses to: - Automatically deploy validator wallets and nominator pools - Periodically top up validator wallets when their balance drops below the threshold (5 TON) -When you created the configuration file in [Step 3](#step-3-create-nodectl-configuration), a `master_wallet` section was included automatically. The `key.name` field contains the vault secret name. If the secret does not exist in the vault yet, the service will **automatically generate** it on first startup. +When you created the configuration file in [Step 3](#step-3-create-nodectl-configuration), a `master_wallet` section was included automatically. The `key.name` field contains the vault secret name — this key was created in [Step 5](#step-5-create-secrets-in-vault). View master wallet information: @@ -213,13 +260,13 @@ This shows the wallet address, balance, state, and public key. --- -## Step 6: Add Nodes +## Step 7: Add Nodes Add each TON validator node to the configuration. For each node you need three things: - **Control Server endpoint** — the IP address and port where the node's Control Server is listening (e.g. `192.168.1.10:3031`). You can find this in the node's `config.json` under `control_server.address`. - **Control Server public key** — the server's public key in Base64 format. You can find it in the node's `config.json` under `control_server.server_key` (derive the public key from the private key, or use the key provided during node setup). -- **Client secret name** — the name of the vault secret for the ADNL client private key. This key will be created later in [Step 9](#step-9-create-secrets-in-vault). For now, just choose a name (e.g. `control-client-secret`). +- **Client secret name** — the name of the vault secret for the ADNL client private key. This key was created in [Step 5](#step-5-create-secrets-in-vault). Use the same name you chose there (e.g. `control-client-secret`). ```bash nodectl config node add \ @@ -255,9 +302,9 @@ nodectl config node ls --- -## Step 7: Add Wallets +## Step 8: Add Wallets -Add a validator wallet for each node. Each wallet needs a unique name and a vault secret name for its private key. The key will be created in [Step 9](#step-9-create-secrets-in-vault). +Add a validator wallet for each node. Each wallet needs a unique name and a vault secret name for its private key. The key was created in [Step 5](#step-5-create-secrets-in-vault). ```bash nodectl config wallet add -n wallet1 -s "wallet1-secret" @@ -289,7 +336,7 @@ nodectl config wallet ls --- -## Step 8: Add Pools +## Step 9: Add Pools Add a Single Nominator Pool for each validator: @@ -321,49 +368,6 @@ nodectl config pool ls --- -## Step 9: Create Secrets in Vault - -Now create the cryptographic keys that you referenced in the previous steps. These keys are stored in the vault and used by nodectl for signing transactions and authenticating with nodes. - -Make sure the `VAULT_URL` environment variable is set (see [Step 2](#step-2-configure-secretsvault)). - -```bash -# Create the control client key (shared by all nodes) -# Must be --extractable because nodectl reads the private key for ADNL connections -nodectl key add -n "control-client-secret" -e - -# Create wallet keys (one per wallet) -nodectl key add -n "wallet1-secret" -nodectl key add -n "wallet2-secret" -nodectl key add -n "wallet3-secret" -``` - -**Options:** - -| Option | Description | -|--------|-------------| -| `-n, --name` | Secret name (must match what you used in config) | -| `-a, --algorithm` | Key algorithm (default: `ed25519`) | -| `-e, --extractable` | Allow private key extraction (required for ADNL client keys) | - -> **Note:** Wallet keys should **not** be extractable. Only control client keys need the `-e` flag. - -Import an existing private key instead of generating a new one: - -```bash -nodectl key import -n "my-key" -k "" -e -``` - -List all secrets in the vault: - -```bash -nodectl key ls -``` - -The output shows the name, algorithm, extractable flag, creation date, and **public key** for each secret. - ---- - ## Step 10: Configure Control Server Client Keys Now that the control client key is created, you need to register its public key on each TON node. This allows nodectl to authenticate and send commands to the nodes. @@ -641,11 +645,93 @@ nodectl config elections stake-policy --reset -n node1 --- +## Step 16: Configure REST API Authentication + +**Authentication is enabled by default.** A freshly generated config includes the `http.auth` section with an empty user list — all protected endpoints return `401` until at least one user is created. The `/health` endpoint remains accessible. + +On first start the service automatically creates a JWT signing key in the vault (secret `auth.jwt-signing-key`). + +**No service restart is required** to change authentication settings. The service hot-reloads the configuration, so creating the first user with `nodectl auth add` makes the API accessible immediately. + +> For a detailed description of roles, token lifecycle, revocation, rate limiting, and monitoring, see the **[Security Guide](./nodectl-security.md)**. + +### 16.1 Create Users + +Use `nodectl auth add` to create users. The password is entered interactively. + +```bash +# Create an operator user (full operational access) +nodectl auth add --username operator --role operator + +# Create a nominator user (read-only access) +nodectl auth add --username viewer --role nominator +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--username` | Username (alphanumeric, `_`, `-`, max 64 chars) | +| `--role` | User role: `operator` or `nominator` | + +Password hashes are stored in the vault (secret name: `auth.users.`). + +### 16.2 List Users + +```bash +nodectl auth ls +``` + +### 16.3 Configure Token TTL + +```bash +nodectl auth set ttl --operator 720h --nominator 24h +``` + +Values accept seconds (`3600`), or duration suffixes (`30s`, `60m`, `8h`). + +### 16.4 Log In to the REST API + +All `nodectl api` commands resolve the service URL in this order: + +1. Explicit `--url` (`-u`) flag +2. `http.bind` value from `--config` (or `CONFIG_PATH`) + +If neither is available, the command fails. When running on the same host as the service, the config file is usually present and the URL is resolved automatically. When connecting from a remote machine, pass `--url` explicitly: + +```bash +# Local — URL from config +nodectl api login operator + +# Remote — explicit URL +nodectl api login operator -u http://192.168.1.10:8080 +``` + +> **Warning:** nodectl serves plain HTTP. If you connect from outside the host, terminate TLS at a reverse proxy or SSH tunnel — otherwise the password and JWT token travel in plain text. + +The command prints the JWT token, its expiration, and the user role. Store the token for subsequent API calls: + +```bash +export NODECTL_API_TOKEN="" +``` + +Once the token is exported, all `nodectl api` commands use it automatically: + +```bash +nodectl api elections +nodectl api validators +nodectl api task elections restart +``` + +--- + ## Security Recommendations ### Network Security -1. **Run Nodectl HTTP server on localhost only** +1. **Always use TLS for external access** — nodectl serves plain HTTP. Passwords sent to `/auth/login` and JWT tokens in `Authorization` headers travel in plain text without TLS. Terminate TLS at a reverse proxy, load balancer, or use an SSH tunnel. + +2. **Bind to localhost when external access is not needed** ```json "http": { @@ -653,9 +739,17 @@ nodectl config elections stake-policy --reset -n node1 } ``` -2. **Use SSH tunneling for remote access** +3. **Use SSH tunneling for remote access** when TLS termination is not available + +### Authentication Security + +1. **Create strong passwords** — minimum 8 characters; use a password manager + +2. **Use short token TTLs in production** — reduce the blast radius of a leaked token + +3. **Revoke tokens immediately** when a user leaves or credentials are compromised (`nodectl auth revoke `) -3. **Never expose the REST API to the public internet** — all endpoints are unauthenticated +4. **See [Security Guide](./nodectl-security.md)** for full details on roles, rate limiting, and monitoring ### Key Security diff --git a/src/node-control/elections/Cargo.toml b/src/node-control/elections/Cargo.toml index 6cbd6d5..e926a88 100644 --- a/src/node-control/elections/Cargo.toml +++ b/src/node-control/elections/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "elections" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' @@ -9,19 +9,14 @@ anyhow = "1.0" async-trait = "0.1" tracing = "0.1" tokio = { version = "1.40", features = ["full"] } -tokio-util = "0.7" -scopeguard = "1" -rand = "0.9.2" hex = { version = "0.4.3", features = ["serde"] } ton-http-api-client = { path = "../ton-http-api-client" } control-client = { path = "../control-client" } -ton_api = { version = "0.4.32", path = "../../tl/ton_api" } adnl = { version = "0.11.38", path = "../../adnl" } ton_block = { path = "../../block" } common = { path = "../common" } base64 = "0.22.1" serde_json = "1.0.145" -num = "0.4" serde = "1.0.228" contracts = { path = "../contracts" } secrets-vault = { path = '../../secrets-vault', default-features = false, features = [ @@ -32,6 +27,5 @@ secrets-vault = { path = '../../secrets-vault', default-features = false, featur ] } [dev-dependencies] -env_logger = "0.11" mockall = "0.13" tokio = { version = "1.40", features = ["full", "test-util"] } diff --git a/src/node-control/elections/src/providers/default.rs b/src/node-control/elections/src/providers/default.rs index 34c93a8..52f94c7 100644 --- a/src/node-control/elections/src/providers/default.rs +++ b/src/node-control/elections/src/providers/default.rs @@ -16,7 +16,7 @@ use control_client::{ AddAdnlAddressRq, AddValidatorAdnlAddrRq, AddValidatorPermKeyRq, AddValidatorTempKeyRq, ClientAPI, SignRq, }, - config_params::{parse_config_param_15, parse_config_param_34}, + config_params::{parse_config_param_15, parse_config_param_34, parse_config_param_36}, }; use std::collections::HashMap; use ton_block::{ConfigParam15, ValidatorSet}; @@ -129,4 +129,13 @@ impl ElectionsProvider for DefaultElectionsProvider { let bytes = self.client.get_config_param(34).await?; parse_config_param_34(&bytes) } + async fn get_next_vset(&mut self) -> anyhow::Result> { + match self.client.get_config_param(36).await { + Ok(bytes) => Ok(Some(parse_config_param_36(&bytes)?)), + Err(e) => { + tracing::trace!("get_next_vset: config param 36 not available: {e:?}"); + Ok(None) + } + } + } } diff --git a/src/node-control/elections/src/providers/traits.rs b/src/node-control/elections/src/providers/traits.rs index 586fbe9..60a4d54 100644 --- a/src/node-control/elections/src/providers/traits.rs +++ b/src/node-control/elections/src/providers/traits.rs @@ -94,4 +94,5 @@ pub trait ElectionsProvider: Send + Sync { async fn account(&mut self, address: &str) -> anyhow::Result; async fn export_public_key(&mut self, key_id: &[u8]) -> anyhow::Result>; async fn get_current_vset(&mut self) -> anyhow::Result; + async fn get_next_vset(&mut self) -> anyhow::Result>; } diff --git a/src/node-control/elections/src/runner.rs b/src/node-control/elections/src/runner.rs index 0e5e741..67fae96 100644 --- a/src/node-control/elections/src/runner.rs +++ b/src/node-control/elections/src/runner.rs @@ -11,8 +11,9 @@ use anyhow::Context as _; use common::{ app_config::{BindingStatus, ElectionsConfig, NodeBinding, StakePolicy}, snapshot::{ - ElectionsParticipantSnapshot, ElectionsSnapshot, ElectionsStatus, SnapshotStore, TimeRange, - ValidatorNodeSnapshot, ValidatorsSnapshot, + ElectionsParticipantSnapshot, ElectionsSnapshot, ElectionsStatus, OurElectionParticipant, + ParticipationStatus, SnapshotStore, StakeSubmission, TimeRange, ValidatorNodeSnapshot, + ValidatorsSnapshot, }, task_cancellation::CancellationCtx, time_format, @@ -49,6 +50,17 @@ const MIN_NANOTON_FOR_STORAGE: u64 = 1_000_000_000; type OnStatusChange = Arc) + Send + Sync>; +/// Record of a single stake submission (internal). +#[derive(Clone, Debug)] +struct StakeSubmissionRecord { + stake: u64, + max_factor: u32, + submission_time: u64, +} + +/// Maximum number of stake submissions to keep per election cycle. +const MAX_STAKE_SUBMISSIONS: usize = 10; + struct Node { api: Box, /// Current validator key id. @@ -57,8 +69,20 @@ struct Node { /// Current participant info. /// Set when new election bid is generated. Reset after new elections started. participant: Option, + /// Last successful stake submission timestamp. + submission_time: Option, /// True if stake was accepted by the elector. Reset after new elections started. stake_accepted: bool, + /// Stake amount accepted by the elector (nanotons). + accepted_stake_amount: Option, + /// History of stake submissions for current election cycle (capped to MAX_STAKE_SUBMISSIONS). + stake_submissions: Vec, + /// True if node is in current validator set (p34). + /// Computed in build_validators_snapshot, used by build_our_participants_snapshot. + is_validator: bool, + /// True if node is elected in next validator set (p36). + /// Computed in build_validators_snapshot, used by build_our_participants_snapshot. + is_next_validator: bool, wallet: Arc, /// Nominator pool instance. Optional. pool: Option>, @@ -95,7 +119,10 @@ impl Node { } fn reset_participation(&mut self) { self.participant = None; + self.submission_time = None; self.stake_accepted = false; + self.accepted_stake_amount = None; + self.stake_submissions.clear(); } async fn stake_balance(&mut self, gas_fee: u64) -> anyhow::Result { match self.pool.as_ref() { @@ -144,6 +171,8 @@ struct SnapshotCache { next_elections_range: Option, // Current validator set (config param 34), cached for is_validator/index calculation. last_validator_set: Option, + // Next validator set (config param 36), if exists. + last_next_validator_set: Option, /// Last binding statuses, cached for comparison in run_loop(). last_binding_statuses: HashMap, } @@ -211,7 +240,12 @@ impl ElectionRunner { stake_policy, key_id: vec![], participant: None, + submission_time: None, stake_accepted: false, + accepted_stake_amount: None, + stake_submissions: Vec::new(), + is_validator: false, + is_next_validator: false, last_error: None, validator_config: ValidatorConfig::new(), binding_status, @@ -246,6 +280,7 @@ impl ElectionRunner { node.last_error = None; } self.refresh_validator_set().await; + self.refresh_next_validator_set().await; self.refresh_validator_configs().await; if let Err(e) = &self.run().await { @@ -321,11 +356,15 @@ impl ElectionRunner { tracing::warn!("elections are finished"); // check if node stakes are accepted by the elector for node in self.nodes.values_mut() { - node.stake_accepted = elections_info - .participants - .iter() - .find(|p| p.wallet_addr == node.wallet_addr()) - .is_some(); + // Reset previous state; only mark as accepted if present in current participants + node.stake_accepted = false; + node.accepted_stake_amount = None; + if let Some(p) = + elections_info.participants.iter().find(|p| p.wallet_addr == node.wallet_addr()) + { + node.stake_accepted = true; + node.accepted_stake_amount = Some(p.stake); + } } return Ok(()); } @@ -390,6 +429,10 @@ impl ElectionRunner { self.nodes.values().map(|node| node.wallet_addr()).collect(); let participants = Self::build_participants_snapshot(elections_info, &wallet_addrs); + let participant_min_stake = + elections_info.participants.iter().map(|p| p.stake).min().map(nanotons_to_dec_string); + let participant_max_stake = + elections_info.participants.iter().map(|p| p.stake).max().map(nanotons_to_dec_string); let validation_start = election_id; let validation_end = election_id + cfg15.validators_elected_for as u64; @@ -420,6 +463,8 @@ impl ElectionRunner { failed: elections_info.failed, participants_count: elections_info.participants.len() as u32, min_stake: nanotons_to_dec_string(elections_info.min_stake), + participant_min_stake, + participant_max_stake, total_stake: nanotons_to_dec_string(elections_info.total_stake), next_validation_range, elections_range, @@ -516,6 +561,7 @@ impl ElectionRunner { Ok(()) } Some(entry) => { + node.key_id = entry.key_id.clone(); tracing::info!( "node [{}] validator key found: election_id={} key_id={}, pubkey={}", node_id, @@ -542,10 +588,21 @@ impl ElectionRunner { ); node.participant = Some(participant.clone()); node.stake_accepted = true; + node.accepted_stake_amount = Some(participant.stake); } None => { tracing::warn!("node [{}] stake not found in elector", node_id); - if node.participant.is_none() { + // Refresh participant if missing or stale (different election cycle) + let needs_refresh = match node.participant.as_ref() { + Some(existing) => existing.election_id != election_id, + None => true, + }; + if needs_refresh { + // Reset participation-related state for the new election cycle + node.stake_accepted = false; + node.accepted_stake_amount = None; + node.submission_time = None; + node.stake_submissions.clear(); node.participant = Some(Participant { stake_message_boc: None, adnl_addr: entry @@ -559,7 +616,9 @@ impl ElectionRunner { }); node.key_id = entry.key_id; } - node.participant.as_mut().map(|p| p.stake = stake); + if let Some(p) = node.participant.as_mut() { + p.stake = stake; + } Self::send_stake(node_id, node, stake).await?; } } @@ -597,11 +656,19 @@ impl ElectionRunner { write_boc(&node.wallet.message(node.elections_addr(), send_value, payload).await?)?; tracing::debug!("wallet external message: boc={}", hex::encode(&msg_boc)); tracing::info!("node [{}] send stake", node_id); - let result = node.api.send_boc(&msg_boc).await; + node.api.send_boc(&msg_boc).await?; + let submission_time = time_format::now(); + let max_factor = node.participant.as_ref().map(|p| p.max_factor).unwrap_or(0); if let Some(participant) = &mut node.participant { participant.stake_message_boc = Some(msg_boc); } - result + node.submission_time = Some(submission_time); + node.stake_submissions.push(StakeSubmissionRecord { stake, max_factor, submission_time }); + // Cap submissions to avoid unbounded growth + if node.stake_submissions.len() > MAX_STAKE_SUBMISSIONS { + node.stake_submissions.remove(0); + } + Ok(()) } async fn build_new_stake_payload(node_id: &str, node: &mut Node) -> anyhow::Result { @@ -778,7 +845,10 @@ impl ElectionRunner { .participants .iter() .map(|p| ElectionsParticipantSnapshot { - pubkey: hex::encode(p.pub_key.as_slice()), + pubkey: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + p.pub_key.as_slice(), + ), adnl: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, p.adnl_addr.as_slice(), @@ -816,6 +886,26 @@ impl ElectionRunner { self.snapshot_cache.last_validator_set = None; } + async fn refresh_next_validator_set(&mut self) { + tracing::trace!("fetch next validator set (p36)"); + for (_node_id, node) in self.nodes.iter_mut() { + match node.api.get_next_vset().await { + Ok(Some(vset)) => { + self.snapshot_cache.last_next_validator_set = Some(vset); + return; + } + Ok(None) => { + // Node returned no next validator set, try other nodes. + tracing::trace!("get next vset: node returned no next validator set (None)"); + } + Err(e) => { + tracing::trace!("get next vset error: {}", e); + } + } + } + self.snapshot_cache.last_next_validator_set = None; + } + async fn refresh_validator_configs(&mut self) { tracing::trace!("fetch validator configs"); for (node_id, node) in self.nodes.iter_mut() { @@ -830,7 +920,8 @@ impl ElectionRunner { } async fn build_validators_snapshot(&mut self) -> ValidatorsSnapshot { - let last_max_factor = self.snapshot_cache.last_max_factor; + let current_election_id = + self.snapshot_cache.last_elections.as_ref().map(|snapshot| snapshot.election_id); let mut node_ids = self.nodes.keys().cloned().collect::>(); node_ids.sort_by(|a, b| a.cmp(b)); @@ -838,41 +929,93 @@ impl ElectionRunner { let mut controlled_nodes = Vec::new(); for node_id in node_ids { let node = self.nodes.get_mut(&node_id).expect("node not found"); - let validator_entry = - if let Some(vset) = self.snapshot_cache.last_validator_set.as_ref() { - find_validator_entry(node, vset) - .await - .map_err(|e| { - let error = anyhow::anyhow!( - "node [{}] find validator entry error: {:#}", - node_id, - e - ); - tracing::error!("{:#}", error); - node.last_error = Some(format!("{:#}", error)) - }) - .unwrap_or(None) - } else { - None - }; + let current_cycle_key = + current_election_id.and_then(|election_id| node.validator_config.find(election_id)); + + let (validator_entry, is_next_validator) = find_validator_entries( + node, + self.snapshot_cache.last_validator_set.as_ref(), + self.snapshot_cache.last_next_validator_set.as_ref(), + ) + .await + .map_err(|e| { + let error = + anyhow::anyhow!("node [{}] find validator entry error: {:#}", node_id, e); + tracing::error!("{:#}", error); + node.last_error = Some(format!("{:#}", error)) + }) + .unwrap_or((None, false)); + + let is_validator = validator_entry.is_some(); + node.is_validator = is_validator; + node.is_next_validator = is_next_validator; + let participant = node.participant.as_ref(); let wallet_addr = Some(node.wallet.address().to_string()); let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); - let max_factor = participant.map(|p| p.max_factor as f32 / 65536.0).or(last_max_factor); let pubkey = validator_entry .as_ref() - .map(|(_, entry)| hex::encode(entry.public_key.as_bytes())) - .or_else(|| participant.map(|p| hex::encode(p.pub_key.as_slice()))); - let adnl = validator_entry + .map(|(_, entry)| { + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + entry.public_key.as_bytes(), + ) + }) + .or_else(|| { + participant.map(|p| { + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + p.pub_key.as_slice(), + ) + }) + }); + let adnl = current_cycle_key .as_ref() - .and_then(|(_, entry)| entry.adnl_addr.as_ref().map(|x| x.as_slice().as_slice())) + .and_then(|entry| entry.adnl_addr()) + .as_deref() + .or_else(|| { + validator_entry.as_ref().and_then(|(_, entry)| { + entry.adnl_addr.as_ref().map(|x| x.as_slice().as_slice()) + }) + }) .or_else(|| participant.map(|p| p.adnl_addr.as_slice())) .map(|x| base64::Engine::encode(&base64::engine::general_purpose::STANDARD, x)); - - let key_id = None; + let key_id = current_cycle_key + .as_ref() + .map(|entry| { + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &entry.key_id, + ) + }) + .or_else(|| { + if node.key_id.is_empty() { + None + } else { + Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &node.key_id, + )) + } + }); + let (key_election_id, key_expires_at, key_expires_at_utc, is_key_active) = + current_cycle_key + .as_ref() + .map(|entry| { + let expires = entry.expired_at; + let now = time_format::now(); + ( + current_election_id, + Some(expires), + Some(time_format::format_ts(expires)), + Some(expires > now), + ) + }) + .unwrap_or((None, None, None, None)); let stake = participant.map(|p| nanotons_to_dec_string(p.stake)); - let is_validator = validator_entry.is_some(); + let validator_index = validator_entry.as_ref().map(|(idx, _)| *idx); + let weight = validator_entry.as_ref().map(|(_, entry)| entry.weight); // Compute and update binding status let is_participating = node.participant.is_some(); @@ -894,38 +1037,164 @@ impl ElectionRunner { controlled_nodes.push(ValidatorNodeSnapshot { node_id, + is_validator, + validator_index, + weight, wallet_addr, pool_addr, - max_factor, pubkey, adnl, key_id, + key_election_id, + key_expires_at, + key_expires_at_utc, + is_key_active, stake, stake_accepted: node.stake_accepted, - is_validator, - validator_index, last_error: node.last_error.clone(), - stake_policy: node.stake_policy.clone(), binding_status: node.binding_status, }); } + let validation_range = + self.snapshot_cache.last_validator_set.as_ref().map(|vset| TimeRange { + start: vset.utime_since() as u64, + start_utc: time_format::format_ts(vset.utime_since() as u64), + end: vset.utime_until() as u64, + end_utc: time_format::format_ts(vset.utime_until() as u64), + }); + ValidatorsSnapshot { controlled_nodes, default_stake_policy: self.default_stake_policy.clone(), + validation_range, } } + fn build_our_participants_snapshot(&self) -> Vec { + let elections_snapshot = self.snapshot_cache.last_elections.as_ref(); + + // Build ranked list of participants by stake (descending) for position calculation + let ranked_participants: Vec<&ElectionsParticipantSnapshot> = + if let Some(snapshot) = elections_snapshot { + let mut sorted: Vec<_> = snapshot.participants.iter().collect(); + sorted.sort_by(|a, b| { + let stake_a: u128 = a.stake.parse().unwrap_or(0); + let stake_b: u128 = b.stake.parse().unwrap_or(0); + stake_b.cmp(&stake_a) + }); + sorted + } else { + Vec::new() + }; + + let mut node_ids = self.nodes.keys().cloned().collect::>(); + node_ids.sort(); + + let mut our_participants = Vec::new(); + for node_id in node_ids { + let node = self.nodes.get(&node_id).expect("node not found"); + let participant = node.participant.as_ref(); + let wallet_addr = Some(node.wallet.address().to_string()); + let pool_addr = node.pool.as_ref().map(|p| p.address().to_string()); + + let pubkey = participant.map(|p| { + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + p.pub_key.as_slice(), + ) + }); + let adnl = participant.map(|p| { + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + p.adnl_addr.as_slice(), + ) + }); + let key_id = if node.key_id.is_empty() { + None + } else { + Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + node.key_id.as_slice(), + )) + }; + + let stake_submissions: Vec = node + .stake_submissions + .iter() + .map(|s| StakeSubmission { + stake: nanotons_to_dec_string(s.stake), + max_factor: s.max_factor as f32 / 65536.0, + submission_time: s.submission_time, + submission_time_utc: time_format::format_ts(s.submission_time), + }) + .collect(); + + let fallback_sender_addr = format!("-1:{}", hex::encode(node.wallet_addr())); + let accepted_stake = if node.stake_accepted { + node.accepted_stake_amount.map(nanotons_to_dec_string).or_else(|| { + node.stake_submissions.last().map(|s| nanotons_to_dec_string(s.stake)) + }) + } else { + None + }; + + // Find position in ranked list (1-based) + let position = ranked_participants + .iter() + .position(|p| p.sender_addr == fallback_sender_addr) + .map(|pos| (pos + 1) as u32); + + let elections_running = matches!( + self.snapshot_cache.last_elections_status, + ElectionsStatus::Active | ElectionsStatus::Finished | ElectionsStatus::Postponed + ); + let status = if node.is_next_validator { + ParticipationStatus::Elected + } else if elections_running && node.stake_accepted { + ParticipationStatus::Accepted + } else if elections_running && !node.stake_submissions.is_empty() { + ParticipationStatus::Submitted + } else if elections_running && node.participant.is_some() { + ParticipationStatus::Participating + } else if node.is_validator { + ParticipationStatus::Validating + } else { + ParticipationStatus::Idle + }; + + our_participants.push(OurElectionParticipant { + node_id, + status, + pubkey, + key_id, + adnl, + stake_submissions, + accepted_stake, + stake_accepted: node.stake_accepted, + elected: node.is_validator || node.is_next_validator, + position, + wallet_addr, + pool_addr, + last_error: node.last_error.clone(), + }); + } + + our_participants + } + async fn publish_snapshot(&mut self, store: &SnapshotStore) { tracing::trace!("update snapshot"); let elections = self.snapshot_cache.last_elections.clone(); let elections_status = self.snapshot_cache.last_elections_status.clone(); let next_elections_range = self.snapshot_cache.next_elections_range.clone(); let validators = self.build_validators_snapshot().await; + let our_participants = self.build_our_participants_snapshot(); store.update_with(|s| { s.elections = elections; s.elections_status = elections_status; s.next_elections_range = next_elections_range; + s.our_participants = our_participants; s.validators = validators; }); } @@ -968,28 +1237,51 @@ impl ElectionRunner { } } -async fn find_validator_entry( +async fn find_validator_entries( node: &mut Node, - vset: &ValidatorSet, -) -> anyhow::Result> { + current_vset: Option<&ValidatorSet>, + next_vset: Option<&ValidatorSet>, +) -> anyhow::Result<(Option<(u16, ValidatorDescr)>, bool)> { let config = &node.validator_config; let mut election_ids = config.keys.keys().cloned().collect::>(); election_ids.sort(); + let mut current_entry: Option<(u16, ValidatorDescr)> = None; + let mut is_in_next = false; + for election_id in &election_ids[election_ids.len().saturating_sub(3)..] { let entry = config .keys .get(election_id) .ok_or_else(|| anyhow::anyhow!("validator entry not found"))?; + let public_key = node.api.export_public_key(&entry.key_id).await?; let mut key = [0u8; 32]; key.copy_from_slice(&public_key); - // Search validator public key in current vset - let index = vset.list().iter().position(|item| item.public_key.as_slice() == &key); - if let Some(idx) = index { - return Ok(Some((u16::try_from(idx)?, vset.list()[idx].clone()))); + + if current_entry.is_none() { + if let Some(vset) = current_vset { + if let Some(idx) = + vset.list().iter().position(|item| item.public_key.as_slice() == &key) + { + current_entry = Some((u16::try_from(idx)?, vset.list()[idx].clone())); + } + } + } + + if !is_in_next { + if let Some(vset) = next_vset { + if vset.list().iter().any(|item| item.public_key.as_slice() == &key) { + is_in_next = true; + } + } + } + + if current_entry.is_some() && is_in_next { + break; } } - Ok(None) + + Ok((current_entry, is_in_next)) } diff --git a/src/node-control/elections/src/runner_tests.rs b/src/node-control/elections/src/runner_tests.rs index fc53b93..415dc07 100644 --- a/src/node-control/elections/src/runner_tests.rs +++ b/src/node-control/elections/src/runner_tests.rs @@ -11,6 +11,7 @@ use common::{ app_config::{ElectionsConfig, NodeBinding, StakePolicy}, snapshot::SnapshotStore, task_cancellation::{CancellationCtx, CancellationReason}, + time_format, }; use contracts::{ ElectionsInfo, ElectorWrapper, NominatorWrapper, Participant, TonWallet, @@ -89,6 +90,7 @@ mock! { async fn account(&mut self, address: &str) -> anyhow::Result; async fn export_public_key(&mut self, key_id: &[u8]) -> anyhow::Result>; async fn get_current_vset(&mut self) -> anyhow::Result; + async fn get_next_vset(&mut self) -> anyhow::Result>; } } @@ -389,6 +391,7 @@ fn setup_default_provider( // get_current_vset: not crucial, return error to skip provider.expect_get_current_vset().returning(|| Err(anyhow::anyhow!("no vset"))); + provider.expect_get_next_vset().returning(|| Ok(None)); // new_validator_key provider @@ -750,6 +753,44 @@ async fn test_no_active_elections() { assert_eq!(runner.snapshot_cache.last_elections_status, ElectionsStatus::Closed); } +#[tokio::test] +async fn test_closed_elections_without_submission_stays_not_submitted() { + let node_id = "node-1"; + let mut harness = TestHarness::new(); + + setup_elector_no_elections(&mut harness.elector_mock); + setup_wallet(&mut harness.wallet_mock); + + let provider = &mut harness.provider_mock; + provider.expect_election_parameters().returning(|| Ok(default_cfg15())); + provider.expect_shutdown().returning(|| Ok(())); + + let mut runner = harness.build(node_id); + let result = runner.run().await; + assert!(result.is_ok()); + assert_eq!(runner.snapshot_cache.last_elections_status, ElectionsStatus::Closed); + + // Simulate stale previous elections snapshot still present in cache. + runner.snapshot_cache.last_elections = Some(ElectionsSnapshot { + election_id: ELECTION_ID, + elections_range: TimeRange { + start: ELECTION_ID.saturating_sub(1800), + start_utc: time_format::format_ts(ELECTION_ID.saturating_sub(1800)), + end: ELECTION_ID.saturating_sub(600), + end_utc: time_format::format_ts(ELECTION_ID.saturating_sub(600)), + }, + ..Default::default() + }); + + let store = Arc::new(SnapshotStore::new()); + runner.publish_snapshot(&store).await; + let snapshot = store.get(); + let node_snapshot = &snapshot.validators.controlled_nodes[0]; + + assert!(!node_snapshot.is_validator); + assert!(node_snapshot.validator_index.is_none()); +} + // ===================================================== // TEST: excluded node skips elections // ===================================================== @@ -1151,6 +1192,7 @@ async fn test_multiple_nodes_one_excluded() { provider2.expect_election_parameters().returning(|| Ok(default_cfg15())); provider2.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); provider2.expect_get_current_vset().returning(|| Err(anyhow::anyhow!("no vset"))); + provider2.expect_get_next_vset().returning(|| Ok(None)); provider2.expect_export_public_key().returning(|_| Ok(PUB_KEY.to_vec())); provider2.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); provider2.expect_shutdown().returning(|| Ok(())); @@ -1479,6 +1521,8 @@ async fn test_build_elections_snapshot() { assert!(!snapshot.failed); assert_eq!(snapshot.participants_count, 2); assert_eq!(snapshot.min_stake, nanotons_to_dec_string(MIN_STAKE)); + assert_eq!(snapshot.participant_min_stake, Some(nanotons_to_dec_string(MIN_STAKE))); + assert_eq!(snapshot.participant_max_stake, Some(nanotons_to_dec_string(MIN_STAKE * 2))); assert_eq!(snapshot.total_stake, nanotons_to_dec_string(MIN_STAKE * 3)); // Validation range @@ -1505,7 +1549,10 @@ async fn test_build_elections_snapshot() { .iter() .find(|p| p.is_controlled) .expect("should have controlled participant"); - assert_eq!(our_participant.pubkey, hex::encode(&PUB_KEY)); + assert_eq!( + our_participant.pubkey, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &PUB_KEY) + ); assert_eq!(our_participant.stake, nanotons_to_dec_string(MIN_STAKE)); let other_participant = snapshot @@ -1513,7 +1560,10 @@ async fn test_build_elections_snapshot() { .iter() .find(|p| !p.is_controlled) .expect("should have non-controlled participant"); - assert_eq!(other_participant.pubkey, hex::encode([0xDD; 32])); + assert_eq!( + other_participant.pubkey, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0xDD; 32]) + ); } // ===================================================== @@ -1690,6 +1740,7 @@ async fn test_publish_snapshot_validators() { assert!(node_snapshot.wallet_addr.is_some()); assert!(node_snapshot.pubkey.is_some()); assert!(node_snapshot.adnl.is_some()); + assert!(node_snapshot.key_id.is_some()); assert!(node_snapshot.stake.is_some()); assert!(!node_snapshot.is_validator, "not in vset"); } @@ -1829,3 +1880,118 @@ fn test_compute_status_idle_when_enabled_no_recover_no_participant() { let status = ElectionRunner::compute_node_status(false, false, false, false); assert_eq!(status, BindingStatus::Idle); } + +// Participation status transitions across election lifecycle +// Simulates: Idle → Participating → Submitted → Accepted → Elected → Validating +// Also verifies that stale election flags don't leak after elections close. +#[tokio::test] +async fn test_participation_status_lifecycle() { + use common::snapshot::ParticipationStatus; + + let node_id = "node-1"; + let mut harness = TestHarness::new(); + + setup_elector_no_elections(&mut harness.elector_mock); + setup_wallet(&mut harness.wallet_mock); + let provider = &mut harness.provider_mock; + provider.expect_election_parameters().returning(|| Ok(default_cfg15())); + provider.expect_validator_config().returning(|| Ok(ValidatorConfig::new())); + provider.expect_get_current_vset().returning(|| Err(anyhow::anyhow!("no vset"))); + provider.expect_get_next_vset().returning(|| Ok(None)); + provider.expect_export_public_key().returning(|_| Ok(PUB_KEY.to_vec())); + provider.expect_account().returning(|_| Ok(fake_account(WALLET_BALANCE))); + provider.expect_shutdown().returning(|| Ok(())); + + let mut runner = harness.build(node_id); + + // Helper to get the status of our node + let get_status = |r: &ElectionRunner| -> ParticipationStatus { + let participants = r.build_our_participants_snapshot(); + participants.into_iter().find(|p| p.node_id == node_id).unwrap().status + }; + + // --- Phase 1: Idle (no elections, not validating) --- + runner.snapshot_cache.last_elections_status = ElectionsStatus::Closed; + assert_eq!(get_status(&runner), ParticipationStatus::Idle); + + // --- Phase 2: Participating (elections active, participant set, no submissions) --- + runner.snapshot_cache.last_elections_status = ElectionsStatus::Active; + let node = runner.nodes.get_mut(node_id).unwrap(); + node.participant = Some(Participant { + pub_key: PUB_KEY.to_vec(), + adnl_addr: ADNL_ADDR.to_vec(), + election_id: ELECTION_ID, + wallet_addr: addr_bytes(&wallet_address()), + stake: 0, + max_factor: 0, + stake_message_boc: None, + }); + assert_eq!(get_status(&runner), ParticipationStatus::Participating); + + // --- Phase 3: Submitted (stake sent) --- + let node = runner.nodes.get_mut(node_id).unwrap(); + node.stake_submissions.push(StakeSubmissionRecord { + stake: 10_000_000_000_000, + max_factor: 3 * 65536, + submission_time: time_format::now(), + }); + assert_eq!(get_status(&runner), ParticipationStatus::Submitted); + + // --- Phase 4: Accepted (elector accepted the stake) --- + let node = runner.nodes.get_mut(node_id).unwrap(); + node.stake_accepted = true; + node.accepted_stake_amount = Some(10_000_000_000_000); + assert_eq!(get_status(&runner), ParticipationStatus::Accepted); + + // --- Phase 5: Elected (node appears in p36 / next validator set) --- + let node = runner.nodes.get_mut(node_id).unwrap(); + node.is_next_validator = true; + assert_eq!(get_status(&runner), ParticipationStatus::Elected); + + // --- Phase 6: Validating (p36 → p34, elections closed) --- + // Node moves from next vset to current vset, elections are done. + let node = runner.nodes.get_mut(node_id).unwrap(); + node.is_next_validator = false; + node.is_validator = true; + runner.snapshot_cache.last_elections_status = ElectionsStatus::Closed; + assert_eq!(get_status(&runner), ParticipationStatus::Validating); + + // --- Phase 7: Verify stale flags don't leak --- + // stake_accepted is still true from phase 4, but elections are closed. + // Must show Validating, NOT Accepted. + let node = runner.nodes.get(node_id).unwrap(); + assert!(node.stake_accepted, "stake_accepted should still be true (stale)"); + assert_eq!(get_status(&runner), ParticipationStatus::Validating); + + // --- Phase 8: New elections start while validating --- + // Node is still in p34, but new election cycle begins and node submits again. + runner.snapshot_cache.last_elections_status = ElectionsStatus::Active; + let node = runner.nodes.get_mut(node_id).unwrap(); + node.stake_accepted = false; + node.accepted_stake_amount = None; + node.stake_submissions.clear(); + node.participant = Some(Participant { + pub_key: PUB_KEY.to_vec(), + adnl_addr: ADNL_ADDR.to_vec(), + election_id: ELECTION_ID + 3600, + wallet_addr: addr_bytes(&wallet_address()), + stake: 0, + max_factor: 0, + stake_message_boc: None, + }); + node.stake_submissions.push(StakeSubmissionRecord { + stake: 15_000_000_000_000, + max_factor: 3 * 65536, + submission_time: time_format::now(), + }); + // Should show Submitted (election activity), NOT Validating + assert_eq!(get_status(&runner), ParticipationStatus::Submitted); + + // --- Phase 9: Back to idle (not validating, no elections) --- + let node = runner.nodes.get_mut(node_id).unwrap(); + node.is_validator = false; + node.participant = None; + node.stake_submissions.clear(); + runner.snapshot_cache.last_elections_status = ElectionsStatus::Closed; + assert_eq!(get_status(&runner), ParticipationStatus::Idle); +} diff --git a/src/node-control/nodectl/Cargo.toml b/src/node-control/nodectl/Cargo.toml index 885d4bb..c1998fd 100644 --- a/src/node-control/nodectl/Cargo.toml +++ b/src/node-control/nodectl/Cargo.toml @@ -1,22 +1,15 @@ [package] +build = '../../common/build/build.rs' name = "nodectl" -version = "0.2.0" +description = "Tool for managing Rust TON node" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' [dependencies] -scopeguard = "1" -strum = "0.27" -strum_macros = "0.27" -serde = { features = ["derive", "rc"], version = "1.0" } tokio = { version = "1.40", features = ["full"] } -tokio-util = "0.7" -async-trait = "0.1.89" clap = { version = "4.4", features = ["derive"] } -toml = "0.8" tracing = "0.1" -thiserror = "1.0" anyhow = "1.0" -control-client = { path = "../control-client" } common = { path = "../common" } commands = { path = "../commands" } diff --git a/src/node-control/nodectl/src/app_cli_args.rs b/src/node-control/nodectl/src/app_cli_args.rs index 3613ff0..2513e4e 100644 --- a/src/node-control/nodectl/src/app_cli_args.rs +++ b/src/node-control/nodectl/src/app_cli_args.rs @@ -11,8 +11,24 @@ use std::sync::OnceLock; static CLI_ARGS: OnceLock = OnceLock::new(); +fn long_version() -> &'static str { + concat!( + env!("CARGO_PKG_VERSION"), + "\nCOMMIT_ID: ", + env!("BUILD_GIT_COMMIT"), + "\nBUILD_DATE: ", + env!("BUILD_TIME"), + "\nCOMMIT_DATE: ", + env!("BUILD_GIT_DATE"), + "\nGIT_BRANCH: ", + env!("BUILD_GIT_BRANCH"), + "\nRUST_VERSION: ", + env!("BUILD_RUST_VERSION"), + ) +} + #[derive(clap::Parser, Clone)] -#[command(author, version, about, long_about)] +#[command(author, version, about, long_about, long_version = long_version())] pub struct AppCliArgs { #[command(subcommand)] pub command: Option, diff --git a/src/node-control/service/Cargo.toml b/src/node-control/service/Cargo.toml index 6a42d0d..8bb7410 100644 --- a/src/node-control/service/Cargo.toml +++ b/src/node-control/service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "service" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' @@ -13,7 +13,6 @@ contracts = { path = "../contracts" } control-client = { path = "../control-client" } elections = { path = "../elections" } hex = "0.4.3" -scopeguard = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.40", features = ["full"] } diff --git a/src/node-control/service/src/auth/jwt.rs b/src/node-control/service/src/auth/jwt.rs index e81e851..c39ab5e 100644 --- a/src/node-control/service/src/auth/jwt.rs +++ b/src/node-control/service/src/auth/jwt.rs @@ -7,7 +7,6 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use super::{Claims, Role}; -use common::app_config::AuthConfig; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use secrets_vault::{ types::{algorithm::Algorithm, secret::Secret, secret_id::SecretId, secret_spec::SecretSpec}, @@ -26,19 +25,21 @@ const JWT_KEY_SECRET_ID: &str = "auth.jwt-signing-key"; pub struct JwtAuth { encoding_key: EncodingKey, decoding_key: DecodingKey, - auth_config: AuthConfig, } impl JwtAuth { /// Creates a new instance, resolving the HMAC-SHA256 signing key - /// from vault (preferred) or config fallback. + /// from vault (preferred) or `jwt_secret` fallback. + /// + /// `jwt_secret` is a base64-encoded key used **only for testing** when + /// vault is not available. In production the vault is always present. pub async fn new( vault: Option>, - auth_config: &AuthConfig, + jwt_secret: Option<&str>, ) -> anyhow::Result { let secret_bytes = if let Some(vault) = vault { Self::load_or_create_key(&vault).await? - } else if let Some(jwt_secret) = &auth_config.jwt_secret { + } else if let Some(jwt_secret) = jwt_secret { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(jwt_secret) @@ -54,21 +55,17 @@ impl JwtAuth { Ok(Self { encoding_key: EncodingKey::from_secret(&secret_bytes), decoding_key: DecodingKey::from_secret(&secret_bytes), - auth_config: auth_config.clone(), }) } - /// generates a new JWT for the given user. Returns `(token, ttl_seconds)`. - /// TTL depends on the role (configured in [`AuthConfig`]). - pub fn generate(&self, username: &str, role: Role) -> anyhow::Result<(String, u64)> { + /// Generates a new JWT for the given user. Returns `(token, ttl_seconds)`. + /// + /// The caller provides the `ttl` (seconds) from the current live config, + /// so that TTL changes applied via config reload take effect immediately. + pub fn generate(&self, username: &str, role: Role, ttl: u64) -> anyhow::Result<(String, u64)> { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let ttl = match role { - Role::Operator => self.auth_config.operator_token_ttl, - Role::Nominator => self.auth_config.nominator_token_ttl, - }; - let claims = Claims { sub: username.to_owned(), role, iat: now, exp: now + ttl }; let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)?; @@ -121,19 +118,14 @@ mod tests { use super::*; use base64::Engine; - fn test_config() -> AuthConfig { - AuthConfig { - operator_token_ttl: 3600, - nominator_token_ttl: 7200, - jwt_secret: Some(base64::engine::general_purpose::STANDARD.encode([42u8; 32])), - ..Default::default() - } + fn test_secret() -> String { + base64::engine::general_purpose::STANDARD.encode([42u8; 32]) } #[tokio::test] async fn sign_and_verify_roundtrip() { - let mgr = JwtAuth::new(None, &test_config()).await.unwrap(); - let (token, ttl) = mgr.generate("admin", Role::Operator).unwrap(); + let mgr = JwtAuth::new(None, Some(&test_secret())).await.unwrap(); + let (token, ttl) = mgr.generate("admin", Role::Operator, 3600).unwrap(); assert_eq!(ttl, 3600); let claims = mgr.verify(&token).unwrap(); @@ -143,27 +135,24 @@ mod tests { #[tokio::test] async fn verify_rejects_invalid_token() { - let mgr = JwtAuth::new(None, &test_config()).await.unwrap(); + let mgr = JwtAuth::new(None, Some(&test_secret())).await.unwrap(); assert!(mgr.verify("not-a-valid-token").is_err()); } #[tokio::test] async fn verify_rejects_wrong_secret() { - let cfg1 = test_config(); - let mgr1 = JwtAuth::new(None, &cfg1).await.unwrap(); - let (token, _) = mgr1.generate("admin", Role::Operator).unwrap(); + let secret1 = test_secret(); + let mgr1 = JwtAuth::new(None, Some(&secret1)).await.unwrap(); + let (token, _) = mgr1.generate("admin", Role::Operator, 3600).unwrap(); - let cfg2 = AuthConfig { - jwt_secret: Some(base64::engine::general_purpose::STANDARD.encode([99u8; 32])), - ..cfg1 - }; - let mgr2 = JwtAuth::new(None, &cfg2).await.unwrap(); + let secret2 = base64::engine::general_purpose::STANDARD.encode([99u8; 32]); + let mgr2 = JwtAuth::new(None, Some(&secret2)).await.unwrap(); assert!(mgr2.verify(&token).is_err()); } #[tokio::test] async fn verify_rejects_expired_token() { - let mgr = JwtAuth::new(None, &test_config()).await.unwrap(); + let mgr = JwtAuth::new(None, Some(&test_secret())).await.unwrap(); let claims = Claims { sub: "admin".to_owned(), role: Role::Operator, @@ -176,16 +165,12 @@ mod tests { #[tokio::test] async fn no_vault_no_secret_fails() { - let cfg = AuthConfig { jwt_secret: None, ..test_config() }; - assert!(JwtAuth::new(None, &cfg).await.is_err()); + assert!(JwtAuth::new(None, None).await.is_err()); } #[tokio::test] async fn short_secret_fails() { - let cfg = AuthConfig { - jwt_secret: Some(base64::engine::general_purpose::STANDARD.encode([1u8; 16])), - ..test_config() - }; - assert!(JwtAuth::new(None, &cfg).await.is_err()); + let short = base64::engine::general_purpose::STANDARD.encode([1u8; 16]); + assert!(JwtAuth::new(None, Some(&short)).await.is_err()); } } diff --git a/src/node-control/service/src/auth/middleware.rs b/src/node-control/service/src/auth/middleware.rs index 2650103..1da10b0 100644 --- a/src/node-control/service/src/auth/middleware.rs +++ b/src/node-control/service/src/auth/middleware.rs @@ -7,7 +7,7 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use super::Role; -use crate::http::http_server_task::AppState; +use crate::{http::http_server_task::AppState, runtime_config::RuntimeConfig}; use axum::{ body::Body, extract::State, @@ -49,10 +49,16 @@ async fn require_role_impl( next: Next, min_role: Role, ) -> Response { - let jwt_auth = match &state.jwt_auth { - Some(mgr) => mgr, - None => return next.run(req).await, - }; + // Check live config: when auth is not configured, pass through. + // This allows auth to be enabled/disabled at runtime via config reload. + { + let cfg = state.runtime_cfg.get(); + if cfg.http.auth.is_none() { + return next.run(req).await; + } + } + + let jwt_auth = &state.jwt_auth; let auth_header = req.headers().get(axum::http::header::AUTHORIZATION).and_then(|v| v.to_str().ok()); diff --git a/src/node-control/service/src/contracts/contracts_task.rs b/src/node-control/service/src/contracts/contracts_task.rs index 004b4cf..cc219b8 100644 --- a/src/node-control/service/src/contracts/contracts_task.rs +++ b/src/node-control/service/src/contracts/contracts_task.rs @@ -19,7 +19,8 @@ use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::{client_json_rpc::ClientJsonRpc, data_models::AccountState}; /// Minimal required balance for the master wallet before it can be deployed. -const DEPLOY_AMOUNT: u64 = 1_000_000_000; // 1 TON +/// Note: 0.1 TON to cover the gas cost of the deploy transaction. +const DEPLOY_AMOUNT: u64 = 1_100_000_000; // 1.1 TON /// Minimal required balance for a wallet before it will be topped up. const MIN_WALLET_BALANCE: u64 = 5_000_000_000; // 5 TON /// Gas cost for sending message from a wallet. diff --git a/src/node-control/service/src/http/auth_tests.rs b/src/node-control/service/src/http/auth_tests.rs index 66767f6..922a5ad 100644 --- a/src/node-control/service/src/http/auth_tests.rs +++ b/src/node-control/service/src/http/auth_tests.rs @@ -68,7 +68,7 @@ fn app_cfg_no_auth() -> Arc { pools: HashMap::new(), bindings: HashMap::new(), ton_http_api: Default::default(), - http: Default::default(), + http: common::app_config::HttpConfig { auth: None, ..Default::default() }, elections: Some(Default::default()), voting: None, master_wallet: None, @@ -107,6 +107,12 @@ fn elections_task(rt: Arc) -> Arc { Arc::new(TaskController::new("elections", Noop, rt)) } +const TEST_JWT_SECRET: &str = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio="; // [42u8; 32] + +async fn test_jwt_auth() -> Arc { + Arc::new(JwtAuth::new(None, Some(TEST_JWT_SECRET)).await.unwrap()) +} + async fn state_with_auth() -> AppState { let cfg = auth_config(); let rt = Arc::new(RuntimeConfigStore::from_app_config(app_cfg_with_auth(cfg.clone()))); @@ -114,19 +120,19 @@ async fn state_with_auth() -> AppState { store: Arc::new(SnapshotStore::new()), runtime_cfg: rt.clone(), elections_task: elections_task(rt.clone()), - jwt_auth: Some(Arc::new(JwtAuth::new(None, &cfg).await.unwrap())), + jwt_auth: test_jwt_auth().await, user_store: Arc::new(UserStore::new(rt as Arc)), login_rate_limiter: Arc::new(tokio::sync::Mutex::new(Default::default())), } } -fn state_no_auth() -> AppState { +async fn state_no_auth() -> AppState { let rt = Arc::new(RuntimeConfigStore::from_app_config(app_cfg_no_auth())); AppState { store: Arc::new(SnapshotStore::new()), runtime_cfg: rt.clone(), elections_task: elections_task(rt.clone()), - jwt_auth: None, + jwt_auth: test_jwt_auth().await, user_store: Arc::new(UserStore::new(rt.clone() as Arc)), login_rate_limiter: Arc::new(tokio::sync::Mutex::new(Default::default())), } @@ -314,7 +320,7 @@ async fn protected_route_invalid_token_401() { #[tokio::test] async fn protected_route_valid_operator_token_200() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; let resp = app(st).oneshot(get_bearer("/v1/elections", &tok)).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -322,7 +328,7 @@ async fn protected_route_valid_operator_token_200() { #[tokio::test] async fn protected_route_valid_nominator_token_200() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("nom", Role::Nominator).unwrap().0; + let tok = st.jwt_auth.generate("nom", Role::Nominator, 3600).unwrap().0; let resp = app(st).oneshot(get_bearer("/v1/elections", &tok)).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -332,7 +338,7 @@ async fn protected_route_valid_nominator_token_200() { #[tokio::test] async fn nominator_forbidden_on_operator_route() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("nom", Role::Nominator).unwrap().0; + let tok = st.jwt_auth.generate("nom", Role::Nominator, 3600).unwrap().0; let body = StakePolicyRequest { policy: StakePolicy::Minimum, node: None }; let resp = app(st).oneshot(post_bearer("/v1/stake_strategy", &body, &tok)).await.unwrap(); assert_eq!(resp.status(), 403); @@ -341,7 +347,7 @@ async fn nominator_forbidden_on_operator_route() { #[tokio::test] async fn operator_allowed_on_operator_route() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; let body = StakePolicyRequest { policy: StakePolicy::Fixed(100), node: None }; let resp = app(st).oneshot(post_bearer("/v1/stake_strategy", &body, &tok)).await.unwrap(); assert_eq!(resp.status(), 200); @@ -350,7 +356,7 @@ async fn operator_allowed_on_operator_route() { #[tokio::test] async fn operator_can_access_nominator_routes() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; let resp = app(st).oneshot(get_bearer("/v1/validators", &tok)).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -381,14 +387,14 @@ async fn login_endpoint_always_public() { #[tokio::test] async fn auth_disabled_all_routes_open() { - let st = state_no_auth(); + let st = state_no_auth().await; let resp = app(st).oneshot(get("/v1/elections")).await.unwrap(); assert_eq!(resp.status(), 200); } #[tokio::test] async fn auth_disabled_operator_routes_open() { - let st = state_no_auth(); + let st = state_no_auth().await; let body = StakePolicyRequest { policy: StakePolicy::Fixed(100), node: None }; let resp = app(st).oneshot(post_json("/v1/stake_strategy", &body)).await.unwrap(); assert_eq!(resp.status(), 200); @@ -399,7 +405,7 @@ async fn auth_disabled_operator_routes_open() { #[tokio::test] async fn me_returns_operator_claims() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; let resp = app(st).oneshot(get_bearer("/auth/me", &tok)).await.unwrap(); assert_eq!(resp.status(), 200); let v = json(resp).await; @@ -410,7 +416,7 @@ async fn me_returns_operator_claims() { #[tokio::test] async fn me_returns_nominator_claims() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("nom", Role::Nominator).unwrap().0; + let tok = st.jwt_auth.generate("nom", Role::Nominator, 3600).unwrap().0; let resp = app(st).oneshot(get_bearer("/auth/me", &tok)).await.unwrap(); assert_eq!(resp.status(), 200); let v = json(resp).await; @@ -421,7 +427,7 @@ async fn me_returns_nominator_claims() { #[tokio::test] async fn protected_route_token_revoked_by_cutoff_401() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; st.runtime_cfg .update_with(|cfg| { @@ -442,8 +448,8 @@ async fn protected_route_token_revoked_by_cutoff_401() { #[tokio::test] async fn protected_route_token_revoked_on_equal_cutoff_401() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; - let claims = st.jwt_auth.as_ref().unwrap().verify(&tok).unwrap(); + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; + let claims = st.jwt_auth.verify(&tok).unwrap(); st.runtime_cfg .update_with(|cfg| { @@ -464,7 +470,7 @@ async fn protected_route_token_revoked_on_equal_cutoff_401() { #[tokio::test] async fn protected_route_token_rejected_after_role_change_401() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; st.runtime_cfg .update_with(|cfg| { @@ -485,7 +491,7 @@ async fn protected_route_token_rejected_after_role_change_401() { #[tokio::test] async fn create_user_via_rest_is_not_allowed() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; let resp = app(st) .oneshot(post_bearer( @@ -505,7 +511,7 @@ async fn create_user_via_rest_is_not_allowed() { #[tokio::test] async fn delete_user_via_rest_returns_404() { let st = state_with_auth().await; - let tok = st.jwt_auth.as_ref().unwrap().generate("op", Role::Operator).unwrap().0; + let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; let req = axum::http::Request::builder() .method("DELETE") diff --git a/src/node-control/service/src/http/http_server_task.rs b/src/node-control/service/src/http/http_server_task.rs index c35ad7c..ce3d6ef 100644 --- a/src/node-control/service/src/http/http_server_task.rs +++ b/src/node-control/service/src/http/http_server_task.rs @@ -19,7 +19,10 @@ use crate::{ }; use common::{ app_config::StakePolicy, - snapshot::{ElectionsSnapshot, ElectionsStatus, SnapshotStore, TimeRange, ValidatorsSnapshot}, + snapshot::{ + ElectionsSnapshot, ElectionsStatus, OurElectionParticipant, SnapshotStore, TimeRange, + ValidatorsSnapshot, + }, task_cancellation::CancellationCtx, time_format, }; @@ -30,7 +33,7 @@ pub struct AppState { pub store: Arc, pub runtime_cfg: Arc, pub elections_task: Arc, - pub jwt_auth: Option>, + pub jwt_auth: Arc, pub user_store: Arc, pub(crate) login_rate_limiter: Arc>, } @@ -48,37 +51,39 @@ pub async fn run( let enable_swagger = cfg.http.enable_swagger; let user_store = Arc::new(UserStore::new(runtime_cfg.clone() as Arc)); - let jwt_auth = if let Some(auth_config) = &cfg.http.auth { - let vault = runtime_cfg.vault(); - let mgr = match JwtAuth::new(vault.clone(), auth_config).await { - Ok(m) => m, - Err(e) => { - tracing::error!( - target: "auth", - event = "auth_setup_failed", - error = ?e, - "authentication setup failed" - ); - return; - } - }; - - tracing::info!(target: "auth", event = "auth_enabled", "authentication enabled"); - Some(Arc::new(mgr)) - } else { - tracing::warn!( - target: "auth", - event = "auth_disabled", - reason = "no_auth_config", - "authentication disabled" - ); - None + // Always create JwtAuth so that auth can be enabled at runtime via config + // reload. + // The middleware decides at request time whether to enforce authentication + // by checking the live config. + let vault = runtime_cfg.vault(); + let jwt_secret = cfg.http.auth.as_ref().and_then(|a| a.jwt_secret.clone()); + let jwt_auth = match JwtAuth::new(vault, jwt_secret.as_deref()).await { + Ok(m) => { + tracing::info!( + target: "auth", + event = "auth_jwt_key_ready", + auth_configured = cfg.http.auth.is_some(), + "JWT signing key loaded", + ); + Arc::new(m) + } + Err(e) => { + tracing::error!( + target: "auth", + event = "auth_setup_failed", + error = ?e, + "authentication setup failed", + ); + return; + } }; drop(cfg); let bind_addr: SocketAddr = match bind.parse() { Ok(a) => a, Err(e) => { + // Intentionally fall back to localhost (not 0.0.0.0) to avoid + // accidentally exposing the API when the configured address is invalid. tracing::error!("invalid http.bind '{}': {} (fallback to 127.0.0.1:8080)", &bind, e); "127.0.0.1:8080".parse().expect("static bind must parse") } @@ -115,8 +120,6 @@ pub async fn run( } pub(crate) fn routes(enable_swagger: bool, state: AppState) -> axum::Router { - let auth_enabled = state.jwt_auth.is_some(); - let mut public = axum::Router::new() .route("/health", axum::routing::get(health_handler)) .route("/openapi.json", axum::routing::get(openapi_handler)) @@ -128,27 +131,27 @@ pub(crate) fn routes(enable_swagger: bool, state: AppState) -> axum::Router { .route("/swagger-ui", axum::routing::get(swagger_ui_handler)); } - let mut authenticated = axum::Router::new() + // Auth middleware is always applied; it checks the live config on every + // request and passes through when `http.auth` is not configured. + let authenticated = axum::Router::new() .route("/v1/elections", axum::routing::get(v1_elections_handler)) - .route("/v1/validators", axum::routing::get(v1_validators_handler)); + .route("/v1/validators", axum::routing::get(v1_validators_handler)) + .route("/auth/me", axum::routing::get(me_handler)) + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::require_nominator, + )); - let mut operator_only = axum::Router::new() + let operator_only = axum::Router::new() .route("/v1/elections/exclude", axum::routing::post(v1_elections_exclude_handler)) .route("/v1/elections/include", axum::routing::post(v1_elections_include_handler)) .route("/v1/stake_strategy", axum::routing::post(v1_stake_strategy_handler)) - .route("/v1/task/elections", axum::routing::post(v1_task_elections_handler)); - - if auth_enabled { - authenticated = - authenticated.route("/auth/me", axum::routing::get(me_handler)).route_layer( - axum::middleware::from_fn_with_state(state.clone(), middleware::require_nominator), - ); - - operator_only = - operator_only.route("/auth/users", axum::routing::get(list_users_handler)).route_layer( - axum::middleware::from_fn_with_state(state.clone(), middleware::require_operator), - ); - } + .route("/v1/task/elections", axum::routing::post(v1_task_elections_handler)) + .route("/auth/users", axum::routing::get(list_users_handler)) + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::require_operator, + )); axum::Router::new() .merge(public) @@ -237,6 +240,13 @@ pub struct ElectionsResponse { pub status: ElectionsStatus, pub result: Option, pub next_elections: Option, + pub our_participants: Vec, +} + +#[derive(Clone, Default, serde::Deserialize)] +pub struct ElectionsQuery { + /// Include full elections participants list in response. + pub include_participants: Option, } #[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] @@ -371,7 +381,8 @@ pub struct UserInfoDto { responses( (status = 200, description = "Service is healthy", body = HealthResponse, example = json!({"ok": true, "result": "OK"})), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(()) )] pub async fn health_handler() -> axum::Json { axum::Json(HealthResponse { ok: true, result: "OK".to_owned() }) @@ -380,20 +391,28 @@ pub async fn health_handler() -> axum::Json { #[utoipa::path( get, path = "/v1/elections", + params( + ("include_participants" = Option, Query, description = "Include full elections participants list") + ), responses( (status = 200, description = "Current elections snapshot (may be null if not available yet)", body = ElectionsResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn v1_elections_handler( axum::extract::State(state): axum::extract::State, + axum::extract::Query(query): axum::extract::Query, ) -> axum::Json { - let snapshot = state.store.get(); + let include_participants = query.include_participants.unwrap_or(false); + let view = state.store.get_elections_view(include_participants); axum::Json(ElectionsResponse { ok: true, - result: snapshot.elections, - status: snapshot.elections_status, - next_elections: snapshot.next_elections_range, + result: view.elections, + status: view.status, + next_elections: view.next_elections, + our_participants: view.our_participants, }) } @@ -404,8 +423,10 @@ pub async fn v1_elections_handler( responses( (status = 200, description = "List of nodes excluded from elections", body = ElectionsExcludeResponse), (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn v1_elections_exclude_handler( state: axum::extract::State, @@ -453,8 +474,10 @@ pub async fn v1_elections_exclude_handler( responses( (status = 200, description = "List of nodes excluded from elections", body = ElectionsExcludeResponse), (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn v1_elections_include_handler( state: axum::extract::State, @@ -500,8 +523,10 @@ pub async fn v1_elections_include_handler( path = "/v1/validators", responses( (status = 200, description = "Current validators snapshot", body = ValidatorsResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn v1_validators_handler( axum::extract::State(state): axum::extract::State, @@ -517,8 +542,10 @@ pub async fn v1_validators_handler( responses( (status = 200, description = "Applied stake policy", body = StakePolicyResponse), (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn v1_stake_strategy_handler( state: axum::extract::State, @@ -566,8 +593,10 @@ pub async fn v1_stake_strategy_handler( responses( (status = 200, description = "Updated elections task state", body = ElectionsTaskControlResponse), (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn v1_task_elections_handler( state: axum::extract::State, @@ -635,19 +664,27 @@ async fn swagger_ui_handler() -> axum::response::Html { (status = 401, description = "Invalid credentials", body = ApiErrorResponse), (status = 429, description = "Too many login attempts", body = ApiErrorResponse), (status = 500, description = "Internal error", body = ApiErrorResponse) - ) + ), + security(()) )] pub async fn login_handler( state: axum::extract::State, headers: axum::http::HeaderMap, req: axum::Json, ) -> Result, AppError> { + let (operator_ttl, nominator_ttl) = { + let cfg_snapshot = state.runtime_cfg.get(); + let auth_cfg = cfg_snapshot + .http + .auth + .as_ref() + .ok_or_else(|| AppError::bad_request("authentication is not configured"))?; + (auth_cfg.operator_token_ttl, auth_cfg.nominator_token_ttl) + }; + validate_username(&req.username).map_err(|e| AppError::bad_request(&e.to_string()))?; - let jwt_auth = state - .jwt_auth - .as_ref() - .ok_or_else(|| AppError::bad_request("authentication is not configured"))?; + let jwt_auth = &state.jwt_auth; let user_store = state.user_store.as_ref(); let now = time_format::now(); let limiter_key = login_limiter_key(&headers, &req.username); @@ -718,7 +755,12 @@ pub async fn login_handler( } }; - let (token, expires_in) = jwt_auth.generate(&req.username, role).map_err(|e| { + let ttl = match role { + crate::auth::Role::Operator => operator_ttl, + crate::auth::Role::Nominator => nominator_ttl, + }; + + let (token, expires_in) = jwt_auth.generate(&req.username, role, ttl).map_err(|e| { tracing::error!( target: "auth", event = "auth_token_generation_error", @@ -739,7 +781,8 @@ pub async fn login_handler( responses( (status = 200, description = "Current user identity", body = MeResponse), (status = 401, description = "Not authenticated", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn me_handler( req: axum::http::Request, @@ -763,7 +806,8 @@ pub async fn me_handler( (status = 200, description = "List of registered users", body = UserListResponse), (status = 401, description = "Not authenticated", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions", body = ApiErrorResponse) - ) + ), + security(("bearerAuth" = [])) )] pub async fn list_users_handler( state: axum::extract::State, @@ -779,8 +823,27 @@ pub async fn list_users_handler( })) } +struct BearerAuthAddon; + +impl utoipa::Modify for BearerAuthAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearerAuth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::HttpBuilder::new() + .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some("Paste a JWT token obtained from POST /auth/login")) + .build(), + ), + ); + } +} + #[derive(utoipa::OpenApi)] #[openapi( + modifiers(&BearerAuthAddon), paths( health_handler, v1_elections_handler, @@ -798,6 +861,7 @@ pub async fn list_users_handler( ApiErrorResponse, HealthResponse, ElectionsResponse, + NodeListRequest, ValidatorsResponse, common::app_config::StakePolicy, common::app_config::BindingStatus, @@ -817,8 +881,12 @@ pub async fn list_users_handler( UserListResponse, UserInfoDto, common::snapshot::Snapshot, + common::snapshot::ElectionsStatus, common::snapshot::ElectionsSnapshot, common::snapshot::ElectionsParticipantSnapshot, + common::snapshot::OurElectionParticipant, + common::snapshot::ParticipationStatus, + common::snapshot::StakeSubmission, common::snapshot::ValidatorsSnapshot, common::snapshot::ValidatorNodeSnapshot, common::snapshot::TimeRange @@ -836,11 +904,16 @@ mod tests { use super::*; use crate::{runtime_config::RuntimeConfigStore, task::task_manager::ServiceTask}; use axum::body::Body; + use base64::Engine; use common::{ app_config::{ - AppConfig, ElectionsConfig, LogConfig, NodeBinding, StakePolicy, TonHttpApiConfig, + AppConfig, ElectionsConfig, HttpConfig, LogConfig, NodeBinding, StakePolicy, + TonHttpApiConfig, + }, + snapshot::{ + ElectionsParticipantSnapshot, ElectionsSnapshot, ElectionsStatus, + OurElectionParticipant, StakeSubmission, TimeRange, ValidatorNodeSnapshot, }, - snapshot::{ElectionsSnapshot, ElectionsStatus, TimeRange, ValidatorNodeSnapshot}, task_cancellation::CancellationCtx, }; use http_body_util::BodyExt; @@ -868,7 +941,12 @@ mod tests { Arc::new(TaskController::new("elections", NoopTask, runtime_cfg)) } - fn test_state( + async fn test_jwt_auth() -> Arc { + let secret = base64::engine::general_purpose::STANDARD.encode([42u8; 32]); + Arc::new(JwtAuth::new(None, Some(&secret)).await.unwrap()) + } + + async fn test_state( store: Arc, runtime_cfg: Arc, elections_task: Arc, @@ -878,7 +956,7 @@ mod tests { store, runtime_cfg, elections_task, - jwt_auth: None, + jwt_auth: test_jwt_auth().await, user_store, login_rate_limiter: Arc::new(tokio::sync::Mutex::new(LoginRateLimiter::default())), } @@ -898,7 +976,7 @@ mod tests { pools: HashMap::new(), bindings, ton_http_api: TonHttpApiConfig::default(), - http: Default::default(), + http: HttpConfig { auth: None, ..Default::default() }, elections: Some(ElectionsConfig { policy, ..Default::default() }), voting: None, master_wallet: None, @@ -914,7 +992,7 @@ mod tests { pools: HashMap::new(), bindings: HashMap::new(), ton_http_api: TonHttpApiConfig::default(), - http: Default::default(), + http: HttpConfig { auth: None, ..Default::default() }, elections: None, voting: None, master_wallet: None, @@ -941,6 +1019,27 @@ mod tests { .unwrap() } + fn collect_component_schema_refs(value: &serde_json::Value, out: &mut Vec) { + match value { + serde_json::Value::Object(map) => { + if let Some(reference) = map.get("$ref").and_then(serde_json::Value::as_str) { + if let Some(name) = reference.strip_prefix("#/components/schemas/") { + out.push(name.to_string()); + } + } + for child in map.values() { + collect_component_schema_refs(child, out); + } + } + serde_json::Value::Array(items) => { + for child in items { + collect_component_schema_refs(child, out); + } + } + _ => {} + } + } + #[tokio::test] async fn stake_policy_invalid_fixed_zero_returns_400() { let store = Arc::new(SnapshotStore::new()); @@ -948,7 +1047,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app .oneshot(post_json( @@ -971,7 +1070,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app .oneshot(post_json( @@ -995,7 +1094,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let state = test_state(store, runtime_cfg.clone(), elections_task); + let state = test_state(store, runtime_cfg.clone(), elections_task).await; let app = routes(false, state); let resp = app @@ -1028,7 +1127,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let state = test_state(store, runtime_cfg, elections_task); + let state = test_state(store, runtime_cfg, elections_task).await; // Disable let app = routes(false, state.clone()); @@ -1081,7 +1180,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app.oneshot(get_request("/health")).await.unwrap(); @@ -1098,7 +1197,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app.oneshot(get_request("/v1/elections")).await.unwrap(); @@ -1107,6 +1206,7 @@ mod tests { assert_eq!(v["ok"], true); assert_eq!(v["status"], "closed"); assert!(v["result"].is_null()); + assert!(v["our_participants"].as_array().unwrap().is_empty()); } #[tokio::test] @@ -1117,6 +1217,18 @@ mod tests { s.elections = Some(ElectionsSnapshot { election_id: 100, participants_count: 5, + min_stake: "100".to_string(), + participant_min_stake: Some("200".to_string()), + participant_max_stake: Some("900".to_string()), + participants: vec![ElectionsParticipantSnapshot { + pubkey: "aa".to_string(), + adnl: "bb".to_string(), + sender_addr: "cc".to_string(), + is_controlled: false, + stake: "300".to_string(), + max_factor: 3.0, + election_id: 100, + }], ..Default::default() }); s.next_elections_range = @@ -1127,7 +1239,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app.oneshot(get_request("/v1/elections")).await.unwrap(); @@ -1137,8 +1249,103 @@ mod tests { assert_eq!(v["status"], "active"); assert_eq!(v["result"]["election_id"], 100); assert_eq!(v["result"]["participants_count"], 5); + assert_eq!(v["result"]["min_stake"], "100"); + assert_eq!(v["result"]["participant_min_stake"], "200"); + assert_eq!(v["result"]["participant_max_stake"], "900"); + assert!(v["result"]["participants"].as_array().unwrap().is_empty()); assert_eq!(v["next_elections"]["start"], 1000); assert_eq!(v["next_elections"]["end"], 2000); + assert!(v["our_participants"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn elections_include_participants_query_returns_full_list() { + let store = Arc::new(SnapshotStore::new()); + store.update_with(|s| { + s.elections_status = ElectionsStatus::Active; + s.elections = Some(ElectionsSnapshot { + election_id: 100, + participants_count: 1, + participants: vec![ElectionsParticipantSnapshot { + pubkey: "aa".to_string(), + adnl: "bb".to_string(), + sender_addr: "cc".to_string(), + is_controlled: true, + stake: "300".to_string(), + max_factor: 3.0, + election_id: 100, + }], + ..Default::default() + }); + }); + + let runtime_cfg = + Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); + let elections_task = test_elections_task(); + + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); + + let resp = + app.oneshot(get_request("/v1/elections?include_participants=true")).await.unwrap(); + + assert_eq!(resp.status(), 200); + let v = body_json(resp).await; + assert_eq!(v["ok"], true); + assert_eq!(v["result"]["participants_count"], 1); + assert_eq!(v["result"]["participants"].as_array().unwrap().len(), 1); + assert_eq!(v["result"]["participants"][0]["pubkey"], "aa"); + } + + #[tokio::test] + async fn elections_returns_our_participants() { + let store = Arc::new(SnapshotStore::new()); + store.update_with(|s| { + s.our_participants.push(OurElectionParticipant { + node_id: "node-1".to_string(), + stake_accepted: true, + stake_submissions: vec![ + StakeSubmission { + stake: "100".to_string(), + max_factor: 3.0, + submission_time: 12345, + submission_time_utc: "2024-01-01T00:00:00Z".to_string(), + }, + StakeSubmission { + stake: "50".to_string(), + max_factor: 3.0, + submission_time: 12400, + submission_time_utc: "2024-01-01T00:01:00Z".to_string(), + }, + ], + accepted_stake: Some("150".to_string()), + elected: true, + position: Some(5), + ..Default::default() + }); + }); + let runtime_cfg = + Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); + let elections_task = test_elections_task(); + + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); + + let resp = app.oneshot(get_request("/v1/elections")).await.unwrap(); + + assert_eq!(resp.status(), 200); + let v = body_json(resp).await; + assert_eq!(v["ok"], true); + let participants = v["our_participants"].as_array().unwrap(); + assert_eq!(participants.len(), 1); + assert_eq!(participants[0]["node_id"], "node-1"); + assert_eq!(participants[0]["stake_accepted"], true); + let submissions = participants[0]["stake_submissions"].as_array().unwrap(); + assert_eq!(submissions.len(), 2); + assert_eq!(submissions[0]["stake"], "100"); + assert_eq!(submissions[0]["max_factor"], 3.0); + assert_eq!(submissions[1]["stake"], "50"); + assert_eq!(participants[0]["accepted_stake"], "150"); + assert_eq!(participants[0]["elected"], true); + assert_eq!(participants[0]["position"], 5); } #[tokio::test] @@ -1148,7 +1355,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app.oneshot(get_request("/v1/validators")).await.unwrap(); @@ -1166,6 +1373,8 @@ mod tests { node_id: "node-1".to_string(), is_validator: true, validator_index: Some(42), + key_id: Some("a2V5X2lk".to_string()), + adnl: Some("YWRubA==".to_string()), ..Default::default() }); s.validators.default_stake_policy = StakePolicy::Minimum; @@ -1175,7 +1384,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app.oneshot(get_request("/v1/validators")).await.unwrap(); @@ -1187,6 +1396,8 @@ mod tests { assert_eq!(nodes[0]["node_id"], "node-1"); assert_eq!(nodes[0]["is_validator"], true); assert_eq!(nodes[0]["validator_index"], 42); + assert_eq!(nodes[0]["key_id"], "a2V5X2lk"); + assert_eq!(nodes[0]["adnl"], "YWRubA=="); } #[tokio::test] @@ -1196,7 +1407,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config(StakePolicy::Minimum))); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app.oneshot(get_request("/openapi.json")).await.unwrap(); @@ -1206,6 +1417,21 @@ mod tests { assert!(v["paths"].as_object().unwrap().contains_key("/health")); assert!(v["paths"].as_object().unwrap().contains_key("/v1/elections")); assert!(v["paths"].as_object().unwrap().contains_key("/v1/validators")); + let schemas = v["components"]["schemas"].as_object().unwrap(); + assert!(schemas.contains_key("ElectionsStatus")); + assert!(schemas.contains_key("NodeListRequest")); + + let mut refs = Vec::new(); + collect_component_schema_refs(&v, &mut refs); + refs.sort(); + refs.dedup(); + let missing_refs: Vec = + refs.into_iter().filter(|name| !schemas.contains_key(name)).collect(); + assert!( + missing_refs.is_empty(), + "openapi has unresolved component schema refs: {:?}", + missing_refs + ); } #[tokio::test] @@ -1236,7 +1462,7 @@ mod tests { )); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg.clone(), elections_task)); + let app = routes(false, test_state(store, runtime_cfg.clone(), elections_task).await); let resp = app .oneshot(post_json( @@ -1266,7 +1492,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config_no_elections())); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app .oneshot(post_json( @@ -1309,7 +1535,7 @@ mod tests { )); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg.clone(), elections_task)); + let app = routes(false, test_state(store, runtime_cfg.clone(), elections_task).await); let resp = app .oneshot(post_json( @@ -1340,7 +1566,7 @@ mod tests { Arc::new(RuntimeConfigStore::from_app_config(test_app_config_no_elections())); let elections_task = test_elections_task(); - let app = routes(false, test_state(store, runtime_cfg, elections_task)); + let app = routes(false, test_state(store, runtime_cfg, elections_task).await); let resp = app .oneshot(post_json( @@ -1354,4 +1580,33 @@ mod tests { let v = body_json(resp).await; assert_eq!(v["ok"], false); } + + #[test] + fn openapi_spec_contains_bearer_auth_scheme() { + let spec = ::openapi(); + let json = serde_json::to_value(&spec).unwrap(); + + // Security scheme is defined in components + let scheme = &json["components"]["securitySchemes"]["bearerAuth"]; + assert_eq!(scheme["type"], "http"); + assert_eq!(scheme["scheme"], "bearer"); + assert_eq!(scheme["bearerFormat"], "JWT"); + + // Protected endpoint references the scheme + let elections_security = &json["paths"]["/v1/elections"]["get"]["security"]; + assert!(elections_security.is_array(), "elections endpoint should have security"); + assert_eq!(elections_security[0]["bearerAuth"], serde_json::json!([])); + + // Public endpoints opt out of security + let health_security = &json["paths"]["/health"]["get"]["security"]; + let login_security = &json["paths"]["/auth/login"]["post"]["security"]; + for (name, sec) in [("health", health_security), ("login", login_security)] { + assert!(sec.is_array(), "{name} endpoint should have a security array"); + let arr = sec.as_array().unwrap(); + assert!( + !arr.iter().any(|v| v.get("bearerAuth").is_some()), + "{name} endpoint should not require bearerAuth" + ); + } + } } diff --git a/src/node-control/ton-http-api-client/Cargo.toml b/src/node-control/ton-http-api-client/Cargo.toml index ee71b6a..703fd99 100644 --- a/src/node-control/ton-http-api-client/Cargo.toml +++ b/src/node-control/ton-http-api-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ton-http-api-client" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = 'GPL-3.0' @@ -9,7 +9,6 @@ serde = { version = "1.0" } serde_json = "1.0.145" base64 = "0.22" anyhow = "1.0" -async-trait = "0.1" tracing = "0.1" uuid = { version = "1", features = ["v4"] } ton_block = { path = "../../block" } diff --git a/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs b/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs index 0b3a3e4..246eb48 100644 --- a/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs +++ b/src/node-control/ton-http-api-client/src/v2/client_json_rpc.rs @@ -10,6 +10,7 @@ use crate::v2::data_models::{ GetAddressInformationRes, GetExtendedAddressInformationRes, GetWalletInformationRes, RunGetMethodParams, RunGetMethodRes, }; +use anyhow::Context; use base64::Engine; use std::{ collections::HashSet, @@ -144,7 +145,7 @@ impl ClientJsonRpc { } if let Some(err) = last_error { - Err(err.context(format!("all {} endpoints failed", total))) + Err(err.context(format!("all endpoints ({}) failed", total))) } else { anyhow::bail!("request failed") } @@ -155,9 +156,10 @@ impl ClientJsonRpc { "config_id": param_id, }); - let config_info = self.json_rpc("getConfigParam", json_params).await.map_err(|e| { - anyhow::anyhow!("Request `getConfigParam({})` return error: {}", param_id, e) - })?; + let config_info = self + .json_rpc("getConfigParam", json_params) + .await + .with_context(|| format!("getConfigParam({})", param_id))?; let b64 = config_info .get("config")