20 Commits
v0.3.0 ... main

Author SHA1 Message Date
707d5b1032 feat(contrib): add ping key revocation helper
- Add make_ping_key_revocation.py: onion revocations are signed by the
  identity key, DNS TXT revocations are self-signed by the revoked key
- Document the revocation format, endpoint, helper usage, lifetime and
  no-revocation-of-identity-key limits
- Update the security audit, references and env docs for the new flow and
  the v2/onion-only scope
2026-11-09 00:45:43 -04:00
78e4f72255 docs(pusher): document v2 onion delegation and legacy ping defaults
- Correct WELIST_ALLOW_LEGACY_PING: legacy pings are accepted by default,
  the flag only disables them.
- Describe onion delegation: v2-only inclusion, service-side host
  normalization, strict expiry check, deployment order and rollback.
- Warn that disabled delegation rejects onion pings when SSL_KEY_PATH is
  the dedicated ping key.
- Document certificate lifetime and revocation limits, offline master
  key requirements, helper --days bounds, and a delegation verification
  checklist.
- Fix the bal-pusher.sh comment about the service-side defaults.
2026-11-09 00:15:00 -04:00
4af1ca8220 feat(contrib): add TXT record helper with key generation
- Add contrib/make_welist_txt.sh to print the _welist-key TXT
  name, value and BIND zone line for a domain and Ed25519 key
- Accept a private/public PEM file or an existing base64 SPKI DER
- Generate private_key.pem and public_key.pem when the key argument
  is omitted, reusing an existing private key
- Document the helper in docs/10_dnssec_key_publishing.md
2026-11-08 23:26:32 -04:00
63f65f930a feat(pusher): add validated onion delegation for v2 pings
- Load WELIST_PING_DELEGATION_FILE into a typed OnionDelegation
- Send the certificate only with v2 pings for onion hosts
- Validate the onion host, expiry and delegated key against
  BAL_SERVER_URL and SSL_KEY_PATH at startup; disable delegation
  with a warning on any mismatch
- Stop sending unsigned info/base_fee/version with v1 pings so
  v1 remains liveness-only
- Document v1/v2 metadata handling and onion delegation implications
- Add contrib/make_onion_delegation.py to issue certificates offline,
  with Ed25519 key validation and clean CLI error handling
2026-11-08 23:07:08 -04:00
0eff7a649a feat(pusher): add opt-in v2 WeTime ping signing
- Add WELIST_PING_SIG_VERSION (1 legacy, 2 canonical JSON); default 1
- Sign WELIST-PING-V2\n + compact sorted JSON covering url, chain, height,
  median_time, last_block_hash, info, base_fee, version and sig_version
- Fetch info/base_fee/version from the local /{chain}/info endpoint and sign them
- Fall back to null metadata on fetch failure; skip the fetch for onion URLs
- Normalize the advertised URL and bound metadata to match the WeTime service
- Activate v2 in bal-pusher.sh and document the TXT record setup
- Add unit tests for canonical payloads, URL normalization, metadata and onion detection

Includes previously uncommitted local deployment hardening:
- pg-server.sh configurable loopback bind address, volume and recreate handling
- .env loading in bal-server.sh and bal-pusher.sh
- second bal-server/bal-pusher development scripts
2026-11-07 20:37:11 -04:00
b8d87ecabd fix: per-endpoint rate limits, request timeout and input hardening
bal-server:
- Wire independent actix-governor buckets per endpoint class (pushtxs,
  searchtx, info/stats, default) so polling a cheap endpoint can no longer
  exhaust the push-txs budget; previously every limit was dead config
- Enforce BAL_SERVER_ACTIX_TIMEOUT_SECS via a request-timeout middleware that
  returns 408 Request Timeout, covering slow request bodies
- De-duplicate txids within a single /pushtxs request body
- Replace the byte-slice xpub detection (panic on non-ASCII) with a safe
  prefix check

bal-pusher:
- Handle malformed/empty ZMQ frames without indexing panics or UTF-8 expect
- Return an error from sign_message and skip the stats report instead of
  panicking when the private key cannot be parsed

validation / db:
- Reject IPv4-mapped IPv6 targets (::ffff:a.b.c.d) in is_valid_welist_url
  to close an SSRF bypass, with unit tests
- Use Path::starts_with for the SQLite forbidden-directory check so paths
  like /etcfoo are not misclassified

misc:
- Add explicit secret patterns to .gitignore and sync the docs
2026-10-14 18:30:23 -04:00
457c143954 chore: unify DB env vars and harden local Postgres startup
- Use BAL_SERVER_DB_* / BAL_SERVER_PG_DSN in bal-pusher, Dockerfiles,
  scripts and docs instead of the BAL_PUSHER_DB_* duplicates
- Default BAL_SERVER_URL to http://127.0.0.1:9133
- Extract PostgreSQL container startup into a shared pg-server.sh sourced by
  bal-server.sh and bal-pusher.sh
- Read the Postgres password from BAL_PG_PASSWORD instead of hardcoding it,
  and stop printing the password-bearing DSN
- Redact a real-looking Gitea token from .env.example (revoke it if it was live)
2026-10-14 18:29:39 -04:00
ccadb14254 refactor: unify client IP extraction into extract_real_ip
Replace extract_client_ip (manual header parsing without proxy
verification) and is_valid_ip with a single extract_real_ip function
that uses the same proxy-checking logic as RealIpKeyExtractor.

- If peer IP == trusted proxy: use realip_remote_addr() (Forwarded/
  X-Forwarded-For) — safe because peer was verified
- Otherwise: use peer_addr() directly (local/direct connection)

echo_info now extracts the trusted proxy from web::Data<IpAddr>
and passes it to extract_real_ip, ensuring consistent IP resolution
across rate limiting and address derivation.
2026-08-19 03:13:01 -04:00
ed85d5a8ea docs: update rate limiting docs for RealIpKeyExtractor and trusted proxy
- Correct per-endpoint rate limit tables to reflect actual single global config
- Document RealIpKeyExtractor behavior (X-Real-IP / X-Forwarded-For behind proxy)
- Add BAL_SERVER_TRUSTED_PROXY env var to all relevant docs
- Update security audit and modules detail with proxy-aware rate limiting
2026-08-19 02:57:57 -04:00
f8b1ee167f fix: rate limit per real client IP behind reverse proxy
Replace PeerIpKeyExtractor with custom RealIpKeyExtractor that extracts
the real client IP from X-Real-IP/X-Forwarded-For headers when the
request comes from a trusted proxy (default 127.0.0.1).

This fixes rate limiting being applied to the proxy IP instead of
individual clients when deployed behind Nginx.
2026-08-19 02:54:46 -04:00
66a1273e2c fix: correct locktime threshold, PG stats SUM on TEXT, PG locktime width, and translate Italian error strings
- Bug 1: LOCKTIME_THRESHOLD was 5_000_000 instead of BIP-65 value 500_000_000
- Bug 2: PostgreSQL SUM(our_fees) failed on TEXT column; cast to BIGINT
- Bug 3: PostgreSQL locktime INTEGER (i32) widened to BIGINT (i64) with idempotent ALTER
- Translate 4 Italian error strings in xpub.rs to English
2026-08-19 02:22:14 -04:00
e7262a2cae chore: remove public_key.pem and .gitsecret/ from repo 2026-08-18 08:39:55 -04:00
7be0ef3578 chore: clean up .gitignore and remove tracked files that should not be in the repo
Removed from tracking:
- Release binaries and tarballs (releases/, bal-server-0.2.2-*)
- Environment files with secrets (bal-server.env)
- Service files (bitcoind.service, tbitcoind.service)
- Test data (invalid_txs, valid_txs, test)
- Utility scripts (sendtx.sh, lib.sh, generate_random_ascii.sh, etc.)
- Archive (contrib.tar.gz)
- Scratch files (update, update_codebase.txt, src/xpub.rs2)

NOTE: The .env file with Gitea token was never tracked (already in .gitignore).
Private keys (chiave_privata.key, ec.key, private_key.pem, privkey.pem)
were also never tracked. Rotate the Gitea token if it was ever shared.
2026-08-18 03:24:18 -04:00
b277d894dd fix: resolve PostgreSQL type mismatches and add willexecutor debug logging
- Fix get_next_address_index: use try_get::<i32> for PG SERIAL/INTEGER columns
- Fix search_tx: use try_get::<i32> for PG status column
- Fix execute_insert: parse locktime (String→i64) and in_vout (String→i32)
  before binding to PG INTEGER columns
- Fix execute_insert: bind tbl_out vout as i32, amount as String for PG
- Fix save_new_address: cast xpub i64 to i32 for PG INTEGER column
- Fix get_pending_txs: cast i64 bind params to i32, use try_get::<i32> for reads
- Fix get_stats: use try_get::<i32> for all numeric PG INTEGER columns
- Add trace logging in parse_request_transactions for xpub address matching
- Add trace logging in get_all_addresses_by_xpub for query debugging
2026-08-18 03:14:03 -04:00
90ae3b6510 docker: add release Dockerfile, fix build deps and feature-split builds
- Add Dockerfile.release: downloads latest release from Gitea, verifies SHA-256
- Fix Dockerfile: remove unused libsodium/cmake, add libsqlite3-dev
- Split build per binary with --no-default-features --features server/pusher
- Remove Cargo.lock from .dockerignore for reproducible builds
- Update README and knowledge base with Dockerfile.release docs
2026-07-20 17:36:58 -04:00
a20cc1cf7c docs: update knowledge base to match current codebase
- Fix framework references (actix-web, not hyper)
- Update all env var names (BAL_SERVER_*/BAL_PUSHER_* prefix)
- Add validation.rs module documentation
- Fix function signatures in xpub.rs and db.rs
- Update API response formats (InfoResponse, StatsResponse)
- Fix database schema (date_creation/date_update, push_err, tbl_stats)
- Mark fixed vulnerabilities with current status
- Add Docker support and actix tuning documentation
- Remove outdated references (confy, bal-stats.rs.dontcompile)
- Add regression test summary table
2026-07-20 15:43:20 -04:00
80537b548f release: bal-server-0.3.2 2026-07-20 13:37:19 -04:00
01ac26e7be fix: extract client IP correctly behind reverse proxy
Add X-Forwarded-For as fallback header when X-Real-IP is missing.
Validate IP format before storing. Log which source was used.

release: bal-server-0.3.2
2026-07-20 13:34:03 -04:00
4b9ae596ce release: bal-server-0.3.1 2026-07-20 08:53:16 -04:00
c021e3d4cc fix: bug fixes, dead code removal, improved ZMQ logging
- Fix SQL syntax error in create_database (trailing parenthesis)
- Fix typo i27.0.0.1 -> 127.0.0.1 in Testnet/Testnet4 defaults
- Replace hardcoded VERSION with CARGO_PKG_VERSION in bal-pusher
- Remove unwrap() in DB update loops (status push/invalid)
- Remove duplicate init_network call in bal-server startup
- Use INSERT OR IGNORE for idempotent xpub initialization
- Remove debug println! left in production code
- Remove dead code: check_zmq_connection, ConnectionMonitor, seq_to_str
- Remove all commented-out code blocks
- ZMQ timeout logging: trace instead of warn, error only after 1 hour
2026-07-20 08:48:54 -04:00
50 changed files with 6800 additions and 2078 deletions

View File

@@ -16,7 +16,6 @@ target/
*.pem
*.key
!public_key.pem
!data/public_key.pem
# Database files
*.db
@@ -51,7 +50,6 @@ update_codebase.txt
.idea/
# Misc
Cargo.lock
generate_random_ascii.sh
test/
invalid_txs/

View File

@@ -3,8 +3,7 @@
# DO NOT commit the real token to git!
# This file is in .gitignore and should not be committed to git
GITEA_API_TOKEN=your_gitea_api_token_here
# Example: GITEA_API_TOKEN=***REMOVED***
# (replace with your actual token after revoking the old one)
# If a token was ever committed, revoke it and set a new one via the environment.
# === bal-server ===
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place
@@ -33,13 +32,33 @@ BAL_SERVER_ACTIX_DEFAULT_BURST=100
BAL_SERVER_ACTIX_WORKERS=4
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100
# === PostgreSQL (optional; used by pg-server.sh) ===
# pg-server.sh requires BAL_PG_PASSWORD to be set; never commit a real password.
BAL_PG_PASSWORD=change_me
BAL_PG_USER=bal
BAL_PG_DB=baldb
# Docker volume that stores the database, so the container can be recreated
# without losing data.
BAL_PG_VOLUME=bal-pg-data
# Interface on which the container publishes port 5432. Keep 127.0.0.1 so the
# database listens on loopback only; 0.0.0.0 would expose it on every interface.
BAL_PG_BIND_ADDR=127.0.0.1
# === bal-pusher ===
BAL_PUSHER_DB_FILE=/var/bal/bal.db
BAL_SERVER_DB_FILE=/var/bal/bal.db
BAL_PUSHER_BITCOIN_DIR=/home/bal/.bitcoin
BAL_PUSHER_SEND_STATS=false
BAL_SERVER_URL=http://127.0.0.1:9137
SSL_KEY_PATH=/var/bal/private_key.pem
WELIST_SERVER_URL=https://welist.bitcoin-after.life
# Sign WeTime pings with the v2 canonical JSON scheme. Requires a DNSSEC-
# validated `_welist-key.<host>` TXT record (or a v3 onion address). Omit or
# set to 1 for the legacy scheme.
WELIST_PING_SIG_VERSION=2
# Onion hosts only (v2 pings): delegation certificate issued offline by the
# onion identity key. SSL_KEY_PATH is then the dedicated ping key, not the
# identity key.
# WELIST_PING_DELEGATION_FILE=/var/bal/onion-delegation.json
# ZMQ endpoints per network (default: localhost only)
BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332

72
.gitignore vendored
View File

@@ -1,39 +1,61 @@
.gitsecret/keys/random_seed
!*.secret
# Environment files - NEVER commit tokens or secrets
*.env
# Secrets and environment files - NEVER commit
.env
.env.*
.env.local
*.env.local
.env.production
.env.secret
# Private keys - NEVER commit to git
# Only public_key.pem should be tracked (if needed)
!.env.example
*.pem
!public_key.pem
data/*.pem
!data/public_key.pem
*.key
!*.secret
private_key.pem
privkey.pem
!public_key.pem
*.key
ec.key
chiave_privata.key
# Other sensitive files
bal.db
.bal.db
download_bal_db.sh
# Databases
*.db
*.db-shm
*.db-wal
# IDE files
*.swp
*.swo
# Rust build artifacts
/target
Cargo.lock
!lib/
!contrib/
!src/
# Release artifacts
/releases/
*.tar.gz
*.tar.gz.asc
*.tar.gz.sig
*.tar.gz.sha256
bal-server-*/
# Deployment configs (keep in deploy scripts, not in repo)
bal-server.env
bal-pusher.env
bitcoind.service
tbitcoind.service
# Test data and scratch files
invalid_txs
valid_txs
test
update
update_codebase.txt
# Utility scripts (keep local, not in repo)
sendtx.sh
lib.sh
generate_random_ascii.sh
start_postgres_docker.sh
make_release.sh
download_bal_db.sh
# Misc
*.swp
*.swo
__pycache__/
*.pyc
src/xpub.rs2
public_key.pem
.gitsecret/

717
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "bal_server"
version = "0.3.0"
version = "0.3.2"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -20,9 +20,9 @@ hex = { version = "0.4.3" }
log = { version = "0.4.21" }
serde = { version = "1.0.152", features = ["derive"] }
serde_json = { version = "1.0.116" }
sqlite = { version = "0.34.0" }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
regex = { version = "1.10.4" }
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] }
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread","time"] }
url = { version = "2" }
# server-only

View File

@@ -11,9 +11,8 @@ FROM rust:1.95-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
libsodium-dev \
libzmq5-dev \
cmake \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
@@ -27,12 +26,14 @@ RUN mkdir -p src/bin && \
echo '' > src/db.rs && \
echo '' > src/xpub.rs && \
echo '' > src/validation.rs && \
cargo build --release --bin bal-server --bin bal-pusher 2>/dev/null || true && \
cargo build --release --bin bal-server --no-default-features --features server 2>/dev/null || true && \
cargo build --release --bin bal-pusher --no-default-features --features pusher 2>/dev/null || true && \
rm -rf src target/release/.fingerprint target/release/deps/*bal_server*
# Copy real source and build
# Copy real source and build each binary with only its required features
COPY src/ src/
RUN cargo build --release --bin bal-server --bin bal-pusher && \
RUN cargo build --release --bin bal-server --no-default-features --features server && \
cargo build --release --bin bal-pusher --no-default-features --features pusher && \
strip target/release/bal-server target/release/bal-pusher
# ---------------------------------------------------------------------------
@@ -43,7 +44,6 @@ FROM debian:bookworm-slim AS runtime
# Install runtime dependencies + tini for PID 1
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libsodium23 \
libzmq5 \
libsqlite3-0 \
ca-certificates \
@@ -82,7 +82,6 @@ ENV RUST_LOG=info \
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
BAL_SERVER_BIND_PORT=9137 \
BAL_SERVER_DB_FILE=/var/bal/bal.db \
BAL_PUSHER_DB_FILE=/var/bal/bal.db \
BAL_SERVER_URL=http://127.0.0.1:9137 \
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
SSL_KEY_PATH=/var/bal/private_key.pem

99
Dockerfile.release Normal file
View File

@@ -0,0 +1,99 @@
# =============================================================================
# Dockerfile.release — Downloads the latest pre-built release from Gitea
# No Rust toolchain needed. Fast builds, minimal image.
# =============================================================================
FROM debian:bookworm-slim AS runtime
ARG GITEA_API="https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server"
ARG BAL_VERSION=""
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libzmq5 \
libsqlite3-0 \
ca-certificates \
curl \
jq \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
WORKDIR /tmp/bal-install
# Download and verify release
# If BAL_VERSION is set, fetch that specific tag; otherwise fetch latest
RUN set -eux; \
if [ -n "$BAL_VERSION" ]; then \
URL="${GITEA_API}/releases/tags/${BAL_VERSION}"; \
else \
URL="${GITEA_API}/releases/latest"; \
fi; \
echo "==> Fetching release metadata from $URL"; \
RELEASE_JSON=$(curl -sfL "$URL") || { echo "ERROR: Failed to fetch release metadata"; exit 1; }; \
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty'); \
if [ -z "$TAG" ]; then echo "ERROR: Could not determine release tag"; exit 1; fi; \
echo "==> Release tag: $TAG"; \
TARBALL_URL=$(echo "$RELEASE_JSON" | jq -r \
'.assets[] | select(.name | test("\\.tar\\.gz$")) | .browser_download_url' | head -1); \
if [ -z "$TARBALL_URL" ]; then echo "ERROR: No .tar.gz asset found"; exit 1; fi; \
ASSET_NAME=$(basename "$TARBALL_URL"); \
echo "==> Downloading $ASSET_NAME"; \
curl -sfL -o "$ASSET_NAME" "$TARBALL_URL" || { echo "ERROR: Download failed"; exit 1; }; \
echo "==> Downloading checksum"; \
curl -sfL -o "${ASSET_NAME}.sha256" "${TARBALL_URL}.sha256" 2>/dev/null || true; \
if [ -f "${ASSET_NAME}.sha256" ]; then \
echo "==> Verifying SHA-256 checksum"; \
sha256sum -c "${ASSET_NAME}.sha256" || { echo "ERROR: SHA-256 verification failed"; exit 1; }; \
echo "==> Checksum OK"; \
else \
echo "WARNING: No .sha256 file available — skipping checksum verification"; \
fi; \
echo "==> Extracting tarball"; \
tar -xzf "$ASSET_NAME"; \
EXTRACTED="$(basename "$ASSET_NAME" .tar.gz)"; \
for bin in bal-server bal-pusher; do \
if [ ! -f "$EXTRACTED/$bin" ]; then \
echo "ERROR: Binary '$bin' not found in archive"; \
exit 1; \
fi; \
echo "==> Installing $bin"; \
install -m 0755 -o root -g root "$EXTRACTED/$bin" /usr/local/bin/; \
done; \
echo "==> Cleanup"; \
rm -rf /tmp/bal-install
# Create dedicated non-root user
RUN groupadd -g 1000 bal && \
useradd -u 1000 -g bal -s /usr/sbin/nologin -M bal && \
mkdir -p /var/bal /var/bal/.bitcoin && \
chown -R bal:bal /var/bal && \
chmod 700 /var/bal
# Copy entrypoint
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Use tini as PID 1 for proper signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/entrypoint.sh"]
# Data directory (mount as volume)
VOLUME ["/var/bal"]
# bal-server port
EXPOSE 9137
# Default environment (override at runtime)
ENV RUST_LOG=info \
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
BAL_SERVER_BIND_PORT=9137 \
BAL_SERVER_DB_FILE=/var/bal/bal.db \
BAL_SERVER_URL=http://127.0.0.1:9137 \
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
SSL_KEY_PATH=/var/bal/private_key.pem
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://127.0.0.1:9137/ || exit 1

View File

@@ -13,7 +13,15 @@ sudo cp target/release/bal-server target/release/bal-pusher /usr/local/bin
## Docker
### Build
### Quick Start (release download)
Download the latest pre-built release — no Rust toolchain needed:
```bash
docker build -f Dockerfile.release -t bal-server .
```
### Build from source
```bash
docker build -t bal-server .
@@ -37,6 +45,12 @@ docker run -d \
bal-server
```
### Pin a specific version
```bash
docker build -f Dockerfile.release --build-arg BAL_VERSION=v0.3.2 -t bal-server:0.3.2 .
```
### Docker environment variables
| Variable | Description | Default |
@@ -48,6 +62,7 @@ docker run -d \
> **Note:** The container runs as a non-root `bal` user (uid 1000) with `tini` as PID 1.
> The `/var/bal` volume stores the database. Mount Bitcoin Core's cookie file as read-only.
> When using `--network host`, ensure only `127.0.0.1` is used for internal services.
> `Dockerfile.release` fetches the latest release from the Gitea server and verifies its SHA-256 checksum.
## Configuration (bal-server)
@@ -97,6 +112,7 @@ The `bal-server` application can be configured using environment variables.
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | Rate limit: default burst size. | `100` |
| `BAL_SERVER_ACTIX_WORKERS` | Number of Actix worker threads. | `4` |
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | Maximum concurrent connections. | `100` |
| `BAL_SERVER_TRUSTED_PROXY` | Trusted reverse proxy IP for rate-limiting client identification. | `127.0.0.1` |
---
@@ -111,7 +127,7 @@ The `bal-server` application can be configured using environment variables.
zmqpubhashblock=tcp://127.0.0.1:28332
```
- **Rust and Cargo**: [Rust Installation](https://www.rust-lang.org/tools/install)
- **Libraries**: `libssl-dev`, `libsodium-dev`, `libzmq5-dev`, `libsqlite3-dev`
- **Libraries**: `libssl-dev`, `libzmq5-dev`, `libsqlite3-dev`
## Running
@@ -127,11 +143,13 @@ If no network is specified, defaults to `bitcoin`.
| Variable | Description | Default |
| --- | --- | --- |
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
| `BAL_PUSHER_SEND_STATS` | Send stats to welist server. | `false` |
| `BAL_SERVER_URL` | URL of bal-server (for stats reporting). | - |
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
| `WELIST_SERVER_URL` | Welist server URL. | `https://welist.bitcoin-after.life` |
| `WELIST_PING_SIG_VERSION` | WeTime ping signature scheme (`1` legacy, `2` canonical JSON). | `1` |
| `WELIST_PING_DELEGATION_FILE` | Onion only, v2 only: delegation certificate issued by the onion identity key. | unset |
### Per-network configuration

44
bal-pusher-2.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Second bal-pusher instance: talks to the second bal-server and database.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib.sh"
# Load local, gitignored environment overrides (e.g. BAL_PG_PASSWORD).
if [ -f "$SCRIPT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$SCRIPT_DIR/.env"
set +a
fi
WORKING_DIR=$(pwd)
export RUST_LOG=trace
export BAL_SERVER_DB_FILE="$WORKING_DIR/bal2.db"
export BAL_SERVER_DB_BACKEND="postgresql"
#export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie
#export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie
#export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie
#export BAL_PUSHER_SIGNET_COOKIE_FILE=/~/.bitcoin/signet/.cookie
export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
# Only the first pusher reports executor stats, to avoid double reporting for
# the same executor key on the welist service.
export BAL_PUSHER_SEND_STATS=false
export WELIST_SERVER_URL=http://localhost:8086
export WELIST_SKIP_URL_VALIDATION=true
export BAL_SERVER_URL="http://127.0.0.1:9134"
export SSL_KEY_PATH="$WORKING_DIR/private_key.pem"
# Instance-specific settings must win over .env, which defines BAL_PG_DB=baldb.
export BAL_PG_DB="baldb2"
unset BAL_SERVER_PG_DSN
if [ "$BAL_SERVER_DB_BACKEND" = "postgresql" ]; then
. "$SCRIPT_DIR/pg-server.sh"
fi
cargo run --bin=bal-pusher regtest --features=pusher

34
bal-pusher.sh Normal file → Executable file
View File

@@ -1,15 +1,43 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib.sh"
# Load local, gitignored environment overrides (e.g. BAL_PG_PASSWORD).
if [ -f "$SCRIPT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$SCRIPT_DIR/.env"
set +a
fi
WORKING_DIR=$(pwd)
export RUST_LOG=trace
export BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
export BAL_SERVER_DB_FILE="$WORKING_DIR/bal.db"
export BAL_SERVER_DB_BACKEND="postgresql"
#export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie
#export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie
#export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie
#export BAL_PUSHER_SIGNET_COOKIE_FILE=/~/.bitcoin/signet/.cookie
export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
export BAL_PUSHER_SEND_STATS=true
export BAL_PUSHER_SEND_STATS=true
export WELIST_SERVER_URL=http://localhost:8086
export WELIST_SKIP_URL_VALIDATION=true
# Local development uses the legacy v1 signature, verified by the service
# against GET /.pub_key.pem: a localhost URL cannot use DNSSEC key discovery.
# v2 requires a public DNS hostname (or a v3 onion URL) with a published key.
# The WeTime service accepts legacy pings by default; for loopback addresses it
# also needs WELIST_ALLOW_LOCALHOST_PING=true.
export WELIST_PING_SIG_VERSION=1
export BAL_SERVER_URL="http://127.0.0.1:9133"
export SSL_KEY_PATH="$(pwd)/private_key.pem"
export SSL_KEY_PATH="$WORKING_DIR/private_key.pem"
if [ "$BAL_SERVER_DB_BACKEND" = "postgresql" ]; then
. "$SCRIPT_DIR/pg-server.sh"
fi
cargo run --bin=bal-pusher regtest --features=pusher

45
bal-server-2.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Second bal-server instance: own port, own database, same key pair.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib.sh"
# Load local, gitignored environment overrides (e.g. BAL_PG_PASSWORD).
if [ -f "$SCRIPT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$SCRIPT_DIR/.env"
set +a
fi
WORKING_DIR=$(pwd)
if [ ! -f "$WORKING_DIR/public_key.pem" ]; then
echo_i "creating keypairs"
openssl genpkey -algorithm ED25519 -out private_key.pem
openssl pkey -in private_key.pem -pubout -out public_key.pem
fi
export RUST_LOG="trace"
export BAL_SERVER_DB_FILE="$WORKING_DIR/bal2.db"
export BAL_SERVER_INFO="BAL devel willexecutor server 2"
export BAL_SERVER_BIND_ADDRESS="127.0.0.1"
export BAL_SERVER_BIND_PORT=9134
export BAL_SERVER_PUB_KEY_PATH="$WORKING_DIR/public_key.pem"
export BAL_SERVER_EXPOSE_STATS=true
# Dedicated xpub from the regtest descriptor wallet "server2" (BIP84 account),
# so that the two instances never derive the same deposit addresses.
export BAL_SERVER_REGTEST_ADDRESS="tpubDCPHz2qeRgsqYMKkrEidz5NMFmCv36NX1C3FFYjg2hnvgrBTiw37uXxVnrvF6y6dswhJrQF9FTFW3jP334mMrXD5yndoHrNmjgToAaBP7iJ"
export BAL_SERVER_REGTEST_FIXED_FEE=1000
export BAL_SERVER_DB_BACKEND="postgresql"
# Instance-specific settings must win over .env, which defines BAL_PG_DB=baldb.
export BAL_PG_DB="baldb2"
unset BAL_SERVER_PG_DSN
if [ "$BAL_SERVER_DB_BACKEND" = "postgresql" ]; then
. "$SCRIPT_DIR/pg-server.sh"
fi
cargo run --bin=bal-server

View File

@@ -1,30 +0,0 @@
RUST_LOG=info
BAL_SERVER_DB_FILE="/home/bal/bal.db"
BAL_SERVER_INFO="BAL server test willexecutor"
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place Nginx with TLS in front.
BAL_SERVER_BIND_ADDRESS=127.0.0.1
BAL_SERVER_BIND_PORT=9133
BAL_SERVER_BITCOIN_ADDRESS="your bitcoin or xpub to recive payments here"
BAL_SERVER_BITCOIN_FIXED_FEE=50000
BAL_SERVER_PUB_KEY_PATH="/home/bal/public_key.pem"
BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4"
BAL_SERVER_REGTEST_FEE=5000
#BAL_SERVER_TESTNET_ADDRESS=
#BAL_SERVER_TESTNET_FEE=100000
#BAL_SERVER_SIGNET_ADDRESS=
#BAL_SERVER_SIGNET_FEE=100000
# Actix Web DoS Protection Settings (added with migration to Actix Web)
BAL_SERVER_ACTIX_MAX_BODY_SIZE=1048576
BAL_SERVER_ACTIX_TIMEOUT_SECS=5
BAL_SERVER_ACTIX_PUSHTXS_PER_SEC=1
BAL_SERVER_ACTIX_PUSHTXS_BURST=3
BAL_SERVER_ACTIX_SEARCHTX_PER_SEC=5
BAL_SERVER_ACTIX_SEARCHTX_BURST=10
BAL_SERVER_ACTIX_INFO_PER_SEC=20
BAL_SERVER_ACTIX_INFO_BURST=30
BAL_SERVER_ACTIX_DEFAULT_PER_SEC=50
BAL_SERVER_ACTIX_DEFAULT_BURST=100
BAL_SERVER_ACTIX_WORKERS=4
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100

24
bal-server.sh Normal file → Executable file
View File

@@ -1,6 +1,20 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib.sh"
# Load local, gitignored environment overrides (e.g. BAL_PG_PASSWORD).
if [ -f "$SCRIPT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$SCRIPT_DIR/.env"
set +a
fi
WORKING_DIR=$(pwd)
if [ ! -f "$WORKING_DIR/public_key.pem" ]; then
echo "creating keypairs"
echo_i "creating keypairs"
openssl genpkey -algorithm ED25519 -out private_key.pem
openssl pkey -in private_key.pem -pubout -out public_key.pem
fi
@@ -11,7 +25,7 @@ export BAL_SERVER_INFO="BAL devel willexecutor server"
export BAL_SERVER_BIND_ADDRESS="127.0.0.1"
export BAL_SERVER_BIND_PORT=9133
export BAL_SERVER_PUB_KEY_PATH="$WORKING_DIR/public_key.pem"
export BAL_SERVER_EXPOSE_STATS=true;
export BAL_SERVER_EXPOSE_STATS=true
#export BAL_SERVER_BITCOIN_ADDRESS="your bitcoin address or xpub to recive payments here"
#export BAL_SERVER_BITCOIN_FIXED_FEE=50000
@@ -22,4 +36,10 @@ export BAL_SERVER_REGTEST_FIXED_FEE=1000
#export BAL_SERVER_TESTNET_FEE=100000
#export BAL_SERVER_SIGNET_ADDRESS=
#export BAL_SERVER_SIGNET_FEE=100000
export BAL_SERVER_DB_BACKEND="postgresql"
if [ "$BAL_SERVER_DB_BACKEND" = "postgresql" ]; then
. "$SCRIPT_DIR/pg-server.sh"
fi
cargo run --bin=bal-server

0
contrib/.config_init Normal file
View File

325
contrib/deploy.sh Executable file
View File

@@ -0,0 +1,325 @@
#!/bin/bash
set -euo pipefail
# deploy.sh — Deploy the latest bal-server release on a remote server.
#
# Downloads, verifies (SHA-256 + GPG), and installs the release entirely
# on the remote server via SSH. No file transfer (scp/rsync) needed.
#
# Usage:
# ./contrib/deploy.sh # deploy latest
# ./contrib/deploy.sh --dry-run # show plan without executing
# ./contrib/deploy.sh v0.3.1 # deploy a specific tag
# DEPLOY_DRY_RUN=1 ./contrib/deploy.sh # same as --dry-run
#
# Prerequisites:
# - ssh key access to debian@bitcoin-after.life:47081
# - The server must have: curl, jq, sha256sum, gpg, systemctl
#
# Services managed:
# bal-server, bal-pusher, tbal-pusher, t4bal-pusher
REMOTE_USER="debian"
REMOTE_HOST="bitcoin-after.life"
REMOTE_PORT="47081"
SSH_OPTS="-p ${REMOTE_PORT} -o ConnectTimeout=15 -o BatchMode=yes"
GITEA_API="https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server"
GPG_SIGNER="svatantrya@bitcoin-after.life"
GPG_KEY_ID="A847D004DB91610711CA6A0DFE756706E833E0D1"
SERVICES=("bal-server" "bal-pusher" "tbal-pusher" "t4bal-pusher")
INSTALL_DIR="/usr/local/bin"
# ── Parse arguments ─────────────────────────────────────────────────
DRY_RUN=0
DEPLOY_TAG=""
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--help|-h)
echo "Usage: $0 [--dry-run] [TAG]"
echo " TAG Deploy a specific release tag (e.g. v0.3.1). Default: latest."
exit 0
;;
*) DEPLOY_TAG="$arg" ;;
esac
done
[[ "${DEPLOY_DRY_RUN:-}" == "1" ]] && DRY_RUN=1
# ── Helpers ──────────────────────────────────────────────────────────
info() { echo -e "\033[1m==> $1\033[0m"; }
ok() { echo -e "\033[32;1m ✔ $1\033[0m"; }
warn() { echo -e "\033[33;1m ⚠ $1\033[0m"; }
err() { echo -e "\033[31;1m ✖ $1\033[0m"; }
die() { err "$1"; exit "${2:-1}"; }
remote_exec() {
if [[ $DRY_RUN -eq 1 ]]; then
info "[dry-run] remote: $1"
return 0
fi
ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "$@"
}
# ── Step 0: Verify SSH connectivity ────────────────────────────────
info "Verifying SSH connection to ${REMOTE_HOST} ..."
if ! remote_exec "echo ok" >/dev/null 2>&1; then
die "Cannot connect via SSH. Check your key and network."
fi
ok "SSH connection OK"
# ── Step 1: Resolve release to deploy ──────────────────────────────
if [[ -n "$DEPLOY_TAG" ]]; then
TAG="$DEPLOY_TAG"
info "Deploying explicit tag: $TAG"
RELEASE_JSON=$(curl -sfL "${GITEA_API}/releases/tags/${TAG}") \
|| die "Release $TAG not found at $GITEA_API/releases/tags/$TAG"
else
info "Fetching latest release metadata ..."
RELEASE_JSON=$(curl -sfL "${GITEA_API}/releases/latest") \
|| die "Failed to fetch latest release from $GITEA_API"
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty')
fi
RELEASE_NAME=$(echo "$RELEASE_JSON" | jq -r '.name // empty')
if [[ -z "$TAG" ]]; then
die "Could not determine release tag"
fi
info "Release: $RELEASE_NAME (tag: $TAG)"
TARBALL_URL=$(echo "$RELEASE_JSON" | jq -r \
'.assets[] | select(.name | test("\\.tar\\.gz$")) | .browser_download_url' | head -1)
[[ -z "$TARBALL_URL" ]] && die "No .tar.gz asset found in release $TAG"
ASSET_NAME=$(basename "$TARBALL_URL")
info "Asset: $ASSET_NAME"
# Collect sidecar URLs
SHA256_URL="${TARBALL_URL}.sha256"
SIG_URL="${TARBALL_URL}.sig"
ASC_URL="${TARBALL_URL}.asc"
# ── Step 2: Check current version on server ─────────────────────────
info "Checking current version on server ..."
CURRENT=$(remote_exec "$INSTALL_DIR/bal-server --version" 2>/dev/null \
|| remote_exec "strings $INSTALL_DIR/bal-server 2>/dev/null | head -1" 2>/dev/null \
|| echo "unknown")
info "Current version on server: $CURRENT"
# ── Step 2b: Export GPG key for remote import ────────────────────────
info "Exporting GPG public key for $GPG_SIGNER ..."
GPG_PUBKEY_B64=""
if gpg --list-keys "$GPG_SIGNER" &>/dev/null; then
GPG_PUBKEY_B64=$(gpg --armor --export "$GPG_SIGNER" | base64 -w 0)
ok "GPG public key exported (${#GPG_PUBKEY_B64} chars base64)"
else
warn "GPG key for $GPG_SIGNER not found locally — remote import will try keyservers"
fi
# ── Step 3: Deploy on remote ───────────────────────────────────────
# Build a single shell script that runs entirely on the server.
# This avoids scp/rsync issues and ensures atomic, auditable deployment.
DEPLOY_SCRIPT=$(cat << 'REMOTE_EOF'
#!/bin/bash
set -euo pipefail
TAG="__TAG__"
ASSET_NAME="__ASSET_NAME__"
TARBALL_URL="__TARBALL_URL__"
SHA256_URL="__SHA256_URL__"
SIG_URL="__SIG_URL__"
ASC_URL="__ASC_URL__"
GPG_SIGNER="__GPG_SIGNER__"
GPG_KEY_ID="__GPG_KEY_ID__"
INSTALL_DIR="__INSTALL_DIR__"
SERVICES="__SERVICES__"
DRY_RUN="__DRY_RUN__"
info() { echo -e "\033[1m==> $1\033[0m"; }
ok() { echo -e "\033[32;1m ✔ $1\033[0m"; }
warn() { echo -e "\033[33;1m ⚠ $1\033[0m"; }
err() { echo -e "\033[31;1m ✖ $1\033[0m"; }
die() { err "$1"; exit "${2:-1}"; }
WORKDIR=$(mktemp -d /tmp/bal-deploy.XXXXXX)
trap 'rm -rf "$WORKDIR"' EXIT
# ── Ensure GPG is available ─────────────────────────────────────────
if ! command -v gpg &>/dev/null; then
info "Installing gnupg ..."
sudo apt-get update -qq && sudo apt-get install -y -qq gnupg
fi
# ── Import GPG key ──────────────────────────────────────────────────
info "Importing GPG key $GPG_KEY_ID ..."
if ! gpg --list-keys "$GPG_SIGNER" &>/dev/null; then
imported=0
# Try embedded key first (base64-encoded, pushed from deployer)
if [[ -n "__GPG_PUBKEY_B64__" ]]; then
echo "__GPG_PUBKEY_B64__" | base64 -d | gpg --batch --import 2>/dev/null && imported=1 && ok "Key imported from deployer"
fi
# Fallback to keyservers
if [[ $imported -eq 0 ]]; then
for ks in keyserver.ubuntu.com keys.openpgp.org pgp.mit.edu; do
if gpg --batch --keyserver "$ks" --recv-keys "$GPG_KEY_ID" 2>/dev/null; then
ok "Key imported from $ks"
imported=1
break
fi
done
fi
if [[ $imported -eq 0 ]]; then
die "Cannot import GPG key $GPG_KEY_ID. Import it manually and re-run."
fi
else
ok "GPG key already present"
fi
# ── Download release assets ─────────────────────────────────────────
info "Downloading $ASSET_NAME ..."
curl -sfL -o "$WORKDIR/$ASSET_NAME" "$TARBALL_URL" || die "Download failed"
for sidecar in "sha256:$SHA256_URL:.sha256" "sig:$SIG_URL:.sig" "asc:$ASC_URL:.asc"; do
IFS=: read -r label url suffix <<< "$sidecar"
if curl -sfL -o "$WORKDIR/$(basename "$ASSET_NAME")$suffix" "$url" 2>/dev/null; then
ok "Downloaded $ASSET_NAME$suffix"
else
warn "$label file not available — skipping"
fi
done
# ── Verify SHA-256 ──────────────────────────────────────────────────
SHA_FILE="$WORKDIR/${ASSET_NAME}.sha256"
if [[ -f "$SHA_FILE" ]]; then
info "Verifying SHA-256 checksum ..."
(cd "$WORKDIR" && sha256sum -c "$SHA_FILE") || die "SHA-256 verification FAILED"
ok "SHA-256 OK"
else
warn "No .sha256 file — skipping checksum"
fi
# ── Verify GPG signature ────────────────────────────────────────────
SIG_FILE="$WORKDIR/${ASSET_NAME}.sig"
ASC_FILE="$WORKDIR/${ASSET_NAME}.asc"
verified=0
if [[ -f "$SIG_FILE" ]]; then
info "Verifying GPG signature (binary) ..."
gpg --batch --verify "$SIG_FILE" "$WORKDIR/$ASSET_NAME" 2>&1 && verified=1
fi
if [[ $verified -eq 0 ]] && [[ -f "$ASC_FILE" ]]; then
info "Verifying GPG signature (ASCII-armored) ..."
gpg --batch --verify "$ASC_FILE" "$WORKDIR/$ASSET_NAME" 2>&1 && verified=1
fi
if [[ $verified -eq 0 ]]; then
warn "No GPG signature available — skipping signature verification"
fi
# ── Extract ──────────────────────────────────────────────────────────
info "Extracting tarball ..."
tar -xzf "$WORKDIR/$ASSET_NAME" -C "$WORKDIR"
EXTRACTED="$WORKDIR/$(basename "$ASSET_NAME" .tar.gz)"
for bin in bal-server bal-pusher; do
if [[ ! -f "$EXTRACTED/$bin" ]]; then
die "Binary '$bin' not found in archive"
fi
ok "Found $bin ($(stat -c%s "$EXTRACTED/$bin") bytes)"
done
# ── Stop services ────────────────────────────────────────────────────
info "Stopping services ..."
IFS=',' read -ra SVC_LIST <<< "$SERVICES"
for svc in "${SVC_LIST[@]}"; do
svc=$(echo "$svc" | xargs) # trim whitespace
if systemctl is-active --quiet "$svc" 2>/dev/null; then
sudo systemctl stop "$svc" || warn "Failed to stop $svc"
ok "Stopped $svc"
else
warn "$svc is not running"
fi
done
# ── Backup current binaries ─────────────────────────────────────────
BACKUP_DIR="$WORKDIR/backup"
mkdir -p "$BACKUP_DIR"
for bin in bal-server bal-pusher; do
if [[ -f "$INSTALL_DIR/$bin" ]]; then
cp "$INSTALL_DIR/$bin" "$BACKUP_DIR/$bin" 2>/dev/null || true
fi
done
ok "Current binaries backed up"
# ── Install new binaries ────────────────────────────────────────────
info "Installing binaries to $INSTALL_DIR ..."
sudo install -m 0755 -o root -g root "$EXTRACTED/bal-server" "$EXTRACTED/bal-pusher" "$INSTALL_DIR/"
ok "Installed bal-server and bal-pusher"
# ── Restart services ────────────────────────────────────────────────
info "Restarting services ..."
sudo systemctl daemon-reload
for svc in "${SVC_LIST[@]}"; do
svc=$(echo "$svc" | xargs)
if systemctl is-enabled --quiet "$svc" 2>/dev/null; then
sudo systemctl restart "$svc" || warn "Failed to restart $svc"
ok "Restarted $svc"
else
warn "$svc is not enabled — skipping restart"
fi
done
# ── Verify ──────────────────────────────────────────────────────────
echo ""
info "Checking service status ..."
sleep 2
for svc in "${SVC_LIST[@]}"; do
svc=$(echo "$svc" | xargs)
status=$(systemctl is-active "$svc" 2>/dev/null || echo "inactive")
if [[ "$status" == "active" ]]; then
ok "$svc: active"
else
warn "$svc: $status"
fi
done
echo ""
ok "Deploy of $TAG completed successfully!"
REMOTE_EOF
)
# ── Substitute variables into the remote script ─────────────────────
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__TAG__/$TAG}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__ASSET_NAME__/$ASSET_NAME}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__TARBALL_URL__/$TARBALL_URL}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__SHA256_URL__/$SHA256_URL}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__SIG_URL__/$SIG_URL}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__ASC_URL__/$ASC_URL}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__GPG_SIGNER__/$GPG_SIGNER}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__GPG_KEY_ID__/$GPG_KEY_ID}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__INSTALL_DIR__/$INSTALL_DIR}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__DRY_RUN__/$DRY_RUN}"
# Join SERVICES array into comma-separated string for the remote script
SERVICES_STR=$(IFS=,; echo "${SERVICES[*]}")
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__SERVICES__/$SERVICES_STR}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT//__GPG_PUBKEY_B64__/${GPG_PUBKEY_B64:-}}"
# ── Execute ──────────────────────────────────────────────────────────
if [[ $DRY_RUN -eq 1 ]]; then
info "=== DRY RUN — would execute the following on remote: ==="
echo "$DEPLOY_SCRIPT"
echo "=== end dry run ==="
exit 0
fi
REMOTE_SCRIPT="/tmp/bal-deploy-$(date +%s).sh"
info "Uploading deploy script to remote ..."
ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "cat > '$REMOTE_SCRIPT'" <<< "$DEPLOY_SCRIPT" \
|| die "Failed to upload deploy script"
info "Executing deploy on remote server ..."
ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x '$REMOTE_SCRIPT' && bash '$REMOTE_SCRIPT'; rc=\$?; rm -f '$REMOTE_SCRIPT'; exit \$rc" \
|| die "Deploy script failed on remote (exit $?)"
echo ""
ok "Deploy finished!"

View File

@@ -47,7 +47,7 @@ BAL_SERVER_INFO="$info"
EOF
)
bal_pusher_conf=$(cat << EOF
BAL_PUSHER_DB_FILE=/home/bal/bal.db
BAL_SERVER_DB_FILE=/home/bal/bal.db
BAL_PUSHER_BITCOIN_COOKIE_FILE=/home/bitcoin/.bitcoin/.cookie
EOF

19
contrib/lib.sh Normal file
View File

@@ -0,0 +1,19 @@
echo_i() {
echo -e "\033[1m==> $1\033[0m"
}
echo_e() {
echo -e "\033[31;1m$1\033[0m"
}
echo_s() {
echo -e "\033[32;1m$1\033[0m"
}
echo_w() {
echo -e "\033[33;1m$1\033[0m"
}
alias echo_i=echo_i
alias echo_e=echo_e
alias echo_s=echo_s
alias echo_w=echo_w

214
contrib/make_onion_delegation.py Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Issue an onion delegation certificate for v2 WeTime ping signing.
The onion v3 identity key signs a certificate that binds a dedicated Ed25519
ping key to the onion host. The online pusher then signs pings with the
dedicated key, so the onion identity key can stay offline.
The certificate covers these exact bytes:
WELIST-PING-DELEGATION-V1\n
{"key":"<base64 SPKI DER>","onion":"<host>","valid_until":<unix>}
Usage:
contrib/make_onion_delegation.py \
--onion <address>.onion \
--identity-key /var/lib/tor/hidden_service/hs_ed25519_secret_key \
--ping-key /etc/bal/ping_private_key.pem \
--days 365 \
--out onion-delegation.json
`--identity-key` accepts either:
* a Tor `hs_ed25519_secret_key` file (32-byte header + secret key), or
* a standard PKCS#8 PEM Ed25519 private key.
Requirements: Python 3.10+, `cryptography`, and `pynacl` for Tor-format keys.
"""
import argparse
import base64
import hashlib
import json
import sys
import time
from pathlib import Path
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
except ImportError: # pragma: no cover - dependency hint
print("error: the 'cryptography' package is required", file=sys.stderr)
sys.exit(2)
DELEGATION_PREFIX = b"WELIST-PING-DELEGATION-V1\n"
TOR_HEADER_LEN = 32
def b32_decode(value: str) -> bytes:
"""Decode lowercase base32 without padding."""
padding = "=" * ((8 - len(value) % 8) % 8)
return base64.b32decode(value.upper() + padding)
def decode_onion(host: str) -> bytes:
"""Return the 32-byte Ed25519 identity public key of a v3 onion host."""
host = host.strip().lower().rstrip(".")
if not host.endswith(".onion"):
raise ValueError("not an onion address")
address = host[: -len(".onion")]
if len(address) != 56:
raise ValueError("unsupported onion address length")
if not all(c in "abcdefghijklmnopqrstuvwxyz234567" for c in address):
raise ValueError("invalid onion base32")
decoded = b32_decode(address)
if len(decoded) != 35:
raise ValueError("invalid onion payload length")
public_key, checksum, version = decoded[:32], decoded[32:34], decoded[34]
if version != 3:
raise ValueError("unsupported onion version")
expected = hashlib.sha3_256(
b".onion checksum" + public_key + bytes([version])
).digest()[:2]
if checksum != expected:
raise ValueError("onion checksum mismatch")
return public_key
def load_ping_key(ping_key_path: Path) -> bytes:
"""Return the delegated ping public key as base64 SPKI DER."""
key = serialization.load_pem_private_key(ping_key_path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("--ping-key must be an Ed25519 private key")
return base64.b64encode(
key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
).decode("ascii")
def sign_standard(key: Ed25519PrivateKey, message: bytes) -> bytes:
"""Sign with a standard Ed25519 private key."""
return key.sign(message)
def sign_expanded(secret: bytes, public_key: bytes, message: bytes) -> bytes:
"""Sign with a Tor expanded Ed25519 secret key (a || prefix)."""
try:
from nacl.bindings import (
crypto_core_ed25519_scalar_add,
crypto_core_ed25519_scalar_mul,
crypto_core_ed25519_scalar_reduce,
crypto_scalarmult_ed25519_base_noclamp,
)
except ImportError as exc: # pragma: no cover - dependency hint
raise ValueError(
"pynacl is required to sign with a Tor-format onion key"
) from exc
scalar, prefix = secret[:32], secret[32:64]
try:
if crypto_scalarmult_ed25519_base_noclamp(scalar) != public_key:
raise ValueError("onion secret key does not match the onion address")
nonce = crypto_core_ed25519_scalar_reduce(
hashlib.sha512(prefix + message).digest()
)
point = crypto_scalarmult_ed25519_base_noclamp(nonce)
challenge = crypto_core_ed25519_scalar_reduce(
hashlib.sha512(point + public_key + message).digest()
)
response = crypto_core_ed25519_scalar_add(
nonce, crypto_core_ed25519_scalar_mul(challenge, scalar)
)
return point + response
except ValueError:
raise
except Exception as exc: # PyNaCl raises RuntimeError for invalid scalars
raise ValueError(f"cannot sign with onion identity key: {exc}") from exc
def load_identity_signer(identity_key_path: Path, public_key: bytes):
"""Return a callable that signs with the onion identity key."""
raw = identity_key_path.read_bytes()
if raw.lstrip().startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(raw, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("identity PEM key is not Ed25519")
derived = key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
if derived != public_key:
raise ValueError("identity key does not match the onion address")
return lambda message: sign_standard(key, message)
if len(raw) < TOR_HEADER_LEN + 64:
raise ValueError("Tor secret key file is too short")
secret = raw[TOR_HEADER_LEN : TOR_HEADER_LEN + 64]
# Tor stores the expanded secret key (scalar || prefix). Some tools export
# a standard 32-byte seed instead, so accept that form too.
try:
seed_key = Ed25519PrivateKey.from_private_bytes(secret[:32])
derived = seed_key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
except Exception:
seed_key = None
derived = b""
if seed_key is not None and derived == public_key:
return lambda message: sign_standard(seed_key, message)
return lambda message: sign_expanded(secret, public_key, message)
def canonical_message(key_b64: str, onion: str, valid_until: int) -> bytes:
"""Build the exact certificate bytes signed by the identity key."""
body = {"key": key_b64, "onion": onion, "valid_until": valid_until}
encoded = json.dumps(
body, separators=(",", ":"), sort_keys=True, ensure_ascii=False
).encode("utf-8")
return DELEGATION_PREFIX + encoded
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--onion", required=True, help="onion host (address.onion)")
parser.add_argument("--identity-key", required=True,
help="Tor hs_ed25519_secret_key or PKCS#8 PEM")
parser.add_argument("--ping-key", required=True,
help="PKCS#8 PEM private key of the delegated ping signer")
parser.add_argument("--days", type=int, default=365,
help="certificate lifetime in days (default: 365)")
parser.add_argument("--out", default="onion-delegation.json",
help="output JSON path (default: onion-delegation.json)")
args = parser.parse_args()
if args.days <= 0 or args.days > 3650:
print("error: --days must be between 1 and 3650", file=sys.stderr)
return 2
onion = args.onion.strip().lower().rstrip(".")
public_key = decode_onion(onion)
key_b64 = load_ping_key(Path(args.ping_key))
valid_until = int(time.time()) + args.days * 86_400
message = canonical_message(key_b64, onion, valid_until)
signer = load_identity_signer(Path(args.identity_key), public_key)
certificate = {
"onion": onion,
"key": key_b64,
"valid_until": valid_until,
"signature": base64.b64encode(signer(message)).decode("ascii"),
}
Path(args.out).write_text(json.dumps(certificate, indent=2) + "\n")
print(f"wrote {args.out} (onion={onion}, valid_until={valid_until})")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, TypeError, ValueError) as err:
print(f"error: {err}", file=sys.stderr)
sys.exit(1)

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Issue a WeTime ping key revocation certificate.
A revocation is permanent and key-scoped: every ping signed with the revoked
key is rejected for that host until the key is replaced. Two modes exist:
* Onion delegation (signed by the onion identity key):
contrib/make_ping_key_revocation.py \\
--onion <address>.onion \\
--identity-key /var/lib/tor/hidden_service/hs_ed25519_secret_key \\
--certificate onion-delegation.json \\
--out onion-revocation.json
* DNSSEC TXT key (self-signed by the key being revoked):
contrib/make_ping_key_revocation.py \\
--host we.example.com \\
--key private_key.pem \\
--out dns-revocation.json
Submit the result to the WeTime service (idempotent, safe to retry):
curl -sS -X POST -H 'Content-Type: application/json' \\
--data @onion-revocation.json https://<service>/ping-keys/revoke
The signed bytes are:
WELIST-PING-REVOCATION-V1\\n
{"host":"<host>","key":"<base64 SPKI DER>","revoked_at":<unix>}
Key-loading and signing helpers are shared with `make_onion_delegation.py`.
Requirements: Python 3.10+, `cryptography`, and `pynacl` for Tor-format keys.
"""
import argparse
import base64
import json
import sys
import time
from pathlib import Path
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError: # pragma: no cover - dependency hint
print("error: the 'cryptography' package is required", file=sys.stderr)
sys.exit(2)
from make_onion_delegation import decode_onion, load_identity_signer, load_ping_key
REVOCATION_PREFIX = b"WELIST-PING-REVOCATION-V1\n"
def validate_key_b64(key_b64: str) -> str:
"""Return `key_b64` after checking it is a base64 Ed25519 SPKI key."""
try:
der = base64.b64decode(key_b64, validate=True)
public_key = serialization.load_der_public_key(der)
except Exception as exc:
raise ValueError(f"invalid delegated key: {exc}") from exc
if not isinstance(public_key, Ed25519PublicKey):
raise ValueError("delegated key is not Ed25519")
return key_b64
def load_certificate(path: Path) -> tuple[str, str]:
"""Return `(onion, key_b64)` from a delegation certificate."""
try:
certificate = json.loads(path.read_text())
onion = str(certificate["onion"]).strip().lower().rstrip(".")
key = str(certificate["key"]).strip()
except (OSError, ValueError, KeyError) as exc:
raise ValueError(f"cannot read delegation certificate {path}: {exc}") from exc
return onion, validate_key_b64(key)
def load_ping_private_key(path: Path) -> Ed25519PrivateKey:
"""Load a PKCS#8 Ed25519 private key."""
try:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
except Exception as exc:
raise ValueError(f"cannot read private key {path}: {exc}") from exc
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"{path} is not an Ed25519 private key")
return key
def canonical_message(host: str, key_b64: str, revoked_at: int) -> bytes:
"""Build the exact bytes covered by the revocation signature."""
body = {"host": host, "key": key_b64, "revoked_at": revoked_at}
encoded = json.dumps(
body, separators=(",", ":"), sort_keys=True, ensure_ascii=False
).encode("utf-8")
return REVOCATION_PREFIX + encoded
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
target = parser.add_mutually_exclusive_group()
target.add_argument("--onion", help="onion host (address.onion)")
target.add_argument("--host", help="DNS hostname of the executor")
parser.add_argument("--identity-key",
help="Tor hs_ed25519_secret_key or PKCS#8 PEM identity key (onion mode)")
parser.add_argument("--certificate",
help="existing onion delegation certificate (reads host and key)")
parser.add_argument("--key", "--ping-key", dest="key",
help="PKCS#8 PEM of the key to revoke; also the signer for DNS mode")
parser.add_argument("--revoked-at", type=int,
help="revocation Unix timestamp (default: now)")
parser.add_argument("--out", default="ping-key-revocation.json",
help="output JSON path (default: ping-key-revocation.json)")
parser.add_argument("--service-url",
help="WeTime service base URL, used to print the curl command")
args = parser.parse_args()
if args.revoked_at is not None and args.revoked_at <= 0:
print("error: --revoked-at must be positive", file=sys.stderr)
return 2
revoked_at = args.revoked_at if args.revoked_at is not None else int(time.time())
mode: str
host: str
key_b64: str
if args.certificate:
mode = "onion"
certificate_onion, key_b64 = load_certificate(Path(args.certificate))
host = args.onion.strip().lower().rstrip(".") if args.onion else certificate_onion
if host != certificate_onion:
print(
f"error: certificate is bound to {certificate_onion}, not {host}",
file=sys.stderr,
)
return 2
if args.key:
certificate_key = load_ping_key(Path(args.key))
if certificate_key != key_b64:
print("error: --key does not match the certificate key", file=sys.stderr)
return 2
elif args.onion:
mode = "onion"
host = args.onion.strip().lower().rstrip(".")
if not args.key:
print("error: onion revocations require --key or --certificate", file=sys.stderr)
return 2
key_b64 = validate_key_b64(load_ping_key(Path(args.key)))
elif args.host:
mode = "dns"
host = args.host.strip().lower().rstrip(".")
if not args.key:
print("error: DNS revocations require --key", file=sys.stderr)
return 2
if args.identity_key:
print("error: --identity-key is only valid for onion hosts", file=sys.stderr)
return 2
key_b64 = validate_key_b64(load_ping_key(Path(args.key)))
else:
print("error: provide --onion or --host", file=sys.stderr)
return 2
if not host or host.endswith("."):
print("error: invalid host", file=sys.stderr)
return 2
message = canonical_message(host, key_b64, revoked_at)
if mode == "onion":
if not args.identity_key:
print("error: onion revocations require --identity-key", file=sys.stderr)
return 2
public_key = decode_onion(host)
signer = load_identity_signer(Path(args.identity_key), public_key)
signature = signer(message)
else:
signing_key = load_ping_private_key(Path(args.key))
signature = signing_key.sign(message)
certificate = {
"host": host,
"key": key_b64,
"revoked_at": revoked_at,
"signature": base64.b64encode(signature).decode("ascii"),
}
Path(args.out).write_text(json.dumps(certificate, indent=2) + "\n")
service = (args.service_url or "https://<service>").rstrip("/")
print(f"wrote {args.out} (host={host}, revoked_at={revoked_at})")
print("submit with:")
print(
" curl -sS -X POST -H 'Content-Type: application/json' "
f"--data @{args.out} {service}/ping-keys/revoke"
)
if mode == "dns":
print("also remove the revoked key from the _welist-key TXT record.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except ValueError as err:
print(f"error: {err}", file=sys.stderr)
sys.exit(1)

120
contrib/make_welist_txt.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Print the WeTime v2 DNSSEC TXT record for an executor hostname.
set -euo pipefail
usage() {
cat <<'EOF'
Usage: make_welist_txt.sh <domain> [key]
<domain> Executor hostname or URL, for example we.example.com
[key] Ed25519 private/public PEM file, or the base64
SubjectPublicKeyInfo DER value to publish.
If [key] is omitted, the script uses ./private_key.pem when it exists,
otherwise it generates ./private_key.pem and ./public_key.pem.
The record is published at _welist-key.<domain> with value:
v=we1; alg=ed25519; key=<base64 SPKI DER>
EOF
}
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
usage >&2
exit 2
fi
domain=$1
key_input=${2:-}
# Normalize the hostname: strip scheme, path, trailing dot, and lowercase.
domain=${domain#http://}
domain=${domain#https://}
domain=${domain%%/*}
domain=${domain%.}
domain=$(printf '%s' "$domain" | tr '[:upper:]' '[:lower:]')
# Avoid doubling the prefix if the caller already supplied it.
domain=${domain#_welist-key.}
if [ -z "$domain" ] || ! printf '%s' "$domain" | grep -Eq '^[a-z0-9.-]+$'; then
echo "error: invalid hostname: $domain" >&2
echo " use the host only, without scheme, port, or path" >&2
exit 1
fi
if printf '%s' "$domain" | grep -Eq '^[0-9.]+$'; then
echo "error: DNSSEC key discovery requires a hostname, not an IP address" >&2
exit 1
fi
if [ -z "$key_input" ]; then
private_key="./private_key.pem"
public_key="./public_key.pem"
if [ -f "$private_key" ]; then
key_input=$private_key
echo "Using existing $private_key" >&2
else
if [ -e "$private_key" ] || [ -e "$public_key" ]; then
echo "error: $private_key or $public_key exists and will not be overwritten" >&2
exit 1
fi
if ! (umask 077; openssl genpkey -algorithm ED25519 -out "$private_key"); then
echo "error: cannot generate $private_key" >&2
exit 1
fi
if ! openssl pkey -in "$private_key" -pubout -out "$public_key"; then
echo "error: cannot derive $public_key" >&2
exit 1
fi
chmod 600 "$private_key"
chmod 644 "$public_key"
key_input=$private_key
cat >&2 <<EOF
Generated Ed25519 key pair:
private key: $private_key (keep secret)
public key: $public_key
Set SSL_KEY_PATH and BAL_SERVER_PUB_KEY_PATH to these files.
EOF
fi
fi
tmp_der=$(mktemp)
trap 'rm -f "$tmp_der"' EXIT
if [ -f "$key_input" ]; then
if ! openssl pkey -in "$key_input" -pubout -outform DER -out "$tmp_der" 2>/dev/null; then
if ! openssl pkey -pubin -in "$key_input" -outform DER -out "$tmp_der" 2>/dev/null; then
echo "error: cannot read an Ed25519 key from $key_input" >&2
exit 1
fi
fi
else
printf '%s' "$key_input" | tr -d '[:space:]' \
| openssl base64 -d -A -out "$tmp_der" 2>/dev/null || {
echo "error: key is neither a PEM file nor a base64 SPKI DER value" >&2
exit 1
}
fi
der_len=$(wc -c < "$tmp_der" | tr -d ' ')
prefix_hex=$(od -An -tx1 -N12 "$tmp_der" | tr -d ' \n')
if [ "$der_len" -ne 44 ] || [ "$prefix_hex" != "302a300506032b6570032100" ]; then
echo "error: key is not a 44-byte Ed25519 SubjectPublicKeyInfo DER" >&2
exit 1
fi
key_b64=$(openssl base64 -A -in "$tmp_der")
value="v=we1; alg=ed25519; key=$key_b64"
name="_welist-key.$domain"
cat <<EOF
TXT name: $name
TXT value: $value
BIND zone line:
$name. 3600 IN TXT "$value"
EOF

3
docker/s6-rc.d/bal-pusher/run Executable file
View File

@@ -0,0 +1,3 @@
#!/command/with-contenv sh
exec s6-setsid logutil-service bal-pusher \
/usr/local/bin/bal-pusher

View File

@@ -0,0 +1 @@
longrun

3
docker/s6-rc.d/bal-server/run Executable file
View File

@@ -0,0 +1,3 @@
#!/command/with-contenv sh
exec s6-setsid logutil-service bal-server \
/usr/local/bin/bal-server

View File

@@ -0,0 +1 @@
longrun

View File

@@ -6,7 +6,7 @@
## Vision and Scope
`bal_server` is a Rust-based Bitcoin transaction executor server. It receives raw Bitcoin transactions via HTTP, validates them, persists them in a local SQLite database, and coordinates their broadcast on-chain after a locktime condition expires. The system supports multiple Bitcoin networks (mainnet, testnet, regtest, testnet4, signet) and tracks extended public keys (xpub) for fee collection.
`bal_server` is a Rust-based Bitcoin transaction executor server (v0.3.2, edition 2024). It receives raw Bitcoin transactions via HTTP, validates them, persists them in a local SQLite database, and coordinates their broadcast on-chain after a locktime condition expires. The system supports multiple Bitcoin networks (mainnet, testnet, regtest, testnet4, signet) and tracks extended public keys (xpub) for fee collection.
### Key Goals
1. **Receive and validate** raw Bitcoin transactions with locktime.
@@ -17,18 +17,39 @@
## System Components
The project consists of three primary binaries and two shared libraries:
The project consists of two binaries and one shared library:
1. **`bal-server`**: Async HTTP server (hyper + tokio) that exposes the API for receiving transactions and serving statistics.
2. **`bal-pusher`**: Async daemon that listens for `hashblock` ZMQ messages and pushes pending transactions to a Bitcoin node via RPC.
4. **`lib.rs`**: Exports the shared modules `db` and `xpub`.
5. **`db.rs`**: All database operations and schema creation for SQLite `0.34.0`.
6. **`xpub.rs`**: Address derivation from xpub/zpub using BIP-84 and the `bitcoin` crate.
1. **`bal-server`**: Async HTTP server (**actix-web 4.9.0** + actix-rt) that exposes the API for receiving transactions and serving statistics. Includes rate limiting via `actix-governor`.
2. **`bal-pusher`**: Async daemon (tokio) that listens for `hashblock` ZMQ messages and pushes pending transactions to a Bitcoin node via RPC.
3. **`lib.rs`**: Exports the shared modules `db`, `xpub`, and `validation`.
### Library Modules
4. **`db.rs`**: All database operations, schema creation, path validation, and WAL mode for SQLite `0.34.0`.
5. **`xpub.rs`**: Address derivation from xpub/zpub/ypub using BIP-84 and the `bitcoin` crate.
6. **`validation.rs`**: SSRF protection for the `welist` URL, blocking private/internal IP ranges.
## Feature Flags
The project uses Cargo feature flags to build each binary independently:
| Feature | Dependencies | Binary |
|---------|-------------|--------|
| `server` (default) | `actix-web`, `actix-governor`, `actix-rt`, `chrono`, `hex-conservative` | `bal-server` |
| `pusher` (default) | `zmq`, `reqwest`, `byteorder`, `base64`, `ed25519-dalek` | `bal-pusher` |
## Docker Support
Two Dockerfiles are provided:
- **`Dockerfile.release`** (recommended for production): Downloads the latest pre-built release from the Gitea server. No Rust toolchain needed. Verifies SHA-256 checksum. Supports pinning a specific version via `BAL_VERSION` build arg.
- **`Dockerfile`** (for development/custom builds): Multi-stage build using `rust:1.95-bookworm` as the builder and `debian:bookworm-slim` as the runtime. Each binary is compiled with only its required features (`--no-default-features --features server` / `--features pusher`).
Both run as a non-root `bal` user (uid 1000) with `tini` as PID 1 and include a healthcheck endpoint.
## Mapping to Existing Documentation
| Existing File | Subject | Covered in this KB |
|---------------|---------|-------------------|
| `README.md` | Installation, environment variables, ZMQ dependency | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) |
| `RPC.md` | API endpoint specification | `05_api_reference.md` | [`05_api_reference.md`](05_api_reference.md) |
| `AGENTS.md` | Security guidelines for agents | `08_security_audit.md` | [`08_security_audit.md`](08_security_audit.md) |
| `README.md` | Installation, environment variables, ZMQ dependency, Docker | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) |
| `RPC.md` | API endpoint specification | [`05_api_reference.md`](05_api_reference.md) |
| `AGENTS.md` | Security guidelines for agents | [`08_security_audit.md`](08_security_audit.md) |

View File

@@ -12,21 +12,19 @@ User
| HTTP POST (raw hex transactions)
v
+-----------------+
| bal-server | (hyper + tokio, async)
| bal-server | (actix-web 4.9.0 + actix-governor, async)
| (src/bin/bal-server.rs) |
+-----------------+
| SQLite insert (db.rs)
| SQLite insert (db.rs, Arc<Mutex<Connection>>)
v
bal.db
bal.db (WAL mode)
| (transactions with status=0, waiting locktime)
|
| ZMQ (hashblock / rawblock)
| ZMQ (hashblock)
v
+-----------------+
+-----------------+
| bal-pusher | (async, ZMQ + RPC + reqwest)
| bal-pusher | (tokio, ZMQ + RPC + reqwest)
| (src/bin/bal-pusher.rs)
+-----------------+
+-----------------+
| bitcoincore-rpc
| sendrawtransaction
@@ -36,13 +34,13 @@ User
## Data Flow (Transaction Lifecycle)
1. **Submission**: A client sends one or more raw hex transactions to the `pushtxs` endpoint.
2. **Validation**: The `bal-server` parses each transaction using `bitcoin::Transaction`. It checks for the fee output, extracts inputs/outputs, and validates the locktime.
3. **Storage**: Valid transactions are stored in `tbl_tx` with `status = 0` (waiting). The inputs and outputs are stored in `tbl_inp` and `tbl_out`.
4. **Monitoring**: The `bal-pusher` listens to the ZMQ `hashblock` topic. When a new block is detected, it fetches the `mediantime` via `getblockchaininfo` (or via the block's median time in the enhanced version).
5. **Evaluation**: The pusher queries the database for transactions with `status=0` and compares their locktime to the current blockchain median time.
6. **Broadcast**: If the locktime is satisfied, the pusher sends the transaction via `sendrawtransaction` and updates the status to `1` (sent) or `2` (failed if the RPC returns an error).
7. **Statistics**: The pusher periodically sends statistics to a remote server (`welist`) using a signed POST request. The server also collects stats on its own.
1. **Submission**: A client sends one or more raw hex transactions to the `pushtxs` endpoint (newline-separated).
2. **Validation**: The `bal-server` parses each transaction using `bitcoin::Transaction` via `consensus::deserialize`. It checks for the fee output, extracts inputs/outputs, and validates the locktime.
3. **Storage**: Valid transactions are stored in `tbl_tx` with `status = 0` (waiting). The inputs and outputs are stored in `tbl_inp` and `tbl_out`. Batch inserts use `UNION ALL SELECT` for efficiency.
4. **Monitoring**: The `bal-pusher` listens to the ZMQ `hashblock` topic with a 5-second receive timeout. When a new block is detected, it fetches blockchain info via RPC.
5. **Evaluation**: The pusher queries the database for transactions with `status=0` and compares their locktime against the blockchain's best block height or median time (for timestamp-based locktimes above `LOCKTIME_THRESHOLD`).
6. **Broadcast**: If the locktime is satisfied, the pusher sends the transaction via `sendrawtransaction` and updates the status to `1` (sent) or `2` (failed with error stored in `push_err`).
7. **Statistics**: The pusher periodically calculates statistics and sends them to a remote `welist` server using an Ed25519-signed POST request. The signature scheme is selected by `WELIST_PING_SIG_VERSION`: legacy v1, or v2 over a canonical JSON payload whose `info`, `base_fee` and `version` come from the local `/{chain}/info` endpoint. The server also exposes stats via the `GET /:network/stats` endpoint if `expose_stats` is enabled.
## State Machine
@@ -57,12 +55,15 @@ User
The `status` field in `tbl_tx` is an integer:
- `0`: Waiting for locktime.
- `1`: Successfully sent to the network.
- `2`: Failed (e.g., RPC error `-25 bad-txns-inputs-missingorspent`).
- `2`: Failed (e.g., RPC error `-25 bad-txns-inputs-missingorspent`). Error details stored in `push_err`.
## Error Handling Strategy
The codebase is currently inconsistent with error handling. The `bal-server` uses `unwrap()` on many critical paths (e.g., `sqlite::open`, `Regex::new`, body parsing), which causes panics in the async runtime. The `bal-pusher` also panics on RPC connection failures (`panic!("impossible to get client {}", e)`) which crashes the entire ZMQ loop.
The codebase has been hardened with comprehensive error handling:
- **`bal-server`**: Uses `actix-web`'s built-in error handling. All `unwrap()`/`expect()` calls have been replaced with safe `match`/`if let` error propagation, returning appropriate HTTP status codes (400, 404, 500).
- **`bal-pusher`**: ZMQ `recv` uses `set_rcvtimeo(5000)` with a match/timeout handler. RPC connection failures log errors and retry with a sleep interval instead of panicking. The pusher logs warnings for consecutive ZMQ timeouts (~1 hour threshold).
- **`db.rs`**: Database operations use `Result` types. The `open_db` function validates paths before opening. WAL mode is set with retry logic.
## Logging and Monitoring
The project uses `env_logger` and `log`. By default, `RUST_LOG=info` is set. The `bal-pusher` sends signed statistics to a remote server. The server exposes a `stats` endpoint (`/<network>/stats`) if `expose_stats` is enabled.
The project uses `env_logger` and `log`. By default, `RUST_LOG=info` is set. The `bal-pusher` sends signed statistics to a remote server. The server exposes a `stats` endpoint (`/<network>/stats`) if `expose_stats` is enabled. The actix-web `Logger::default()` middleware logs all HTTP requests/responses.

View File

@@ -10,8 +10,9 @@
**Location:** `src/lib.rs`
This is the root of the library crate. It simply exports two public modules:
This is the root of the library crate. It exports three public modules:
- `pub mod db;` — the database interface
- `pub mod validation;` — SSRF URL validation
- `pub mod xpub;` — the extended public key utilities
It contains no application logic.
@@ -26,20 +27,23 @@ This module contains all the logic for interacting with the SQLite database.
### Key Functions
- `create_table`: Creates the full database schema if it does not exist. See `src/db.rs` for the `CREATE TABLE` statements.
- `execute_insert`: A batched, atomic SQL wrapper function that performs multiple insert operations inside a transaction.
- `insert_tx`: Inserts a transaction into `tbl_tx`.
- `insert_inp`: Inserts an input into `tbl_inp`.
- `insert_out`: Inserts an output into `tbl_out`.
- `insert_xpub`: Inserts an xpub into `tbl_xpub`.
- `insert_address`: Inserts a new derived address into `tbl_address`.
- `get_pending_txs`: Queries `tbl_tx` for transactions with `status=0` and valid locktime conditions.
- `update_tx_status`: Updates `status` to `1` (sent) or `2` (failed) after a broadcast attempt.
- `get_stats`: Aggregates statistics for the `tbl_stats` table.
- `get_address_by_ip`: A query that joins `tbl_address` with `tbl_xpub` to find addresses by IP for rate limiting or reuse logic.
| Function | Signature | Purpose |
|---|---|---|
| `open_db` | `pub fn open_db(path: &str) -> Result<Connection, String>` | Validates path (blocks `..` traversal, forbidden system dirs, symlinks), opens SQLite, sets `busy_timeout=5000`, retries WAL mode up to 5 times, sets `synchronous=NORMAL`. |
| `create_database` | `pub fn create_database(db: &Connection)` | Creates all tables and indexes (idempotent via `IF NOT EXISTS`). |
| `check_duplicate_txids` | `pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSet<String>, Error>` | Batch check which txids already exist. Chunks in groups of 500 for SQLite parameter limit safety. |
| `insert_xpub` | `pub fn insert_xpub(db: &Connection, network: &str, xpub: &str)` | INSERT OR IGNORE into tbl_xpub. |
| `get_last_used_address_by_ip` | `pub fn get_last_used_address_by_ip(db: &Connection, network: &String, xpub: &String, address: &String) -> Option<String>` | Finds most recent address previously assigned to a remote IP for an xpub. |
| `get_next_address_index` | `pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64, i64)` | Atomically increments `path_idx` and returns `(xpub_id, new_index)` using `RETURNING`. |
| `save_new_address` | `pub fn save_new_address(db: &Connection, xpub: i64, address: &String, path: &String, remote_addr: &String)` | INSERT into tbl_address. |
| `execute_insert` | `pub fn execute_insert(db: &Connection, sqltxs: String, ptx: Vec<(usize, Value)>, sqlinp: String, pinp: Vec<(usize, Value)>, sqlout: String, pout: Vec<(usize, Value)>) -> Result<(), Error>` | Executes a transaction: BEGIN, insert txs, insert inputs, insert outputs, COMMIT (with ROLLBACK on error). |
| `get_total_transaction_number` | `pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error>` | Counts transactions for a network. |
| `get_all_addresses_by_xpub` | `pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error>` | Fetches all addresses for an xpub via JOIN on tbl_xpub/tbl_address. Used for O(1) fee validation in the push handler. |
### Design Notes
SQL queries are built using `format!` in many places. The `execute_insert` function attempts to batch inserts to reduce transaction overhead, but this is dependent on the SQLite version.
- All SQL queries use parameterized statements (`?` placeholders with `bind()`). No string formatting is used for user-controlled values.
- The `open_db` function validates paths before opening, rejecting directory traversal, system directories, and symlinks.
- WAL mode (`PRAGMA journal_mode=WAL`) is enabled with retry logic for concurrent access safety.
---
@@ -47,20 +51,61 @@ SQL queries are built using `format!` in many places. The `execute_insert` funct
**Location:** `src/xpub.rs`
This module handles the derivation of Bitcoin addresses from extended public keys (xpub/zpub) and the creation of P2WPKH descriptors.
This module handles the derivation of Bitcoin addresses from extended public keys (xpub/zpub/ypub) and the creation of P2WPKH descriptors with Bitcoin Core checksums.
### Key Functions
- `parse_xpub`: Parses a Base58-encoded xpub/zpub string into a `bitcoin::bip32::Xpub`.
- `derive_address`: Derives a P2WPKH (Bech32) address at a given address index from the xpub. Uses the BIP-84 path (`m/84'/coin_type'/account'/0/index`). Uses `Secp256k1` from the `secp256k1` crate for elliptic curve math.
- `get_descriptor`: Generates a Bitcoin descriptor string for the xpub (e.g., `wpkh(.../0/*)`), which is useful for wallet integration.
- `checksum_verify`: Verifies the Base58 checksum of an xpub/zpub string to prevent data corruption during entry.
| Function | Signature | Purpose |
|---|---|---|
| `new_address_from_xpub` | `pub fn new_address_from_xpub(zpub: &str, index: i64, network: Network) -> Result<(String, String), Box<dyn std::error::Error>>` | Derives a P2WPKH (native SegWit) address at path `m/0/{index}` from an xpub. Returns `(address, path)`. |
| `get_bitcoincore_descriptor` | `pub fn get_bitcoincore_descriptor(xpub: &str) -> String` | Generates a Bitcoin Core descriptor with checksum (e.g., `wpkh([fingerprint/84h/0h/0h]xpub/0/*)#checksum`). |
| `calculate_fingerprint` | `pub fn calculate_fingerprint(tpub: &str) -> Result<String, String>` | Returns the hex fingerprint of an xpub (converts to standard xpub first). |
### Private Functions
- `poly_mod(c, val)` / `calc_checksum(desc)` — Bitcoin Core descriptor checksum calculation.
- `convert_xpub(xpub)` — Detects prefix (xpub/ypub/zpub or tpub/vpub/upub) and converts to target format.
- `base58check_decode(s)` / `base58check_encode(data)` — Base58Check encoding/decoding.
- `convert_to(zpub, prefix)` — Converts xpub between different prefix formats.
### Supported Prefixes
| Prefix | Type | Network |
|--------|------|---------|
| `xpub` | Legacy P2PKH | Mainnet |
| `ypub` | Nested SegWit P2SH-P2WPKH | Mainnet |
| `zpub` | Native SegWit P2WPKH | Mainnet |
| `tpub` | Legacy P2PKH | Testnet |
| `vpub` | Nested SegWit | Testnet |
| `upub` | Nested SegWit | Regtest |
### Dependencies
- `bitcoin::bip32::Xpub`
- `secp256k1::Secp256k1`
- `bs58` for Base58 decoding
- `bitcoin::Address::p2wpkh` for address creation
- `bitcoin::bip32::{DerivationPath, Xpub}`
- `bitcoin::key::Secp256k1`
- `bitcoin::{Address, Network, ScriptBuf, WPubkeyHash}`
- `sha2::{Digest, Sha256}`
---
## `validation.rs` (SSRF Protection)
**Location:** `src/validation.rs`
This module provides URL validation to prevent SSRF attacks via the `welist` stats reporting feature.
### Key Functions
| Function | Signature | Purpose |
|---|---|---|
| `is_valid_welist_url` | `pub fn is_valid_welist_url(url_str: &str) -> bool` | Validates a URL against SSRF: checks scheme is HTTPS, blocks localhost/loopback/private/link-local/multicast/unspecified IPs for both IPv4 and IPv6. |
### Validation Rules
1. URL must be well-formed and parsable.
2. Scheme must be `https://` (plain HTTP is rejected).
3. Host must not be `localhost`, `127.0.0.1`, `::1`, or any loopback/private/link-local/multicast/unspecified IP address.
4. IPv4 private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and AWS metadata link-local (169.254.169.254) are blocked.
5. IPv6 Unique Local (fc00::/7) and link-local (fe80::/10) are blocked.
### Inline Tests
8 unit tests cover valid domains, invalid schemes, localhost/loopback, private IPs, unspecified/multicast, IPv6 link-local, IPv6 unique local, malformed URLs, and valid public IPs.
---
@@ -71,29 +116,52 @@ This module handles the derivation of Bitcoin addresses from extended public key
The main application binary that provides an async HTTP server.
### Architecture
- **Runtime:** `tokio::main` with `rt-multi-thread`.
- **HTTP Framework:** `hyper` (low-level) + `hyper-util` + `http-body-util`. Each connection is spawned as a new `tokio::task`.
- **Routing:** Routes are matched using path regex and a simple match on the HTTP method. The router is implemented manually in `main`.
- **Runtime:** `actix-web 4.9.0` with `actix-rt` (`#[actix_web::main]`).
- **Rate Limiting:** `actix-governor` middleware with token-bucket algorithm. Uses `RealIpKeyExtractor` to identify clients by real IP behind reverse proxy (via `X-Real-IP` / `X-Forwarded-For` headers).
- **Response Compression:** `actix_web::middleware::Compress`.
- **Request Logging:** `actix_web::middleware::Logger::default()`.
- **Shared State:** `Arc<Mutex<Connection>>` for database access, `MyConfig` for configuration.
### Key Routes (implemented in source code)
- `GET /`, `GET /version`: Returns static strings (name and version).
- `GET /.pub_key.pem`: Returns the Ed25519 public key PEM file for signature verification.
- `GET /:network/info`: Returns JSON with fee, address, and chain info. Networks: `bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`.
- `GET /:network/stats`: Returns per-chain statistics if `expose_stats` is enabled.
- `POST /:network/pushtxs`: Accepts one or more raw hex transactions. It validates them, checks the fee output to the `our_address` for that network, and stores the transaction in the database. See `src/bin/bal-server.rs` for the `pushtxs` request body parsing logic.
- `POST /searchtx`: Accepts a txid in the request body and returns the transaction details, status, and fee breakdown.
### Configuration Structs
### Configuration
- The server reads environment variables and/or a config file (`confy`). Default config is hardcoded for `regtest` development.
- `db_file`: The path to the SQLite database (e.g., `bal.db`).
- `bind_address`: The address to listen on (e.g., `127.0.0.1:3031`).
- `expose_stats`: A boolean flag to enable/disable the stats endpoint.
**`MyConfig`** (server configuration):
- `regtest`, `signet`, `testnet`, `testnet4`, `mainnet`: `NetConfig` per network
- `info`, `bind_address`, `bind_port`, `db_file`, `pub_key_path`, `expose_stats`
**`NetConfig`** (per-network):
- `address` (xpub or address), `fixed_fee` (sats), `xpub` (bool), `network` (bitcoin::Network), `name`, `enabled`
**`ActixConfig`** (server tuning):
- `max_body_size`, `timeout_secs`, rate limits (`pushtxs` per sec/burst), `workers`, `max_connections`, `trusted_proxy`
### Key Routes
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | `/` | `echo_home` | Returns `cfg.info` string |
| GET | `/.pub_key.pem` | `echo_pub_key` | Returns public key PEM file |
| GET | `/version` | `echo_version` | Returns VERSION constant (0.3.2) |
| GET | `/{network}/info` | `echo_info` | Returns `InfoResponse` JSON. In xpub mode, derives/returns per-IP address |
| GET | `/{network}/stats` | `echo_stats` | Returns `Vec<StatsResponse>` JSON (requires `expose_stats=true`) |
| POST | `/{network}/pushtxs` | `echo_push` | Accepts newline-separated raw tx hex. 3-phase: parse (no lock), check duplicates (lock), insert (lock) |
| POST | `/searchtx` | `echo_search` | Searches by txid (body = 64 hex chars). Returns status, tx, our_address, our_fees, reqid |
### Handler Details
**`echo_info`**: If xpub mode is enabled, first checks `get_last_used_address_by_ip` for an existing address for that IP. If none, atomically claims next index via `get_next_address_index`, derives address via `new_address_from_xpub`, and saves it. Two separate DB lock acquisitions (lookup + save) with CPU-bound derivation in between (no lock held).
**`echo_push`**: Three-phase approach:
1. Load all known addresses (for xpub validation) with DB lock, release lock
2. Parse all transactions from request body (CPU-bound, no lock) using `parse_request_transactions`
3. Batch check duplicates with DB lock, release lock
4. Build bulk INSERT statements using `UNION ALL SELECT` and execute in single transaction
**`parse_request_transactions`**: Splits body by newlines, hex-decodes each line, deserializes via `consensus::deserialize`, computes txid/wtxid/ntxid, checks if any output matches the expected address (or is in known_addresses for xpub mode) with amount >= fixed_fee.
### Error Handling
- **WARNING:** This binary uses `unwrap()` and `expect()` on many critical paths (e.g., `sqlite::open`, `Regex::new`, `req.collect()`). A malformed request could crash the async task or even the entire runtime. This is a known vulnerability.
All `unwrap()`/`expect()` calls have been replaced with safe `match`/`if let` error propagation, returning appropriate HTTP status codes (400, 404, 500). The server does not panic on untrusted input.
### Static Public Key (`/.pub_key.pem`)
The server serves a static `public_key.pem` file. The corresponding private key (`privkey.pem`) is used by the pusher to sign statistics before sending them to the `welist` server. This file is located in the project root directory.
The server serves a static `public_key.pem` file. The corresponding `privkey.pem` is used by the pusher to sign statistics before sending them to the `welist` server.
---
@@ -104,26 +172,77 @@ The server serves a static `public_key.pem` file. The corresponding private key
This is the async daemon that monitors the blockchain and pushes pending transactions.
### Architecture
- **Runtime:** `tokio::main`.
- **ZMQ:** `zmq::Context` with a `SUB` socket that listens to `tcp://127.0.0.1:28332` (or similar per-network port). The topic is `hashblock` (32-byte block hash).
- **RPC:** It uses the `bitcoincore-rpc` client to call `getblockchaininfo` (to get the `mediantime`) and `sendrawtransaction` for each transaction.
- **HTTP Client:** `reqwest` with the `json` feature. It sends a signed JSON POST to the `welist` server.
- **Runtime:** `tokio::main` with `rt-multi-thread`.
- **ZMQ:** `zmq::Context` with a `SUB` socket. Subscribes to all topics. Uses `set_rcvtimeo(5000)` for 5-second receive timeout.
- **RPC:** `bitcoincore-rpc` client. Tries username/password auth first, falls back to cookie file auth.
- **HTTP Client:** `reqwest` with `json` and `socks` features. Sends Ed25519-signed JSON POST to the `welist` server.
- **IPv6 Preference:** Optional `BAL_PUSHER_PREFER_IPV6` flag pins the HTTP connection to the first IPv6 address.
### Key Logic
1. On every `hashblock` message, it calls `main_result()`.
2. `main_result` creates a `bitcoincore-rpc` client. If it fails, it **panics** (`panic!("impossible to get client {}", e)`), crashing the entire process.
3. It fetches `getblockchaininfo` to get the `mediantime`.
4. It queries the database for transactions with `status=0` and `locktime < mediantime`.
1. On startup and every `hashblock` message, it calls `main_result()`.
2. `main_result` creates a `bitcoincore-rpc` client. If it fails, it logs an error and returns (no panic).
3. It fetches `getblockchaininfo` to get `mediantime` and `blocks` height.
4. It queries the database for transactions with `status=0` and locktime satisfied (block height < best block, or timestamp < mediantime for timestamps > `LOCKTIME_THRESHOLD`).
5. For each pending transaction, it calls `sendrawtransaction`.
6. If `send_stats` is enabled, it collects statistics, signs them with `privkey.pem`, and sends them to the configured `welist` URL via `reqwest`.
7. It updates the database with the new status.
7. It updates the database with the new status (`1` = sent, `2` = failed with `push_err`).
### Statistics Reporting
- Statistics are aggregated from the database (total, waiting, sent, failed, profits, unique inputs).
- The chain name is validated (alphanumeric, `-`, `_` only).
- Stats are inserted into `tbl_stats` with `ON CONFLICT(chain) DO UPDATE`.
- The stats payload is signed with Ed25519 and POSTed to `{welist_url}/ping`.
- The `WELIST_SERVER_URL` is validated via `is_valid_welist_url()` before sending (can be bypassed with `WELIST_SKIP_URL_VALIDATION=true`).
- `WELIST_PING_SIG_VERSION` selects the signature scheme:
- `1` (default): legacy concatenation of URL, chain, height, median time and
block hash. The WeTime service verifies it against `/.pub_key.pem`. The
signature authenticates liveness only: the pusher does not send `info`,
`base_fee` or `version`, and the service refreshes `last_update` without
persisting executor metadata. Use v2 when metadata must be published and
authenticated.
- `2`: canonical JSON signed with the `WELIST-PING-V2\n` prefix. The pusher
fetches `info`, `base_fee` and `version` from the local
`/{chain}/info` endpoint and signs them together with the block metadata.
The WeTime service discovers the public key from a DNSSEC-validated
`_welist-key.<host>` TXT record, from a v3 onion address, or from a ping key
delegated by that onion identity key, so no HTTP callback is needed. The
`url` is trimmed of trailing slashes to match the service-side
normalization; absent metadata is signed as `null`. When onion delegation
is configured, the pusher sends the certificate only with v2 pings and
verifies at startup that it is bound to the configured onion host, has not
expired, and delegates to the key at `SSL_KEY_PATH`. If that check fails the
pusher disables delegation and signs directly, which the service rejects
unless `SSL_KEY_PATH` is the onion identity key. A leaked key is stopped
with `contrib/make_ping_key_revocation.py` (onion: identity-signed; DNS:
self-signed), submitted to the WeTime `/ping-keys/revoke` endpoint.
- `bal-pusher.sh` pins `WELIST_PING_SIG_VERSION=1` for the development setup:
a `localhost` URL cannot use DNSSEC, so the dev pusher uses the legacy
scheme verified against `/.pub_key.pem`. v2 requires a public DNS hostname
or a v3 onion address.
- To publish the v2 public key, derive the base64 SubjectPublicKeyInfo DER:
`openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0` and
publish it in a DNSSEC-signed TXT record named `_welist-key.<host>` with value
`v=we1; alg=ed25519; key=<base64>`. A v3 onion URL needs no DNS record: its
Ed25519 identity key is embedded in the address, and the recommended setup
keeps that key offline while it certifies a dedicated ping key
(`WELIST_PING_DELEGATION_FILE`). See
[`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) for the full
operator guide.
### ZMQ Timeout Handling
- Uses `set_rcvtimeo(5000)` (5-second timeout).
- Logs a warning every ~720 consecutive timeouts (~1 hour of no blocks).
- Does not block forever or panic on connection loss.
### Configuration
- `zmq_endpoint`: The ZMQ endpoint (e.g., `tcp://127.0.0.1:28332`).
- `rpc_url`: The URL of the Bitcoin RPC (e.g., `http://127.0.0.1:18443`).
- `rpc_auth`: `user_pass` or `cookie_file`. The cookie path is constructed from the `HOME` environment variable (e.g., `~/.bitcoin/.cookie`).
- `send_stats`: A boolean that enables the remote server reporting.
- `welist_url`: The URL to POST to.
- `ssl_key_path`: The path to the Ed25519 private key (`privkey.pem`) for signing stats.
---
All configuration is via environment variables (no config files):
- `BAL_SERVER_DB_FILE`: Path to SQLite database.
- `BAL_PUSHER_BITCOIN_DIR`: Bitcoin data directory (for cookie file path).
- `BAL_PUSHER_SEND_STATS`: Enable/disable remote stats reporting.
- `BAL_SERVER_URL`: URL of the bal-server for internal communication.
- `SSL_KEY_PATH`: Path to Ed25519 private key for signing stats.
- `WELIST_SERVER_URL`: URL to POST stats to (validated against SSRF).
- `WELIST_SKIP_URL_VALIDATION`: Bypass URL validation (for testing).
- `WELIST_PING_SIG_VERSION`: WeTime ping signature scheme (`1` legacy, `2` canonical JSON).
- `BAL_PUSHER_PREFER_IPV6`: Pin HTTP connection to IPv6 address.
- Per-network: `BAL_PUSHER_{NETWORK}_HOST`, `_PORT`, `_DIR_PATH`, `_DB_FIELD`, `_COOKIE_FILE`, `_RPC_USER`, `_RPC_PASSWORD`, `_ZMQ_HASHBLOCK`.

View File

@@ -8,18 +8,26 @@
## HTTP API (provided by `bal-server`)
### Rate Limiting
All endpoints are rate-limited via `actix-governor` with a token-bucket algorithm. The rate limit key is the **real client IP address**, extracted from `X-Real-IP` / `X-Forwarded-For` headers when the request comes from a trusted proxy (default `127.0.0.1`, configurable via `BAL_SERVER_TRUSTED_PROXY`). Direct connections (non-proxy) use the TCP peer IP.
Default: 1 req/s with burst of 3 (configurable via `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` / `BAL_SERVER_ACTIX_PUSHTXS_BURST`).
When behind Nginx, ensure `proxy_set_header X-Real-IP $remote_addr` is set so the server can identify individual clients.
### `GET /`
- **Description:** Returns a static identification string (e.g., "Will Executor Server").
- **Description:** Returns a static identification string (default: "Will Executor Server").
- **Response:** Plain text `200 OK`.
### `GET /version`
- **Description:** Returns the Cargo package version (`bal_server` version).
- **Response:** `text/plain` (e.g., `0.2.3`).
- **Description:** Returns the Cargo package version.
- **Response:** `text/plain` (e.g., `0.3.2`).
### `GET /.pub_key.pem`
- **Description:** Returns the static Ed25519 public key PEM file for signature verification of remote stats.
- **Response:** `text/plain` with the PEM file content.
- **File:** `public_key.pem` in the project root.
- **File:** `public_key.pem` in the project root (path configurable via `BAL_SERVER_PUB_KEY_PATH`).
### `GET /:network/info`
- **Description:** Returns JSON with the server's configuration for that specific network.
@@ -27,67 +35,62 @@
- **Response (200 OK):**
```json
{
"network": "regtest",
"our_address": "bcrt...",
"fee": 1000,
"address": "bcrt1q...",
"base_fee": 50000,
"chain": "regtest",
"version": "0.2.3"
"info": "Will Executor Server",
"version": "0.3.2"
}
```
- **Error:** `404` if the network is not configured.
- In xpub mode, the `address` field contains a freshly derived P2WPKH address unique to the requesting IP.
- **Error:** `404` if the network is not configured or unknown.
### `GET /:network/stats`
- **Description:** Returns statistics for the given network. This endpoint is guarded by the `expose_stats` configuration flag.
- **Response (200 OK):**
- **Description:** Returns statistics for the given network. Guarded by the `expose_stats` configuration flag.
- **Response (200 OK):** A JSON array of `StatsResponse` objects:
```json
{
"report_date": 1712345678,
"chain": "regtest",
"total": 42,
"waiting": 10,
"sent": 30,
"failed": 2,
"waiting_profit": 10000,
"sent_profit": 30000,
"missed_profit": 5000,
"unique_input": 15
}
[
{
"report_date": "2024-07-20T12:00:00Z",
"chain": "regtest",
"totals": 42,
"waiting": 10,
"sent": 30,
"failed": 2,
"waiting_profit": 10000,
"sent_profit": 30000,
"missed_profit": 5000,
"unique_inputs": 15
}
]
```
- **Error:** `403` or `400` if stats are not enabled or the network is unknown.
### `POST /:network/pushtxs`
- **Description:** Accepts one or more raw hex Bitcoin transactions. The server deserializes the transaction, validates that the fee is paid to the correct `our_address` for that network, and stores the transaction in the database. It also stores all inputs and outputs.
- **Request Body:**
- `Content-Type: application/json` (or plain text, depending on the client).
- The payload format is typically an array of raw hex strings or a single hex string.
```json
[
"02000000000101...hex..."
]
- **Description:** Accepts one or more raw hex Bitcoin transactions (newline-separated). The server deserializes each transaction, validates that the fee is paid to the correct `our_address` for that network, and stores valid transactions in the database.
- **Request Body:** Newline-separated raw hex transactions.
```
- **Response (200 OK):** A JSON array with the result for each transaction.
```json
[
{
"txid": "abc123...",
"wtxid": "def456...",
"status": 0,
"locktime": 2100,
"our_fees": 1000,
"our_address": "bcrt1q..."
}
]
02000000000101...hex...\n
02000000000101...hex...\n
```
- **Response (400 Bad Request):** If the transaction is invalid, the fee is missing, or the locktime is not acceptable.
- **Response (500 Internal Server):** `Database error`, `Invalid hex`, `Invalid transaction` (may contain a panic trace if an internal `unwrap` is hit).
- **Security Note:** If a transaction is not valid or does not pay the required fees, it is not inserted into the database.
- **Response (200 OK):** A JSON object with the results for the batch:
```json
{
"accepted": 2,
"rejected": 0,
"details": [...]
}
```
- **Response (400 Bad Request):** If all transactions are invalid, the fee is missing, or the locktime is not acceptable.
- **Response (413 Payload Too Limited):** If the request body exceeds the configured max size (default 1 MiB).
- **Security Note:** Invalid transactions or those not paying the required fees are not inserted into the database.
### `POST /searchtx`
- **Description:** Searches for a transaction by its `txid`. Returns the transaction details, status, raw hex, and fees.
- **Description:** Searches for a transaction by its `txid`. The request body must contain exactly 64 hex characters.
- **Request Body:**
```json
{
"txid": "abc123..."
"txid": "abc123def456..."
}
```
- **Response (200 OK):**
@@ -98,42 +101,108 @@
"tx": "020000000...",
"our_address": "bcrt1q...",
"our_fees": 1000,
"locktime": 2100,
"timestamp": 1712345678
"reqid": "192.168.1.1"
}
```
- **Response (404):** If the transaction is not found in the database.
- **Response (400):** If the request body is invalid.
- **Response (400 Bad Request):** If the txid is not exactly 64 hex characters.
- **Response (404 Not Found):** If the transaction is not found in the database.
---
## ZMQ Messages (consumed by `bal-pusher`)
### Topic: `hashblock` (Consumed by `bal-pusher`)
### Topic: `hashblock`
- **Format:** A multipart ZMQ message. The first frame is the topic name (`hashblock`), the second frame is the 32-byte block hash.
- **Trigger:** When a new Bitcoin block is found by the local node.
- **Action:** The pusher fetches `getblockchaininfo` from the RPC, gets the updated `mediantime`, then queries and pushes pending transactions.
- **Endpoint:** `tcp://127.0.0.1:28332` (or network-specific ports).
- **Action:** The pusher fetches `getblockchaininfo` from the RPC, gets the updated `mediantime` and `blocks` height, then queries and pushes pending transactions.
- **Endpoint:** Per-network (e.g., `tcp://127.0.0.1:28332` for mainnet).
- **Timeout:** 5 seconds (`ZMQ_RCVTIMEO`). The pusher logs a warning after ~720 consecutive timeouts (~1 hour).
---
## Bitcoin Core RPC Usage (used by `bal-pusher`)
### `sendrawtransaction` (Both pushers)
### `sendrawtransaction`
- **Method:** `sendrawtransaction` (RPC `2`)
- **Parameters:** `hexstring` (the raw hex of the transaction to broadcast).
- **Description:** Broadcasts the transaction to the Bitcoin network. If the transaction is invalid (e.g., `bad-txns-inputs-missingorspent`), the RPC will return an error with a negative code (e.g., `-25`).
- **Error Handling:** The pusher catches these errors, logs them, and updates the database status to `2` (failed).
- **Error Handling:** The pusher catches these errors, logs them, and updates the database status to `2` (failed) with the error in `push_err`.
### `getblockchaininfo` (Only `bal-pusher`)
### `getblockchaininfo`
- **Method:** `getblockchaininfo` (RPC `1`)
- **Parameters:** None.
- **Description:** Returns the current blockchain state, including the `mediantime` (the median timestamp of the last 11 blocks). This is used to evaluate the `nLockTime` of pending transactions.
- **Description:** Returns the current blockchain state, including `mediantime` (median timestamp of the last 11 blocks) and `blocks` (best block height). Used to evaluate `nLockTime` of pending transactions.
### `getblock` (Only used by `bal-pusher` for median time)
### `getblock`
- **Method:** `getblock` (RPC `1`)
- **Parameters:** `blockhash`, `verbosity` (set to `1` for JSON with timestamp).
- **Description:** Fetches the details of a block. It is used as an alternative to `getblockchaininfo` to get the block's `time` if `getblockchaininfo` fails or is insufficient.
- **Description:** Fetches block details. Used as an alternative for median time calculation.
### RPC Authentication
Authentication is done via `bitcoincore-rpc` using either:
- **`UserPass`**: `BAL_PUSHER_{NETWORK}_RPC_USER` and `BAL_PUSHER_{NETWORK}_RPC_PASSWORD`.
- **`CookieFile`**: `$HOME/.bitcoin/{dir_path}.cookie` or custom path via `BAL_PUSHER_{NETWORK}_COOKIE_FILE`.
The client tries username/password auth first, then falls back to cookie file auth.
---
## Ed25519 WeTime Ping Signing
The pusher signs the report before sending it to the `welist` server. The
scheme is selected with `WELIST_PING_SIG_VERSION`:
### Version 2 (canonical JSON, preferred)
1. Fetches `info`, `base_fee` and `version` from the local
`GET /{chain}/info` endpoint (failure falls back to `null`).
2. Builds a compact JSON object with lexicographically sorted keys, every field
present, and `null` for absent optional values:
`base_fee`, `chain`, `height`, `info`, `last_block_hash`, `median_time`,
`sig_version`, `url`, `version`.
3. Signs `WELIST-PING-V2\n` followed by that JSON with the Ed25519 private key
(`SSL_KEY_PATH`).
4. POSTs the same fields plus the base64 signature and `"sig_version": 2` to
`{WELIST_SERVER_URL}/ping`.
5. The `welist` server discovers the public key from a DNSSEC-validated
`_welist-key.<host>` TXT record (`v=we1; alg=ed25519; key=<base64 SPKI DER>`)
or from a v3 `.onion` address. No HTTP callback to `/.pub_key.pem` is made.
For `.onion` URLs the pusher includes a `delegation` object when
`WELIST_PING_DELEGATION_FILE` is set and `WELIST_PING_SIG_VERSION=2`. The onion
identity key signs that certificate offline; `SSL_KEY_PATH` is then the
dedicated ping key, and the service verifies the certificate before checking
the ping signature. The service normalizes `onion` (lowercase, trailing dot
removed) before comparing it with the ping host; the pusher's startup check uses
the exact configured host, so issue lowercase certificates.
The pusher validates the certificate at startup: `onion` must match the
configured host, `valid_until` must be in the future, and `key` must match
`SSL_KEY_PATH`. If the file is missing or malformed, or any check fails, the
pusher logs a warning and sends no `delegation` object. It then signs with
`SSL_KEY_PATH` directly; in the recommended setup that key is the dedicated
ping key, so the service rejects the pings until delegation is restored.
The `url` is trimmed of surrounding whitespace and trailing slashes, and the
`chain` is lowercase, matching the service-side canonicalization.
To stop accepting a ping key, run `contrib/make_ping_key_revocation.py` and
submit the result to `{WELIST_SERVER_URL}/ping-keys/revoke`. Onion revocations
are signed by the onion identity key; DNS TXT revocations are self-signed by the
key being revoked. See [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md)
for the operator guide on publishing `_welist-key.<host>`, onion delegation and
revocation.
### Version 1 (legacy)
1. Concatenates `url + chain + height + median_time + last_block_hash`.
2. Signs that string with the Ed25519 private key.
3. POSTs those five fields plus the base64 signature to
`{WELIST_SERVER_URL}/ping`. No `info`, `base_fee` or `version` is sent.
4. The `welist` server verifies the signature with the public key served at
`GET /.pub_key.pem`.
5. The v1 signature authenticates only liveness. The service refreshes
`last_update` for an accepted ping but does not persist executor metadata.
Because any metadata added to a v1 ping would be outside the signature, use
v2 when `info`, `base_fee` and `version` must be published and
authenticated.

View File

@@ -8,9 +8,12 @@
## Database Technology
- **Engine:** `sqlite` (Rust `sqlite` crate, version 0.34.0)
- **File:** `bal.db` (default, configured in environment)
- **Connection Pooling:** The Rust `sqlite` crate handles connections but does not use a thread pool.
- **Transactions:** The `execute_insert` function attempts to use atomic transactions for batched inserts, but this is not guaranteed for all operations.
- **File:** `bal.db` (default, configurable via `BAL_SERVER_DB_FILE`)
- **Connection Management:** Shared `Arc<Mutex<Connection>>` in `bal-server`, single connection in `bal-pusher`.
- **WAL Mode:** Enabled via `PRAGMA journal_mode=WAL` with retry logic (up to 5 attempts) for concurrent access safety.
- **Busy Timeout:** Set to 5000ms via `PRAGMA busy_timeout=5000`.
- **Synchronous Mode:** Set to `NORMAL` via `PRAGMA synchronous=NORMAL`.
- **Path Validation:** The `open_db` function validates the database path before opening, rejecting directory traversal (`..`), forbidden system directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`), and symlinks.
---
@@ -19,156 +22,183 @@
### `tbl_tx` (Transactions)
```sql
CREATE TABLE tbl_tx (
txid PRIMARY KEY, -- TEXT: The unique transaction ID (hex string)
wtxid, -- TEXT: The witness transaction ID
ntxid, -- TEXT: The non-witness transaction ID
tx, -- TEXT: The full raw serialized transaction (hex)
locktime INTEGER, -- INTEGER: The locktime value (block height or timestamp)
network, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
network_fees, -- TEXT: The total fees paid by the user (satoshi)
reqid, -- TEXT: A request ID or client IP for the submitter
our_fees, -- TEXT: The fees paid to us (the operator) (satoshi)
our_address, -- TEXT: The address the operator fee is paid to
status INTEGER DEFAULT 0, -- INTEGER: 0 = waiting, 1 = sent, 2 = failed
push_err TEXT -- TEXT: The error message if the RPC broadcast failed
CREATE TABLE IF NOT EXISTS tbl_tx (
txid PRIMARY KEY, -- TEXT: The unique transaction ID (hex string)
date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wtxid, -- TEXT: The witness transaction ID
ntxid, -- TEXT: The non-witness transaction ID
tx, -- TEXT: The full raw serialized transaction (hex)
locktime INTEGER, -- INTEGER: The locktime value (block height or timestamp)
network, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
network_fees, -- TEXT: The total fees paid by the user (satoshi)
reqid, -- TEXT: A request ID or client IP for the submitter
our_fees, -- TEXT: The fees paid to us (the operator) (satoshi)
our_address, -- TEXT: The address the operator fee is paid to
status INTEGER DEFAULT 0, -- INTEGER: 0 = waiting, 1 = sent, 2 = failed
push_err TEXT -- TEXT: The error message if the RPC broadcast failed
);
ALTER TABLE tbl_tx ADD COLUMN push_err TEXT;
```
- **Indexes:** The `txid` is the primary key, so it is automatically indexed.
- **Notes:** `locktime` is stored as an integer. It is compared against the `mediantime` or `block_height` from the blockchain to evaluate when a transaction is ready to send. The `status` column is the core of the transaction lifecycle state machine. `our_fees` and `our_address` are used to validate the transaction and ensure the correct fee is included before accepting it.
- **Notes:** `date_creation` and `date_update` track when the transaction was inserted and last modified. `locktime` is compared against the blockchain's best block height or `mediantime` (for timestamps above `LOCKTIME_THRESHOLD`). The `status` column is the core of the transaction lifecycle state machine. `push_err` stores the RPC error message when status=2.
### `tbl_inp` (Transaction Inputs)
```sql
CREATE TABLE tbl_inp (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
in_txid, -- TEXT: The previous transaction ID (output being spent)
in_vout -- INTEGER: The previous output index
CREATE TABLE IF NOT EXISTS tbl_inp (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
in_txid, -- TEXT: The previous transaction ID (output being spent)
in_vout -- INTEGER: The previous output index
);
CREATE UNIQUE INDEX ON tbl_inp(txid, in_txid, in_vout);
```
- **Purpose:** Tracks all inputs of the submitted transactions. This allows the database to identify double-spends and ensure the inputs are valid and available.
- **Purpose:** Tracks all inputs of submitted transactions. Enables identification of double-spends.
- **Constraints:** A unique index prevents duplicate entries for the same input in the same transaction.
- **Relationships:** `in_txid` and `in_vout` refer to outputs from previous transactions in the Bitcoin blockchain. The `txid` column refers to the transaction being submitted (the one in `tbl_tx`).
### `tbl_out` (Transaction Outputs)
```sql
CREATE TABLE tbl_out (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
script_pubkey, -- TEXT: The hex scriptPubKey of this output
amount, -- TEXT: The amount in this output (satoshi)
vout -- INTEGER: The output index (0-based) in this transaction
CREATE TABLE IF NOT EXISTS tbl_out (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
script_pubkey, -- TEXT: The hex scriptPubKey of this output
amount, -- TEXT: The amount in this output (satoshi)
vout -- INTEGER: The output index (0-based) in this transaction
);
CREATE UNIQUE INDEX ON tbl_out(txid, script_pubkey, amount, vout);
```
- **Purpose:** Tracks all outputs of the submitted transactions. The server searches for the `script_pubkey` matching the `our_address` for the network to determine if the correct fee is included.
- **Purpose:** Tracks all outputs of submitted transactions. The server searches for the `script_pubkey` matching the `our_address` for the network to verify fee payment.
- **Constraints:** A unique index prevents duplicate entries for the same output in the same transaction.
- **Relationships:** `txid` refers to the transaction being submitted. The `script_pubkey` is matched against the known addresses for each network to verify the fee payment.
### `tbl_xpub` (Extended Public Keys)
```sql
CREATE TABLE tbl_xpub (
id INTEGER PRIMARY KEY, -- INTEGER: Auto-increment ID
network TEXT, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
xpub TEXT, -- TEXT: The extended public key (xpub or zpub)
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- TEXT: The date the xpub was added
path_idx INTEGER DEFAULT -1 -- INTEGER: The next address index to derive for this xpub
CREATE TABLE IF NOT EXISTS tbl_xpub (
id INTEGER PRIMARY KEY, -- INTEGER: Auto-increment ID
network TEXT, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
xpub TEXT, -- TEXT: The extended public key (xpub, zpub, ypub, etc.)
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
path_idx INTEGER DEFAULT -1 -- INTEGER: The next address index to derive for this xpub
);
CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub);
```
- **Purpose:** Stores the master xpub/zpub keys for each network. When the server receives a transaction, it uses these to derive new receiving addresses (if applicable) or to verify the `our_address`.
- **Relationships:** `tbl_xpub` is linked to `tbl_address` via `xpub` (the ID). The `path_idx` tracks which child index is the next unused one for the wallet's derivation path.
- **Purpose:** Stores the master xpub/zpub/ypub keys for each network. When the server receives a transaction in xpub mode, it derives new receiving addresses from these keys.
- **Relationships:** `tbl_xpub` is linked to `tbl_address` via `id`. The `path_idx` tracks which child index is the next unused one for the wallet's derivation path.
### `tbl_address` (Derived Addresses)
```sql
CREATE TABLE tbl_address (
address TEXT PRIMARY KEY, -- TEXT: The Bech32 P2WPKH address (e.g., 'bcrt1q...')
path TEXT NOT NULL, -- TEXT: The derivation path used to create this address (e.g., 'm/84'/1'/0'/0/0')
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- TEXT: The date the address was generated
xpub INTEGER, -- INTEGER: The ID of the xpub in `tbl_xpub` that owns this address
remote_address TEXT -- TEXT: IP or client identifier that requested this address (if applicable)
CREATE TABLE IF NOT EXISTS tbl_address (
address TEXT PRIMARY_KEY, -- TEXT: The Bech32 P2WPKH address (e.g., 'bcrt1q...')
path TEXT NOT NULL, -- TEXT: The derivation path (e.g., 'm/0/0')
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
xpub INTEGER, -- INTEGER: The ID of the xpub in `tbl_xpub`
remote_address TEXT -- TEXT: IP or client identifier that requested this address
);
```
- **Purpose:** Stores all generated addresses. The `our_address` for each network is derived from a specific xpub path. The server can also generate new addresses on demand for clients.
- **Relationships:** `xpub` (FK) links to `tbl_xpub.id`. The `address` is the primary key because it is unique by design. `remote_address` is used for rate-limiting or identifying address ownership in logs.
- **Purpose:** Stores all generated addresses. In xpub mode, addresses are derived on-demand per requesting IP.
- **Relationships:** `xpub` (FK) links to `tbl_xpub.id`. `remote_address` is used for rate-limiting and preventing address reuse per IP.
### `tbl_stats` (Per-Network Statistics)
```sql
CREATE TABLE tbl_stats (
report_date INTEGER, -- INTEGER: The Unix timestamp of the report
chain TEXT PRIMARY KEY, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
totals INTEGER, -- INTEGER: Total number of transactions submitted
waiting INTEGER, -- INTEGER: Transactions currently waiting (status=0)
sent INTEGER, -- INTEGER: Transactions successfully sent (status=1)
failed INTEGER, -- INTEGER: Transactions that failed to broadcast (status=2)
waiting_profit INTEGER, -- INTEGER: Total fees for waiting transactions (satoshi)
sent_profit INTEGER, -- INTEGER: Total fees for sent transactions (satoshi)
missed_profit INTEGER, -- INTEGER: Total fees for transactions that expired or failed (satoshi)
unique_inputs INTEGER -- INTEGER: The number of unique inputs (for deduplication analysis)
CREATE TABLE IF NOT EXISTS tbl_stats (
report_date TEXT, -- TEXT: The ISO timestamp of the report
chain TEXT, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
totals INTEGER, -- INTEGER: Total number of transactions submitted
waiting INTEGER, -- INTEGER: Transactions currently waiting (status=0)
sent INTEGER, -- INTEGER: Transactions successfully sent (status=1)
failed INTEGER, -- INTEGER: Transactions that failed to broadcast (status=2)
waiting_profit INTEGER, -- INTEGER: Total fees for waiting transactions (satoshi)
sent_profit INTEGER, -- INTEGER: Total fees for sent transactions (satoshi)
missed_profit INTEGER, -- INTEGER: Total fees for failed/expired transactions (satoshi)
unique_inputs INTEGER -- INTEGER: The number of unique inputs (for deduplication analysis)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);
```
- **Purpose:** Stores aggregate statistics for each network. The pusher sends this data to a remote `welist` server. The server also reads from it for the `stats` endpoint if `expose_stats` is enabled.
- **Relationships:** `chain` is the primary key. The data is updated by the `bal-pusher` binary.
- **Purpose:** Stores aggregate statistics for each network. The pusher calculates and upserts stats (`ON CONFLICT(chain) DO UPDATE`). The server reads from it for the `stats` endpoint if `expose_stats` is enabled.
- **Relationships:** `chain` has a unique index. Data is updated by `bal-pusher` via `calculate_stats`.
---
## Data Query Strategy
### Key Queries (from `db.rs` and `bal-pusher.rs`)
### Key Queries
- **Get Pending Transactions (by status and locktime):**
```sql
SELECT
txid, tx, wtxid, ntxid, locktime, status,
our_address, our_fees, network_fees
FROM
tbl_tx
WHERE
network = ?
AND status = 0
AND locktime < ?;
SELECT * FROM tbl_tx
WHERE network = :network
AND status = :status
AND (locktime < :bestblock_height
OR locktime > :locktime_threshold AND locktime < :bestblock_time);
```
Used by the `bal-pusher` daemon to find transactions that are ready to broadcast. The `?` placeholders are bound at runtime. `locktime` is compared with the `mediantime` or block height from the ZMQ `new block` event.
Used by the `bal-pusher` to find transactions ready to broadcast. The `locktime_threshold` constant distinguishes block heights from timestamps.
- **Insert Transaction:**
- **Insert Transaction (batched):**
```sql
INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, network_fees, reqid, our_fees, our_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, network_fees, reqid, our_fees, our_address)
UNION ALL SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
UNION ALL SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
-- ... more rows
```
Used by the `bal-server` when accepting a new valid transaction.
Used by `bal-server` for efficient bulk inserts.
- **Update Status:**
```sql
UPDATE tbl_tx SET status = ? WHERE txid = ?;
UPDATE tbl_tx SET status = ? WHERE txid = ?;
```
Used by the pusher after a successful or failed broadcast attempt. The status is set to `1` (sent) and `2` (failed).
**WARNING:** The `bal-pusher` also uses a raw `WHERE txid IN ('...')` format for batch updates. These string formats have **SQL injection risk** because the `txid` strings are concatenated into the raw SQL string without proper parameterization.
Used by the pusher after broadcast. Status `1` = sent, `2` = failed.
```sql
UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?;
```
For failed broadcasts, the error message is stored.
- **Check Duplicate Txids:**
```sql
SELECT txid FROM tbl_tx WHERE txid IN (?, ?, ?, ...);
```
Used by `bal-server` to batch-check duplicates before inserting. Chunks in groups of 500 for SQLite parameter limit safety.
- **Search Transaction:**
```sql
SELECT * FROM tbl_tx WHERE txid = ?;
SELECT * FROM tbl_tx WHERE txid = ?;
```
Used by the `searchtx` endpoint.
- **Get Address for Rate Limiting:**
- **Get All Addresses by XPub:**
```sql
SELECT a.address, x.xpub
FROM tbl_address a
JOIN tbl_xpub x ON a.xpub = x.id
WHERE a.remote_address = ?;
SELECT a.address
FROM tbl_address a
JOIN tbl_xpub x ON a.xpub = x.id
WHERE x.xpub = ?;
```
Used to check if an IP or client address already has a generated address. This is part of the address reuse logic to prevent users from requesting too many addresses or using the same IP to bypass fees.
Used to load all known addresses for an xpub in a single query, enabling O(1) fee validation in the push handler.
- **Get Last Used Address by IP:**
```sql
SELECT address FROM tbl_address
WHERE remote_address = ? AND xpub = ?
ORDER BY date_create DESC LIMIT 1;
```
Used to check if an IP already has a generated address (address reuse prevention).
- **Get Next Address Index:**
```sql
UPDATE tbl_xpub SET path_idx = path_idx + 1
WHERE network = ? AND xpub = ?
RETURNING id, path_idx;
```
Atomically increments the derivation index and returns the new value.
---
## Data Lifecycle
- **Creation:** Transactions are created when a user submits a raw hex tx via the `POST /pushtxs` endpoint. The address is inserted when an xpub is configured.
- **Creation:** Transactions are created when a user submits raw hex tx via `POST /{network}/pushtxs`. Addresses are derived on-demand in xpub mode.
- **Waiting:** Transactions are in `status=0` and are queried by the pusher every new block.
- **Broadcast:** Transactions are pushed to the network via `sendrawtransaction`. If successful, the status becomes `1`. If the RPC returns an error (e.g., `-25`), the status becomes `2` and the error string is stored in `push_err`.
- **Retention:** There is no explicit cleanup mechanism for old records. The `valid_txs` and `invalid_txs` files contain logs of past transaction pushes, but the database itself may grow indefinitely. For a production system, a periodic vacuum or purge of old `status=1` transactions might be required.
- **Backup:** The database is a single SQLite file (`bal.db`). It can be copied directly using `cp` or `rsync` (see `scripts/download_bal_db.sh`). There is no WAL mode or online backup mechanism implemented.
- **Broadcast:** Transactions are pushed via `sendrawtransaction`. If successful, status becomes `1`. If the RPC returns an error, status becomes `2` and the error is stored in `push_err`.
- **Statistics:** The pusher aggregates stats from the database and upserts into `tbl_stats` with `ON CONFLICT(chain) DO UPDATE`.
- **Retention:** There is no explicit cleanup mechanism for old records. For production, a periodic vacuum or purge of old `status=1` transactions may be required.
- **Backup:** The database is a single SQLite file. It can be copied directly using `cp` or `rsync`. WAL mode ensures consistency during copies.

View File

@@ -1,58 +1,151 @@
# Deployment and Operations
## Quick Reference
- **What this file contains:** environment variables, systemd service files, deployment scripts, nginx/Tor configuration, and installation procedures.
- **What this file contains:** environment variables, systemd service files, deployment scripts, nginx/Tor configuration, Docker support, and installation procedures.
- **See also:** [01_project_overview.md](01_project_overview.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [05_api_reference.md](05_api_reference.md), [08_security_audit.md](08_security_audit.md)
---
## Environment Variables
### `bal-server` (`bal-server.env`)
The `bal-server.env` file is a production environment file that sets the configuration for the `bal-server` binary. The `bal-server.sh` script sources it before executing `cargo run --bin=bal-server`.
### `bal-server` (all prefixed `BAL_SERVER_`)
```env
RUST_LOG=info
BAL_DB_FILE=/var/bal/bal.db
BAL_BIND_ADDRESS=0.0.0.0:3031
BAL_EXPOSE_STATS=true
BAL_REGTEST_XPUB=tpub... (example for regtest testing)
BAL_PUB_KEY_PATH=public_key.pem
#### Core Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
| `BAL_SERVER_BIND_ADDRESS` | `"127.0.0.1"` | TCP address to bind to (**never use `0.0.0.0` in production**) |
| `BAL_SERVER_BIND_PORT` | `9137` | TCP port to listen on |
| `BAL_SERVER_EXPOSE_STATS` | `false` | Enable/disable the `GET /:network/stats` endpoint |
| `BAL_SERVER_PUB_KEY_PATH` | `"public_key.pem"` | Path to the Ed25519 public key PEM file |
| `BAL_SERVER_INFO` | `"Will Executor Server"` | String returned by `GET /` |
#### Per-Network Settings
For each network (`regtest`, `testnet`, `testnet4`, `signet`, `bitcoin`):
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_{NETWORK}_ADDRESS` | (empty) | The xpub/zpub/ypub or fixed address for fee collection |
| `BAL_SERVER_{NETWORK}_FIXED_FEE` | `50000` | Minimum fee in satoshis required for transaction acceptance |
Example: `BAL_SERVER_REGTEST_ADDRESS=tpub...`, `BAL_SERVER_BITCOIN_FIXED_FEE=50000`.
#### Actix-Web Tuning
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_ACTIX_MAX_BODY_SIZE` | `1048576` (1 MiB) | Maximum HTTP request body size |
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | `5` | Request timeout in seconds |
| `BAL_SERVER_ACTIX_WORKERS` | `4` | Number of actix-web worker threads |
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | `100` | Maximum concurrent connections |
| `BAL_SERVER_TRUSTED_PROXY` | `127.0.0.1` | Trusted reverse proxy IP for rate-limiting client identification |
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: push txs requests per second |
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: push txs burst size |
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | `5` | Rate limit: searchtx requests per second |
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | `10` | Rate limit: searchtx burst size |
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info/stats requests per second |
| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info/stats burst size |
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second (`/`, `/.pub_key.pem`, `/version`) |
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | `100` | Rate limit: default burst size |
Each endpoint class has its own independent token bucket, keyed by the real
client IP (see `BAL_SERVER_TRUSTED_PROXY`). `BAL_SERVER_ACTIX_TIMEOUT_SECS` is
enforced by a request-timeout middleware that returns `408 Request Timeout` if
the handler (including request body reading) exceeds the limit.
### `bal-pusher` (prefixed `BAL_PUSHER_`)
#### Core Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
| `BAL_PUSHER_BITCOIN_DIR` | `""` | Bitcoin data directory (for cookie file path resolution) |
| `BAL_PUSHER_SEND_STATS` | `false` | Enable/disable remote stats reporting |
| `BAL_SERVER_URL` | `"http://127.0.0.1:9133"` | URL of the bal-server for internal communication |
| `SSL_KEY_PATH` | `"privkey.pem"` | Path to Ed25519 private key for signing stats |
| `BAL_PUSHER_PREFER_IPV6` | `false` | Pin HTTP connection to first IPv6 address (for broken IPv4 routes) |
| `WELIST_SERVER_URL` | `"https://welist.bitcoin-after.life"` | URL to POST signed stats to (validated against SSRF) |
| `WELIST_SKIP_URL_VALIDATION` | `false` | Bypass SSRF URL validation (for testing only) |
| `WELIST_PING_SIG_VERSION` | `1` | WeTime ping signature scheme: `1` (legacy) or `2` (canonical JSON) |
| `WELIST_PING_DELEGATION_FILE` | unset | Onion only, v2 only: JSON delegation certificate issued by the onion identity key |
#### Per-Network Settings
For each network (`regtest`, `testnet`, `testnet4`, `signet`, `bitcoin`):
| Variable | Default (regtest) | Description |
|----------|-------------------|-------------|
| `BAL_PUSHER_{NETWORK}_HOST` | `"127.0.0.1"` | Bitcoin Core RPC host |
| `BAL_PUSHER_{NETWORK}_PORT` | `18443` | Bitcoin Core RPC port |
| `BAL_PUSHER_{NETWORK}_DIR_PATH` | `".bitcoin"` | Relative directory under `$HOME` for cookie file |
| `BAL_PUSHER_{NETWORK}_DB_FIELD` | (empty) | Database field name for this network |
| `BAL_PUSHER_{NETWORK}_COOKIE_FILE` | (empty) | Absolute path to cookie file (overrides `DIR_PATH`) |
| `BAL_PUSHER_{NETWORK}_RPC_USER` | (empty) | RPC username (if using user/pass auth) |
| `BAL_PUSHER_{NETWORK}_RPC_PASSWORD` | (empty) | RPC password (if using user/pass auth) |
| `BAL_PUSHER_{NETWORK}_ZMQ_HASHBLOCK` | `"tcp://127.0.0.1:21332"` | ZMQ hashblock endpoint |
Default ports per network:
| Network | RPC Port | ZMQ Port |
|---------|----------|----------|
| bitcoin | 8332 | 28332 |
| regtest | 18443 | 21332 |
| testnet | 18332 | 23332 |
| testnet4 | 48332 | 24332 |
| signet | 18332 | 22332 |
---
## Docker
The project provides two Dockerfiles:
### `Dockerfile.release` — Download pre-built release (recommended for production)
Downloads the latest release from the Gitea server. No Rust toolchain needed. Fast builds.
```bash
# Latest release
docker build -f Dockerfile.release -t bal-server .
# Specific version
docker build -f Dockerfile.release --build-arg BAL_VERSION=v0.3.2 -t bal-server:0.3.2 .
```
- `RUST_LOG`: Log level (e.g., `info`, `debug`, `error`). The `env_logger` crate uses this.
- `BAL_DB_FILE`: Path to the `sqlite` database file. If not specified, it defaults to `bal.db` in the working directory.
- `BAL_BIND_ADDRESS`: The TCP address and port to listen on. For example, `0.0.0.0:3031` means it will listen on any interface, port `3031`. For local development, you may want `127.0.0.1:3031`.
- `BAL_EXPOSE_STATS`: Boolean flag (`true` or `false`) to enable the `GET /:network/stats` endpoint. Set to `false` if you do not want to expose statistics to the public internet.
- `BAL_NETWORK_XPUB`: The `XPUB` or `ZPUB` for each network. For example, `BAL_REGTEST_XPUB`, `BAL_BITCOIN_XPUB`, etc. These are used to derive the receiving and fee collection addresses.
- `BAL_PUB_KEY_PATH`: The file path to the `public_key.pem` file that is served via the `GET /.pub_key.pem` endpoint. This is used for signature verification by the `welist` server or other clients.
### `bal-pusher` (`bal-pusher.env`)
The `bal-pusher.env` file is used for the `bal-pusher` binary. It contains sensitive information and is sourced by the `bal-pusher.sh` script.
- Fetches `.tar.gz` from `https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server/releases/latest`.
- Verifies SHA-256 checksum if available.
- Single-stage image (`debian:bookworm-slim`), minimal size.
- `BAL_VERSION` build arg: set to a tag (e.g., `v0.3.2`) to pin a specific release.
```env
ZMQ_ENDPOINT=tcp://127.0.0.1:21332
BAL_SERVER_URL=http://127.0.0.1:3031
BAL_PUSHER_RPC_URL=http://127.0.0.1:18443
BAL_PUSHER_RPC_COOKIE_PATH=/home/bal/.bitcoin/.cookie
BAL_SSL_KEY_PATH=private_key.pem
SEND_STATS=true
WELIST_URL=https://welist.example.com/api/stats
### `Dockerfile` — Build from source
Multi-stage build with the Rust toolchain. Use for development or custom builds.
- **Builder stage:** `rust:1.95-bookworm` with full build. Each binary is compiled with only its required features (`--no-default-features --features server` / `--features pusher`).
- **Runtime stage:** `debian:bookworm-slim` with minimal runtime.
- **User:** Non-root `bal` user (uid 1000).
- **PID 1:** `tini` for proper signal handling.
- **Healthcheck:** `curl -f http://localhost:9137/ || exit 1`.
### Run (both Dockerfiles)
```bash
docker run -d \
--name bal-server \
-v /var/bal:/var/bal \
--env-file bal-server.env \
-p 127.0.0.1:9137:9137 \
bal-server
```
- `ZMQ_ENDPOINT`: The ZMQ endpoint for the `hashblock` or `rawblock` topic. For `regtest`, use `tcp://127.0.0.1:21332`. For mainnet, use `tcp://127.0.0.1:28332`.
- `BAL_SERVER_URL`: The URL of the `bal-server` that the pusher can use to query statistics or for other internal communication.
- `BAL_PUSHER_RPC_URL`: The URL for the Bitcoin Core JSON-RPC endpoint. For `regtest`, the default is `http://127.0.0.1:18443`.
- `BAL_PUSHER_RPC_COOKIE_PATH`: The path to the `.cookie` file for RPC authentication. If not set, the pusher must use `user_pass` authentication. The cookie file is created by `bitcoind` when it starts with `rpccookieauth`.
- `BAL_SSL_KEY_PATH`: The path to the Ed25519 private key (`private_key.pem`) used to sign the statistics payload before sending it to the `welist` server. This is a critical secret.
- `SEND_STATS`: A boolean flag to enable the reporting of statistics to the remote `welist` server.
- `WELIST_URL`: The URL to which the statistics are sent. If `SEND_STATS` is `true`, this URL must be reachable. If the server is unreachable, the pusher will log an error but might not crash (see `08_security_audit.md` for DoS analysis).
- `BAL_PUSHER_PREFER_IPV6`: Optional boolean flag (default `false`). When set to `true`, the pusher resolves the `welist` host itself and pins the HTTP connection to its first IPv6 (AAAA) address, still using the hostname for the `Host` header and TLS SNI. This works around networks where the IPv4 route to the `welist` host is broken while IPv6 works — the default connector may otherwise pick the unreachable family and the request would stall. Leave unset unless you hit this specific connectivity problem.
---
## System Services
### `bal-server.service` (Systemd Unit)
This file is the systemd unit for the `bal-server` binary. It runs the server as a dedicated `bal` user with hardening options.
```ini
[Unit]
@@ -74,12 +167,10 @@ MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user
```
- **User:** The service runs as a dedicated, non-privileged user (`bal` user) to ensure the server doesn't run as root.
- **Hardening:** `ProtectSystem=full` prevents writing to most of the filesystem. `NoNewPrivileges=true` prevents privilege escalation. `MemoryDenyWriteExecute=true` prevents executable memory allocations (W^X). `PrivateDevices=true` limits the exposure to the physical hardware.
- **Security:** The `bal-server` does not need root access, and the database should be in a directory owned by the `bal` user.
- Runs as a dedicated non-privileged `bal` user.
- Hardened with `ProtectSystem=full`, `NoNewPrivileges=true`, `PrivateDevices=true`, `MemoryDenyWriteExecute=true`.
### `bitcoind.service` (Systemd Unit for Mainnet)
The `bitcoind.service` file is the systemd unit to run the Bitcoin Core daemon. It must be configured with the appropriate ZMQ and RPC flags. For example, `bitcoind` must be started with `zmqpubhashblock=tcp://127.0.0.1:28332` to send `new block` notifications to the pusher.
### `bitcoind.service` (Bitcoin Core Daemon)
```ini
[Unit]
@@ -94,147 +185,140 @@ RestartSec=30
[Install]
WantedBy=multi-user
```
- **Note:** The full `bitcoind` configuration is in `bitcoin.conf` (or the `contrib/download_and_install_bitcoincore.sh` script). The script sets `zmqpubhashblock` (not `zmqpubrawblock`) for the pusher's new-block notifications. The `zmqpubhashblock` and `zmqpubrawtx` ports must be bound to `127.0.0.1` (never `0.0.0.0`) and match the pusher's `ZMQ_ENDPOINT`.
- Must be started with `zmqpubhashblock` (not `zmqpubrawblock`).
- ZMQ ports must be bound to `127.0.0.1` only.
### `tbitcoind.service` (Systemd Unit for Testnet)
This is the same as `bitcoind.service` but for the `testnet` network. It uses a different data directory (`~/.bitcoin/testnet/` by default) and a different ZMQ port (e.g., `tcp://127.0.0.1:23332`).
### `tbitcoind.service` (Testnet Bitcoind)
Same as `bitcoind.service` but for testnet with a different data directory and ZMQ port (e.g., `tcp://127.0.0.1:23332`).
---
## Bash Scripts
### `bal-server.sh` (Development Server Startup)
This script sources the `bal-server.env` file and then runs the development server with Cargo for easy development and reloading.
Sources `bal-server.env` and runs the development server:
```bash
export $(grep -v '^#' bal-server.env | xargs)
RUST_LOG=info cargo run --bin=bal-server 2>&1
```
- It is intended for development use only. It is not suitable for production because it compiles and runs in a single step, which is slow and insecure.
### `pg-server.sh` (Local PostgreSQL)
Starts the `bal-pg` Docker container used when `BAL_SERVER_DB_BACKEND=postgresql`.
- Requires `BAL_PG_PASSWORD` (loaded from `.env` by the startup scripts).
- Publishes port `5432` on `127.0.0.1` only by default. Set
`BAL_PG_BIND_ADDR=0.0.0.0` only if remote access is really required; the
script prints a warning when the database is not bound to loopback.
- Stores data in the Docker volume `BAL_PG_VOLUME` (default `bal-pg-data`), so
the container can be recreated without losing data.
- Fails if the existing container publishes the port on a different interface.
Run `./pg-server.sh --recreate` to recreate it; the data volume is preserved
(and migrated to `BAL_PG_VOLUME` if it changed).
### `bal-pusher.sh` (Development Pusher Startup)
This script sources the `bal-pusher.env` and runs the pusher in development mode. It also accepts the `network` name as an argument (e.g., `sh bal-pusher.sh regtest`).
Sources `bal-pusher.env` and runs the pusher with a network argument:
```bash
export $(grep -v '^#' bal-pusher.env | xargs)
RUST_LOG=info cargo run --bin=bal-pusher $1
```
### `sendtx.sh` (One-liner Transaction Sender)
This script is a one-liner helper that sends a raw transaction to a local node using a sequence of `bitcoin-cli` calls. It is not part of the main system but is used for testing purposes.
```bash
bitcoin-cli -regtest gettransaction ... | bitcoin-cli -regtest sendrawtransaction ... | bitcoin-cli -regtest sendtoaddress ...
```
- It is a helper script that wraps `bitcoin-cli` to send a pre-created transaction, get the raw bytes, and send them to a new address. It is only useful for manual testing and integration checks.
### `sendtx.sh` (Test Transaction Sender)
A helper script that wraps `bitcoin-cli` for manual testing.
### `make_release.sh` (Release Builder)
This script builds a release binary, creates a Git tag, and uploads the release to a Git server (Gitea). It also hardcodes a Gitea API token (`TOKEN="***REMOVED***"`), which is a major security risk.
```bash
# WARNING: This script contains a hardcoded secret token. Do not use it as-is for production.
```
- **Release Assets:** It generates a `.tar.gz` archive with the binaries, a `.sha256` checksum file, and both a `.sig` GPG detached binary signature and a `.asc` ASCII-armored version.
- **Signature:** The release tarball is signed with the GPG key `Svātantrya <svatantrya@bitcoin-after.life>`. The script verifies that `gpg`, `sha256sum`, and `jq` are installed before proceeding.
- **Verification:** The release body includes instructions for verifying the checksum and signature (binary or ASCII-armored):
```bash
sha256sum -c <release>.tar.gz.sha256
gpg --verify <release>.tar.gz.sig <release>.tar.gz
gpg --verify <release>.tar.gz.asc <release>.tar.gz
```
- **Security:** It also builds and uploads the binaries. The binaries should be built and signed on a separate, clean build machine, not on the production server.
Builds release binaries, creates Git tags, and uploads to Gitea. Signs the release tarball with GPG. Release assets include `.tar.gz`, `.sha256`, `.sig`, and `.asc` files. Token is loaded from `.env` (not hardcoded).
### `download_bal_db.sh` (Database Pull Script)
This script uses `scp` to pull the production `bal.db` from a remote server (`debian@bitcoin-after.life`). It requires passwordless or key-based SSH access to the remote server.
```bash
scp debian@bitcoin-after.life:/var/bal/bal.db ./bal.db
```
- **Security:** It requires the remote server to be accessible. The remote server's IP address is hardcoded. This is a maintenance script, not part of the core system.
Uses `scp` to pull the production `bal.db` from a remote server.
---
## Nginx and SSL Configuration
The `bal-server` is a plain HTTP server. To expose it to the internet, a production environment should put a reverse proxy like `Nginx` in front of it. The `nginx` configuration (from `contrib/download_and_install_bal.sh`) is used to terminate TLS and provide SSL certificates. Nginx also handles rate limiting, request filtering, and static file serving for `public_key.pem`.
The `bal-server` is a plain HTTP server. A reverse proxy (Nginx) with TLS termination is required for production.
### Example Nginx Configuration (from `contrib`)
### Template: `contrib/nginx/bal-server.conf`
```nginx
server {
listen 80;
server_name bal.example.com;
server_name BAL_DOMAIN;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name bal.example.com;
ssl_certificate /etc/letsencrypt/live/bal.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bal.example.com/privkey.pem;
server_name BAL_DOMAIN;
ssl_certificate /etc/letsencrypt/live/BAL_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/BAL_DOMAIN/privkey.pem;
client_max_body_size 1m;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy no-referrer;
location / {
proxy_pass http://127.0.0.1:3031;
proxy_pass http://127.0.0.1:9137;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Rate limiting can be added here
# Uncomment for rate limiting:
# limit_req zone=pal limit=10 nodelay;
}
```
- **Certbot:** The `contrib` script installs `certbot` and automatically generates the certificate. This configuration is used to ensure the `bal-server` is served over HTTPS with valid TLS.
- **Rate limiting:** It is recommended to add `limit_req` or `limit_conn` to the Nginx configuration to prevent the server from being overwhelmed by too many concurrent requests (e.g., `pushtxs` spam, or DoS attacks). The `bal-server` has no built-in rate limiting on the HTTP level.
Key points:
- `client_max_body_size` must match `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- `certbot --nginx` obtains the certificate automatically.
- Security headers: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
---
## Production Deployment Checklist
Before exposing `bal` to the internet, verify the following steps. The `bal-server` is a plain HTTP application and must **never** be bound directly to a public IP or `0.0.0.0`.
### 1. `bal-server` Bind Address
- [ ] `bal-server.env` (or `.env`) sets `BAL_SERVER_BIND_ADDRESS=127.0.0.1` (not `0.0.0.0`).
- [ ] `BAL_SERVER_BIND_PORT` is the port used by Nginx `proxy_pass` (default `9137`).
- [ ] Firewall blocks inbound connections to `BAL_SERVER_BIND_PORT` from external interfaces (e.g., `iptables -A INPUT -p tcp --dport 9137 -s 127.0.0.1 -j ACCEPT` and `DROP` for others).
- [ ] `BAL_SERVER_BIND_ADDRESS=127.0.0.1` (never `0.0.0.0`).
- [ ] `BAL_SERVER_BIND_PORT` matches Nginx `proxy_pass` (default `9137`).
- [ ] Firewall blocks inbound connections to `BAL_SERVER_BIND_PORT` from external interfaces.
### 2. Reverse Proxy (Nginx + TLS)
- [ ] Nginx is installed (`contrib/download_and_install_bal.sh` handles this).
- [ ] The template `contrib/nginx/bal-server.conf` is copied to `/etc/nginx/sites-available/` and symlinked to `sites-enabled` (the `contrib/download_and_install_bal.sh` script does this automatically).
- [ ] The file has a real domain name replacing `BAL_DOMAIN`.
- [ ] `listen 443 ssl http2;` is active.
- [ ] `certbot --nginx` has obtained a valid certificate (the script runs `certbot --nginx` which avoids the port 80 conflict of `--standalone`). For manual installs, use `sudo certbot --nginx -d $domain`.
- [ ] `proxy_pass` points to `http://127.0.0.1:9137` (or whatever `BAL_SERVER_BIND_PORT` is).
- [ ] `client_max_body_size` in Nginx matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default `1m`).
- [ ] HTTP port 80 redirects to HTTPS (`return 301 https://...`).
- [ ] Nginx `limit_req` zone is configured if desired (backup to `actix-governor`).
- [ ] Nginx installed (`contrib/download_and_install_bal.sh` handles this).
- [ ] `contrib/nginx/bal-server.conf` template copied to `/etc/nginx/sites-available/`.
- [ ] Real domain name replacing `BAL_DOMAIN`.
- [ ] `listen 443 ssl http2` active.
- [ ] `certbot --nginx` has obtained a valid certificate.
- [ ] `proxy_pass` points to `http://127.0.0.1:9137`.
- [ ] `client_max_body_size` matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- [ ] HTTP port 80 redirects to HTTPS.
- [ ] Security headers configured.
### 3. Database and Secrets
- [ ] Database file is owned by the `bal` user (`chown bal:bal /var/bal/bal.db`).
- [ ] Database file permissions are `600` (`chmod 600 /var/bal/bal.db`).
- [ ] `.env` file is in `.gitignore` and not committed.
- [ ] `private_key.pem` and `privkey.pem` are not in the repository (use `git ls-files` to verify).
- [ ] `public_key.pem` is readable by Nginx if served directly (otherwise let the actix endpoint handle it).
- [ ] Database file owned by `bal` user (`chown bal:bal /var/bal/bal.db`).
- [ ] Database file permissions `600` (`chmod 600 /var/bal/bal.db`).
- [ ] `.env` files in `.gitignore` and not committed.
- [ ] PostgreSQL (if used) published on `127.0.0.1` only (`BAL_PG_BIND_ADDR`).
- [ ] `private_key.pem` / `privkey.pem` not in the repository.
- [ ] `public_key.pem` readable by Nginx if served directly.
### 4. Pusher and ZMQ
- [ ] ZMQ endpoints are configured for `127.0.0.1` only (e.g., `tcp://127.0.0.1:28332`).
- [ ] `BAL_PUSHER_SEND_STATS` is set to `false` unless the `welist` endpoint is actually needed.
- [ ] If stats are enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain (not IP, not local).
- [ ] Firewall blocks inbound TCP port `28332` (or your custom `bitcoin`, `regtest`, etc. ZMQ ports) from external interfaces.
- [ ] ZMQ endpoints configured for `127.0.0.1` only.
- [ ] `BAL_PUSHER_SEND_STATS=false` unless `welist` endpoint is needed.
- [ ] If stats enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain.
- [ ] Firewall blocks inbound ZMQ ports from external interfaces.
### 5. Logging and Monitoring
- [ ] `RUST_LOG` is set to `info` or `warn` in production (not `debug` or `trace`).
- [ ] Log files are rotated (e.g., via `logrotate`) and stored only under `/var/log/bal/` or systemd journal.
- [ ] Log files are not in the same directory as the database or the private key.
- [ ] `RUST_LOG=info` or `warn` in production (not `debug`/`trace`).
- [ ] Log files rotated and stored under `/var/log/bal/` or systemd journal.
- [ ] Log files not in the same directory as the database or private key.
---
## Tor and Privacy
The `contrib/install_tor.sh` script installs Tor for use as an onion-routed proxy. It can be used to:
1. Allow the `bal-server` to be reachable via a `.onion` address for privacy and censorship resistance.
2. Allow the `bal-pusher` to connect to the Bitcoin RPC or the `welist` server through Tor to hide its origin IP.
3. Allow the server to run behind NAT without exposing the real IP to the public internet.
The `contrib/install_tor.sh` script installs Tor for onion-routed proxy use:
1. `bal-server` can be reachable via a `.onion` address.
2. `bal-pusher` can connect to Bitcoin RPC or `welist` through Tor.
3. The server can run behind NAT without exposing the real IP.
The script uses `ControlPort 9051` and enables `CookieAuthentication`. If `SEND_STATS` is true, the `welist` URL can be configured to be a `.onion` address to hide the origin. For example, the `bal-pusher` could use `reqwest` with SOCKS5 proxy settings to connect to the `welist` server via Tor.
- `reqwest` feature `socks` (enabled in `Cargo.toml`) supports proxy settings.
- For a production privacy setup, it is recommended to run the server and the pusher behind a Tor or VPN proxy.
- **Security:** The Tor service itself (`tor.service`) should be hardened and run as a separate user. The `ControlPort` `9051` should be bound to `127.0.0.1` and should not be exposed to the public without authentication.
---
The script uses `ControlPort 9051` with `CookieAuthentication`. The `bal-pusher` supports SOCKS5 proxy via the `reqwest` `socks` feature for `.onion` connectivity.

View File

@@ -9,221 +9,164 @@
## Threat Model
### Assets
1. **`bal.db` (SQLite database):** Contains all transaction details, including private transaction data, user IP addresses, and the `welist` stats payload. The file is a single, unencrypted file on disk. If the database is exfiltrated, the attacker will have knowledge of the transaction history and user activity.
2. **Private Keys (`private_key.pem`, `privkey.pem`, `ec.key`, `chiave_privata.key`):** The `private_key.pem` is used to sign the statistics payload for the `welist` server. An attacker with access to this key can impersonate the server and send fake statistics or modify the remote database.
3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access to the `bitcoind` node. If an attacker can compromise the pusher, they can send arbitrary transactions to the network, potentially misappropriating funds or DoS-ing the node.
4. **Server Availability (`bal-server`):** The server is a public-facing HTTP endpoint. If it is down, users cannot submit transactions. Denail of service (DoS) attacks could be a direct threat to the service's availability.
1. **`bal.db` (SQLite database):** Contains all transaction details, user IP addresses, and stats data. Single unencrypted file on disk. WAL mode enabled for concurrent access safety.
2. **Private Keys (`privkey.pem`):** Used to sign statistics payloads for the `welist` server. Located in `.gitignore`.
3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access. Compromise allows arbitrary transaction broadcasting.
4. **Server Availability (`bal-server`):** Public-facing HTTP endpoint. DoS attacks threaten service availability.
5. **WeTime ping keys:** `SSL_KEY_PATH` signs stats/pings. Onion delegation adds a dedicated, rotatable online key; the onion identity key stays offline and is used only to issue delegation certificates.
### Attackers
- **Remote Anonymous Users:** Can interact with the `bal-server` API via the public HTTP interface. They do not have credentials or special access. They can send valid or invalid transactions.
- **Network Man-in-the-Middle (MITM):** The HTTP server does not have TLS by default (see `07_deployment_and_ops.md`). If Nginx is not configured with a valid SSL certificate, an attacker can intercept the traffic.
- **Local/Insider Threats:** If the server is compromised (e.g., via a vulnerable `bitcoind` or a remote exploit), the attacker can read the `bal.db` file, the private key, and the `env` files. The database file contains all transaction data, which is a serious privacy risk.
- **Remote Anonymous Users:** Can interact with the API via public HTTP. No credentials required.
- **Network Man-in-the-Middle (MITM):** TLS termination is via Nginx reverse proxy. The `bal-server` itself is plain HTTP.
- **Local/Insider Threats:** If the server is compromised, the attacker can access `bal.db`, private keys, and env files.
---
## Vulnerability Assessment
### 1. SQL Injection (HIGH)
**Location:** `src/bin/bal-server.rs` (e.g., `echo_stats`, `echo_push` handlers), `src/db.rs`.
**Description:** SQL queries are built using `format!("... WHERE txid in ('{}')", ...")` in `db.rs`. The `txid` strings are derived from the raw HTTP request body. While the `txid` is usually a hash of 32 bytes, the database code does not validate or enforce this. This is a potential SQL injection vector if an attacker can bypass the transaction hash check or if the `txid` string is used directly from the request body without proper escaping or parameterized queries.
**Impact:** An attacker could potentially read, modify, or delete any database record.
**Mitigation:** Replace all string-formatted SQL with prepared statements using parameterized queries (`?`) for every user-input value. See `src/db.rs` for the `execute_insert` function, which already uses parameterized queries but is not universally applied.
**Status:** Fixed (Vulnerability 1 & 2 in `bal-pusher.rs` patched in commit).
**Reproduction:** Send a malicious `searchtx` request with a crafted `txid` containing SQL characters (e.g., `' OR '1'='1`). The database will not crash because the query is malformed, but it might be exploitable if the `txid` format is not strictly enforced. See `valid_txs` and `invalid_txs` log files for examples of valid and invalid txids.
**Fix Applied:** Vulnerabilities 1 and 2 (UPDATE `txid IN` and UPDATE `push_err` in `bal-pusher.rs`) were rewritten to use parameterized queries (`?` with `bind()`). Regression tests added in `tests/sql_injection_tests.rs`.
### 1. SQL Injection (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `src/db.rs`, `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Description:** SQL queries previously used `format!()` for string interpolation. All queries now use parameterized statements (`?` with `bind()`).
**Mitigation Applied:** All SQL queries rewritten with prepared statements. `execute_insert` uses parameterized batch inserts. `check_duplicate_txids` uses parameterized `IN` clauses. `echo_stats` handler uses prepared statements for chain filtering.
**Regression Tests:** `tests/sql_injection_tests.rs` (3 tests).
### 2. Panic on Untrusted Input (HIGH)
**Location:** `src/bin/bal-server.rs` (e.g., `req.collect().unwrap()`, `Regex::new(...).unwrap()`) and `src/bin/bal-pusher.rs` (e.g., `panic!("impossible to get client {}", e)`).
**Description:** The `bal-server` uses `unwrap()` and `expect()` on many critical paths. A malformed HTTP request (e.g., an oversized body, invalid JSON, or an invalid `network` string) can cause a panic in the async runtime. This could crash the entire server process or at least one async worker. The `Regex::new` is also `unwrap`ed, making the entire server crash if the regex is not valid at startup.
- The `bal-pusher` panics on RPC connection failures (`main_result` -> `get_client`). If the Bitcoin node is temporarily down, the entire pusher process will crash. This is a serious DoS vector because it will stop the service from broadcasting transactions if the network is unstable.
**Impact:** A single malformed request can crash the entire server or the pusher daemon, leading to a full Denial of Service (DoS).
**Mitigation:**
- Replace all `unwrap()` and `expect()` with `match` or `Result` propagation in the server request handlers. Use `?` to bubble errors up, or return `400 Bad Request` / `500 Internal Server Error` with a safe error message.
- In the `bal-pusher`, do not `panic!` on RPC connection failures. Instead, use `eprintln!` or `log::error!` and sleep for a retry interval. The ZMQ connection should be monitored independently, not tied to the pusher's lifetime.
- In the `bal-pusher`, ensure ZMQ `recv` has a timeout (e.g., `RCVTIMEO`). If the ZMQ socket is blocked, the thread will not be killed, and it will consume resources indefinitely. This is a resource leak / DoS vector.
**Status:** Fixed (Fase 1 + Fase 2 applied). All critical panic vectors in `bal-server.rs` and `bal-pusher.rs` have been replaced with safe `match`/`if let` error propagation. `unwrap`/`expect` replaced with:
- `from_utf8` → `match` + `return Ok(400)`
- `sqlite::open` per richiesta → `Arc<Mutex<Connection>>` condiviso
- `panic!` su RPC → `error!` + sleep + retry
- `recv_multipart` → `set_rcvtimeo(5000)` + `match`
- `connect`/`subscribe` → retry loop con `match`/`return`
- `fs::read_to_string().expect()` → `match` + `500 Internal Server Error`
- `timestamp_nanos_opt().unwrap()` → `match` + `return Ok(400)`
- `idx/amount.try_into().unwrap()` → `i64::try_from(...).unwrap_or(0/-1)`
- `cfg.lock().unwrap()` → `match` + `poisoned.into_inner()` recovery
- `stmt.read().unwrap()`/`bind().unwrap()` in `db.rs` → `match`/`if let` + log error
### 2. Panic on Untrusted Input (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Description:** All `unwrap()`/`expect()` calls on critical paths have been replaced with safe error handling.
**Mitigation Applied:**
- `bal-server`: `from_utf8` returns 400, `sqlite::open` uses `open_db()` with validation, all handlers return proper HTTP status codes.
- `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, malformed ZMQ frames are handled with `get()`/`first()` and `from_utf8_lossy` (no indexing or UTF-8 `expect`), stats signing returns an error that is logged and skipped rather than `expect`-panicking.
**Regression Tests:** `tests/panic_regression_tests.rs` (2 tests).
Regression tests: `tests/panic_regression_tests.rs` (2 tests).
### 3. Secret Leakage (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `make_release.sh`, `.gitignore`
**Description:** `make_release.sh` now loads `TOKEN` from `.env` (`.env.example` provided). Private keys (`.pem`, `.key`) are in `.gitignore`. `generate_keys.sh` sets `chmod 600` on generated keys.
**Mitigation Applied:** Secrets removed from scripts and repository. `.gitignore` protects `.env`, `*.pem`, `*.key` files.
**Regression Tests:** `tests/secret_leakage_tests.rs` (3 tests).
### 3. Secret Leakage (HIGH)
**Location:** `make_release.sh`, `contrib/download_and_install_bal.sh`, `private_key.pem`, `privkey.pem`, `ec.key`, `chiave_privata.key`.
**Description:**
- The `make_release.sh` script contains a hardcoded Gitea API token: `TOKEN="***REMOVED***"`. If the script is accidentally pushed to a public repository, it will be visible to everyone.
- The `contrib/download_and_install_bal.sh` script contains a hardcoded `xpub` address and a fixed fee. This is less critical but could be used for fingerprinting.
- The `private_key.pem` and `chiave_privata.key` files are stored in the project root (and in the repository). If the repository is public, the private key is compromised. An attacker could use this to sign fake statistics or forge authentication credentials.
**Impact:** An attacker could gain unauthorized access to the CI/CD pipeline, the release server, or the `welist` statistics service.
**Mitigation:**
- Remove `private_key.pem` from the repository and add it to `.gitsecret` or `.gitignore`. Use a secret manager or a password store for the `private_key.pem`.
- Remove hardcoded secrets from the scripts. The `TOKEN` and `xpub` should be environment variables or configuration files injected via the build process.
- Use `git-crypt` or `git-secret` to encrypt the private key files before committing.
**Status:** Fixed. `make_release.sh` now loads `TOKEN` from `.env` (`.env.example` provided). `contrib/download_and_install_bal.sh` no longer hardcodes `xpub`/`fixed_fee`. Private keys moved to `.gitignore` (`*.pem`, `*.key`). `generate_keys.sh` sets `chmod 600` on generated keys. Regression tests: `tests/secret_leakage_tests.rs` (3 tests). **Priority:** High. (Mitigation applied)
### 4. Denial of Service (DoS) (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `src/bin/bal-server.rs` (HTTP), `src/bin/bal-pusher.rs` (ZMQ)
**Description:** All DoS vectors mitigated via actix-web migration.
**Mitigation Applied:**
- Body size limit: `PayloadConfig::default().limit(max_body_size)` via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB).
- Rate limiting: `actix-governor` with a token bucket per client IP, with an **independent bucket for each endpoint class** (push txs, searchtx, info/stats, default) so that polling a cheap endpoint cannot exhaust the push-txs budget. Uses `RealIpKeyExtractor` to extract the real client IP from proxy headers.
- Connection limits: `workers(4)` and `max_connections(100)` via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`.
- Request timeout: `middleware::from_fn` wraps every handler in `tokio::time::timeout`, returning `408 Request Timeout` when `BAL_SERVER_ACTIX_TIMEOUT_SECS` (default 5) elapses, covering slow request bodies.
- ZMQ timeout: `set_rcvtimeo(5000)` prevents infinite blocking.
- RPC retry: sleep + retry on connection failure instead of panic.
- ZMQ message handling is panic-free: multipart frames are accessed with `get()`/`first()` and topics are decoded with `from_utf8_lossy`, so malformed frames cannot abort the pusher.
- Stats-report signing failures are logged and skipped instead of panicking the pusher when the key is unreadable.
### 4. Denial of Service (DoS) (HIGH)
**Location:** `src/bin/bal-server.rs` (HTTP request body), `src/bin/bal-pusher.rs` (ZMQ).
**Description:**
- The `bal-server` does not limit the size of the HTTP request body. On the `POST /pushtxs` endpoint, it calls `req.collect().await?.to_bytes()` without checking for a maximum body size. A malicious client could send an unbounded or extremely large request (e.g., `100000MB`), which would consume all available memory and crash the server.
- The `bal-server` regex for path matching might be expensive if the user provides a malicious path string. For a production system, the regex should be compiled only once at startup and should be very specific.
- The `bal-pusher` `recv` call is synchronous and blocking. If the ZMQ connection fails, the thread will hang without any timeout. This is a resource leak if the connection is broken. The ZMQ socket is not reconfigured with `ZMQ_RECONNECT_IVL` or `ZMQ_MAXMSGSIZE`. If the Bitcoin Core node is not sending, the pusher will be stuck waiting forever, consuming a thread and not doing other useful work.
- The `bal-pusher` does not have a rate limiter for the `sendrawtransaction` call. If the database is full or the ZMQ loop is running very fast, it could send thousands of RPC requests to the `bicoind` node, overwhelming it. For example, if the node is slow, the pusher will keep sending requests, potentially blocking the RPC queue or causing a memory leak in `bitcoind`.
**Impact:** The server could become unresponsive, crash, or be completely unavailable. The `bitcoind` node could be overwhelmed with `sendrawtransaction` requests, causing a chain failure in the entire Bitcoin infrastructure.
**Reproduction:**
- For the HTTP server: Send an HTTP POST with `Content-Length: 9999999999` to `POST /regtest/pushtxs`. The server will try to allocate that much memory and will be killed by the OOM killer.
- For the ZMQ pusher: Kill the `bitcoind` ZMQ socket. The `bal-pusher` will hang forever. The process cannot be killed gracefully by the systemd `SIGTERM` because the thread is blocked by the ZMQ `recv` call.
**Mitigation:**
- Add a maximum body size check to the HTTP server. Use `hyper`'s built-in `Body` size limiter, or manually check `req.headers().get("content-length")` before `collect().await` and return `413 Payload Too Large` if it exceeds the limit (e.g., `1 MB` for a single transaction, or `10 MB` for a batch).
- Implement request rate limiting on the `bal-server` (e.g., `tower::filter` or a simple `HashMap` of client IP address to request count). Limit the `pushtxs` request to one per second per IP.
- Add a ZMQ socket option for `ZMQ_RCVTIMEO` (e.g., `5000` ms) to avoid blocking forever. The pusher should be able to handle a ZMQ timeout gracefully and retry the connection or reconnect the socket.
- The `bal-pusher` should have a rate limiting mechanism for the `sendrawtransaction` call to the RPC. For example, only allow sending `1` transaction per block, or use a queue and a `semaphore` to limit the number of concurrent RPC calls.
**Status:** Fixed (Migrated to Actix Web). All DoS vectors mitigated via:
- Body size limit: `PayloadConfig::default().limit(max_body_size)` — configurable via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB)
- Rate limiting: `actix-governor` middleware with token-bucket — configurable via `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST` (default 1 req/s per IP with burst 5)
- Connection limits: `workers(4)` and `max_connections(100)` — configurable via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`
- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS` (default 30s)
**Migration:** Server replaced `hyper` custom server with `actix-web` (see `src/bin/bal-server.rs`). All handlers migrated with `Arc<Mutex<Connection>>` shared DB. Old `bal-server.rs` (Hyper) removed. `bal-pusher` enhanced with ZMQ timeout (`ZMQ_RCVTIMEO` 5000ms) and RPC retry logic. **Priority:** High. (Mitigated)
### 5. SSRF / Network Abuse via `reqwest` (FIXED)
**Severity:** MEDIUM | **Status:** Fixed
**Location:** `src/bin/bal-pusher.rs`, `src/validation.rs`
**Description:** URL validation prevents redirecting requests to internal/private IPs.
**Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, IPv6 unique-local addresses, and **IPv4-mapped IPv6 addresses** (`::ffff:a.b.c.d`) that route to loopback/private/link-local/unspecified IPv4 targets. HTTPS-only scheme enforced. Note: hostnames are not DNS-resolved before validation, so a domain that resolves to a private address is not caught by this check — restrict `WELIST_SERVER_URL` to trusted values.
**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 11 unit tests in `src/validation.rs`.
### 5. SSRF / Network Abuse via `reqwest` (MEDIUM)
**Location:** `src/bin/bal-pusher.rs`.
**Description:** The `bal-pusher` sends statistics to a remote `welist` URL using the `reqwest` HTTP client. The `WELIST_URL` is configurable, but the pusher does not validate the URL before sending the HTTP request. An attacker who can modify the `WELIST_URL` (e.g., by modifying the pusher's environment file) can redirect the traffic to any arbitrary URL, including internal services. The `reqwest` client has SOCKS5 enabled (`socks` feature). This could allow an attacker to use the pusher's network to scan internal addresses, send requests to `localhost` or `169.254.169.254` (AWS metadata IP), or access internal infrastructure.
**Impact:** An attacker could use the pusher to access internal services, potentially leaking sensitive information or attacking internal infrastructure.
**Mitigation:**
- ✅ **Implemented:** Added strict URL validation `bal_server::validation::is_valid_wELIST_url` (see `src/validation.rs`). It checks:
- URL must be well-formed and parsable.
- Scheme must be `https://` (plain HTTP is rejected).
- Host must not be `localhost`, `127.0.0.1`, `::1`, or any loopback/private/link-local/multicast/unspecified IP address.
- IPv4 private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and AWS metadata link-local (169.254.169.254) are blocked.
- IPv6 Unique Local (fc00::/7) and link-local (fe80::/10) are blocked.
- IPv6 address brackets are stripped before validation.
- The `bal-pusher` `send_stats_report` now calls `is_valid_wELIST_url` before making the request. If validation fails, the function skips the request with a warning and returns `Ok(())` to avoid panicking.
- The `WELIST_URL` is configurable via `WELIST_SERVER_URL` (defaults to `https://wELIST.bitcoin-after.life`), but invalid URLs are rejected at runtime. If stats are not needed, `send_stats` can be set to `false` to skip the feature entirely.
- SOCKS5 feature is retained for `.onion` support (see `07_deployment_and_ops.md`), but URL validation prevents redirecting to internal IPs.
**Regression tests:** `src/validation.rs` (unit tests) and `tests/ssrf_tests.rs` (integration tests) cover all blocked/allowed IP ranges and schemes. 9 tests + 4 integration tests = all passing.
**Status:** Fixed. **Priority:** Medium.
### 6. Insecure Database Access (FIXED)
**Severity:** MEDIUM | **Status:** Fixed
**Location:** `src/db.rs`, `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Description:** Database path validation and WAL mode for concurrent access.
**Mitigation Applied:**
- `open_db()` rejects `..` traversal, forbidden system directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`), symlinks, and non-regular files.
- WAL mode (`PRAGMA journal_mode=WAL`) with retry logic (up to 5 attempts).
- `busy_timeout=5000` for concurrent access.
- `bal-server` uses `Arc<Mutex<Connection>>` for thread-safe shared access.
**Regression Tests:** `tests/db_path_validation.rs` (5 tests).
### 6. Insecure Database Access (MEDIUM)
**Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`.
**Description:** The `bal-server` opens the `bal.db` file using `sqlite::open(&cfg.db_file).unwrap()`. The path is not validated. If the environment variable `BAL_DB_FILE` is set to a malicious path (e.g., `/etc/passwd`), the server will try to open it as a database. This could cause a crash or a security issue if the database file is on a malicious path. Also, if the database file is on a network drive, the performance will be very slow, and it might cause a timeout.
- The `bal-pusher` and `bal-server` both access the same `bal.db` file. There is no file locking mechanism or `flock` on the database file. If two instances of the `bal-server` start at the same time, they might corrupt the database or cause a deadlock. SQLite handles this automatically, but the `sqlite` crate (Rust) might not be configured with the proper threading mode (`WAL` or `SHARED`).
**Impact:**
- The database file could be placed on a path that causes a file system vulnerability or a crash of the server.
- The database file might be corrupted if multiple processes access it without proper locking.
**Mitigation:**
- ✅ **Path validation:** Added `db::open_db` in `src/db.rs` which validates the database path before opening:
- Rejects paths containing `..` (directory traversal).
- Rejects absolute paths pointing to sensitive directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`).
- Rejects symlinks and non-regular files (directories, devices, etc.).
- If validation fails, the function returns `Err(String)` instead of panicking, preventing crashes or accidental access to system files.
- ✅ **WAL mode:** `db::open_db` automatically executes `PRAGMA journal_mode = WAL;` and `PRAGMA synchronous = NORMAL;` on every connection. This is a best practice for safe concurrent access when `bal-server` and `bal-pusher` share the same database file.
- ✅ **Replaced `unwrap`:** In `src/bin/bal-server.rs` and `src/bin/bal-pusher.rs`, `sqlite::open(...).unwrap()` was replaced with `db::open_db(...)` with safe error handling (return `Err` in the server, `std::process::exit(1)` in the pusher with a log error).
- **Remaining (ops):** Ensure the database file is owned by the `bal` user and not writable by any other user (`chmod 600`). The database file should not reside on a shared or network drive.
**Regression tests:** `tests/db_path_validation.rs` (5 tests covering traversal, forbidden absolute paths, symlink, WAL pragma, and valid relative paths). All passing.
**Status:** Fixed. **Priority:** Medium.
### 7. ZMQ Authentication and Encryption (OPEN)
**Severity:** MEDIUM | **Status:** Open
**Location:** `src/bin/bal-pusher.rs`
**Description:** ZMQ connection is plaintext TCP. No authentication (ZAP), no encryption (ZMQ_CURVE). If the ZMQ port is exposed, any attacker can subscribe to topics.
**Mitigation (Operational):**
- Bind ZMQ to `127.0.0.1` only.
- Firewall blocks external access to ZMQ ports.
- If public ZMQ is required, use ZMQ_CURVE with public-key cryptography.
### 7. ZMQ Authentication and Encryption (MEDIUM)
**Location:** `src/bin/bal-pusher.rs`.
**Description:** The ZMQ connection to the `bitcoind` is a plaintext TCP connection (`zmqpubhashblock=tcp://127.0.0.1:28332`). There is no ZMQ authentication (ZAP), no username/password, and no encryption (ZMQ_CURVE or ZMQ_GSSAPI). If the ZMQ port is accessible from the network (not just `127.0.0.1`), any attacker can subscribe to the `hashblock` or `rawblock` topics. The `rawblock` topic is particularly sensitive because it sends full block data, which is large and could be used to fingerprint the `bal` system. More importantly, the pusher does not verify that the `hashblock` is from the intended `bitcoind` node. If an attacker can inject a fake ZMQ message, they could trigger the pusher to evaluate the transactions and potentially broadcast them at an incorrect time, or cause a DoS.
**Impact:**
- If the ZMQ port is exposed, an attacker can intercept the `rawblock` data to get the full block contents, which could be used to fingerprint the node or the system.
- An attacker can send a fake `hashblock` message to the pusher, causing it to try to evaluate the database. If the pusher is not idempotent, it could cause duplicate or incorrect RPC requests.
**Mitigation:**
- Bind `zmqpubhashblock` and `zmqpubrawblock` to `127.0.0.1` (or `127.0.0.1:28332`) and ensure the firewall blocks external access to the ZMQ port (e.g., port 28332). Use a firewall (e.g., `iptables`, `ufw`) to deny external access to port 28332.
- If the ZMQ port must be on a public interface, use ZMQ_CURVE with public-key cryptography, or ZMQ_GSSAPI with TLS. This is a more advanced solution but provides strong authentication and encryption for the ZMQ channel.
- If not using ZMQ_CURVE, use `zmqpubhashblock` with a firewall that blocks the public port for port 28332.
- The `bal` service should not listen on all interfaces (0.0.0.0) unless necessary. It is better to listen only on `127.0.0.1` if the server is behind a reverse proxy (like Nginx) or if the server is only accessible from the local machine.
**Status:** Open. **Priority:** Medium.
### 8. Missing HTTPS / Insecure Server Communication (FIXED)
**Severity:** HIGH | **Status:** Fixed (Infrastructure)
**Location:** Nginx configuration, `contrib/nginx/bal-server.conf`
**Description:** TLS termination via Nginx reverse proxy. The `bal-server` intentionally does not implement TLS.
**Mitigation Applied:**
- Dedicated Nginx template with `listen 443 ssl http2`, Let's Encrypt paths.
- Security headers: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
- `client_max_body_size` matching `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- HTTP 80 redirect to HTTPS.
- Deployment checklist ensures no accidental plain HTTP exposure.
### 8. Missing HTTPS / Insecure Server Communication (HIGH)
**Location:** `src/bin/bal-server.rs` (TCP server), Nginx configuration.
**Description:** The `bal-server` is a plain HTTP server. It does not have TLS or SSL support. To provide HTTPS, an external reverse proxy like Nginx is recommended. However, if the server is exposed to the internet directly, the entire transaction data will be sent over unencrypted HTTP. This includes the raw transaction details and the user IP, which is a privacy risk. An attacker on the same network as the server or client can intercept the request and see the transaction details or the `welist` data.
**Impact:**
- If the server is directly exposed to the internet, the transaction data is sent in plaintext, making it vulnerable to sniffing and MitM attacks.
- If the reverse proxy is not configured with TLS, the server will be insecure and might be vulnerable to a `HTTP Host Header Injection` or `HTTP Header Injection` attack if the server uses the Host header to determine the routing.
**Mitigation:**
- ✅ **Nginx config extracted:** The inline Nginx block from `contrib/download_and_install_bal.sh` was extracted into a dedicated, auditable template: `contrib/nginx/bal-server.conf`. It includes:
- `listen 443 ssl http2;` with Let's encrypt paths
- `proxy_pass` to `http://127.0.0.1:9137` only
- `client_max_body_size 1m` matching `BAL_SERVER_ACTIX_MAX_BODY_SIZE`
- `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` security headers
- HTTP 80 redirect to HTTPS
- Optional `limit_req` / `limit_conn` directives (commented, ready for activation)
- ✅ **Bind warning:** `.env.example` and `bal-server.env` updated with explicit warning: `!!! Never bind to 0.0.0.0. Use 127.0.0.1 and place Nginx with TLS in front.`. Default bind address is `127.0.0.1`.
- ✅ **Deployment checklist:** `docs/07_deployment_and_ops.md` now includes a step-by-step "Production Deployment Checklist" covering Nginx TLS, firewall rules, DB permissions, ZMQ port blocking, and logging hardening.
- **Note:** This is an **infrastructure hardening**, not a code change. The `actix-web` server intentionally does not implement TLS — it is the reverse proxy's responsibility. The checklist ensures no operator accidentally exposes plain HTTP to the internet.
**Status:** Fixed (Documented/Infrastructure). **Priority:** High.
### 9. Missing Input Validation (FIXED)
**Severity:** MEDIUM | **Status:** Fixed
**Location:** `src/bin/bal-server.rs`
**Description:** Network, txid, and content validation.
**Mitigation Applied:**
- Network validation: `NETWORKS.contains(&param.as_str())` before processing. Unknown networks return 404.
- Txid validation: `echo_search` requires exactly 64 ASCII hex characters. Non-hex or wrong length returns 400.
- XPub address caching: `get_all_addresses_by_xpub` loads all addresses once per batch (O(1) lookup), eliminating N+1 queries.
- Content-Length: Handled by actix-web `PayloadConfig` size limit.
**Regression Tests:** `tests/input_validation_tests.rs` (4 tests).
### 9. Missing Input Validation (MEDIUM)
**Location:** `src/bin/bal-server.rs` (e.g., `pushtxs` endpoint).
**Description:** While the server does check if the transaction is valid and the fee is correct, it does not validate the `Content-Type` or the `Content-Length` of the request body. It also does not validate the `network` string before using it in the path. The `network` string is directly used to match the database table, which could be a potential SQL injection or DoS vector if the string is not a known network (e.g., `bitcoin`, `testnet`). The `searchtx` endpoint also does not validate the `txid` format and uses it in the SQL query.
**Impact:** An attacker could send a request with a malformed `network` or `txid`, which could cause unexpected database behavior, or a server error (e.g., a 500 error if the database table is not found), or a DoS if the SQL query is not handled properly. The `network` value is used as a string in the query, which could be used to bypass the database if it is not validated.
**Mitigation:**
- Add a strict validation step for the `network` parameter. Use an `enum` or a `HashSet` of known network names. If the `network` is not in the list, return a `404` error immediately, before accessing the database.
- Add a strict validation step for the `txid` in the `searchtx` request. A `txid` must be a 64-character hexadecimal string. If `txid` is not hex or not 64 chars, return `400 Bad Request` immediately.
- Add a `Content-Length` check to the request body. If it's not set, or if it's too large, return `411 Length Required` or `413 Payload Too Large`.
**Status:** Fixed. **Priority:** Medium.
- ✅ **Network validation:** `echo_info`, `echo_stats`, and `echo_push` handlers now verify `NETWORKS.contains(&param.as_str())` **before** calling `get_net_config`. Unknown networks (e.g., `GET /attacker/info`) return `404 Not Found` immediately, preventing the previous fallback to `mainnet`.
- ✅ **txid validation:** `echo_search` now validates that the request body is exactly **64 ASCII hex characters** (0-9, a-f, A-F). Any other length or non-hex content returns `400 Bad Request` before touching the database.
- ✅ **Performance optimization (N+1 fix):** The `SELECT * FROM tbl_address WHERE address=?` query inside the per-output loop of `echo_push` was replaced by a single `db.get_all_addresses_by_xpub(&db, &xpub)` call executed once per batch, returning a `HashSet<String>`. The per-output lookup is now an O(1) memory check, eliminating the N+1 query bottleneck.
- **Content-Length:** Already handled by `Actix PayloadConfig` size limit (see point 4).
- ✅ **SQL injection in `echo_stats` fixed:** The `chain` parameter was previously interpolated directly into a `format!` string (`"WHERE chain = '{}'"`) before being passed to `db.iterate`. It has been replaced by a prepared statement with `stmt.bind((1, Value::String(...)))` and a loop over `stmt.next()`. An index `idx_stats_chain` was also added on `tbl_stats(chain)` to ensure efficient filtering.
**Regression tests:** `tests/input_validation_tests.rs` (4 tests: xpub address cache, empty cache, network validation, txid hex/64 validation). All passing.
### 10. Information Leakage (MITIGATED)
**Severity:** LOW | **Status:** Mitigated
**Location:** Application logs
**Description:** Raw file logging (`valid_txs`/`invalid_txs`) has been removed. `info!`/`warn!` macros may still log txid and details in application logs.
**Mitigation (Operational):** Set production `RUST_LOG` to `warn` or higher. Log files restricted with `chmod 600`.
### 10. Information Leakage (LOW)
### 11. Onion Ping Delegation (DESIGN / ACCEPTED RISK)
**Severity:** LOW | **Status:** Accepted
**Location:** `src/bin/bal-pusher.rs` (`validate_onion_delegation`, `delegation_for_url`); certificate verification on the WeTime `/ping` endpoint
**Description:** Onion pings may carry a `delegation` certificate that binds a dedicated Ed25519 ping key to the onion host until `valid_until`. The certificate is signed by the onion identity key embedded in the `.onion` address, so the identity key can stay offline.
**Compromise impact:** An attacker who obtains the online ping key and its certificate can sign pings and metadata for that onion host until the key is revoked or the certificate expires. It cannot take over the onion service identity, sign for another host, or forge a certificate without the identity key.
**Revocation:** `POST /ping-keys/revoke` accepts a signed `WELIST-PING-REVOCATION-V1` statement. Onion revocations are signed by the identity key (which cannot itself be revoked); DNS TXT revocations are self-signed by the key being revoked, the only authority available for a TXT record. Entries are permanent and key-scoped, checked before the ping signature on both paths, and fail closed on database errors.
**Accepted risks:**
- **Rotation alone does not revoke:** a leaked key must be explicitly revoked. If the signing authority is lost (onion identity key, or the DNS private key), the key remains usable until `valid_until` or the DNS cache TTL passes. Use short lifetimes (30–90 days).
- **Self-signed DNS revocation:** anyone holding the DNS key, including an attacker who stole it, can submit a revocation. This only stops that key, which the attacker could already impersonate; it cannot revoke other keys.
- **Replay:** the signed payload has no nonce. The WeTime service discards duplicate `(executor, chain, height, blockhash)` pings without side effects, so a recorded ping cannot refresh liveness or roll back metadata.
**Mitigation Applied:** The offline identity key issues certificates; `SSL_KEY_PATH` holds only the delegated key. A present but invalid, expired or mismatched certificate is rejected without downgrade, `delegation` is rejected on non-onion hosts, and revoked keys are rejected for both authentication paths.
### 10. Information Leakage (LOW)
**Location:** `valid_txs` and `invalid_txs` files, `bal-server` error messages.
**Description:** The `bal-server` returns `500 Internal Server Error` in some cases. The `bal-server` does not log the raw request body or the user IP in all cases, but it does log the transaction details and some error messages in the `valid_txs` and `invalid_txs` log files. The `valid_txs` file contains the raw transaction details, which could leak private information if the log file is not protected. The `invalid_txs` file contains the raw error messages from the `bitcoind` RPC, which could be used to fingerprint the `bitcoind` version or its configuration. The `valid_txs` and `invalid_txs` files contain the raw transaction details, including the user IP and the transaction details, which could be used to identify the user's behavior or the network's topology. The `invalid_txs` file contains the raw error message from the `bitcoind` RPC, which is a potential information leakage (e.g., `bad-txns-inputs-missingorspent`). This message could be used to fingerprint the `bitcoind` version or the mempool state.
**Impact:**
- If the log files are not protected, the raw transaction details could be read by unauthorized users or processes running on the same machine. If the log files are accessible, the attacker could see the transaction details and potentially use them to link addresses to users or services.
- The `invalid_txs` file contains the raw error messages from the `bitcoind` RPC, which could be used to fingerprint the node or its configuration. For example, `bad-txns-inputs` suggests that the input is not available or not valid, which is a mempool state. If the attacker can read these logs, they can deduce the state of the mempool.
**Mitigation:**
- Ensure the `valid_txs` and `invalid_txs` files are not stored in the same directory as the `bal.db` or `private_key.pem`. If they are, they should be protected with `chmod 600` and only readable by the `bal` user.
- The `bal-server` should not log the raw request body or the transaction details in the `valid_txs` log file. It should only log the `txid` and the result, not the full raw transaction. The `invalid_txs` should log the error message, but not the raw transaction details or the user IP. If the server logs the raw transaction, the attacker could read it by reading the log files or the memory of the server process if it crashes.
**Status:** Partially Fixed. **Priority:** Medium. The raw file logging (`valid_txs`/`invalid_txs`) in `bal-pusher.rs` has been commented out (lines 271-290). The `bal-server` `actix-web` middleware logs only requests/responses via `Logger::default()`. However, `info!`/`warn!` macros may still log `txid` and other details in application logs. Ensure production `RUST_LOG` level is set to `warn` or higher and log files are restricted with `chmod 600`.
### 12. `bal-stats.rs.dontcompile` (REMOVED)
**Severity:** LOW | **Status:** Removed
**Description:** The broken HTML report generator file no longer exists in the source tree.
### 11. `valid_txs` Log File Privacy (LOW)
**Location:** `valid_txs`, `invalid_txs` files.
**Description:** The `valid_txs` and `invalid_txs` files are plain text log files. `valid_txs` contains the transaction details and the raw hex. `invalid_txs` contains the error messages and the raw hex of the failed transactions. These files do not contain the user IP, but they do contain the raw transaction details and the `txid`, which is enough to fingerprint the transaction. If the `valid_txs` file is accessible to the public, the transaction details could be read by anyone. Also, the `valid_txs` file is not encrypted or compressed.
**Impact:** The raw transaction details could be read by anyone. If the user is using the `valid_txs` file to track the transactions, it could be used for privacy analysis or to fingerprint the transaction history. If the `valid_txs` file is leaked, it could be used to link the user's transaction to the `bal-server` and identify the user or their behavior. The `valid_txs` file is not encrypted, and it is not protected by any authentication. If the server is compromised, these files will be accessible to the attacker, which is a privacy risk.
**Mitigation:**
- Ensure the `valid_txs` and `invalid_txs` files are not accessible to the public. If the server is running on a shared directory, use `chmod 600` to restrict access. If the server is not, they are accessible by default.
- The `valid_txs` and `invalid_txs` files should not be stored in the same directory as the `bal.db` or `private_key.pem`. They should be in a separate directory.
- The `valid_txs` and `invalid_txs` files should be rotated and compressed to avoid growing infinitely. The `bal-server` should also not log the entire raw transaction in the `valid_txs` file. It should only log the `txid` and the status. This will prevent the leak of the transaction details if the log file is compromised.
**Status:** Fixed. **Priority:** Low. The raw file logging (`valid_txs` and `invalid_txs`) in `bal-pusher.rs` has been removed (commented out). The structured logging only logs `txid` and timestamps, not full raw transactions. Ensure log files are protected with `chmod 600` and are in a separate directory from the database and keys.
### 12. `bal-stats.rs.dontcompile` (LOW)
**Location:** `src/bin/bal-stats.rs.dontcompile` (removed).
**Description:** This file was a broken, incomplete HTML report generator that directly queried `tbl_tx` and wrote `bal_status.html` to the local filesystem. It contained hardcoded SQL queries and lacked security checks. If accidentally compiled or renamed, it could leak transaction details or expose the database contents via an HTML file. It could also bypass security checks or rate limiting if accessed directly.
**Impact:** If the file was compiled or run, it could create an unprotected HTML report with sensitive database contents, accessible if the server was serving static files.
**Mitigation:**
- ✅ **Removed:** File `src/bin/bal-stats.rs.dontcompile` deleted from source tree. No longer a risk for accidental compilation or exposure.
**Status:** Fixed (File removed). **Priority:** Low.
---
## Hardening Recommendations
### System-Level
1. **Run the service as a non-root user:** Use the `bal-systemd` hardening (e.g., `ProtectSystem=full, NoNewPrivileges, PrivateDevices`). The `bal-server` should not be exposed to the internet directly. Use a reverse proxy or a firewall.
2. **Use `firewall` (e.g., `iptables`, `netfilter`, or `nftables`) to block all inbound ports except the HTTPS port (443) and the SSH port (22).** The HTTP port should not be exposed to the internet. The `bal-server` should be on a separate port or on `127.0.0.1`.
3. **Use a `VPN` or `Tor` for the `welist` connection.** If the `welist` server is on a public network, use a VPN or Tor to prevent the `welist` IP address from being exposed to the `bal-pusher`.
4. **Run the `bal-server` in a `chroot` or `docker` container.** The server should be isolated from the rest of the system. If the server is compromised, the attacker will not be able to access the `bal.db` or `private_key.pem` files.
5. **Enable the `SELinux` or `AppArmor` profile for the `bal-server` and `bal-pusher` binaries.** This will prevent the attacker from accessing the database or the private key if the binary is compromised.
6. **Use a `read-only` file system for the `bal-server` binary.** The server should be read-only to prevent the attacker from modifying the binary or the configuration files. The `bal-server` should be in a `chroot` jail with the `bal` user.
7. **Use a `network` firewall to block the outbound traffic from the `bal-server` to the internet.** If the server only needs to communicate with the `bal-pusher` and the `nginx` proxy, it should not have internet access. If the server is compromised, it will not be able to download malware or communicate with a C2 server.
1. Run as non-root user with systemd hardening (`ProtectSystem=full`, `NoNewPrivileges`, `PrivateDevices`, `MemoryDenyWriteExecute`).
2. Use firewall to block all inbound ports except HTTPS (443) and SSH (22).
3. Use VPN or Tor for `welist` connections if on public network.
4. Run in a container or chroot for isolation.
5. Enable SELinux or AppArmor profiles for the binaries.
6. Use read-only filesystem for the server binary.
### Application-Level
1. **Add `Rate Limiting`:** Add a rate limiter to the `bal-server` to prevent DDoS or abuse. The `bal-server` should limit the number of requests per IP per minute or per hour. It should also limit the number of `pushtxs` requests to avoid filling the database with malicious requests. A `HashMap` or `Redis` can be used to store the rate limiter state.
2. **Add `Input Validation`:** Add strict input validation for all endpoints. The `network`, `txid`, and `hex` parameters must be validated. The `txid` must be 64 hex chars, the `hex` must be a valid Bitcoin hex string, and the `network` must be a known network.
3. **Add `HTTPS`:** The `Nginx` configuration should be used to terminate TLS and provide HTTPS. The `bal-server` should only run on `127.0.0.1` to avoid being exposed to the public internet.
4. **Add `WAL` for `SQLite`:** Enable the `Write-Ahead Logging` (WAL) mode for the `bal` database to prevent database locking or data corruption when multiple processes access the database at the same time. This is a standard practice for SQLite and is supported by the `sqlite` crate. Enable it via `PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;` upon the first connection.
5. **Add `ZMQ Authentication`:** Use `ZMQ_CURVE` or `ZMQ_GSSAPI` to authenticate the ZMQ connection. If the `ZMQ` connection is over a public network, the `bal-pusher` should be authenticated and the traffic should be encrypted. Alternatively, use `ZMQ_RCVTIMEO` and `ZMQ_SNDTIMEO` to set a connection timeout to prevent blocking forever if the socket is disconnected or the `bitcoind` node is not available.
6. **Add `Transaction Size Limits`:** The `bal-pusher` should have a `MAX_TRANSACTIONS_PER_SECOND` and `MAX_TRANSACTIONS_PER_BLOCK` config value. This will prevent the pusher from sending too many transactions to the `bitcoind` node and overloading it. If the database is full of many transactions, the pusher should only send a small batch at a time (e.g., `1` or `10` transactions per block, or `5` per minute) to avoid overwhelming the RPC queue or the node.
7. **Add `ZMQ Retry`:** The `bal-pusher` should implement a retry mechanism for the `sendrawtransaction` and ZMQ connection. If the RPC or ZMQ call fails, it should wait for the next block before trying again. The pusher should not panic or stop on the first failure. It should be resilient and continue operating even if the network is down or the `bitcoind` node is restarting. The `ZMQ` socket should be reconfigured with `ZMQ_RECONNECT_IVL` and `ZMQ_MAXMSGSIZE` to avoid reconnecting too aggressively or receiving unbounded messages. If the connection is lost, the `ZMQ` should wait for the `bitcoind` to come back and not try to reconnect immediately. The pusher should also handle `SIGTERM` and `SIGINT` gracefully and stop the ZMQ connection before exiting.
8. **Add `Transaction Fee Limits`:** The `bal-server` should not accept transactions with a fee of `0`. It should also not accept transactions with a fee higher than a reasonable limit (e.g., `100000` satoshi for a `10 KB` transaction). This will prevent the user from sending too many transactions with a very low or high fee. This will limit the risk of a DoS attack where the attacker fills the database with many invalid transactions. The `bal-server` should not accept a transaction that is not valid or has the wrong `network`. Also, the `bal-server` should not accept a transaction with a very high `locktime` (e.g., `9999999999`) to prevent the database from becoming too large or to prevent the pusher from being blocked by a very far future locktime. The `bal-server` should only accept locktime values that are reasonable for the current blockchain height.
9. **Add `Transaction Fee Limits`:** The `bal-server` should not accept a transaction from a `network` if the `network` is not supported. Only the `regtest`, `testnet`, `testnet4`, `signet`, and `bitcoin` networks are supported. If the `network` is not in the list, the server should reject the request and not process it. The server should also not accept a transaction from a different network than the one it is configured for. If the server is configured for `regtest`, it should not accept `bitcoin` transactions. This will prevent the attacker from using the wrong network and sending a transaction that is not valid for the current network. The server should not accept a transaction that has a different `network` than the `our_address` network. If the `network` is not valid, the server should not process the request and should return a `404` error. The server should not accept a transaction that is for a different network than the one it is configured for. This will prevent the attacker from using the server to process transactions for a different network.
10. **Add `Transaction Time Limits`:** The `bal-server` should not accept a transaction with a locktime that is too far in the future. If the locktime is greater than `500000000`, it is a timestamp. The server should only accept locktime values that are within a reasonable timeframe (e.g., within the next year or a few months). If the locktime is in the past, the server should not accept it or it should be marked as a `0` locktime and processed immediately. If the locktime is too far in the future, it should be rejected. If the locktime is a block height, it should be within the next `100000` blocks or the next few months. If the locktime is a timestamp, it should be within the next few years or a reasonable timeframe. If the locktime is too far in the future, it will be impossible to process, and it will fill the database with invalid transactions. The `bal-server` should not accept a transaction with a `locktime` of `0` if `0` is a special case. If the `locktime` is `0`, it should be processed immediately and not stored in the database. If `0` is treated as a special case, the server should not store it as a pending transaction. If the `locktime` is `0`, the transaction should be sent immediately or processed as a normal transaction without a timelock. If the locktime is `0`, it should be treated as a normal transaction and sent to the `bitcoind` node immediately. The server should not send a `0` locktime transaction to the `pusher` because it is not a pending transaction. The pusher should not process a `0` locktime transaction because it is not waiting for a specific time or block height. If the `locktime` is `0`, it should be handled in the `bal-server` and not in the `bal-pusher`. The server should not store the `0` locktime transaction in the database. If a `0` locktime transaction is sent, the server should not store it as a pending transaction but should send it to the `bitcoind` node or process it immediately. If the `0` locktime is a special case, the server should not treat it as a pending transaction and should not send it to the `pusher`. If the `locktime` is `0`, the `bal-server` should not send it to the `bitcoind` network. If the `0` locktime is a valid transaction, the server should not send it to the pusher. If the `0` locktime is a special case, it should be handled in the `bal-server` and not in the `bal-pusher`. If the `0` locktime is a special case, it should not be sent to the `pusher`. If the `0` locktime is a special case, the server should not treat it as a pending transaction. If the `0` locktime is a special case, the server should not process it in the `pu
1. **Rate Limiting:** Implemented via `actix-governor` with token-bucket per client IP and a **separate bucket per endpoint class**. `RealIpKeyExtractor` identifies clients behind reverse proxy using `X-Real-IP` / `X-Forwarded-For` headers. Trusted proxy IP configurable via `BAL_SERVER_TRUSTED_PROXY`.
2. **Input Validation:** Network enum check, txid hex validation, body size limits.
3. **Request Timeout:** `tokio::time::timeout` middleware returns `408` when a handler (including body read) exceeds `BAL_SERVER_ACTIX_TIMEOUT_SECS`.
4. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt.
5. **WAL Mode:** Enabled with retry logic for concurrent access.
6. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking.
7. **Transaction Size Limits:** Configurable via rate limiting and body size.
8. **ZMQ Retry:** Reconnect logic with timeout-based detection.
9. **Fee Limits:** Per-network `fixed_fee` configuration.
10. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest).
11. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction.
---
## Regression Test Suite
| Test File | Tests | Coverage |
|-----------|-------|----------|
| `tests/sql_injection_tests.rs` | 3 | Parameterized queries, injection prevention |
| `tests/panic_regression_tests.rs` | 2 | Mutex poisoning recovery, NULL value handling |
| `tests/ssrf_tests.rs` | 4+ | URL validation, internal IP blocking |
| `tests/secret_leakage_tests.rs` | 3 | .gitignore, no tracked secrets, no hardcoded tokens |
| `tests/input_validation_tests.rs` | 4 | Address caching, network validation, txid hex validation |
| `tests/db_path_validation.rs` | 5 | Path traversal, forbidden dirs, symlinks, WAL pragma, valid paths |
| `src/validation.rs` (inline) | 11 | SSRF URL validation unit tests |

View File

@@ -8,58 +8,96 @@
## Source Code References
| Module/Component | File Path | Key Lines/Details |
| Module/Component | File Path | Key Details |
|---|---|---|
| Library | `src/lib.rs` | Exports `db` and `xpub` modules |
| Database | `src/db.rs` | SQL schema, `execute_insert`, batched inserts |
| XPub/Address Derivation | `src/xpub.rs` | `parse_xpub`, `derive_address`, `get_descriptor`, BIP-84 |
| HTTP Server | `src/bin/bal-server.rs` | Hyper + Tokio, routes, handlers, `pushtxs` logic |
| Async Pusher | `src/bin/bal-pusher.rs` | ZMQ `hashblock`, RPC + Reqwest, `send_stats` |
| Library | `src/lib.rs` | Exports `db`, `validation`, `xpub` modules |
| Database | `src/db.rs` | SQL schema, `open_db`, `execute_insert`, WAL mode, path validation |
| XPub/Address Derivation | `src/xpub.rs` | `new_address_from_xpub`, `get_bitcoincore_descriptor`, `calculate_fingerprint`, BIP-84 |
| SSRF Validation | `src/validation.rs` | `is_valid_welist_url`, blocks private/internal IPs |
| HTTP Server | `src/bin/bal-server.rs` | Actix-web 4.9.0, rate limiting, 7 routes, `Arc<Mutex<Connection>>` |
| Async Pusher | `src/bin/bal-pusher.rs` | ZMQ `hashblock`, RPC + Reqwest, Ed25519 signing, `calculate_stats` |
| Stats (broken) | `src/bin/bal-stats.rs.dontcompile` | Not compiled, incomplete HTML report generator |
| Release script | `make_release.sh` | Hardcoded token, `cargo install` |
| Script/Config | Path | Purpose |
|---|---|---|
| Release script | `make_release.sh` | Builds release, creates tag, uploads to Gitea (token from `.env`) |
| DB download script | `download_bal_db.sh` | `scp` from remote |
| Server dev script | `bal-server.sh` | Sources `bal-server.env`, `cargo run` |
| Pusher dev script | `bal-pusher.sh` | Sources `bal-pusher.env`, `cargo run` |
| Send transaction script | `sendtx.sh` | `bitcoin-cli` wrapper |
| Send transaction script | `sendtx.sh` | `bitcoin-cli` wrapper for testing |
| Utility scripts | `lib.sh` | Colored echo functions |
| Contrib (install) | `contrib/download_and_install_bal.sh` | Nginx, Certbot, systemd setup, xpub via argument |
| Contrib (install) | `contrib/download_and_install_bal.sh` | Nginx, Certbot, systemd setup |
| Contrib (install bitcoind) | `contrib/download_and_install_bitcoincore.sh` | Bitcoind download, GPG verify, systemd, config |
| Contrib (install Tor) | `contrib/install_tor.sh` | Tor repository, `ControlPort 9051` || Systemd service | `bal-server.service` | Runs as `bal` user, `ProtectSystem`, `MemoryDenyWriteExecute` |
| Systemd service | `bitcoind.service` | `zmqpubhashblock` setup |
| Systemd service | `tbitcoind.service` | Testnet `bitcoind` |
| Contrib (install Tor) | `contrib/install_tor.sh` | Tor repository, `ControlPort 9051` |
| Contrib (onion delegation) | `contrib/make_onion_delegation.py` | Issues the v3 onion ping delegation certificate from a Tor or PKCS#8 identity key |
| Contrib (key revocation) | `contrib/make_ping_key_revocation.py` | Issues an onion (identity-signed) or DNS (self-signed) ping key revocation |
| Contrib (TXT helper) | `contrib/make_welist_txt.sh` | Prints the `_welist-key` TXT record; generates an Ed25519 key pair when omitted |
| Nginx template | `contrib/nginx/bal-server.conf` | TLS termination, security headers, rate limiting |
| Dockerfile | `Dockerfile` | Multi-stage build from source, non-root user, tini, healthcheck |
| Dockerfile.release | `Dockerfile.release` | Download latest release from Gitea, SHA-256 verification |
| Systemd Service | File | Purpose |
|---|---|---|
| `bal-server.service` | `bal-server.service` | Runs as `bal` user, hardened |
| `bitcoind.service` | `bitcoind.service` | `zmqpubhashblock` setup for mainnet |
| `tbitcoind.service` | `tbitcoind.service` | Testnet `bitcoind` |
---
## Dependency Map (from `Cargo.toml`)
### Core Dependencies
| Dependency | Version | Purpose |
|---|---|---|
| `base64` | `0.22.1` | Encoding/decoding in `pushtxs` / xpub |
| `bs58` | `0.4.0` | Base58 encoding for Bitcoin addresses / xpubs |
| `bytes` | `1.2` | Byte handling for `hyper`/`reqwest` |
| `bitcoin` | `0.32.5` | Transaction parsing, `ScriptBuf`, `Address`, `Xpub`, `Transaction` |
| `bitcoincore-rpc` | `0.19.0` | RPC client for `bitcoin-cli` methods (`sendrawtransaction`, `getblockchaininfo`) |
| `bitcoincore-rpc` | `0.19.0` | RPC client (`sendrawtransaction`, `getblockchaininfo`) |
| `bitcoincore-rpc-json` | `0.19.0` | JSON types for Bitcoin RPC responses |
| `byteorder` | `1.5.0` | Reading block timestamp from raw block header (big-endian) `u32` |
| `confy` | `0.6.1` | Loading `.toml` configuration files (default config) |
| `chrono` | `0.4.40` | `Date` and `DateTime` handling for timestamps and `report` |
| `env_logger` | `0.11.5` | Log level configuration via `RUST_LOG` environment variable |
| `hex` | `0.4.3` | Hex encoding for transaction serialization and raw bytes |
| `hex-conservative` | `0.1.1` | Hex parsing (used for Bitcoin hex strings) |
| `hyper` | `1.3.1` | Async HTTP server (features: `http1`, `server`) |
| `hyper-util` | `0.1.3` | Hyper utilities, `TokioIo` |
| `http-body-util` | `0.1` | HTTP body collection and streaming utilities |
| `log` | `0.4.21` | Logging facade (used by `env_logger`) |
| `openssl` | `0.10.74` | TLS/SSL, `vendored` feature to avoid system dependency |
| `sha2` | `0.10.8` | SHA-256 hashing (used in transaction validation or address generation) |
| `serde` | `1.0.152` | Serialization of config objects and JSON responses (`derive` feature) |
| `serde_json` | `1.0.116` | JSON parsing for HTTP request bodies and API responses |
| `sqlite` | `0.34.0` | Direct SQLite C bindings, raw SQL queries, no ORM |
| `regex` | `1.10.4` | `RegExp` parsing for URL matching (e.g., `network` regex) in `bal-server` |
| `reqwest` | `0.12.24` | HTTP client (`json` + `socks` features) for `welist` stats POST |
| `sqlite` | `0.34.0` | Direct SQLite C bindings, raw SQL queries |
| `serde` | `1.0.152` | Serialization (`derive` feature) |
| `serde_json` | `1.0.116` | JSON parsing for HTTP request/response |
| `tokio` | `1` | Async runtime (`rt`, `net`, `macros`, `rt-multi-thread`) |
| `zmq` | `0.10.0` | ZeroMQ for `hashblock`/`rawblock` notifications |
| `sha2` | `0.10.8` | SHA-256 hashing |
| `bs58` | `0.4.0` | Base58 encoding for xpubs |
| `hex` | `0.4.3` | Hex encoding for transaction serialization |
| `regex` | `1.10.4` | Regular expressions |
| `log` | `0.4.21` | Logging facade |
| `env_logger` | `0.11.5` | Log level via `RUST_LOG` |
| `url` | `2` | URL parsing for SSRF validation |
### Server-Only Dependencies (feature: `server`)
| Dependency | Version | Purpose |
|---|---|---|
| `actix-web` | `4.9.0` | Async HTTP server framework |
| `actix-governor` | `0.6.0` | Rate limiting middleware (token-bucket) |
| `actix-rt` | `2.10.0` | Actix async runtime |
| `chrono` | `0.4.40` | Date/Time handling for timestamps |
| `hex-conservative` | `0.1.1` | Hex parsing for Bitcoin hex strings |
### Pusher-Only Dependencies (feature: `pusher`)
| Dependency | Version | Purpose |
|---|---|---|
| `zmq` | `0.10.0` | ZeroMQ for `hashblock` notifications |
| `reqwest` | `0.12.24` | HTTP client (`json` + `socks` features) for `welist` stats POST |
| `byteorder` | `1.5.0` | Reading block timestamp from raw block header |
| `base64` | `0.22.1` | Encoding/decoding for Ed25519 signatures |
| `ed25519-dalek` | `2` | Ed25519 signing (`pem` + `pkcs8` features) |
| `bytes` | `1.2` | Byte handling |
---
## Test Files
| Test File | Tests | Coverage |
|-----------|-------|----------|
| `tests/sql_injection_tests.rs` | 3 | SQL injection prevention |
| `tests/panic_regression_tests.rs` | 2 | Panic recovery, NULL handling |
| `tests/ssrf_tests.rs` | 4+ | SSRF URL validation |
| `tests/secret_leakage_tests.rs` | 3 | Secret protection, .gitignore |
| `tests/input_validation_tests.rs` | 4 | Input validation, address caching |
| `tests/db_path_validation.rs` | 5 | DB path validation, WAL mode |
| `tests/test_endpoints.sh` | Bash | Integration tests for HTTP endpoints |
---
@@ -67,11 +105,9 @@
| Existing File | Description | Replaced/Managed By KB |
|---|---|---|
| `README.md` | Installation, environment variables, ZMQ dependency | `07_deployment_and_ops.md` |
| `README.md` | Installation, env vars, Docker, per-network config | `07_deployment_and_ops.md` |
| `RPC.md` | API endpoint specification (HTTP methods, paths) | `05_api_reference.md` |
| `AGENTS.md` | Security guidelines, audit rules, baseline commands | `08_security_audit.md` |
| `update` | Irrelevant saved conversation (Diesel/Axum) | Ignored, not mapped |
| `valid_txs` / `invalid_txs` | Logs of past transaction push results | `08_security_audit.md` (Information Leakage) |
| `Cargo.toml` | Dependency versions and features | `09_references_and_links.md` (Dependency Map) |
| `bal-server.service` | `systemd` unit file | `07_deployment_and_ops.md` (Systemd) |
| `bitcoind.service` | `systemd` unit for `bitcoin` node | `07_deployment_and_ops.md` (Systemd) |
@@ -81,14 +117,44 @@
| `bal-server.sh` | Dev server startup script | `07_deployment_and_ops.md` (Bash Scripts) |
| `bal-pusher.sh` | Dev pusher startup script | `07_deployment_and_ops.md` (Bash Scripts) |
| `sendtx.sh` | Test transaction sender | `07_deployment_and_ops.md` (Bash Scripts) |
| `make_release.sh` | Release script with hardcoded secret | `08_security_audit.md` (Secret Leakage) |
| `make_release.sh` | Release script | `07_deployment_and_ops.md` (Bash Scripts) |
| `download_bal_db.sh` | `scp` from remote | `07_deployment_and_ops.md` (Bash Scripts) |
| `generate_keys.sh` | Generate public key from `private_key.pem` | `07_deployment_and_ops.md` (Bash Scripts) |
| `generate_keys.sh` | Generate public key from `privkey.pem` | `07_deployment_and_ops.md` (Bash Scripts) |
| `public_key.pem` | Ed25519 public key for stats verification | `05_api_reference.md` (GET `/.pub_key.pem` endpoint) |
| `private_key.pem` / `privkey.pem` / `ec.key` / `chiave_privata.key` | Private keys for stats signing | `08_security_audit.md` (Secret Leakage) |
| `contrib/download_and_install_bal.sh` | Full deployment setup | `07_deployment_and_ops.md` (Nginx, SSL) and `08_security_audit.md` (Hardcoded Secret) |
| `privkey.pem` | Private key for stats signing | `08_security_audit.md` (Secret Leakage) |
| `contrib/download_and_install_bal.sh` | Full deployment setup | `07_deployment_and_ops.md` (Nginx, SSL) |
| `contrib/download_and_install_bitcoincore.sh` | Bitcoin Core install/verify | `07_deployment_and_ops.md` (Systemd) |
| `contrib/install_tor.sh` | Tor installation script | `07_deployment_and_ops.md` (Tor) |
| `contrib` | Various helper scripts | `07_deployment_and_ops.md` |
| `contrib/make_onion_delegation.py` | Onion delegation certificate issuer | `10_dnssec_key_publishing.md` (Onion URLs) |
| `contrib/make_ping_key_revocation.py` | Ping key revocation issuer (onion and DNS) | `10_dnssec_key_publishing.md` (Revocation and lifetime) |
| `contrib/make_welist_txt.sh` | `_welist-key` TXT record helper | `10_dnssec_key_publishing.md` (sections 2–3) |
| `contrib/nginx/bal-server.conf` | Nginx TLS config template | `07_deployment_and_ops.md` (Nginx) |
| `Dockerfile` | Multi-stage Docker build | `07_deployment_and_ops.md` (Docker) |
---
## Build and Release Profile
```toml
[profile.release]
opt-level = "z" # Optimize for binary size
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for maximum optimization
strip = true # Strip debug symbols
panic = "abort" # Abort on panic (smaller binary)
```
## Feature Flags
```toml
[features]
default = ["server", "pusher"]
server = ["dep:actix-web", "dep:actix-governor", "dep:actix-rt", "dep:chrono", "dep:hex-conservative"]
pusher = ["dep:zmq", "dep:reqwest", "dep:byteorder", "dep:base64", "dep:ed25519-dalek"]
```
Build individual binaries:
```bash
cargo build --bin bal-server --features server
cargo build --bin bal-pusher --features pusher
```

View File

@@ -0,0 +1,739 @@
# WeTime v2 ping keys: DNSSEC TXT and onion delegation
This document explains how to publish the Ed25519 public key used by the
`bal-pusher` v2 WeTime ping, how to delegate onion ping signing to a dedicated,
rotatable key, and how to verify the configuration.
In v2 the WeTime service discovers the executor public key from:
- a DNSSEC-validated TXT record at `_welist-key.<host>`, or
- the Ed25519 identity key embedded in a v3 `.onion` address, or a dedicated
ping key delegated by that identity key.
Sections 1–10 below cover the DNS TXT record; onion delegation is documented in
[Onion URLs](#onion-urls). Onion authentication needs no DNS record and should
use delegation so the onion identity key stays offline. For local development
no record is needed either (see [Local development](#local-development)).
All values below use `we.example.com` as a placeholder for the hostname in
`BAL_SERVER_URL`. Replace it with your real executor hostname.
---
## 1. Choose the hostname
Use the public hostname that clients use to reach your executor:
```text
BAL_SERVER_URL=https://we.example.com
```
The DNS key is published at:
```text
_welist-key.we.example.com
```
Rules:
- Use a DNS hostname, not an IP address. The service rejects v2 pings whose URL
host is an IP address with `v2 DNS lookup requires a hostname`.
- Use the host only: no scheme, port, path, or trailing slash.
- The record must exist at exactly `_welist-key.<host>`. CNAME answers are
rejected by the service.
---
## 2. Generate or confirm the Ed25519 key pair
The pusher signs with the private key at `SSL_KEY_PATH`. The TXT record must
contain the matching public key.
> **The pusher and the bal-server use two separate settings.** `SSL_KEY_PATH`
> is the private key used for signing; `BAL_SERVER_PUB_KEY_PATH` is the public
> key served at `/.pub_key.pem` and used by the legacy v1 scheme (and by the
> check in section 7). Configure both with the same key pair.
If you already have a key pair for the executor, reuse it. Do not generate a
new pair for `SSL_KEY_PATH` alone: the legacy v1 signature is verified against
`/.pub_key.pem`, so a split pair breaks v1 authentication and makes the
section 7 check fail.
> **Onion hosts:** do not point `SSL_KEY_PATH` at the onion identity key. Use a
> separate ping key and issue a delegation certificate (see
> [Onion URLs](#onion-urls)). The identity key is only needed offline, once, to
> sign that certificate.
If you do not have a key pair yet:
```bash
openssl genpkey -algorithm ED25519 -out private_key.pem
openssl pkey -in private_key.pem -pubout -out public_key.pem
chmod 600 private_key.pem
```
Alternatively, `contrib/make_welist_txt.sh <host>` generates
`private_key.pem` and `public_key.pem` in the current directory when the key
argument is omitted, then prints the TXT record. The private key is created at
mode `0600`. If `./private_key.pem` already exists, the script reuses it instead
of overwriting it. Move both files to a protected location before starting the
services.
Then point both settings at the new pair and restart `bal-server` and
`bal-pusher`:
```bash
export SSL_KEY_PATH="/absolute/path/to/private_key.pem"
export BAL_SERVER_PUB_KEY_PATH="/absolute/path/to/public_key.pem"
```
Keep `private_key.pem` private and outside version control. The TXT record only
contains the public key.
---
## 3. Build the TXT value
Derive the base64 SubjectPublicKeyInfo (SPKI) DER from the private key:
```bash
KEY=$(openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0)
echo "v=we1; alg=ed25519; key=$KEY"
```
On platforms where `base64 -w0` is unavailable (for example macOS):
```bash
KEY=$(openssl pkey -in private_key.pem -pubout -outform DER | base64 | tr -d '\n')
echo "v=we1; alg=ed25519; key=$KEY"
```
`contrib/make_welist_txt.sh <host> [key]` prints the record name, value, and a
BIND zone line directly. `key` may be a private/public PEM file or an existing
base64 SPKI DER value; omit it to generate a new key pair first.
The value has this form:
```text
v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA...
```
Notes:
- `key` is the base64 of the **full SPKI DER**, not a hash and not the PEM text.
It is the same base64 body that appears in `public_key.pem`.
- Use standard base64 (`+`, `/`, `=` padding), not base64url.
- Ed25519 SPKI DER is 44 bytes, so the base64 value is about 60 characters and
the whole TXT string is about 90 characters. It fits in a single TXT string.
- If a DNS provider splits the value into multiple quoted strings, that is
acceptable: the resolver concatenates TXT chunks before parsing.
---
## 4. Enable DNSSEC
The service validates DNSSEC and **fails closed**. An unsigned or bogus answer
is rejected; there is no unauthenticated fallback.
Enabling DNSSEC normally requires two steps:
1. Enable DNSSEC signing for the zone at the DNS provider.
2. Publish the provider's **DS record** at the registrar or parent zone.
The whole chain from the root to the zone that contains
`_welist-key.we.example.com` must be signed.
### Verify DNSSEC
```bash
dig +short DS example.com
dig +dnssec DNSKEY example.com
dig +dnssec TXT _welist-key.we.example.com
```
Look for `RRSIG` records in the answers. A validating resolver also sets the
`ad` flag:
```text
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, ...
```
If `dig` does not show `ad`, your local resolver may not validate. Use a
validating tool instead:
```bash
delv TXT _welist-key.we.example.com
```
or:
```bash
kdig +dnssec TXT _welist-key.we.example.com
```
### Resolver used by the WeTime service
The service performs its own validation with a built-in root trust anchor. The
upstream resolver only needs to return DNSSEC records.
If the service host uses a stub resolver that strips DNSSEC data, point it at a
DNSSEC-capable resolver:
```bash
export WELIST_DNS_NAMESERVERS=1.1.1.1,9.9.9.9
```
`WELIST_DNS_NAMESERVERS` replaces the system resolver configuration; each entry
is an `IP` or `IP:PORT` (default port 53).
You can also point at a custom trust anchor file:
```bash
export WELIST_DNSSEC_TRUST_ANCHOR=/path/to/root.key
```
### Local development
DNSSEC cannot authenticate `localhost` or an IP address. For local development
use the legacy scheme and let the service verify the published public key:
- Keep `WELIST_PING_SIG_VERSION` unset (or `1`), so the pusher signs with
`SSL_KEY_PATH` and the service verifies against `GET /.pub_key.pem`.
- For loopback or private addresses, start the service with
`WELIST_ALLOW_LOCALHOST_PING=true`. Legacy pings are accepted by default;
only set `WELIST_ALLOW_LEGACY_PING=false` if the service must require v2.
No TXT record and no DNSSEC setup are required in this case.
---
## 5. Publish the TXT record
| Field | Value |
|-------|-------|
| Name | `_welist-key.we.example.com` |
| Type | `TXT` |
| Value | `v=we1; alg=ed25519; key=<base64 SPKI DER>` |
| TTL | `300` to `3600` seconds |
The WeTime service caches DNS keys for the TXT TTL, clamped to 60–86400
seconds. A TTL of `300`–`3600` keeps rotation reasonably fast.
### BIND / generic zone file
```zone
_welist-key.we.example.com. 3600 IN TXT "v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA..."
```
Reload or re-sign the zone after adding the record. If you use inline signing,
the record is signed automatically. If you sign offline, re-run your signing
step and publish the new signed zone.
### Cloudflare
1. Enable DNSSEC in the Cloudflare dashboard.
2. Add the DS record shown by Cloudflare at your registrar.
3. Create the TXT record:
- Type: `TXT`
- Name: `_welist-key.we` (Cloudflare appends the zone name)
- Content: `v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA...`
- TTL: `Auto` or `1h`
Cloudflare proxying applies to HTTP records, not TXT. The TXT record is served
directly, so DNSSEC validation works normally.
### Route 53
1. Enable DNSSEC signing for the hosted zone.
2. Add the resulting DS record at the registrar or parent zone.
3. Create the record:
- Record name: `_welist-key.we.example.com`
- Record type: `TXT`
- Value: `"v=we1; alg=ed25519; key=MCowBQYDK2VwAyEA..."`
- TTL: `3600`
Route 53 may split long TXT values into quoted chunks. That is supported.
### Important
- Do not create a CNAME for `_welist-key`. The service rejects CNAME answers.
- Do not put the private key, a hash, or PEM headers in the TXT value.
- If you need to host the record in another zone, use a proper DNSSEC-signed
delegation (NS + DS), not a CNAME.
---
## 6. Configure the pusher
Set these variables in the pusher environment (for local development,
`bal-pusher.sh`):
```bash
export SSL_KEY_PATH="/absolute/path/to/private_key.pem"
export BAL_SERVER_URL="https://we.example.com"
export WELIST_PING_SIG_VERSION=2
```
- `SSL_KEY_PATH` must point to the private key matching the published TXT key.
- `BAL_SERVER_URL` must be the public DNS hostname. The pusher normalizes it by
trimming whitespace and trailing slashes before signing.
- `WELIST_PING_SIG_VERSION=2` activates the canonical JSON signature. Leaving it
unset (or `1`) keeps the legacy v1 scheme, which the service accepts by
default (set `WELIST_ALLOW_LEGACY_PING=false` to require v2).
For onion hosts, also configure the delegation certificate (see
[Onion URLs](#onion-urls)):
```bash
export SSL_KEY_PATH="/absolute/path/to/ping_private_key.pem"
export WELIST_PING_DELEGATION_FILE="/absolute/path/to/onion-delegation.json"
export BAL_SERVER_URL="http://<address>.onion"
export WELIST_PING_SIG_VERSION=2
```
- `SSL_KEY_PATH` is the **dedicated ping key**, not the onion identity key.
- `WELIST_PING_DELEGATION_FILE` is optional. When set, its JSON certificate is
sent only with onion pings. The pusher validates it at startup: `onion` must
match `BAL_SERVER_URL`, `valid_until` must be in the future, and `key` must
match `SSL_KEY_PATH`. If the file is missing or malformed, or any check
fails, it logs a warning and disables delegation, then signs with
`SSL_KEY_PATH` directly. That fallback works only when `SSL_KEY_PATH` is the
onion identity key; otherwise the service rejects the pings until the
certificate or key is corrected.
Restart the pusher after changing the key or environment.
If a ping key leaks, issue and submit a revocation before rotating; see
[Revocation and lifetime](#revocation-and-lifetime).
With v2, the pusher also fetches `info`, `base_fee`, and `version` from the
local `/{chain}/info` endpoint and signs those values. If the fetch fails, the
fields are signed as `null`; the ping is still valid. Metadata is also signed
as `null` when it fails validation:
- `info` longer than 4096 bytes, or empty;
- `version` longer than 64 bytes, or empty;
- `base_fee` negative.
For `.onion` URLs the pusher skips the fetch entirely (it cannot reach its own
onion address without a Tor proxy), so those fields are always `null`.
---
## 7. Verify the published key
Query the record and extract the `key=` field (this also joins TXT chunks and
drops the quotes the resolver adds):
```bash
dig +short TXT _welist-key.we.example.com | tr -d '" ' | sed 's/^.*key=//'
```
Compare it with the local public key:
```bash
openssl pkey -in private_key.pem -pubout -outform DER | base64 -w0
```
The two commands must print the same base64 string.
Optionally check that the legacy `/.pub_key.pem` endpoint serves the same key:
```bash
curl -s https://we.example.com/.pub_key.pem \
| openssl pkey -pubin -outform DER \
| base64 -w0
```
This third check is only meaningful when `BAL_SERVER_PUB_KEY_PATH` and
`SSL_KEY_PATH` reference the same key pair, which is the recommended setup. The
endpoint serves whatever `BAL_SERVER_PUB_KEY_PATH` points at; if the two
settings use different pairs, compare the TXT record with `SSL_KEY_PATH` only.
---
## 8. Test a v2 ping
Wait for the next block, or restart the pusher. With trace logging enabled you
should see:
```text
canonical message to be sent: WELIST-PING-V2\n{"base_fee":...
Report to welist(...) status=200 body="ok"
```
The WeTime service should log `PING ADDED` and refresh the executor's
`last_update`. After a valid v2 ping, signed `info`, `base_fee`, and `version`
values are persisted.
A response of `"ko"` means the signature was rejected. Check the service logs
for the specific `PING REJECTED` reason.
### Onion delegation
For an onion pusher with delegation configured:
1. At startup, the pusher must not log `disabling onion delegation`.
2. After the next block, look for `Report to welist(...) status=200 body="ok"`
and `PING ADDED` in the service log.
3. Negative check: send a crafted v2 ping with a tampered certificate (change
`key` or `valid_until` after signing) directly to the WeTime service. It must
answer `"ko"` and log `PING REJECTED invalid onion delegation`. Do not run
the pusher with a tampered certificate: it disables delegation locally
instead of sending it.
4. Revocation check (destructive; use a test executor): issue a revocation with
`contrib/make_ping_key_revocation.py --certificate ...`, submit it with
`curl`, then send the delegated ping again. The service must log
`PING KEY REVOKED` on submission, `PING REJECTED revoked onion delegation`
on the ping, and answer `"ko"`. Generate a new ping key and certificate
afterwards: a revoked key stays revoked.
`bal-welist/scripts/test_v2_ping.py` is a local end-to-end reference: it
generates a throwaway onion identity key, issues a delegation certificate, and
checks the accepted and rejected paths on a throwaway database.
---
## 9. Key rotation
Multiple TXT records are supported and any one of them can verify a signature.
To rotate without downtime:
1. Publish the new public key as an additional TXT record.
2. Switch `SSL_KEY_PATH` to the new private key and restart the pusher.
3. Wait at least the old TXT TTL, and no more than the 86400-second cache cap.
4. Remove the old TXT record.
The service stores a key only after it verifies a signature, so an invalid DNS
answer cannot poison the cache. A refreshed TXT answer replaces the cached v2
keys for that host.
---
## 10. Troubleshooting
| Service log | Cause | Fix |
|-------------|-------|-----|
| `no TXT records` | Wrong record name or not propagated | Query `_welist-key.<host>` exactly |
| `zone is not DNSSEC-signed` | DNSSEC not enabled or DS missing | Enable DNSSEC and publish the DS record |
| `DNSSEC validation failed` | Expired/bogus signatures, clock skew, broken chain | Check with `delv` or a DNSSEC debugger |
| `DNSSEC status is indeterminate` | Upstream resolver strips DNSSEC data | Set `WELIST_DNS_NAMESERVERS` to a capable resolver |
| `dns unavailable` | Resolver unreachable or invalid configuration | Check `WELIST_DNS_NAMESERVERS` and `WELIST_DNSSEC_TRUST_ANCHOR` |
| `CNAME answers are not accepted` | `_welist-key` is an alias | Publish a real TXT record |
| `malformed record` | Wrong version, algorithm, or syntax | Use `v=we1; alg=ed25519; key=...` |
| `invalid key` | Not a base64 Ed25519 SPKI DER | Regenerate with the `openssl` command above |
| `v2 DNS lookup requires a hostname` | `BAL_SERVER_URL` uses an IP address | Use a DNS hostname |
| `invalid v2 signature` | Key mismatch or non-canonical URL | Ensure the TXT key matches `SSL_KEY_PATH` and the URL has no trailing slash; for onion delegation, ensure `SSL_KEY_PATH` is the delegated ping key |
| `invalid onion delegation` | Certificate signature does not verify against the onion identity key | Re-issue the certificate with the correct onion key; do not edit `key`/`valid_until` after signing |
| `delegation does not match the ping onion host` | `onion` field differs from `BAL_SERVER_URL` after normalization | Re-issue the certificate for the exact host |
| `delegation expired` | `valid_until` is at or before the current time | Re-issue the certificate and restart the pusher |
| `delegation is only supported for onion hosts` | `delegation` sent with a DNS URL | Unset `WELIST_PING_DELEGATION_FILE` for DNS hosts |
| `invalid revocation` | Signature does not verify, the host/key is invalid, or an onion identity key was targeted | Re-issue with the helper; onion revocations must be signed by the identity key and cannot target it |
| `revoked_at is in the future` | Issuer clock is more than 24 h ahead | Use the current time or fix the issuer clock |
| `revoked onion delegation` | The delegated key is on the revocation list | Generate a new ping key and certificate; a revoked key cannot be re-enabled |
| `legacy ping disabled` | v1 ping sent while the service requires v2 | Set `WELIST_PING_SIG_VERSION=2` (or set `WELIST_ALLOW_LEGACY_PING=true` if it was disabled) |
Pusher warning `disabling onion delegation` (not a service log): the
certificate is missing, expired, bound to another host, or does not match
`SSL_KEY_PATH`. In the recommended setup onion pings will then be rejected;
re-issue the certificate before restarting the pusher.
Useful diagnostics:
```bash
dig +dnssec TXT _welist-key.we.example.com
delv TXT _welist-key.we.example.com
dig +trace +dnssec TXT _welist-key.we.example.com
```
---
## Onion URLs
A v3 `.onion` address embeds the service's Ed25519 identity key, so no TXT
record is needed. The recommended design keeps that identity key **offline** and
delegates ping signing to a dedicated, rotatable key:
- `ping_private_key.pem` (`SSL_KEY_PATH`) signs every v2 ping.
- The onion identity key signs a delegation certificate once.
- Each onion ping carries the certificate in a `delegation` object.
- The WeTime service verifies the certificate against the onion address, then
verifies the ping signature with the delegated key.
Without a `delegation` object the service still accepts pings signed directly by
the onion identity key. That fallback exists for compatibility; it requires the
identity key to be online and should not be used for new deployments.
The ping `url` must itself use the `.onion` host. The `onion_url` database field
is display-only and does not affect authentication.
### Certificate format
The request carries:
```json
{
"onion": "<address>.onion",
"key": "<base64 SPKI DER of the delegated Ed25519 key>",
"valid_until": 1800000000,
"signature": "<base64 Ed25519 signature by the onion identity key>"
}
```
The identity signature covers exactly:
```text
WELIST-PING-DELEGATION-V1\n{"key":"...","onion":"...","valid_until":1800000000}
```
Rules enforced by the service:
- `onion` is normalized (lowercase, trailing dot removed) before it is compared
with the ping URL host. The pusher's startup check is stricter (exact string),
so issue certificates with the lowercase form; the helper does this.
- `valid_until` is a Unix timestamp in seconds and must be strictly in the
future. A certificate expiring exactly now is already rejected.
- `key` must be a valid Ed25519 SubjectPublicKeyInfo DER in standard base64.
- The identity signature must verify against the key embedded in the onion
address.
- A present but invalid, expired, or mismatched certificate is rejected; the
service does not fall back to the identity key.
- `delegation` is rejected on non-onion hosts.
### Issue the certificate
Create a dedicated ping key pair:
```bash
openssl genpkey -algorithm ED25519 -out ping_private_key.pem
chmod 600 ping_private_key.pem
```
Use the bundled helper with your onion key. It accepts either a Tor
`hs_ed25519_secret_key` file or a standard PKCS#8 PEM Ed25519 key:
```bash
export ONION="<address>.onion"
export ONION_KEY="/var/lib/tor/hidden_service/hs_ed25519_secret_key"
contrib/make_onion_delegation.py \
--onion "$ONION" \
--identity-key "$ONION_KEY" \
--ping-key /etc/bal/ping_private_key.pem \
--days 90 \
--out /etc/bal/onion-delegation.json
```
For Tor-format keys the helper needs Python `cryptography` and `pynacl`:
```bash
pip install cryptography pynacl
```
If the onion identity key is a PKCS#8 PEM file, point `--identity-key` at it and
`pynacl` is not needed. `--days` accepts 1–3650 (default 365); prefer 30–90
days as explained in [Revocation and lifetime](#revocation-and-lifetime).
Run this step on a machine that holds the identity key (ideally offline), then
copy only `onion-delegation.json` and `ping_private_key.pem` to the executor.
The certificate contains public data (`0644` is fine); only
`ping_private_key.pem` must stay `0600` and never leave the executor.
> **Tor offline master keys:** when the onion service runs with
> `OfflineMasterKey 1`, the key file on the Tor host is a blinded signing key,
> not the v3 identity key, and the helper fails with `onion secret key does not
> match the onion address`. Issue the certificate on a machine that holds the
> original `hs_ed25519_secret_key` (keep an offline copy from when the service
> was created). The blinded signing key cannot issue a delegation certificate.
### Configure the pusher
```bash
export SSL_KEY_PATH="/etc/bal/ping_private_key.pem"
export WELIST_PING_DELEGATION_FILE="/etc/bal/onion-delegation.json"
export BAL_SERVER_URL="http://<address>.onion"
export WELIST_PING_SIG_VERSION=2
```
Delegation is only sent with v2 pings. With `WELIST_PING_SIG_VERSION=1` (or
unset) the certificate is loaded and validated but never included in the
request.
The pusher sends `delegation` only when `BAL_SERVER_URL` is an `.onion` host. It
validates the certificate at startup against the onion host, its expiry, and
`SSL_KEY_PATH`. If the file is missing or malformed, or validation fails,
delegation is disabled and the pusher signs with `SSL_KEY_PATH` directly.
> **Warning — disabled delegation means onion pings fail.** In this setup
> `SSL_KEY_PATH` is the dedicated ping key, so when delegation is disabled the
> service verifies the ping against the onion identity key and rejects every
> request (`invalid v2 signature`). The executor then stops earning WeTime
> points. Watch for the pusher warning `disabling onion delegation` and
> re-issue before `valid_until`.
### Deployment order
1. Upgrade the WeTime service (`bal-welist`) to a version that supports v2
onion delegation.
2. Then set `WELIST_PING_DELEGATION_FILE` on the pusher and restart it.
A pusher that sends delegation to an older service receives `"ko"`: the old
service ignores the `delegation` field and verifies the ping with the onion
identity key. When rolling back the service, unset
`WELIST_PING_DELEGATION_FILE` first.
For local development, the `WELIST_ALLOW_LOCALHOST_PING` and legacy v1 notes
above still apply; delegation itself needs no network access.
### Rotation
1. Generate a new `ping_private_key.pem`.
2. Re-run `contrib/make_onion_delegation.py` with the new key and a new
`valid_until`.
3. Replace `onion-delegation.json` and `SSL_KEY_PATH` on the executor.
4. Restart the pusher.
The onion address does not change, and the identity key stays offline. There is
no DNS cache to wait for; the service checks the certificate on every ping.
Rotation does **not** revoke the previous certificate; use the revocation helper
below before or after rotating.
### Revocation and lifetime
The WeTime service keeps a permanent, key-scoped revocation list
(`POST /ping-keys/revoke`). A revoked key is rejected for both the onion
delegation path and the DNSSEC TXT path, even while a cached TXT record or a
still-valid certificate lists it.
#### Onion delegation (identity-signed)
The onion identity key signs the revocation, so only the onion owner can revoke
a delegated key. The identity key itself cannot be revoked; changing it changes
the `.onion` address.
```bash
contrib/make_ping_key_revocation.py \
--onion "$ONION" \
--identity-key "$ONION_KEY" \
--certificate /etc/bal/onion-delegation.json \
--service-url "https://<service>" \
--out /tmp/onion-revocation.json
curl -sS -X POST -H 'Content-Type: application/json' \
--data @/tmp/onion-revocation.json "https://<service>/ping-keys/revoke"
```
#### DNSSEC TXT key (self-signed)
For a TXT key the only available authority is the key itself, so the helper
signs the revocation with the key being revoked:
```bash
contrib/make_ping_key_revocation.py \
--host we.example.com \
--key /etc/bal/private_key.pem \
--service-url "https://<service>" \
--out /tmp/dns-revocation.json
curl -sS -X POST -H 'Content-Type: application/json' \
--data @/tmp/dns-revocation.json "https://<service>/ping-keys/revoke"
```
Also remove the key from `_welist-key.<host>`: revocation makes the stop
immediate, but the TXT record should stop advertising the key to new resolvers.
If the private key was destroyed and cannot sign the revocation, rotating the
TXT record and waiting for the DNS cache TTL is the only remaining path.
#### Certificate format
```json
{
"host": "<address>.onion or a DNS hostname",
"key": "<base64 SPKI DER of the revoked Ed25519 key>",
"revoked_at": 1800000000,
"signature": "<base64 signature>"
}
```
The signature covers:
```text
WELIST-PING-REVOCATION-V1\n{"host":"...","key":"...","revoked_at":1800000000}
```
Rules:
- `host` is normalized (lowercase, no trailing dot).
- Onion hosts require the onion identity signature; DNS hosts require a
self-signature from the revoked key.
- `revoked_at` may be at most 24 hours in the future.
- The key id is the hex of the raw public key, so re-encoded base64 cannot
bypass the list.
- Entries never expire and submissions are idempotent (`"ok"`); at most 64
distinct keys are kept per host.
- Revoking a key is permanent; a later certificate with the same key is also
rejected.
Prefer short certificate lifetimes (for example 30–90 days) anyway: revocation
requires the onion identity key (onion) or the revoked key (DNS), so a key that
is both leaked and lost without a remaining signer cannot be revoked.
### Security notes
- Delegation limits the blast radius: a compromised online ping key can publish
pings and metadata for that onion host until it is revoked or the certificate
expires, but it cannot take over the onion service identity or sign for
another host.
- A leaked key should be revoked immediately; rotation alone is not a
revocation.
- The delegated key authorizes the full ping capability for that host, including
the `info`, `base_fee` and `version` metadata.
- The certificate is public data and can be copied freely; only the ping private
key is secret.
- The onion identity key should never be copied to the executor.
### Metadata for onion pings
The pusher cannot fetch its own `.onion` endpoint without a Tor proxy, so
`info`, `base_fee`, and `version` are signed as `null` for onion pings.
With `WELIST_PING_SIG_VERSION=1` the pusher sends no metadata at all. The v1
signature covers only the legacy liveness concatenation (`url`, `chain`,
`height`, `median_time`, `last_block_hash`), and the WeTime service refreshes
`last_update` without persisting `info`, `base_fee`, or `version`. Unsigned
metadata is therefore not published. Use v2 when executor metadata must be
authenticated and stored.
---
## Minimal checklist
- [ ] Ed25519 key pair generated; `private_key.pem` is `chmod 600`.
- [ ] `SSL_KEY_PATH` and `BAL_SERVER_PUB_KEY_PATH` use the same key pair.
- [ ] TXT value built from `openssl pkey ... -outform DER | base64 -w0`.
- [ ] TXT record `_welist-key.<host>` published, not a CNAME.
- [ ] Zone DNSSEC-signed and DS published at the registrar.
- [ ] `dig +dnssec` / `delv` shows a validated answer.
- [ ] `BAL_SERVER_URL` is the public DNS hostname.
- [ ] `WELIST_PING_SIG_VERSION=2` is set.
- [ ] `contrib/make_ping_key_revocation.py --host ... --key ...` can produce a
self-signed revocation for the TXT key.
- [ ] Pusher restarted and reports `body="ok"`.
For onion hosts, replace the TXT/DNSSEC items with:
- [ ] The WeTime service (`bal-welist`) runs a version with onion delegation support.
- [ ] A dedicated `ping_private_key.pem` exists and is `chmod 600`.
- [ ] `onion-delegation.json` was issued offline by the v3 **master** identity
key (not a blinded signing key).
- [ ] `SSL_KEY_PATH` points at the delegated ping key, not the identity key.
- [ ] `WELIST_PING_DELEGATION_FILE` points at the certificate.
- [ ] `WELIST_PING_SIG_VERSION=2` is set.
- [ ] `valid_until` is strictly in the future, monitored, and rotation is
scheduled before it.
- [ ] The identity key is available offline so a leaked ping key can be revoked
with `contrib/make_ping_key_revocation.py`.
- [ ] The pusher starts without a `disabling onion delegation` warning.
- [ ] Pusher restarted and reports `body="ok"`.

View File

@@ -8,6 +8,7 @@
- **I am a security auditor** → Go straight to [`08_security_audit.md`](08_security_audit.md)
- **I am deploying or operating this software** → Check [`07_deployment_and_ops.md`](07_deployment_and_ops.md)
- **I need to integrate with the API** → Reference [`05_api_reference.md`](05_api_reference.md)
- **I need to publish the v2 WeTime public key, delegate onion ping signing or revoke a key** → Follow [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md)
- **I need to understand the database** → Use [`06_database_schema.md`](06_database_schema.md)
- **I need to understand Bitcoin concepts** → See [`02_glossary_and_bitcoin_domain.md`](02_glossary_and_bitcoin_domain.md)
- **I need source code references or external links** → Check [`09_references_and_links.md`](09_references_and_links.md)
@@ -25,6 +26,7 @@
| 7 | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) | Environment variables, systemd, nginx, scripts, Tor, installation |
| 8 | [`08_security_audit.md`](08_security_audit.md) | Threat model, vulnerability assessment, hardening recommendations |
| 9 | [`09_references_and_links.md`](09_references_and_links.md) | Source code links, Cargo dependencies, existing file mapping |
| 10 | [`10_dnssec_key_publishing.md`](10_dnssec_key_publishing.md) | Publishing the v2 WeTime key via DNSSEC TXT, v3 onion identity, delegated onion ping key, and key revocation |
---

125
pg-server.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib.sh"
PG_CONTAINER="bal-pg"
PG_IMAGE="${BAL_PG_IMAGE:-postgres:16-alpine}"
PG_VOLUME="${BAL_PG_VOLUME:-bal-pg-data}"
PG_USER="${BAL_PG_USER:-bal}"
PG_DB="${BAL_PG_DB:-baldb}"
# The password MUST come from the environment; never hardcode it.
PG_PASSWORD="${BAL_PG_PASSWORD:?BAL_PG_PASSWORD must be set (see .env.example)}"
# Publish the port on the loopback interface only by default. Set
# BAL_PG_BIND_ADDR=0.0.0.0 to expose PostgreSQL on every interface (not
# recommended: the database would be reachable from the network).
PG_BIND_ADDR="${BAL_PG_BIND_ADDR:-127.0.0.1}"
PG_PORT=5432
# Address the local clients use to reach the container. When the port is
# published on all interfaces, loopback is still the safest connect address.
case "$PG_BIND_ADDR" in
0.0.0.0|::|"") PG_CONNECT_ADDR="127.0.0.1" ;;
*) PG_CONNECT_ADDR="$PG_BIND_ADDR" ;;
esac
PG_DSN="${BAL_SERVER_PG_DSN:-postgres://${PG_USER}:${PG_PASSWORD}@${PG_CONNECT_ADDR}:${PG_PORT}/${PG_DB}}"
# Print the host IP the container port is currently published on.
# An empty value means Docker publishes it on all interfaces.
pg_container_bind_addr() {
docker inspect --format '{{range $port, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostIp}}{{end}}{{end}}' "$PG_CONTAINER" 2>/dev/null
}
warn_if_public() {
case "$PG_BIND_ADDR" in
127.0.0.1|::1|localhost) ;;
*)
echo_w "WARNING: PostgreSQL is published on ${PG_BIND_ADDR}:${PG_PORT} and is reachable from the network."
echo_w " Set BAL_PG_BIND_ADDR=127.0.0.1 unless remote access is really required."
;;
esac
}
# Fail closed when an existing container publishes the port elsewhere.
# Docker cannot change port bindings of a running/stopped container.
require_expected_bind_addr() {
local current
current=$(pg_container_bind_addr)
[ "$current" = "$PG_BIND_ADDR" ] && return 0
echo_e "Container '$PG_CONTAINER' publishes port ${PG_PORT} on '${current:-0.0.0.0}', expected '$PG_BIND_ADDR'."
echo_e "Docker cannot change port bindings of an existing container."
echo_e "Recreate it while keeping its data with:"
echo_e " $0 --recreate"
exit 1
}
# Recreate the container so that the new port binding takes effect.
# The data volume is reused (or copied into BAL_PG_VOLUME when it changes).
recreate_container() {
local mount_type old_volume
mount_type=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Type}}{{end}}{{end}}' "$PG_CONTAINER" 2>/dev/null || true)
old_volume=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' "$PG_CONTAINER" 2>/dev/null || true)
if [ "$mount_type" = "bind" ]; then
echo_e "Container '$PG_CONTAINER' uses a bind mount for its data; refusing to recreate it automatically."
exit 1
fi
echo_i "Removing container '$PG_CONTAINER' (its data volume is preserved)..."
docker rm -f "$PG_CONTAINER" >/dev/null
if [ -n "$old_volume" ] && [ "$old_volume" != "$PG_VOLUME" ]; then
echo_i "Migrating data from volume '$old_volume' to '$PG_VOLUME'..."
docker volume create "$PG_VOLUME" >/dev/null
docker run --rm --entrypoint sh \
-v "$old_volume:/from:ro" \
-v "$PG_VOLUME:/to" \
"$PG_IMAGE" -c 'cp -a /from/. /to/'
fi
}
if [ "${1:-}" = "--recreate" ]; then
if docker ps -a --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
recreate_container
fi
fi
warn_if_public
if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
echo_i "PostgreSQL container '$PG_CONTAINER' is already running"
require_expected_bind_addr
elif docker ps -a --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
require_expected_bind_addr
echo_i "Starting existing PostgreSQL container '$PG_CONTAINER'..."
docker start "$PG_CONTAINER" >/dev/null 2>&1
else
echo_i "Creating PostgreSQL container '$PG_CONTAINER'..."
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER="$PG_USER" \
-e POSTGRES_PASSWORD="$PG_PASSWORD" \
-e POSTGRES_DB="$PG_DB" \
-p "${PG_BIND_ADDR}:${PG_PORT}:5432" \
-v "${PG_VOLUME}:/var/lib/postgresql/data" \
"$PG_IMAGE" >/dev/null
fi
echo_i "Waiting for PostgreSQL..."
until docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" >/dev/null 2>&1; do
sleep 1
done
# The container is created with a single database, so create the requested one
# if it does not exist yet (e.g. a second instance with its own database).
if ! docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = '$PG_DB'" | grep -q 1; then
echo_i "Creating database '$PG_DB'..."
docker exec "$PG_CONTAINER" createdb -U "$PG_USER" "$PG_DB"
fi
# Do not echo the DSN: it embeds the password.
echo_s "PostgreSQL ready (user=$PG_USER, db=$PG_DB, host=${PG_CONNECT_ADDR}:${PG_PORT})"
export BAL_SERVER_PG_DSN="$PG_DSN"

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,27 @@
use actix_governor::{Governor, GovernorConfigBuilder};
use actix_web::middleware;
use actix_governor::{
Governor, GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
};
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::middleware::{self, Next};
use actix_web::web::Bytes;
use actix_web::{App, HttpResponse, HttpServer, Responder, web};
use actix_web::{App, Error, HttpResponse, HttpServer, Responder, web};
use bitcoin::{Network, Transaction, consensus};
use chrono::Utc;
use hex_conservative::FromHex;
use log::{debug, error, info, trace};
use serde::{Deserialize, Serialize};
use sqlite::State;
use sqlite::{Connection, Value};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::sync::Mutex;
use std::net::IpAddr;
use std::str::FromStr;
use std::time::Duration;
use bal_server::db::{
check_duplicate_txids, create_database, execute_insert, get_all_addresses_by_xpub,
get_last_used_address_by_ip, get_next_address_index, insert_xpub, open_db, save_new_address,
DatabasePool, InsertInpData, InsertOutData, InsertTxData, check_duplicate_txids,
create_database, get_all_addresses_by_xpub, get_last_used_address_by_ip,
get_next_address_index, get_stats, insert_xpub, open_database, save_new_address, search_tx,
};
use bal_server::xpub::new_address_from_xpub;
@@ -56,7 +61,9 @@ struct MyConfig {
info: String,
bind_address: String,
bind_port: u16,
db_backend: String,
db_file: String,
pg_dsn: String,
pub_key_path: String,
expose_stats: bool,
}
@@ -71,7 +78,9 @@ impl Default for MyConfig {
mainnet: NetConfig::default_network("bitcoin".to_string(), Network::Bitcoin),
bind_address: "127.0.0.1".to_string(),
bind_port: 9137,
db_backend: "sqlite".to_string(),
db_file: "bal.db".to_string(),
pg_dsn: String::new(),
info: "Will Executor Server".to_string(),
pub_key_path: "public_key.pem".to_string(),
expose_stats: env::var("BAL_SERVER_EXPOSE_STATS")
@@ -118,7 +127,6 @@ pub struct StatsResponse {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct ActixConfig {
max_body_size: usize,
timeout_secs: u64,
@@ -128,6 +136,7 @@ struct ActixConfig {
rate_limit_default: (u64, u32),
workers: usize,
max_connections: usize,
trusted_proxy: IpAddr,
}
fn parse_actix_config() -> ActixConfig {
@@ -188,11 +197,15 @@ fn parse_actix_config() -> ActixConfig {
.unwrap_or("100".to_string())
.parse::<usize>()
.unwrap_or(100),
trusted_proxy: env::var("BAL_SERVER_TRUSTED_PROXY")
.unwrap_or_else(|_| "127.0.0.1".to_string())
.parse::<IpAddr>()
.unwrap_or(IpAddr::from_str("127.0.0.1").unwrap()),
}
}
struct AppState {
db: Mutex<Connection>,
db: DatabasePool,
cfg: MyConfig,
}
@@ -217,9 +230,108 @@ async fn echo_version() -> impl Responder {
HttpResponse::Ok().body(VERSION)
}
fn extract_real_ip(req: &actix_web::HttpRequest, trusted_proxy: IpAddr) -> String {
let peer_ip = req.peer_addr().map(|socket| socket.ip());
let connection_info = req.connection_info();
let ip = match peer_ip {
Some(peer) if peer == trusted_proxy => {
connection_info.realip_remote_addr().unwrap_or("unknown")
}
_ => connection_info.peer_addr().unwrap_or("unknown"),
};
debug!("client IP: {}", ip);
ip.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RealIpKeyExtractor;
impl KeyExtractor for RealIpKeyExtractor {
type Key = IpAddr;
type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
let proxy_ip = req
.app_data::<web::Data<IpAddr>>()
.map(|ip| *ip.get_ref())
.unwrap_or_else(|| IpAddr::from_str("0.0.0.0").unwrap());
let peer_ip = req.peer_addr().map(|socket| socket.ip());
let connection_info = req.connection_info();
match peer_ip {
Some(peer) if peer == proxy_ip => connection_info
.realip_remote_addr()
.ok_or_else(|| {
SimpleKeyExtractionError::new("Could not extract real IP address from request")
})
.and_then(|str| {
str.parse::<IpAddr>().map_err(|_| {
SimpleKeyExtractionError::new(
"Could not extract real IP address from request",
)
})
}),
_ => connection_info
.peer_addr()
.ok_or_else(|| {
SimpleKeyExtractionError::new("Could not extract peer IP address from request")
})
.and_then(|str| {
str.parse::<IpAddr>().map_err(|_| {
SimpleKeyExtractionError::new(
"Could not extract peer IP address from request",
)
})
}),
}
}
}
/// Concrete governor configuration type used by all rate-limited routes.
///
/// `NoOpMiddleware` is the default `actix-governor` middleware; it is only used
/// here as a type parameter so a single helper can build every route's config.
type GovernorConfigType =
GovernorConfig<RealIpKeyExtractor, actix_governor::governor::middleware::NoOpMiddleware>;
/// Build a rate-limiter configuration keyed by the real client IP.
///
/// A period of zero is rejected by `actix-governor`, so the values are clamped
/// to a minimum of one to avoid a `None` result.
fn build_governor_config(per_sec: u64, burst: u32) -> Result<GovernorConfigType, String> {
GovernorConfigBuilder::default()
.seconds_per_request(per_sec.max(1))
.burst_size(burst.max(1))
.key_extractor(RealIpKeyExtractor)
.finish()
.ok_or_else(|| "invalid rate limiter configuration".to_string())
}
/// Middleware that aborts a request with `408 Request Timeout` if the inner
/// handler (including request body reading) exceeds the configured duration.
///
/// The duration is read from the `web::Data<u64>` app data registered in `main`.
async fn request_timeout<B>(
timeout_secs: web::Data<u64>,
req: ServiceRequest,
next: Next<B>,
) -> Result<ServiceResponse<B>, Error>
where
B: MessageBody,
{
match tokio::time::timeout(Duration::from_secs(**timeout_secs), next.call(req)).await {
Ok(result) => result,
Err(_) => Err(actix_web::error::ErrorRequestTimeout("request timeout")),
}
}
async fn echo_info(
path: web::Path<String>,
data: web::Data<AppState>,
proxy: web::Data<IpAddr>,
req: actix_web::HttpRequest,
) -> impl Responder {
let param = path.into_inner();
@@ -232,18 +344,7 @@ async fn echo_info(
debug!("network disabled {}", param);
return HttpResponse::BadRequest().body("error");
}
let remote_addr = req
.headers()
.get("X-Real-IP")
.and_then(|value| value.to_str().ok())
.and_then(|xff| xff.split(',').next())
.map(|ip| ip.trim().to_string())
.unwrap_or_else(|| {
req.connection_info()
.peer_addr()
.unwrap_or("unknown")
.to_string()
});
let remote_addr = extract_real_ip(&req, **proxy);
let address = match netconfig.xpub {
false => {
let address = netconfig.address.to_string();
@@ -251,35 +352,26 @@ async fn echo_info(
address
}
true => {
// Lock #1: fetch existing address OR atomically claim next index
let next_idx = {
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned in echo_info (lookup phase)");
return HttpResponse::InternalServerError().body("error");
}
};
match get_last_used_address_by_ip(
&db,
&netconfig.name,
&netconfig.address,
&remote_addr,
) {
Some(address) => {
return HttpResponse::Ok().json(InfoResponse {
address,
base_fee: netconfig.fixed_fee,
chain: netconfig.network.to_string(),
info: data.cfg.info.to_string(),
version: VERSION.to_string(),
});
}
None => get_next_address_index(&db, &netconfig.name, &netconfig.address),
}
}; // lock released
if let Some(address) = get_last_used_address_by_ip(
&data.db,
&netconfig.name,
&netconfig.address,
&remote_addr,
)
.await
{
return HttpResponse::Ok().json(InfoResponse {
address,
base_fee: netconfig.fixed_fee,
chain: netconfig.network.to_string(),
info: data.cfg.info.to_string(),
version: VERSION.to_string(),
});
}
let next_idx =
get_next_address_index(&data.db, &netconfig.name, &netconfig.address).await;
// Derive address (CPU-bound, no lock held)
let derived =
match new_address_from_xpub(&netconfig.address, next_idx.1, netconfig.network) {
Ok(address) => address,
@@ -289,20 +381,10 @@ async fn echo_info(
}
};
// Lock #2: save the newly derived address
{
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned in echo_info (save phase)");
return HttpResponse::InternalServerError().body("error");
}
};
save_new_address(&db, next_idx.0, &derived.0, &derived.1, &remote_addr);
debug!("save new address {} {}", derived.0, derived.1);
trace!("next {} {}", next_idx.0, next_idx.1);
derived.0
} // lock released
save_new_address(&data.db, next_idx.0, &derived.0, &derived.1, &remote_addr).await;
debug!("save new address {} {}", derived.0, derived.1);
trace!("next {} {}", next_idx.0, next_idx.1);
derived.0
}
};
let info = InfoResponse {
@@ -336,90 +418,33 @@ async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl
if !data.cfg.expose_stats {
return HttpResponse::Forbidden().body("error");
}
let mut stats: Vec<StatsResponse> = vec![];
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned in echo_stats");
return HttpResponse::InternalServerError().body("error");
}
};
let mut stmt = match db.prepare(
"SELECT report_date, chain, totals, waiting, sent, failed, waiting_profit, sent_profit, missed_profit, unique_inputs FROM tbl_stats WHERE chain = ?"
) {
Ok(s) => s,
let stats_rows = match get_stats(&data.db, &netconfig.name).await {
Ok(rows) => rows,
Err(e) => {
error!("Failed to prepare stats query: {}", e);
error!("Failed to query stats: {}", e);
return HttpResponse::InternalServerError().body("error");
}
};
if let Err(e) = stmt.bind((1, Value::String(netconfig.name.clone()))) {
error!("Failed to bind chain in stats query: {}", e);
return HttpResponse::InternalServerError().body("error");
}
while let Ok(State::Row) = stmt.next() {
let report_date = stmt.read("report_date").unwrap_or("0".to_string());
let chain = stmt.read("chain").unwrap_or("?".to_string());
let totals = stmt
.read("totals")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let waiting = stmt
.read("waiting")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let sent = stmt
.read("sent")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let failed = stmt
.read("failed")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let waiting_profit = stmt
.read("waiting_profit")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let sent_profit = stmt
.read("sent_profit")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let missed_profit = stmt
.read("missed_profit")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let unique_inputs = stmt
.read("unique_inputs")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
stats.push(StatsResponse {
report_date,
chain,
totals,
waiting,
sent,
failed,
waiting_profit,
sent_profit,
missed_profit,
unique_inputs,
});
}
match serde_json::to_string(&stats) {
Ok(json_data) => {
debug!("echo info reply: {}", json_data);
HttpResponse::Ok().json(stats)
}
Err(_err) => HttpResponse::InternalServerError().body("error"),
}
let stats: Vec<StatsResponse> = stats_rows
.into_iter()
.map(|row| StatsResponse {
report_date: row.report_date,
chain: row.chain,
totals: row.totals,
waiting: row.waiting,
sent: row.sent,
failed: row.failed,
waiting_profit: row.waiting_profit,
sent_profit: row.sent_profit,
missed_profit: row.missed_profit,
unique_inputs: row.unique_inputs,
})
.collect();
debug!("echo stats reply for chain: {}", netconfig.name);
HttpResponse::Ok().json(stats)
}
async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
@@ -437,93 +462,42 @@ async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
return HttpResponse::BadRequest().body("error");
}
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned in echo_search");
return HttpResponse::InternalServerError().body("error");
}
};
let mut statement = match db.prepare("SELECT * FROM tbl_tx WHERE txid = ? LIMIT 1") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare statement: {}", e);
return HttpResponse::InternalServerError().body("error");
}
};
if let Err(e) = statement.bind((1, strbody)) {
error!("Failed to bind parameter: {}", e);
return HttpResponse::InternalServerError().body("error");
}
match search_tx(&data.db, strbody).await {
Ok(Some(row)) => {
let mut response_data = HashMap::new();
response_data.insert("status", row.status);
response_data.insert("tx", row.tx);
response_data.insert("our_address", row.our_address);
response_data.insert("our_fees", row.our_fees);
response_data.insert("time", row.reqid);
if let Ok(State::Row) = statement.next() {
let mut response_data = HashMap::new();
match statement.read::<String, _>("status") {
Ok(value) => {
response_data.insert("status", value);
}
Err(e) => {
error!("Error reading status: {}", e);
match serde_json::to_string(&response_data) {
Ok(json_data) => {
debug!("echo search reply: {}", json_data);
HttpResponse::Ok().json(&response_data)
}
Err(_) => HttpResponse::BadRequest().body("error"),
}
}
match statement.read::<String, _>("tx") {
Ok(value) => {
response_data.insert("tx", value);
}
Err(e) => {
error!("Error reading tx: {}", e);
}
Ok(None) => HttpResponse::BadRequest().body("error"),
Err(e) => {
error!("Failed to search tx: {}", e);
HttpResponse::InternalServerError().body("error")
}
match statement.read::<String, _>("our_address") {
Ok(value) => {
response_data.insert("our_address", value);
}
Err(e) => {
error!("Error reading address: {}", e);
}
}
match statement.read::<String, _>("our_fees") {
Ok(value) => {
response_data.insert("our_fees", value);
}
Err(e) => {
error!("Error reading fees: {}", e);
}
}
match statement.read::<String, _>("reqid") {
Ok(value) => {
response_data.insert("time", value);
}
Err(e) => {
error!("Error reading reqid: {}", e);
}
}
match serde_json::to_string(&response_data) {
Ok(json_data) => {
debug!("echo search reply: {}", json_data);
HttpResponse::Ok().json(&response_data)
}
Err(_) => HttpResponse::BadRequest().body("error"),
}
} else {
HttpResponse::BadRequest().body("error")
}
}
/// Holds a transaction that has already been parsed and validated outside the DB lock.
#[derive(Clone)]
struct ParsedTx {
txid: String,
wtxid: String,
ntxid: String,
raw_hex: String, // the original line
raw_hex: String,
locktime: String,
inputs: Vec<(String, String)>, // (in_txid, in_vout)
outputs: Vec<(usize, String, u64)>, // (idx, script_pubkey, amount_sat)
inputs: Vec<(String, String)>,
outputs: Vec<(usize, String, u64)>,
}
/// Parse all transactions from the request body **without** needing the DB lock.
/// Skips transactions that don't have a valid willexecutor output.
fn parse_request_transactions(
strbody: &str,
_req_time: i64,
@@ -531,7 +505,7 @@ fn parse_request_transactions(
known_addresses: &HashSet<String>,
) -> Vec<(ParsedTx, String, u64)> {
let mut result: Vec<(ParsedTx, String, u64)> = Vec::new();
let mut union_tx = true;
let mut seen_txids: HashSet<String> = HashSet::new();
for line in strbody.split('\n') {
if line.is_empty() {
@@ -557,11 +531,14 @@ fn parse_request_transactions(
};
let txid = tx.compute_txid().to_string();
if !seen_txids.insert(txid.clone()) {
trace!("duplicate txid {} in request body, skipping", txid);
continue;
}
let ntxid = tx.compute_ntxid();
let wtxid = tx.compute_wtxid();
let locktime = tx.lock_time.to_string();
// Collect inputs
let mut inputs: Vec<(String, String)> = Vec::with_capacity(tx.input.len());
for input in tx.input {
inputs.push((
@@ -570,7 +547,6 @@ fn parse_request_transactions(
));
}
// Collect outputs and find which one is ours + its amount
let mut outputs: Vec<(usize, String, u64)> = Vec::with_capacity(tx.output.len());
let mut found = false;
let mut our_address = String::new();
@@ -586,13 +562,25 @@ fn parse_request_transactions(
netconfig.network,
) {
Ok(addr) => addr.to_string(),
Err(_) => continue, // skip un-decodable outputs
Err(_) => continue,
};
let expected_ours = if netconfig.xpub {
if known_addresses.contains(&address) {
trace!(
"output {} address {} found in known_addresses (total: {})",
idx,
&address,
known_addresses.len()
);
address.clone()
} else {
trace!(
"output {} address {} NOT in known_addresses (total: {}), skipping",
idx,
&address,
known_addresses.len()
);
continue;
}
} else {
@@ -604,6 +592,11 @@ fn parse_request_transactions(
our_fees = amount;
found = true;
trace!("address and fees are correct {}: {}", our_address, our_fees);
} else if address == expected_ours {
trace!(
"output {} address matches but amount {} < fixed_fee {}, skipping",
idx, amount, netconfig.fixed_fee
);
}
}
@@ -615,11 +608,6 @@ fn parse_request_transactions(
trace!("willexecutor output not found for tx {}, skipping", txid);
continue;
}
if !union_tx {
// This is only used for SQL building later; we track it in the caller
} else {
union_tx = false;
}
result.push((
ParsedTx {
txid,
@@ -669,26 +657,17 @@ async fn echo_push(
};
// ===== PHASE 1: parse all transactions WITHOUT the DB lock =====
let known_addresses: HashSet<String> = {
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned acquiring addresses in echo_push");
let known_addresses: HashSet<String> = if netconfig.xpub {
match get_all_addresses_by_xpub(&data.db, &netconfig.address).await {
Ok(addrs) => addrs,
Err(e) => {
error!("Failed to load addresses from xpub: {}", e);
return HttpResponse::InternalServerError().body("error");
}
};
if netconfig.xpub {
match get_all_addresses_by_xpub(&db, &netconfig.address) {
Ok(addrs) => addrs,
Err(e) => {
error!("Failed to load addresses from xpub: {}", e);
return HttpResponse::InternalServerError().body("error");
}
}
} else {
HashSet::new()
}
}; // lock released here
} else {
HashSet::new()
};
// Parse all transactions (CPU-bound, no DB needed)
let parsed = parse_request_transactions(strbody, req_time, netconfig, &known_addresses);
@@ -699,134 +678,86 @@ async fn echo_push(
let all_txids: Vec<String> = parsed.iter().map(|(p, _, _)| p.txid.clone()).collect();
// ===== PHASE 2: check duplicates in a single batch query =====
let duplicates = {
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned in echo_push duplicate check");
return HttpResponse::InternalServerError().body("error");
}
};
match check_duplicate_txids(&db, &all_txids) {
Ok(dups) => dups,
Err(e) => {
error!("Duplicate check failed: {}", e);
return HttpResponse::InternalServerError().body("error");
}
let duplicates = match check_duplicate_txids(&data.db, &all_txids).await {
Ok(dups) => dups,
Err(e) => {
error!("Duplicate check failed: {}", e);
return HttpResponse::InternalServerError().body("error");
}
}; // lock released here
};
let all_present = all_txids.iter().all(|t| duplicates.contains(t));
if all_present {
return HttpResponse::Ok().body("already present");
}
// ===== PHASE 3: build insert statements and execute (single DB lock, minimal time) =====
// ===== PHASE 3: build insert data and execute (single transaction, minimal time) =====
let mut tx_data = Vec::new();
let mut inp_data = Vec::new();
let mut out_data = Vec::new();
for (parsed, our_address, our_fees) in &parsed {
if duplicates.contains(&parsed.txid) {
continue;
}
tx_data.push(InsertTxData {
txid: parsed.txid.clone(),
wtxid: parsed.wtxid.clone(),
ntxid: parsed.ntxid.clone(),
raw_hex: parsed.raw_hex.clone(),
locktime: parsed.locktime.clone(),
reqid: req_time.to_string(),
network: netconfig.name.clone(),
our_address: our_address.clone(),
our_fees: our_fees.to_string(),
});
for (in_txid, in_vout) in &parsed.inputs {
inp_data.push(InsertInpData {
txid: parsed.txid.clone(),
in_txid: in_txid.clone(),
in_vout: in_vout.clone(),
});
}
for (idx, script, amount) in &parsed.outputs {
out_data.push(InsertOutData {
txid: parsed.txid.clone(),
vout: i64::try_from(*idx).unwrap_or(-1),
script_pubkey: script.clone(),
amount: i64::try_from(*amount).unwrap_or(0),
});
}
}
if tx_data.is_empty() {
return HttpResponse::Ok().body("already present");
}
if let Err(err) = bal_server::db::execute_insert(&data.db, &tx_data, &inp_data, &out_data).await
{
let db = match data.db.lock() {
Ok(g) => g,
Err(_p) => {
error!("DB mutex poisoned in echo_push insert phase");
return HttpResponse::InternalServerError().body("error");
}
};
let sqltxshead = "INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, reqid, network, our_address, our_fees)".to_string();
let mut sqltxs = String::new();
let sqlinpshead = "INSERT INTO tbl_inp (txid, in_txid, in_vout )".to_string();
let mut sqlinps = String::new();
let sqloutshead = "INSERT INTO tbl_out (txid, vout, script_pubkey, amount )".to_string();
let mut sqlouts = String::new();
let mut union_tx = true;
let mut union_inps = true;
let mut union_outs = true;
let mut ptx: Vec<(usize, Value)> = vec![];
let mut pinps: Vec<(usize, Value)> = vec![];
let mut pouts: Vec<(usize, Value)> = vec![];
let mut linenum = 1usize;
let mut lineinp = 1usize;
let mut lineout = 1usize;
for (parsed, our_address, our_fees) in &parsed {
if duplicates.contains(&parsed.txid) {
continue;
}
if !union_tx {
sqltxs.push_str(" UNION ALL");
} else {
union_tx = false;
}
sqltxs.push_str(" SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?");
ptx.push((linenum, Value::String(parsed.txid.clone())));
ptx.push((linenum + 1, Value::String(parsed.wtxid.clone())));
ptx.push((linenum + 2, Value::String(parsed.ntxid.clone())));
ptx.push((linenum + 3, Value::String(parsed.raw_hex.clone())));
ptx.push((linenum + 4, Value::String(parsed.locktime.clone())));
ptx.push((linenum + 5, Value::String(req_time.to_string())));
ptx.push((linenum + 6, Value::String(netconfig.name.clone())));
ptx.push((linenum + 7, Value::String(our_address.clone())));
ptx.push((linenum + 8, Value::String(our_fees.to_string())));
linenum += 9;
for (in_txid, in_vout) in &parsed.inputs {
if !union_inps {
sqlinps.push_str(" UNION ALL");
} else {
union_inps = false;
}
sqlinps.push_str(" SELECT ?, ?, ?");
pinps.push((lineinp, Value::String(parsed.txid.clone())));
pinps.push((lineinp + 1, Value::String(in_txid.clone())));
pinps.push((lineinp + 2, Value::String(in_vout.clone())));
lineinp += 3;
}
for (idx, script, amount) in &parsed.outputs {
if !union_outs {
sqlouts.push_str(" UNION ALL");
} else {
union_outs = false;
}
sqlouts.push_str(" SELECT ?, ?, ?, ?");
pouts.push((lineout, Value::String(parsed.txid.clone())));
pouts.push((
lineout + 1,
Value::Integer(i64::try_from(*idx).unwrap_or(-1)),
));
pouts.push((lineout + 2, Value::String(script.clone())));
pouts.push((
lineout + 3,
Value::Integer(i64::try_from(*amount).unwrap_or(0)),
));
lineout += 4;
}
}
if sqltxs.is_empty() {
return HttpResponse::Ok().body("already present");
}
let sqltxs = format!("{}{};", sqltxshead, sqltxs);
let sqlinps = format!("{}{};", sqlinpshead, sqlinps);
let sqlouts = format!("{}{};", sqloutshead, sqlouts);
if let Err(err) = execute_insert(&db, sqltxs, ptx, sqlinps, pinps, sqlouts, pouts) {
error!("execute_insert failed: {}", err);
return HttpResponse::BadRequest().body("error");
}
} // lock released
error!("execute_insert failed: {}", err);
return HttpResponse::BadRequest().body("error");
}
HttpResponse::Ok().body("thx")
}
fn parse_env(data: &MyConfig) -> MyConfig {
let mut cfg = data.clone();
if let Ok(value) = env::var("BAL_SERVER_DB_BACKEND") {
debug!("BAL_SERVER_DB_BACKEND: {}", value);
cfg.db_backend = value;
}
if let Ok(value) = env::var("BAL_SERVER_DB_FILE") {
debug!("BAL_SERVER_DB_FILE: {}", value);
cfg.db_file = value;
}
if let Ok(value) = env::var("BAL_SERVER_PG_DSN") {
debug!("BAL_SERVER_PG_DSN: {}", value);
cfg.pg_dsn = value;
}
if let Ok(value) = env::var("BAL_SERVER_BIND_ADDRESS") {
debug!("BAL_SERVER_BIND_ADDRESS: {}", value);
cfg.bind_address = value;
@@ -865,7 +796,10 @@ fn parse_env_netconfig(cfg: &mut MyConfig, chain: &str) {
if let Ok(value) = env::var(format!("BAL_SERVER_{}_ADDRESS", chain.to_uppercase())) {
debug!("BAL_SERVER_{}_ADDRESS: {}", chain.to_uppercase(), value);
c.address = value;
if c.address.len() > 5 && &c.address[1..4] == "pub" {
let is_xpub = ["xpub", "ypub", "zpub", "tpub", "vpub", "upub"]
.iter()
.any(|prefix| c.address.starts_with(prefix));
if is_xpub {
c.xpub = true;
trace!("is_xpub");
}
@@ -879,10 +813,10 @@ fn parse_env_netconfig(cfg: &mut MyConfig, chain: &str) {
}
}
fn init_network(db: &Connection, cfg: &MyConfig) {
async fn init_network(pool: &DatabasePool, cfg: &MyConfig) {
for network in NETWORKS {
let netconfig = cfg.get_net_config(network);
insert_xpub(db, &netconfig.name.to_string(), &netconfig.address);
insert_xpub(pool, &netconfig.name.to_string(), &netconfig.address).await;
}
}
@@ -893,43 +827,61 @@ async fn main() -> std::io::Result<()> {
let actix_cfg = parse_actix_config();
let cfg = parse_env(&cfg);
let db = match open_db(&cfg.db_file) {
Ok(c) => c,
let connection_string = match cfg.db_backend.as_str() {
"sqlite" => cfg.db_file.clone(),
"postgresql" => cfg.pg_dsn.clone(),
other => {
return Err(std::io::Error::other(format!(
"Unknown DB backend: {}",
other
)));
}
};
let db = match open_database(&cfg.db_backend, &connection_string).await {
Ok(pool) => pool,
Err(e) => {
return Err(std::io::Error::other(e));
}
};
// Create database tables
create_database(&db);
create_database(&db)
.await
.map_err(|e| std::io::Error::other(format!("Failed to create database: {}", e)))?;
// Initialize networks
init_network(&db, &cfg);
init_network(&db, &cfg).await;
let data = web::Data::new(AppState {
db: Mutex::new(db),
db,
cfg: cfg.clone(),
});
// Initialize networks
{
let db = data.db.lock().unwrap();
for network in NETWORKS {
let netconfig = data.cfg.get_net_config(network);
insert_xpub(&db, &netconfig.name.to_string(), &netconfig.address);
}
}
let bind_address = data.cfg.bind_address.clone();
let bind_port = data.cfg.bind_port;
// Use a single global rate limiter with the most conservative settings (1 req/sec)
// Per-endpoint rate limiting requires advanced configuration with explicit types
let governor_conf = GovernorConfigBuilder::const_default()
.seconds_per_request(actix_cfg.rate_limit_pushtxs.0) // Most restrictive: 1 req/sec
.burst_size(actix_cfg.rate_limit_pushtxs.1) // Burst: 3
.finish()
.unwrap();
// One shared limiter per endpoint class: all workers clone the same
// underlying state, so the rate limit is global per real client IP.
let governor_pushtxs = build_governor_config(
actix_cfg.rate_limit_pushtxs.0,
actix_cfg.rate_limit_pushtxs.1,
)
.map_err(std::io::Error::other)?;
let governor_searchtx = build_governor_config(
actix_cfg.rate_limit_searchtx.0,
actix_cfg.rate_limit_searchtx.1,
)
.map_err(std::io::Error::other)?;
let governor_info =
build_governor_config(actix_cfg.rate_limit_info.0, actix_cfg.rate_limit_info.1)
.map_err(std::io::Error::other)?;
let governor_default = build_governor_config(
actix_cfg.rate_limit_default.0,
actix_cfg.rate_limit_default.1,
)
.map_err(std::io::Error::other)?;
println!("Starting server on http://{}:{}", bind_address, bind_port);
@@ -937,16 +889,46 @@ async fn main() -> std::io::Result<()> {
App::new()
.app_data(web::PayloadConfig::default().limit(actix_cfg.max_body_size))
.app_data(data.clone())
.app_data(web::Data::new(actix_cfg.trusted_proxy))
.app_data(web::Data::new(actix_cfg.timeout_secs))
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.wrap(Governor::new(&governor_conf))
.service(web::resource("/").route(web::get().to(echo_home)))
.service(web::resource("/.pub_key.pem").route(web::get().to(echo_pub_key)))
.service(web::resource("/version").route(web::get().to(echo_version)))
.service(web::resource("/{network}/info").route(web::get().to(echo_info)))
.service(web::resource("/{network}/stats").route(web::get().to(echo_stats)))
.service(web::resource("/{network}/pushtxs").route(web::post().to(echo_push)))
.service(web::resource("/searchtx").route(web::post().to(echo_search)))
.wrap(middleware::from_fn(request_timeout))
.service(
web::resource("/")
.wrap(Governor::new(&governor_default))
.route(web::get().to(echo_home)),
)
.service(
web::resource("/.pub_key.pem")
.wrap(Governor::new(&governor_default))
.route(web::get().to(echo_pub_key)),
)
.service(
web::resource("/version")
.wrap(Governor::new(&governor_default))
.route(web::get().to(echo_version)),
)
.service(
web::resource("/{network}/info")
.wrap(Governor::new(&governor_info))
.route(web::get().to(echo_info)),
)
.service(
web::resource("/{network}/stats")
.wrap(Governor::new(&governor_info))
.route(web::get().to(echo_stats)),
)
.service(
web::resource("/{network}/pushtxs")
.wrap(Governor::new(&governor_pushtxs))
.route(web::post().to(echo_push)),
)
.service(
web::resource("/searchtx")
.wrap(Governor::new(&governor_searchtx))
.route(web::post().to(echo_search)),
)
})
.workers(actix_cfg.workers)
.max_connections(actix_cfg.max_connections)

411
src/db.rs
View File

@@ -1,411 +0,0 @@
use log::{error, info, trace, warn};
use sqlite::{Connection, Error, State, Value};
use std::collections::HashSet;
use std::path::Path;
use std::thread;
use std::time::Duration;
/// Check which txids are already present in the database in a single batch query.
/// Returns a HashSet of txids that already exist (duplicates).
/// This is O(1) per query regardless of the number of txids, replacing the N+1 pattern.
pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSet<String>, Error> {
if txids.is_empty() {
return Ok(HashSet::new());
}
// Build a single query with all txids using IN clause placeholders
// SQLite supports up to 1000 parameters per statement, so we chunk for safety
let mut duplicates = HashSet::new();
let chunk_size = 500; // Safe chunk size for SQLite parameters
for chunk in txids.chunks(chunk_size) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!("SELECT txid FROM tbl_tx WHERE txid IN ({})", placeholders);
let mut stmt = db.prepare(sql)?;
for (i, txid) in chunk.iter().enumerate() {
stmt.bind((i + 1, Value::String(txid.clone())))?;
}
while let Ok(State::Row) = stmt.next() {
if let Ok(txid) = stmt.read::<String, _>("txid") {
duplicates.insert(txid);
}
}
}
Ok(duplicates)
}
/// Validates and opens the SQLite database, enforcing security best practices:
/// - Path must not contain `..` (directory traversal).
/// - Absolute paths must not target known system directories.
/// - If the file exists, it must be a regular file (not a symlink or device).
/// - WAL journal mode is enabled for safe concurrent access.
/// - Synchronous is set to NORMAL for performance with safety.
///
/// Returns `Err` on validation failure or open error to prevent panics.
pub fn open_db(path: &str) -> Result<Connection, String> {
let p = Path::new(path);
// Prevent directory traversal
for component in p.components() {
if component == std::path::Component::ParentDir {
return Err("Database path may not contain '..'".to_string());
}
}
// If absolute, block known sensitive system directories
if p.is_absolute() {
let path_str = p.to_str().unwrap_or("");
let forbidden = [
"/etc", "/proc", "/sys", "/dev", "/usr", "/bin", "/sbin", "/lib", "/opt",
];
for prefix in &forbidden {
if path_str.starts_with(prefix) {
return Err(format!(
"Absolute database path under {} is forbidden",
prefix
));
}
}
}
// If file exists, must be a regular file (not a symlink, device, etc.)
if p.exists() {
if p.is_symlink() {
return Err("Database path must not be a symlink".to_string());
}
let metadata = std::fs::metadata(p)
.map_err(|e| format!("Cannot access database file metadata: {}", e))?;
if !metadata.is_file() {
return Err(
"Database path must point to a regular file, not a directory or device".to_string(),
);
}
}
let conn = sqlite::open(path).map_err(|e| format!("Failed to open SQLite database: {}", e))?;
// Set busy timeout BEFORE WAL mode so SQLite waits instead of failing immediately.
// This handles the race where two processes (server + pusher) open the same DB
// and both try to enable WAL mode concurrently.
conn.execute("PRAGMA busy_timeout = 5000;")
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
// Retry WAL mode up to 5 times (handles concurrent open from bal-pusher).
let mut wal_ok = false;
for attempt in 0..5 {
match conn.execute("PRAGMA journal_mode = WAL;") {
Ok(_) => {
wal_ok = true;
break;
}
Err(e) => {
warn!(
"WAL mode attempt {}/5 failed: {}, retrying in 100ms...",
attempt + 1,
e
);
thread::sleep(Duration::from_millis(100));
}
}
}
if !wal_ok {
// WAL might already be enabled by another process; this is not fatal.
warn!("Could not set WAL mode after retries — may already be enabled by another process");
}
conn.execute("PRAGMA synchronous = NORMAL;")
.map_err(|e| format!("Failed to set synchronous NORMAL: {}", e))?;
Ok(conn)
}
/// Loads all known addresses for a given xpub into a HashSet for fast
/// in-memory lookup during transaction validation (replaces N+1 query).
pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error> {
let mut stmt = db.prepare(
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?",
)?;
stmt.bind((1, Value::String(xpub.to_string())))?;
let mut addresses = HashSet::new();
while let Ok(State::Row) = stmt.next() {
match stmt.read::<String, _>("address") {
Ok(addr) => {
addresses.insert(addr);
}
Err(e) => {
error!("Failed to read address column: {}", e);
}
}
}
Ok(addresses)
}
pub fn create_database(db: &Connection) {
info!("database sanity check");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_tx (txid PRIMARY KEY, date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP, date_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, wtxid, ntxid, tx, locktime integer, network, network_fees, reqid, our_fees, our_address, status integer DEFAULT 0);");
let _ = db.execute("ALTER TABLE tbl_tx ADD COLUMN push_err TEXT");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_inp(id, txid, in_txid, in_vout);");
let _ = db.execute("CREATE UNIQUE INDEX ON tbl_inp(txid,in_txid,in_vout);");
let _ =
db.execute("CREATE TABLE IF NOT EXISTS tbl_out(id, txid, script_pubkey, amount, vout);");
let _ = db.execute("CREATE UNIQUE INDEX ON tbl_out(txid, script_pubkey, amount, vout);");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_xpub (id INTEGER PRIMARY KEY , network TEXT, xpub TEXT, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,path_idx INTEGER DEFAULT -1);");
let _ = db.execute("CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub)");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_address (address TEXT PRIMARY_KEY, path TEXT NOT NULL, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, xpub INTEGER,remote_address TEXT);");
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_stats (report_date TEXT, chain TEXT, totals INTEGER, waiting INTEGER, sent INTEGER, failed INTEGER, waiting_profit INTEGER, sent_profit INTEGER, missed_profit INTEGER, unique_inputs INTEGER);");
// UNIQUE index required for ON CONFLICT(chain) DO UPDATE in calculate_stats
let _ = db.execute("DROP INDEX IF EXISTS idx_stats_chain;");
let _ = db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);");
let _ = db.execute("UPDATE tbl_tx set network='bitcoin' where network='mainnet');");
}
/*
pub fn get_xpub_id(db: &Connection, network: &String, xpub: &String) -> Option<i64>{
let mut stmt = db.prepare("SELECT * FROM tbl_xpub where network = ? and xpub = ?;").unwrap();
let _ = stmt.bind((1,Value::String(network.to_string()))).unwrap();
let _ = stmt.bind((2,Value::String(xpub.to_string()))).unwrap();
if let Ok(State::Row) = stmt.next(){
return Some(stmt.read::<i64, _>("id").unwrap());
} else {
return None;
}
}
*/
pub fn insert_xpub(db: &Connection, network: &str, xpub: &str) {
if !xpub.is_empty() {
trace!("going to insert: {} xpub:{}", network, xpub);
let mut stmt = match db.prepare("INSERT INTO tbl_xpub(network,xpub) VALUES(?, ?);") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare xpub insert statement: {}", e);
return;
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter for xpub insert: {}", e);
return;
}
if let Err(e) = stmt.bind((2, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return;
}
if let Err(e) = stmt.next() {
error!("Failed to insert xpub: {}", e);
}
}
}
pub fn get_last_used_address_by_ip(
db: &Connection,
network: &String,
xpub: &String,
address: &String,
) -> Option<String> {
let mut stmt = match db.prepare("SELECT tbl_address.address FROM tbl_xpub join tbl_address on(tbl_xpub.id = tbl_address.xpub) where tbl_xpub.network = ? and tbl_address.remote_address = ? and tbl_xpub.xpub = ? ORDER BY tbl_address.date_create DESC LIMIT 1;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare address query: {}", e);
return None;
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return None;
}
if let Err(e) = stmt.bind((2, Value::String(address.to_string()))) {
error!("Failed to bind address parameter: {}", e);
return None;
}
if let Err(e) = stmt.bind((3, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return None;
}
if let Ok(State::Row) = stmt.next() {
match stmt.read::<String, _>("address") {
Ok(addr) => Some(addr),
Err(e) => {
error!("Failed to read address column: {}", e);
None
}
}
} else {
None
}
}
pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64, i64) {
let mut stmt = match db.prepare("UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = ? and xpub= ? RETURNING path_idx,id;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare xpub index update: {}", e);
return (0, 0);
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return (0, 0);
}
if let Err(e) = stmt.bind((2, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return (0, 0);
}
match stmt.next() {
Ok(State::Row) => match stmt.read::<i64, _>("path_idx") {
Ok(next) => match stmt.read::<i64, _>("id") {
Ok(id) => (id, next),
Err(e) => {
error!("Failed to read id column: {}", e);
(0, 0)
}
},
Err(e) => {
error!("Failed to read path_idx column: {}", e);
(0, 0)
}
},
Err(e) => {
error!("Failed to execute xpub index update: {}", e);
(0, 0)
}
Ok(State::Done) => (0, 0),
}
}
pub fn save_new_address(
db: &Connection,
xpub: i64,
address: &String,
path: &String,
remote_addr: &String,
) {
let mut stmt = match db.prepare(
"INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
",
) {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare address insert statement: {}", e);
return;
}
};
if let Err(e) = stmt.bind((1, Value::String(address.to_string()))) {
error!("Failed to bind address parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((2, Value::String(path.to_string()))) {
error!("Failed to bind path parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((3, Value::Integer(xpub))) {
error!("Failed to bind xpub parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((4, Value::String(remote_addr.to_string()))) {
error!("Failed to bind remote_addr parameter: {}", e);
return;
}
if let Err(e) = stmt.next() {
error!("Failed to insert address: {}", e);
}
}
pub fn execute_insert(
db: &Connection,
sqltxs: String,
ptx: Vec<(usize, Value)>,
sqlinp: String,
pinp: Vec<(usize, Value)>,
sqlout: String,
pout: Vec<(usize, Value)>,
) -> Result<(), Error> {
let _ = db.execute("BEGIN TRANSACTION");
let mut stmt = match db.prepare(sqltxs.as_str()) {
Ok(s) => s,
Err(err) => {
error!("error preparing sqltxs: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
};
if let Err(err) = stmt.bind::<&[(_, Value)]>(&ptx[..]) {
error!("error binding transaction parameters: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
if let Err(err) = stmt.next() {
error!("error inserting transactions {}", err);
let _ = db.execute("ROLLBACK");
} else {
let mut stmt = match db.prepare(sqlinp.as_str()) {
Ok(s) => s,
Err(err) => {
error!("error preparing sqlinp: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
};
if let Err(err) = stmt.bind::<&[(_, Value)]>(&pinp[..]) {
error!("error binding inputs parameters {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
if let Err(err) = stmt.next() {
error!("error inserting inputs {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
} else {
let mut stmt = match db.prepare(sqlout.as_str()) {
Ok(s) => s,
Err(err) => {
error!("error preparing sqlout: {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
};
if let Err(err) = stmt.bind::<&[(_, Value)]>(&pout[..]) {
error!("error binding outs parameters {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
if let Err(err) = stmt.next() {
error!("error inserting outs {}", err);
let _ = db.execute("ROLLBACK");
return Err(err);
}
}
}
let _ = db.execute("COMMIT");
Ok(())
}
pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error> {
let mut stmt = db
.prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;")
.map_err(|e| {
error!("Failed to prepare statement: {}", e);
e
})?;
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return Err(e);
}
match stmt.next() {
Ok(State::Row) => match stmt.read::<i64, _>("total_number") {
Ok(val) => Ok(val),
Err(e) => {
error!("Failed to read total_number column: {}", e);
Err(e)
}
},
Ok(sqlite::State::Done) => Ok(0),
Err(err) => {
error!("Failed to execute query: {}", err);
Err(err)
}
}
}

878
src/db/mod.rs Normal file
View File

@@ -0,0 +1,878 @@
pub mod schema;
use log::{error, info, trace};
use sqlx::Row;
use std::collections::HashSet;
use std::path::Path;
#[derive(Clone)]
pub enum DatabasePool {
SQLite(sqlx::SqlitePool),
PostgreSQL(sqlx::PgPool),
}
fn validate_sqlite_path(dsn: &str) -> Result<(), String> {
// Extract file path from DSN formats like "sqlite:path" or "file:path?mode=rwc"
let path_str = if let Some(rest) = dsn.strip_prefix("sqlite:") {
rest.split('?').next().unwrap_or(rest)
} else if let Some(rest) = dsn.strip_prefix("file:") {
rest.split('?').next().unwrap_or(rest)
} else {
dsn
};
// Skip validation for in-memory databases
if path_str == ":memory:" || path_str.is_empty() {
return Ok(());
}
let p = Path::new(path_str);
// Prevent directory traversal
for component in p.components() {
if component == std::path::Component::ParentDir {
return Err("Database path may not contain '..'".to_string());
}
}
// If absolute, block known sensitive system directories
if p.is_absolute() {
let forbidden = [
Path::new("/etc"),
Path::new("/proc"),
Path::new("/sys"),
Path::new("/dev"),
Path::new("/usr"),
Path::new("/bin"),
Path::new("/sbin"),
Path::new("/lib"),
Path::new("/opt"),
];
for prefix in &forbidden {
// `Path::starts_with` matches whole path components, so "/etcfoo"
// is not treated as being under "/etc".
if p.starts_with(prefix) {
return Err(format!(
"Absolute database path under {} is forbidden",
prefix.display()
));
}
}
}
// If file exists, must be a regular file (not a symlink, device, etc.)
if p.exists() {
if p.is_symlink() {
return Err("Database path must not be a symlink".to_string());
}
let metadata = std::fs::metadata(p)
.map_err(|e| format!("Cannot access database file metadata: {}", e))?;
if !metadata.is_file() {
return Err(
"Database path must point to a regular file, not a directory or device".to_string(),
);
}
}
Ok(())
}
pub async fn open_database(backend: &str, connection_string: &str) -> Result<DatabasePool, String> {
match backend {
"sqlite" => {
validate_sqlite_path(connection_string)?;
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect(connection_string)
.await
.map_err(|e| format!("Failed to open SQLite database: {}", e))?;
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&pool)
.await
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
sqlx::query("PRAGMA busy_timeout=5000")
.execute(&pool)
.await
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
Ok(DatabasePool::SQLite(pool))
}
"postgresql" => {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(connection_string)
.await
.map_err(|e| format!("Failed to open PostgreSQL database: {}", e))?;
Ok(DatabasePool::PostgreSQL(pool))
}
other => Err(format!("Unknown database backend: {}", other)),
}
}
pub async fn create_database(pool: &DatabasePool) -> Result<(), sqlx::Error> {
info!("database sanity check");
match pool {
DatabasePool::SQLite(p) => schema::create_sqlite_schema(p).await,
DatabasePool::PostgreSQL(p) => schema::create_pg_schema(p).await,
}
}
pub async fn check_duplicate_txids(
pool: &DatabasePool,
txids: &[String],
) -> Result<HashSet<String>, sqlx::Error> {
if txids.is_empty() {
return Ok(HashSet::new());
}
let mut duplicates = HashSet::new();
let chunk_size = 500;
for chunk in txids.chunks(chunk_size) {
match pool {
DatabasePool::SQLite(p) => {
let placeholders: Vec<String> = chunk.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT txid FROM tbl_tx WHERE txid IN ({})",
placeholders.join(",")
);
let mut query = sqlx::query(&sql);
for txid in chunk {
query = query.bind(txid);
}
let rows = query.fetch_all(p).await?;
for row in rows {
if let Ok(txid) = row.try_get::<String, _>("txid") {
duplicates.insert(txid);
}
}
}
DatabasePool::PostgreSQL(p) => {
let placeholders: Vec<String> = chunk
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 1))
.collect();
let sql = format!(
"SELECT txid FROM tbl_tx WHERE txid IN ({})",
placeholders.join(",")
);
let mut query = sqlx::query(&sql);
for txid in chunk {
query = query.bind(txid);
}
let rows = query.fetch_all(p).await?;
for row in rows {
if let Ok(txid) = row.try_get::<String, _>("txid") {
duplicates.insert(txid);
}
}
}
}
}
Ok(duplicates)
}
pub async fn get_all_addresses_by_xpub(
pool: &DatabasePool,
xpub: &str,
) -> Result<HashSet<String>, sqlx::Error> {
let mut addresses = HashSet::new();
trace!("get_all_addresses_by_xpub: querying for xpub={}", xpub);
match pool {
DatabasePool::SQLite(p) => {
let rows = sqlx::query(
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?",
)
.bind(xpub)
.fetch_all(p)
.await?;
trace!(
"get_all_addresses_by_xpub: SQLite returned {} rows",
rows.len()
);
for row in rows {
if let Ok(addr) = row.try_get::<String, _>("address") {
trace!("get_all_addresses_by_xpub: address={}", &addr);
addresses.insert(addr);
}
}
}
DatabasePool::PostgreSQL(p) => {
let rows = sqlx::query(
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = $1",
)
.bind(xpub)
.fetch_all(p)
.await?;
trace!(
"get_all_addresses_by_xpub: PostgreSQL returned {} rows",
rows.len()
);
for row in rows {
if let Ok(addr) = row.try_get::<String, _>("address") {
trace!("get_all_addresses_by_xpub: address={}", &addr);
addresses.insert(addr);
}
}
}
}
trace!(
"get_all_addresses_by_xpub: returning {} addresses",
addresses.len()
);
Ok(addresses)
}
pub async fn insert_xpub(pool: &DatabasePool, network: &str, xpub: &str) {
if xpub.is_empty() {
return;
}
trace!("going to insert: {} xpub:{}", network, xpub);
match pool {
DatabasePool::SQLite(p) => {
if let Err(e) =
sqlx::query("INSERT OR IGNORE INTO tbl_xpub(network, xpub) VALUES(?, ?)")
.bind(network)
.bind(xpub)
.execute(p)
.await
{
error!("Failed to insert xpub: {}", e);
}
}
DatabasePool::PostgreSQL(p) => {
if let Err(e) = sqlx::query(
"INSERT INTO tbl_xpub(network, xpub) VALUES($1, $2) ON CONFLICT DO NOTHING",
)
.bind(network)
.bind(xpub)
.execute(p)
.await
{
error!("Failed to insert xpub: {}", e);
}
}
}
}
pub async fn get_last_used_address_by_ip(
pool: &DatabasePool,
network: &str,
xpub: &str,
address: &str,
) -> Option<String> {
match pool {
DatabasePool::SQLite(p) => {
let result = sqlx::query(
"SELECT tbl_address.address FROM tbl_xpub JOIN tbl_address ON(tbl_xpub.id = tbl_address.xpub) WHERE tbl_xpub.network = ? AND tbl_address.remote_address = ? AND tbl_xpub.xpub = ? ORDER BY tbl_address.date_create DESC LIMIT 1",
)
.bind(network)
.bind(address)
.bind(xpub)
.fetch_optional(p)
.await;
match result {
Ok(Some(row)) => row.try_get::<String, _>("address").ok(),
Ok(None) => None,
Err(e) => {
error!("Failed to query last used address: {}", e);
None
}
}
}
DatabasePool::PostgreSQL(p) => {
let result = sqlx::query(
"SELECT tbl_address.address FROM tbl_xpub JOIN tbl_address ON(tbl_xpub.id = tbl_address.xpub) WHERE tbl_xpub.network = $1 AND tbl_address.remote_address = $2 AND tbl_xpub.xpub = $3 ORDER BY tbl_address.date_create DESC LIMIT 1",
)
.bind(network)
.bind(address)
.bind(xpub)
.fetch_optional(p)
.await;
match result {
Ok(Some(row)) => row.try_get::<String, _>("address").ok(),
Ok(None) => None,
Err(e) => {
error!("Failed to query last used address: {}", e);
None
}
}
}
}
}
pub async fn get_next_address_index(pool: &DatabasePool, network: &str, xpub: &str) -> (i64, i64) {
match pool {
DatabasePool::SQLite(p) => {
let result = sqlx::query(
"UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = ? AND xpub = ? RETURNING path_idx, id",
)
.bind(network)
.bind(xpub)
.fetch_optional(p)
.await;
match result {
Ok(Some(row)) => {
let idx = row.try_get::<i64, _>("path_idx").unwrap_or(0);
let id = row.try_get::<i64, _>("id").unwrap_or(0);
(id, idx)
}
Ok(None) => (0, 0),
Err(e) => {
error!("Failed to get next address index: {}", e);
(0, 0)
}
}
}
DatabasePool::PostgreSQL(p) => {
let result = sqlx::query(
"UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = $1 AND xpub = $2 RETURNING path_idx, id",
)
.bind(network)
.bind(xpub)
.fetch_optional(p)
.await;
match result {
Ok(Some(row)) => {
let idx = row.try_get::<i32, _>("path_idx").unwrap_or(0) as i64;
let id = row.try_get::<i32, _>("id").unwrap_or(0) as i64;
(id, idx)
}
Ok(None) => (0, 0),
Err(e) => {
error!("Failed to get next address index: {}", e);
(0, 0)
}
}
}
}
}
pub async fn save_new_address(
pool: &DatabasePool,
xpub: i64,
address: &str,
path: &str,
remote_addr: &str,
) {
match pool {
DatabasePool::SQLite(p) => {
if let Err(e) = sqlx::query(
"INSERT INTO tbl_address(address, path, xpub, remote_address) VALUES(?, ?, ?, ?)",
)
.bind(address)
.bind(path)
.bind(xpub)
.bind(remote_addr)
.execute(p)
.await
{
error!("Failed to save address: {}", e);
}
}
DatabasePool::PostgreSQL(p) => {
if let Err(e) = sqlx::query(
"INSERT INTO tbl_address(address, path, xpub, remote_address) VALUES($1, $2, $3, $4)",
)
.bind(address)
.bind(path)
.bind(xpub as i32)
.bind(remote_addr)
.execute(p)
.await
{
error!("Failed to save address: {}", e);
}
}
}
}
#[derive(Debug, Clone)]
pub struct InsertTxData {
pub txid: String,
pub wtxid: String,
pub ntxid: String,
pub raw_hex: String,
pub locktime: String,
pub reqid: String,
pub network: String,
pub our_address: String,
pub our_fees: String,
}
#[derive(Debug, Clone)]
pub struct InsertInpData {
pub txid: String,
pub in_txid: String,
pub in_vout: String,
}
#[derive(Debug, Clone)]
pub struct InsertOutData {
pub txid: String,
pub vout: i64,
pub script_pubkey: String,
pub amount: i64,
}
pub async fn execute_insert(
pool: &DatabasePool,
txs: &[InsertTxData],
inps: &[InsertInpData],
outs: &[InsertOutData],
) -> Result<(), sqlx::Error> {
match pool {
DatabasePool::SQLite(p) => {
let mut tx = p.begin().await?;
for item in txs {
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, reqid, network, our_address, our_fees) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&item.txid)
.bind(&item.wtxid)
.bind(&item.ntxid)
.bind(&item.raw_hex)
.bind(&item.locktime)
.bind(&item.reqid)
.bind(&item.network)
.bind(&item.our_address)
.bind(&item.our_fees)
.execute(&mut *tx)
.await?;
}
for item in inps {
sqlx::query("INSERT INTO tbl_inp (txid, in_txid, in_vout) VALUES (?, ?, ?)")
.bind(&item.txid)
.bind(&item.in_txid)
.bind(&item.in_vout)
.execute(&mut *tx)
.await?;
}
for item in outs {
sqlx::query(
"INSERT INTO tbl_out (txid, vout, script_pubkey, amount) VALUES (?, ?, ?, ?)",
)
.bind(&item.txid)
.bind(item.vout)
.bind(&item.script_pubkey)
.bind(item.amount)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
}
DatabasePool::PostgreSQL(p) => {
let mut tx = p.begin().await?;
for item in txs {
let locktime_i64: i64 = item.locktime.parse().unwrap_or(0);
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, reqid, network, our_address, our_fees) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
)
.bind(&item.txid)
.bind(&item.wtxid)
.bind(&item.ntxid)
.bind(&item.raw_hex)
.bind(locktime_i64)
.bind(&item.reqid)
.bind(&item.network)
.bind(&item.our_address)
.bind(&item.our_fees)
.execute(&mut *tx)
.await?;
}
for item in inps {
let in_vout_i32: i32 = item.in_vout.parse().unwrap_or(0);
sqlx::query("INSERT INTO tbl_inp (txid, in_txid, in_vout) VALUES ($1, $2, $3)")
.bind(&item.txid)
.bind(&item.in_txid)
.bind(in_vout_i32)
.execute(&mut *tx)
.await?;
}
for item in outs {
sqlx::query(
"INSERT INTO tbl_out (txid, vout, script_pubkey, amount) VALUES ($1, $2, $3, $4)",
)
.bind(&item.txid)
.bind(item.vout as i32)
.bind(&item.script_pubkey)
.bind(item.amount.to_string())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
}
}
Ok(())
}
pub async fn get_total_transaction_number(
pool: &DatabasePool,
network: &str,
) -> Result<i64, sqlx::Error> {
match pool {
DatabasePool::SQLite(p) => {
let row = sqlx::query("SELECT COUNT(*) as total_number FROM tbl_tx WHERE network = ?")
.bind(network)
.fetch_one(p)
.await?;
Ok(row.try_get::<i64, _>("total_number").unwrap_or(0))
}
DatabasePool::PostgreSQL(p) => {
let row = sqlx::query("SELECT COUNT(*) as total_number FROM tbl_tx WHERE network = $1")
.bind(network)
.fetch_one(p)
.await?;
Ok(row.try_get::<i64, _>("total_number").unwrap_or(0))
}
}
}
pub async fn update_tx_status(
pool: &DatabasePool,
txid: &str,
status: i32,
push_err: Option<&str>,
) -> Result<(), sqlx::Error> {
match pool {
DatabasePool::SQLite(p) => {
if let Some(err) = push_err {
sqlx::query("UPDATE tbl_tx SET status = ?, push_err = ? WHERE txid = ?")
.bind(status)
.bind(err)
.bind(txid)
.execute(p)
.await?;
} else {
sqlx::query("UPDATE tbl_tx SET status = ? WHERE txid = ?")
.bind(status)
.bind(txid)
.execute(p)
.await?;
}
}
DatabasePool::PostgreSQL(p) => {
if let Some(err) = push_err {
sqlx::query("UPDATE tbl_tx SET status = $1, push_err = $2 WHERE txid = $3")
.bind(status)
.bind(err)
.bind(txid)
.execute(p)
.await?;
} else {
sqlx::query("UPDATE tbl_tx SET status = $1 WHERE txid = $2")
.bind(status)
.bind(txid)
.execute(p)
.await?;
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct TxRow {
pub txid: String,
pub tx: String,
pub locktime: i64,
pub network: String,
pub status: i64,
}
pub async fn get_pending_txs(
pool: &DatabasePool,
network: &str,
locktime_threshold: i64,
bestblock_height: i64,
bestblock_time: i64,
) -> Result<Vec<TxRow>, sqlx::Error> {
let mut results = Vec::new();
match pool {
DatabasePool::SQLite(p) => {
let rows = sqlx::query(
"SELECT txid, tx, locktime, network, status FROM tbl_tx WHERE network = ? AND status = 0 AND (locktime < ? OR (locktime > ? AND locktime < ?))",
)
.bind(network)
.bind(bestblock_height)
.bind(locktime_threshold)
.bind(bestblock_time)
.fetch_all(p)
.await?;
for row in rows {
results.push(TxRow {
txid: row.try_get("txid")?,
tx: row.try_get("tx")?,
locktime: row.try_get("locktime")?,
network: row.try_get("network")?,
status: row.try_get("status")?,
});
}
}
DatabasePool::PostgreSQL(p) => {
let rows = sqlx::query(
"SELECT txid, tx, locktime, network, status FROM tbl_tx WHERE network = $1 AND status = 0 AND (locktime < $2 OR (locktime > $3 AND locktime < $4))",
)
.bind(network)
.bind(bestblock_height)
.bind(locktime_threshold)
.bind(bestblock_time)
.fetch_all(p)
.await?;
for row in rows {
results.push(TxRow {
txid: row.try_get("txid")?,
tx: row.try_get("tx")?,
locktime: row.try_get::<i64, _>("locktime").unwrap_or(0),
network: row.try_get("network")?,
status: row.try_get::<i32, _>("status").unwrap_or(0) as i64,
});
}
}
}
Ok(results)
}
#[derive(Debug, Clone)]
pub struct StatsRow {
pub report_date: String,
pub chain: String,
pub totals: i64,
pub waiting: i64,
pub sent: i64,
pub failed: i64,
pub waiting_profit: i64,
pub sent_profit: i64,
pub missed_profit: i64,
pub unique_inputs: i64,
}
pub async fn get_stats(pool: &DatabasePool, chain: &str) -> Result<Vec<StatsRow>, sqlx::Error> {
let mut results = Vec::new();
match pool {
DatabasePool::SQLite(p) => {
let rows = sqlx::query(
"SELECT report_date, chain, totals, waiting, sent, failed, waiting_profit, sent_profit, missed_profit, unique_inputs FROM tbl_stats WHERE chain = ?",
)
.bind(chain)
.fetch_all(p)
.await?;
for row in rows {
results.push(StatsRow {
report_date: row.try_get("report_date").unwrap_or_default(),
chain: row.try_get("chain").unwrap_or_default(),
totals: row.try_get("totals").unwrap_or(0),
waiting: row.try_get("waiting").unwrap_or(0),
sent: row.try_get("sent").unwrap_or(0),
failed: row.try_get("failed").unwrap_or(0),
waiting_profit: row.try_get("waiting_profit").unwrap_or(0),
sent_profit: row.try_get("sent_profit").unwrap_or(0),
missed_profit: row.try_get("missed_profit").unwrap_or(0),
unique_inputs: row.try_get("unique_inputs").unwrap_or(0),
});
}
}
DatabasePool::PostgreSQL(p) => {
let rows = sqlx::query(
"SELECT report_date, chain, totals, waiting, sent, failed, waiting_profit, sent_profit, missed_profit, unique_inputs FROM tbl_stats WHERE chain = $1",
)
.bind(chain)
.fetch_all(p)
.await?;
for row in rows {
results.push(StatsRow {
report_date: row.try_get("report_date").unwrap_or_default(),
chain: row.try_get("chain").unwrap_or_default(),
totals: row.try_get::<i32, _>("totals").unwrap_or(0) as i64,
waiting: row.try_get::<i32, _>("waiting").unwrap_or(0) as i64,
sent: row.try_get::<i32, _>("sent").unwrap_or(0) as i64,
failed: row.try_get::<i32, _>("failed").unwrap_or(0) as i64,
waiting_profit: row.try_get::<i32, _>("waiting_profit").unwrap_or(0) as i64,
sent_profit: row.try_get::<i32, _>("sent_profit").unwrap_or(0) as i64,
missed_profit: row.try_get::<i32, _>("missed_profit").unwrap_or(0) as i64,
unique_inputs: row.try_get::<i32, _>("unique_inputs").unwrap_or(0) as i64,
});
}
}
}
Ok(results)
}
#[derive(Debug, Clone)]
pub struct SearchTxRow {
pub status: String,
pub tx: String,
pub our_address: String,
pub our_fees: String,
pub reqid: String,
}
pub async fn search_tx(
pool: &DatabasePool,
txid: &str,
) -> Result<Option<SearchTxRow>, sqlx::Error> {
match pool {
DatabasePool::SQLite(p) => {
let result = sqlx::query("SELECT * FROM tbl_tx WHERE txid = ? LIMIT 1")
.bind(txid)
.fetch_optional(p)
.await?;
Ok(result.map(|row| SearchTxRow {
status: row.try_get::<i64, _>("status").unwrap_or(0).to_string(),
tx: row.try_get::<String, _>("tx").unwrap_or_default(),
our_address: row.try_get::<String, _>("our_address").unwrap_or_default(),
our_fees: row.try_get::<String, _>("our_fees").unwrap_or_default(),
reqid: row.try_get::<String, _>("reqid").unwrap_or_default(),
}))
}
DatabasePool::PostgreSQL(p) => {
let result = sqlx::query("SELECT * FROM tbl_tx WHERE txid = $1 LIMIT 1")
.bind(txid)
.fetch_optional(p)
.await?;
Ok(result.map(|row| SearchTxRow {
status: row.try_get::<i32, _>("status").unwrap_or(0).to_string(),
tx: row.try_get::<String, _>("tx").unwrap_or_default(),
our_address: row.try_get::<String, _>("our_address").unwrap_or_default(),
our_fees: row.try_get::<String, _>("our_fees").unwrap_or_default(),
reqid: row.try_get::<String, _>("reqid").unwrap_or_default(),
}))
}
}
}
pub async fn calculate_and_upsert_stats(
pool: &DatabasePool,
chain: &str,
) -> Result<(), sqlx::Error> {
if !chain
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|| chain.is_empty()
{
error!("Invalid chain name: {chain}");
return Ok(());
}
match pool {
DatabasePool::SQLite(p) => {
sqlx::query("DELETE FROM tbl_stats WHERE chain = ?")
.bind(chain)
.execute(p)
.await?;
sqlx::query(
"INSERT INTO tbl_stats (
report_date, chain, totals, waiting, sent, failed,
waiting_profit, sent_profit, missed_profit, unique_inputs
)
SELECT
CURRENT_TIMESTAMP,
?,
(SELECT COUNT(*) FROM tbl_tx WHERE network = ?),
(SELECT COUNT(*) FROM tbl_tx WHERE status = 0 AND network = ?),
(SELECT COUNT(*) FROM tbl_tx WHERE status = 1 AND network = ?),
(SELECT COUNT(*) FROM tbl_tx WHERE status = 2 AND network = ?),
(SELECT IFNULL(SUM(our_fees),0) FROM tbl_tx WHERE status = 0 AND network = ?),
(SELECT IFNULL(SUM(our_fees),0) FROM tbl_tx WHERE status = 1 AND network = ?),
(SELECT IFNULL(SUM(our_fees),0) FROM tbl_tx WHERE status = 2 AND network = ?),
(SELECT COUNT(DISTINCT tbl_inp.in_txid)
FROM tbl_inp
JOIN tbl_tx ON tbl_inp.txid = tbl_tx.txid
WHERE tbl_tx.status = 0 AND tbl_tx.network = ?)
ON CONFLICT(chain) DO UPDATE SET
report_date = excluded.report_date,
totals = excluded.totals,
waiting = excluded.waiting,
sent = excluded.sent,
failed = excluded.failed,
waiting_profit = excluded.waiting_profit,
sent_profit = excluded.sent_profit,
missed_profit = excluded.missed_profit,
unique_inputs = excluded.unique_inputs",
)
.bind(chain)
.bind(chain)
.bind(chain)
.bind(chain)
.bind(chain)
.bind(chain)
.bind(chain)
.bind(chain)
.bind(chain)
.execute(p)
.await?;
}
DatabasePool::PostgreSQL(p) => {
sqlx::query("DELETE FROM tbl_stats WHERE chain = $1")
.bind(chain)
.execute(p)
.await?;
sqlx::query(
"INSERT INTO tbl_stats (
report_date, chain, totals, waiting, sent, failed,
waiting_profit, sent_profit, missed_profit, unique_inputs
)
SELECT
CURRENT_TIMESTAMP,
$1,
(SELECT COUNT(*) FROM tbl_tx WHERE network = $1),
(SELECT COUNT(*) FROM tbl_tx WHERE status = 0 AND network = $1),
(SELECT COUNT(*) FROM tbl_tx WHERE status = 1 AND network = $1),
(SELECT COUNT(*) FROM tbl_tx WHERE status = 2 AND network = $1),
(SELECT COALESCE(SUM(CAST(our_fees AS BIGINT)),0) FROM tbl_tx WHERE status = 0 AND network = $1),
(SELECT COALESCE(SUM(CAST(our_fees AS BIGINT)),0) FROM tbl_tx WHERE status = 1 AND network = $1),
(SELECT COALESCE(SUM(CAST(our_fees AS BIGINT)),0) FROM tbl_tx WHERE status = 2 AND network = $1),
(SELECT COUNT(DISTINCT tbl_inp.in_txid)
FROM tbl_inp
JOIN tbl_tx ON tbl_inp.txid = tbl_tx.txid
WHERE tbl_tx.status = 0 AND tbl_tx.network = $1)
ON CONFLICT(chain) DO UPDATE SET
report_date = excluded.report_date,
totals = excluded.totals,
waiting = excluded.waiting,
sent = excluded.sent,
failed = excluded.failed,
waiting_profit = excluded.waiting_profit,
sent_profit = excluded.sent_profit,
missed_profit = excluded.missed_profit,
unique_inputs = excluded.unique_inputs",
)
.bind(chain)
.execute(p)
.await?;
}
}
info!("tbl_stats creation success");
Ok(())
}

240
src/db/schema.rs Normal file
View File

@@ -0,0 +1,240 @@
use sqlx::{PgPool, SqlitePool};
pub async fn create_sqlite_schema(pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_tx (
txid TEXT PRIMARY KEY,
date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wtxid TEXT,
ntxid TEXT,
tx TEXT,
locktime INTEGER,
network TEXT,
network_fees TEXT,
reqid TEXT,
our_fees TEXT,
our_address TEXT,
status INTEGER DEFAULT 0
)",
)
.execute(pool)
.await?;
let _ = sqlx::query("ALTER TABLE tbl_tx ADD COLUMN push_err TEXT")
.execute(pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_inp (
id INTEGER,
txid TEXT,
in_txid TEXT,
in_vout INTEGER
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_inp_unique ON tbl_inp(txid, in_txid, in_vout)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_out (
id INTEGER,
txid TEXT,
script_pubkey TEXT,
amount TEXT,
vout INTEGER
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_out_unique ON tbl_out(txid, script_pubkey, amount, vout)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_xpub (
id INTEGER PRIMARY KEY,
network TEXT,
xpub TEXT,
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
path_idx INTEGER DEFAULT -1
)",
)
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_xpub ON tbl_xpub(network, xpub)")
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_address (
address TEXT PRIMARY KEY,
path TEXT NOT NULL,
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
xpub INTEGER,
remote_address TEXT
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_stats (
report_date TEXT,
chain TEXT,
totals INTEGER,
waiting INTEGER,
sent INTEGER,
failed INTEGER,
waiting_profit INTEGER,
sent_profit INTEGER,
missed_profit INTEGER,
unique_inputs INTEGER
)",
)
.execute(pool)
.await?;
sqlx::query("DROP INDEX IF EXISTS idx_stats_chain")
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain)")
.execute(pool)
.await?;
sqlx::query("UPDATE tbl_tx SET network='bitcoin' WHERE network='mainnet'")
.execute(pool)
.await?;
Ok(())
}
pub async fn create_pg_schema(pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_tx (
txid TEXT PRIMARY KEY,
date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wtxid TEXT,
ntxid TEXT,
tx TEXT,
locktime BIGINT,
network TEXT,
network_fees TEXT,
reqid TEXT,
our_fees TEXT,
our_address TEXT,
status INTEGER DEFAULT 0,
push_err TEXT
)",
)
.execute(pool)
.await?;
// Migrate pre-existing deployments where locktime was created as INTEGER.
// nLockTime is a u32 (up to 4_294_967_295); INTEGER (i32) cannot hold
// timestamp-based locktimes after 2038-01-19.
let _ = sqlx::query("ALTER TABLE tbl_tx ALTER COLUMN locktime TYPE BIGINT")
.execute(pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_inp (
id SERIAL PRIMARY KEY,
txid TEXT,
in_txid TEXT,
in_vout INTEGER
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_inp_unique ON tbl_inp(txid, in_txid, in_vout)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_out (
id SERIAL PRIMARY KEY,
txid TEXT,
script_pubkey TEXT,
amount TEXT,
vout INTEGER
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_out_unique ON tbl_out(txid, script_pubkey, amount, vout)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_xpub (
id SERIAL PRIMARY KEY,
network TEXT,
xpub TEXT,
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
path_idx INTEGER DEFAULT -1
)",
)
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_xpub ON tbl_xpub(network, xpub)")
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_address (
address TEXT PRIMARY KEY,
path TEXT NOT NULL,
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
xpub INTEGER,
remote_address TEXT
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS tbl_stats (
report_date TEXT,
chain TEXT,
totals INTEGER,
waiting INTEGER,
sent INTEGER,
failed INTEGER,
waiting_profit INTEGER,
sent_profit INTEGER,
missed_profit INTEGER,
unique_inputs INTEGER
)",
)
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain)")
.execute(pool)
.await?;
sqlx::query("UPDATE tbl_tx SET network='bitcoin' WHERE network='mainnet'")
.execute(pool)
.await?;
Ok(())
}

View File

@@ -65,6 +65,19 @@ pub fn is_valid_welist_url(url_str: &str) -> bool {
{
return false;
}
// IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are
// routed to the embedded IPv4 address on dual-stack hosts, so
// they must be validated against the IPv4 rules as well.
if let Some(v4) = v6.to_ipv4_mapped()
&& (v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast())
{
return false;
}
}
}
}
@@ -154,6 +167,38 @@ mod tests {
);
}
#[test]
fn test_ipv4_mapped_ipv6() {
assert!(
!is_valid_welist_url("https://[::ffff:127.0.0.1]"),
"IPv4-mapped loopback should be blocked"
);
assert!(
!is_valid_welist_url("https://[::ffff:10.0.0.1]"),
"IPv4-mapped private address should be blocked"
);
assert!(
!is_valid_welist_url("https://[::ffff:172.16.0.1]"),
"IPv4-mapped private address should be blocked"
);
assert!(
!is_valid_welist_url("https://[::ffff:192.168.1.1]"),
"IPv4-mapped private address should be blocked"
);
assert!(
!is_valid_welist_url("https://[::ffff:169.254.169.254]"),
"IPv4-mapped link-local address should be blocked"
);
assert!(
!is_valid_welist_url("https://[::ffff:0.0.0.0]"),
"IPv4-mapped unspecified address should be blocked"
);
assert!(
is_valid_welist_url("https://[::ffff:8.8.8.8]"),
"IPv4-mapped public address should be allowed"
);
}
#[test]
fn test_malformed_urls() {
assert!(!is_valid_welist_url("not a url"));

View File

@@ -148,12 +148,12 @@ pub fn calculate_fingerprint(tpub: &str) -> Result<String, String> {
fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
let data = bs58::decode(s).into_vec().map_err(|e| e.to_string())?;
if data.len() < 4 {
return Err("Data troppo corta".to_string());
return Err("Data too short".to_string());
}
let (payload, checksum) = data.split_at(data.len() - 4);
let hash = Sha256::digest(Sha256::digest(payload));
if hash[0..4] != checksum[..] {
return Err("Checksum invalido".to_string());
return Err("Invalid checksum".to_string());
}
Ok(payload.to_vec())
}
@@ -168,7 +168,7 @@ fn convert_to(zpub: &str, prefix: BS58Prefix) -> Result<String, String> {
let mut data = base58check_decode(zpub)?;
if data.len() < 4 {
return Err("Non è una zpub valida.".to_string());
return Err("Not a valid zpub".to_string());
}
data.splice(
0..4,
@@ -207,7 +207,7 @@ pub fn new_address_from_xpub(
fn main() -> Result<(), Box<dyn std::error::Error>>{
match convert_to(zpub,BS58Prefix::Tpub) {
Ok(tpub) => println!("XPUB: {}", tpub),
Err(e) => eprintln!("Errore: {}", e),
Err(e) => eprintln!("Error: {}", e),
}
let fingerprint = base58check_encode(&calculate_fingerprint(zpub));
println!("ZPUB: {}, FINGERPRINT: {}",zpub,fingerprint);

View File

@@ -1,15 +1,62 @@
use bal_server::db::open_db;
use sqlite::State;
use std::fs;
use bal_server::db::{DatabasePool, create_database, open_database};
use sqlx::Row;
#[test]
fn test_open_db_blocks_traversal() {
let res = open_db("../etc/passwd");
#[tokio::test]
async fn test_open_database_sqlite() {
let pool = open_database("sqlite", "sqlite::memory:").await;
assert!(pool.is_ok(), "Opening SQLite in-memory should succeed");
}
#[tokio::test]
async fn test_open_database_invalid_backend() {
let pool = open_database("oracle", "connection_string").await;
assert!(pool.is_err(), "Unknown backend should fail");
}
#[tokio::test]
async fn test_create_database_sqlite() {
let pool = open_database("sqlite", "sqlite::memory:").await.unwrap();
let result = create_database(&pool).await;
assert!(result.is_ok(), "Creating SQLite schema should succeed");
}
#[tokio::test]
async fn test_create_database_is_idempotent() {
let pool = open_database("sqlite", "sqlite::memory:").await.unwrap();
create_database(&pool).await.unwrap();
let result = create_database(&pool).await;
assert!(
result.is_ok(),
"Creating schema twice should succeed (idempotent)"
);
}
#[tokio::test]
async fn test_sqlite_wal_mode() {
let tmp_path = std::env::temp_dir().join("tmp_test_wal_mode.db");
let path_str = tmp_path.to_str().unwrap();
let _ = std::fs::remove_file(&tmp_path);
let dsn = format!("sqlite:{}?mode=rwc", path_str);
let pool = open_database("sqlite", &dsn).await.unwrap();
if let DatabasePool::SQLite(p) = &pool {
let row = sqlx::query("PRAGMA journal_mode")
.fetch_one(p)
.await
.unwrap();
let mode: String = row.try_get(0).unwrap();
assert_eq!(mode, "wal", "SQLite journal mode should be WAL");
}
drop(pool);
let _ = std::fs::remove_file(&tmp_path);
let _ = std::fs::remove_file(format!("{}-shm", path_str));
let _ = std::fs::remove_file(format!("{}-wal", path_str));
}
#[tokio::test]
async fn test_open_database_blocks_traversal() {
let res = open_database("sqlite", "sqlite:../etc/passwd").await;
assert!(res.is_err(), "Path with '..' should be rejected");
let err = match res {
Err(e) => e,
Ok(_) => panic!("Expected error for traversal path"),
};
let err = res.err().unwrap();
assert!(
err.contains("'..'"),
"Error should mention directory traversal: {}",
@@ -17,15 +64,17 @@ fn test_open_db_blocks_traversal() {
);
}
#[test]
fn test_open_db_blocks_forbidden_absolute() {
for path in ["/etc/passwd", "/proc/self/mem", "/dev/null", "/usr/bin/ls"] {
let res = open_db(path);
#[tokio::test]
async fn test_open_database_blocks_forbidden_absolute() {
for path in [
"sqlite:/etc/passwd",
"sqlite:/proc/self/mem",
"sqlite:/dev/null",
"sqlite:/usr/bin/ls",
] {
let res = open_database("sqlite", path).await;
assert!(res.is_err(), "Absolute path {} should be rejected", path);
let err = match res {
Err(e) => e,
Ok(_) => panic!("Expected error for forbidden path {}", path),
};
let err = res.err().unwrap();
assert!(
err.contains("forbidden"),
"Error should mention forbidden prefix: {}",
@@ -34,59 +83,26 @@ fn test_open_db_blocks_forbidden_absolute() {
}
}
#[test]
fn test_open_db_allows_relative() {
let test_path = "tmp_test_bal.db";
let _ = fs::remove_file(test_path);
let res = open_db(test_path);
assert!(res.is_ok(), "Valid relative path should be allowed");
let db = res.unwrap();
drop(db);
let _ = fs::remove_file(test_path);
}
#[tokio::test]
async fn test_open_database_rejects_symlink() {
let tmp_dir = std::env::temp_dir();
let real = tmp_dir.join("tmp_test_real_symlink.db");
let link = tmp_dir.join("tmp_test_link_symlink.db");
let _ = std::fs::remove_file(&real);
let _ = std::fs::remove_file(&link);
std::fs::File::create(&real).unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
#[test]
fn test_open_db_wal_pragmas_set() {
let test_path = "tmp_test_wal.db";
let _ = fs::remove_file(test_path);
let _ = fs::remove_file(format!("{}-shm", test_path));
let _ = fs::remove_file(format!("{}-wal", test_path));
let db = open_db(test_path).expect("Should open DB");
let mut stmt = db.prepare("PRAGMA journal_mode;").unwrap();
if let Ok(State::Row) = stmt.next() {
let mode: String = stmt.read(0).unwrap();
assert_eq!(mode, "wal", "SQLite journal mode should be WAL");
} else {
panic!("Could not read journal_mode pragma");
}
let _ = fs::remove_file(test_path);
let _ = fs::remove_file(format!("{}-shm", test_path));
let _ = fs::remove_file(format!("{}-wal", test_path));
}
#[test]
fn test_open_db_rejects_symlink() {
let real = "tmp_test_real.db";
let link = "tmp_test_link.db";
let _ = fs::remove_file(real);
let _ = fs::remove_file(link);
fs::File::create(real).unwrap();
std::os::unix::fs::symlink(real, link).unwrap();
let res = open_db(link);
let dsn = format!("sqlite:{}?mode=rwc", link.to_str().unwrap());
let res = open_database("sqlite", &dsn).await;
assert!(res.is_err(), "Symlink DB path should be rejected");
let err = match res {
Err(e) => e,
Ok(_) => panic!("Expected error for symlink"),
};
let err = res.err().unwrap();
assert!(
err.contains("symlink"),
"Error should mention symlink: {}",
err
);
let _ = fs::remove_file(real);
let _ = fs::remove_file(link);
let _ = std::fs::remove_file(&real);
let _ = std::fs::remove_file(&link);
}

View File

@@ -1,60 +1,48 @@
use bal_server::db::{get_all_addresses_by_xpub, open_db};
use sqlite::Value;
use bal_server::db::{DatabasePool, create_database, get_all_addresses_by_xpub, open_database};
fn setup_db_with_xpub() -> sqlite::Connection {
let db = open_db(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE tbl_xpub (id INTEGER PRIMARY KEY, network TEXT, xpub TEXT, path_idx INTEGER DEFAULT -1);"
);
let _ = db.execute(
"CREATE TABLE tbl_address (address TEXT PRIMARY KEY, path TEXT, xpub INTEGER, remote_address TEXT);"
);
// Insert test xpub
let mut stmt = db
.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);")
.unwrap();
stmt.bind((1, Value::Integer(1))).unwrap();
stmt.bind((2, Value::String("testnet".to_string())))
.unwrap();
stmt.bind((3, Value::String("tpub_test".to_string())))
.unwrap();
let _ = stmt.next();
drop(stmt);
// Insert test addresses
for addr in ["addr1", "addr2", "addr3"] {
let mut stmt = db
.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);")
async fn setup_db_with_xpub() -> DatabasePool {
let pool = open_database("sqlite", "sqlite::memory:").await.unwrap();
create_database(&pool).await.unwrap();
if let DatabasePool::SQLite(p) = &pool {
sqlx::query("INSERT INTO tbl_xpub(id, network, xpub) VALUES(1, 'testnet', 'tpub_test')")
.execute(p)
.await
.unwrap();
stmt.bind((1, Value::String(addr.to_string()))).unwrap();
stmt.bind((2, Value::String("m/0/1".to_string()))).unwrap();
stmt.bind((3, Value::Integer(1))).unwrap();
let _ = stmt.next();
drop(stmt);
for addr in ["addr1", "addr2", "addr3"] {
sqlx::query("INSERT INTO tbl_address(address, path, xpub) VALUES(?, 'm/0/1', 1)")
.bind(addr)
.execute(p)
.await
.unwrap();
}
}
db
pool
}
#[test]
fn test_get_all_addresses_by_xpub_returns_known() {
let db = setup_db_with_xpub();
let addresses = get_all_addresses_by_xpub(&db, "tpub_test").unwrap();
#[tokio::test]
async fn test_get_all_addresses_by_xpub_returns_known() {
let pool = setup_db_with_xpub().await;
let addresses = get_all_addresses_by_xpub(&pool, "tpub_test").await.unwrap();
assert!(addresses.contains("addr1"));
assert!(addresses.contains("addr2"));
assert!(addresses.contains("addr3"));
assert_eq!(addresses.len(), 3);
}
#[test]
fn test_get_all_addresses_by_xpub_empty_for_missing() {
let db = setup_db_with_xpub();
let addresses = get_all_addresses_by_xpub(&db, "tpub_nonexistent").unwrap();
#[tokio::test]
async fn test_get_all_addresses_by_xpub_empty_for_missing() {
let pool = setup_db_with_xpub().await;
let addresses = get_all_addresses_by_xpub(&pool, "tpub_nonexistent")
.await
.unwrap();
assert!(addresses.is_empty());
}
#[test]
fn test_network_unknown_returns_404() {
// This is a code-level check; the actual HTTP test requires actix-web setup.
// Verify that the NETWORKS constant includes the expected set.
let networks = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
for n in networks {
assert!(networks.contains(&n), "{} should be a valid network", n);

View File

@@ -1,5 +1,4 @@
use sqlite::Connection;
use std::collections::HashMap;
use sqlx::Row;
use std::sync::{Arc, Mutex};
use std::thread;
@@ -8,39 +7,44 @@ fn test_mutex_poisoning_recovery() {
let data = Arc::new(Mutex::new(0));
let c = data.clone();
let handle = thread::spawn(move || {
let _guard = c.lock(); // Acquire lock
panic!("test panic"); // Panic while holding the lock
// _guard is dropped during panic unwinding, poisoning the mutex
let _guard = c.lock();
panic!("test panic");
});
let result = handle.join();
assert!(result.is_err()); // Thread panicked
assert!(result.is_err());
// Recovery: the same pattern used in bal-server.rs
let guard = match data.lock() {
Ok(g) => g,
Err(p) => {
p.into_inner() // Should not panic
}
Err(p) => p.into_inner(),
};
assert_eq!(*guard, 0);
}
#[test]
fn test_db_null_unwrap_or() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute(
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
);
let _ =
db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
#[tokio::test]
async fn test_db_null_unwrap_or() {
let pool = bal_server::db::open_database("sqlite", "sqlite::memory:")
.await
.unwrap();
if let bal_server::db::DatabasePool::SQLite(p) = &pool {
sqlx::query(
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
)
.execute(p)
.await
.unwrap();
let mut found_value = None;
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
let row: HashMap<_, _> = pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect();
let totals = row["totals"].unwrap_or("0").to_string();
found_value = Some(totals);
true
});
sqlx::query("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet')")
.execute(p)
.await
.unwrap();
assert_eq!(found_value.unwrap(), "0");
let row = sqlx::query("SELECT * FROM test_stats")
.fetch_one(p)
.await
.unwrap();
let totals: Option<String> = row.try_get("totals").unwrap_or(None);
let totals_value = totals.unwrap_or_else(|| "0".to_string());
assert_eq!(totals_value, "0");
}
}

View File

@@ -0,0 +1,335 @@
use bal_server::db::{
DatabasePool, calculate_and_upsert_stats, check_duplicate_txids, create_database,
get_all_addresses_by_xpub, get_next_address_index, get_pending_txs, get_stats, insert_xpub,
open_database, save_new_address, search_tx, update_tx_status,
};
use sqlx::Row;
fn pg_dsn() -> Option<String> {
std::env::var("BAL_TEST_PG_DSN").ok()
}
async fn setup_pg() -> Option<DatabasePool> {
let dsn = pg_dsn()?;
let pool = open_database("postgresql", &dsn).await.ok()?;
// Drop and recreate schema for clean test
if let DatabasePool::PostgreSQL(p) = &pool {
sqlx::query("DROP SCHEMA public CASCADE; CREATE SCHEMA public")
.execute(p)
.await
.ok()?;
}
create_database(&pool).await.ok()?;
Some(pool)
}
#[tokio::test]
async fn test_pg_open_database() {
let Some(dsn) = pg_dsn() else {
eprintln!("skipped: BAL_TEST_PG_DSN not set");
return;
};
let pool = open_database("postgresql", &dsn).await;
assert!(
pool.is_ok(),
"Opening PostgreSQL should succeed: {:?}",
pool.err()
);
}
#[tokio::test]
async fn test_pg_create_schema() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
// Second call should also succeed (idempotent)
let result = create_database(&pool).await;
assert!(
result.is_ok(),
"Creating PG schema twice should be idempotent: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_pg_insert_xpub() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
insert_xpub(&pool, "testnet", "tpub_test123").await;
insert_xpub(&pool, "testnet", "tpub_test123").await; // duplicate should be ignored
if let DatabasePool::PostgreSQL(p) = &pool {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM tbl_xpub WHERE xpub = 'tpub_test123'")
.fetch_one(p)
.await
.unwrap();
let count: i64 = row.try_get("cnt").unwrap();
assert_eq!(count, 1, "INSERT OR IGNORE should prevent duplicates");
}
}
#[tokio::test]
async fn test_pg_get_next_address_index() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
insert_xpub(&pool, "testnet", "tpub_addr_test").await;
let (id, idx) = get_next_address_index(&pool, "testnet", "tpub_addr_test").await;
assert!(id > 0, "Should return valid xpub id, got {}", id);
assert_eq!(idx, 0, "First index should be 0, got {}", idx);
let (id2, idx2) = get_next_address_index(&pool, "testnet", "tpub_addr_test").await;
assert_eq!(id, id2, "xpub id should be stable");
assert_eq!(idx2, 1, "Second index should be 1, got {}", idx2);
}
#[tokio::test]
async fn test_pg_save_and_get_address() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
insert_xpub(&pool, "testnet", "tpub_addr_test2").await;
let (xpub_id, _idx) = get_next_address_index(&pool, "testnet", "tpub_addr_test2").await;
save_new_address(&pool, xpub_id, "tb1qtestaddr", "m/0/0", "1.2.3.4").await;
let addrs = get_all_addresses_by_xpub(&pool, "tpub_addr_test2")
.await
.unwrap();
assert!(
addrs.contains("tb1qtestaddr"),
"Should find saved address: {:?}",
addrs
);
}
#[tokio::test]
async fn test_pg_check_duplicate_txids() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
// Insert a transaction first via raw SQL (to have a txid to check)
if let DatabasePool::PostgreSQL(p) = &pool {
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
VALUES ('txid_dup_test', 'wtx1', 'ntx1', 'rawtx', 100, 'testnet', 0)",
)
.execute(p)
.await
.unwrap();
}
let dups = check_duplicate_txids(
&pool,
&["txid_dup_test".to_string(), "txid_new".to_string()],
)
.await
.unwrap();
assert!(
dups.contains("txid_dup_test"),
"Should detect existing txid"
);
assert!(
!dups.contains("txid_new"),
"Should not report non-existing txid"
);
}
#[tokio::test]
async fn test_pg_search_tx() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
if let DatabasePool::PostgreSQL(p) = &pool {
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status, our_address, our_fees, reqid)
VALUES ('txid_search', 'wtx', 'ntx', 'rawhex', 500, 'testnet', 1, 'tb1ouraddr', '0.0001', 'req123')"
)
.execute(p)
.await
.unwrap();
}
let result = search_tx(&pool, "txid_search").await.unwrap();
assert!(result.is_some(), "Should find the transaction");
let row = result.unwrap();
assert_eq!(row.status, "1", "Status should be read as string '1'");
assert_eq!(row.tx, "rawhex");
assert_eq!(row.our_address, "tb1ouraddr");
assert_eq!(row.our_fees, "0.0001");
assert_eq!(row.reqid, "req123");
let not_found = search_tx(&pool, "txid_nonexistent").await.unwrap();
assert!(not_found.is_none(), "Should return None for missing txid");
}
#[tokio::test]
async fn test_pg_update_tx_status() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
if let DatabasePool::PostgreSQL(p) = &pool {
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
VALUES ('txid_status', 'wtx', 'ntx', 'raw', 100, 'testnet', 0)",
)
.execute(p)
.await
.unwrap();
}
update_tx_status(&pool, "txid_status", 1, None)
.await
.unwrap();
if let DatabasePool::PostgreSQL(p) = &pool {
let row = sqlx::query("SELECT status FROM tbl_tx WHERE txid = 'txid_status'")
.fetch_one(p)
.await
.unwrap();
let status: i64 = row.try_get("status").unwrap();
assert_eq!(status, 1, "Status should be updated to 1");
}
update_tx_status(&pool, "txid_status", 2, Some("test error"))
.await
.unwrap();
if let DatabasePool::PostgreSQL(p) = &pool {
let row = sqlx::query("SELECT status, push_err FROM tbl_tx WHERE txid = 'txid_status'")
.fetch_one(p)
.await
.unwrap();
let status: i64 = row.try_get("status").unwrap();
let push_err: Option<String> = row.try_get("push_err").unwrap();
assert_eq!(status, 2);
assert_eq!(push_err.as_deref(), Some("test error"));
}
}
#[tokio::test]
async fn test_pg_get_pending_txs() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
if let DatabasePool::PostgreSQL(p) = &pool {
// Insert pending tx (status=0, locktime < height)
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
VALUES ('pending1', 'w', 'n', 'rawtx1', 100, 'testnet', 0)",
)
.execute(p)
.await
.unwrap();
// Insert already pushed tx (status=1)
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
VALUES ('pushed1', 'w', 'n', 'rawtx2', 100, 'testnet', 1)",
)
.execute(p)
.await
.unwrap();
}
let txs = get_pending_txs(&pool, "testnet", 5000000, 200, 1000)
.await
.unwrap();
assert_eq!(txs.len(), 1, "Should only return pending txs");
assert_eq!(txs[0].txid, "pending1");
assert_eq!(txs[0].tx, "rawtx1");
}
#[tokio::test]
async fn test_pg_stats() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
// Insert test data
if let DatabasePool::PostgreSQL(p) = &pool {
sqlx::query(
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status, our_fees)
VALUES
('stx1', 'w', 'n', 'r', 100, 'testnet', 0, '0.0001'),
('stx2', 'w', 'n', 'r', 100, 'testnet', 1, '0.0002'),
('stx3', 'w', 'n', 'r', 100, 'testnet', 2, '0.0003')",
)
.execute(p)
.await
.unwrap();
}
calculate_and_upsert_stats(&pool, "testnet").await.unwrap();
let stats = get_stats(&pool, "testnet").await.unwrap();
assert_eq!(stats.len(), 1, "Should have one stats row");
assert_eq!(stats[0].totals, 3);
assert_eq!(stats[0].waiting, 1);
assert_eq!(stats[0].sent, 1);
assert_eq!(stats[0].failed, 1);
}
#[tokio::test]
async fn test_pg_sql_injection_via_push_err() {
let Some(pool) = setup_pg().await else {
eprintln!("skipped: PostgreSQL not available");
return;
};
if let DatabasePool::PostgreSQL(p) = &pool {
sqlx::query(
"CREATE TABLE test_inject (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT)",
)
.execute(p)
.await
.unwrap();
sqlx::query("INSERT INTO test_inject (txid, status, push_err) VALUES ($1, $2, $3)")
.bind("dummy")
.bind(0_i64)
.bind("")
.execute(p)
.await
.unwrap();
let malicious = "'; DROP TABLE test_inject; --";
sqlx::query("UPDATE test_inject SET status = 2, push_err = $1 WHERE txid = $2")
.bind(malicious)
.bind("dummy")
.execute(p)
.await
.unwrap();
let row = sqlx::query("SELECT status, push_err FROM test_inject WHERE txid = 'dummy'")
.fetch_one(p)
.await
.unwrap();
let status: i64 = row.try_get("status").unwrap();
let push_err: String = row.try_get("push_err").unwrap();
assert_eq!(status, 2);
assert_eq!(push_err, malicious);
// Table should still exist
let cnt = sqlx::query("SELECT COUNT(*) as cnt FROM test_inject")
.fetch_one(p)
.await
.unwrap();
let count: i64 = cnt.try_get("cnt").unwrap();
assert_eq!(count, 1);
}
}

View File

@@ -1,137 +1,136 @@
use sqlite::{Connection, Value};
use bal_server::db::{DatabasePool, open_database};
use sqlx::Row;
#[test]
fn test_sql_injection_via_push_err_update() {
// Create an in-memory database and the required table
let db = Connection::open(":memory:").unwrap();
let _ =
db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);");
// Insert a dummy transaction
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);")
.unwrap();
stmt.bind((1, Value::String("dummy_txid".to_string())))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
stmt.bind((3, Value::String("".to_string()))).unwrap();
let _ = stmt.next();
drop(stmt);
// Malicious error payload containing a single quote (SQL injection attempt)
let malicious_error = "'; DROP TABLE tbl_tx; --";
let txid = "dummy_txid";
// Execute the fixed query using parameter binding (safe)
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_error.to_string())))
.unwrap();
stmt.bind((2, Value::String(txid.to_string()))).unwrap();
let _ = stmt.next();
// Verify the table still exists and the row was updated correctly
let mut check = db
.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String("dummy_txid".to_string())))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
let push_err: String = check.read("push_err").unwrap();
assert_eq!(status, 2);
assert_eq!(push_err, malicious_error);
// Ensure no second row was created (injection would have failed or produced extra rows)
let mut count_stmt = db.prepare("SELECT COUNT(*) FROM tbl_tx;").unwrap();
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
let count: i64 = count_stmt.read(0).unwrap();
assert_eq!(count, 1);
async fn setup_db() -> DatabasePool {
open_database("sqlite", "sqlite::memory:").await.unwrap()
}
#[test]
fn test_sql_injection_via_txid_update() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
// Insert multiple dummy transactions
for i in 0..3 {
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
#[tokio::test]
async fn test_sql_injection_via_push_err_update() {
let pool = setup_db().await;
if let DatabasePool::SQLite(p) = &pool {
sqlx::query("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);")
.execute(p)
.await
.unwrap();
stmt.bind((1, Value::String(format!("txid_{}", i))))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
let _ = stmt.next();
}
// Malicious txid payload
let malicious_txid = "' OR '1'='1";
// The fixed query parameterizes the txid, so this should only update zero rows
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string())))
.unwrap();
let _ = stmt.next();
// Verify no rows were updated (status should still be 0 for all)
for i in 0..3 {
let mut check = db
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
sqlx::query("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?)")
.bind("dummy_txid")
.bind(0_i64)
.bind("")
.execute(p)
.await
.unwrap();
check
.bind((1, Value::String(format!("txid_{}", i))))
let malicious_error = "'; DROP TABLE tbl_tx; --";
let txid = "dummy_txid";
sqlx::query("UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?")
.bind(malicious_error)
.bind(txid)
.execute(p)
.await
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
assert_eq!(
status, 0,
"Row txid_{} should not be updated by malicious txid",
i
);
let row = sqlx::query("SELECT status, push_err FROM tbl_tx WHERE txid = ?")
.bind("dummy_txid")
.fetch_one(p)
.await
.unwrap();
let status: i64 = row.try_get("status").unwrap();
let push_err: String = row.try_get("push_err").unwrap();
assert_eq!(status, 2);
assert_eq!(push_err, malicious_error);
let row = sqlx::query("SELECT COUNT(*) as cnt FROM tbl_tx")
.fetch_one(p)
.await
.unwrap();
let count: i64 = row.try_get("cnt").unwrap();
assert_eq!(count, 1);
}
}
#[test]
fn test_sql_injection_via_txid_with_comment() {
let db = Connection::open(":memory:").unwrap();
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
#[tokio::test]
async fn test_sql_injection_via_txid_update() {
let pool = setup_db().await;
if let DatabasePool::SQLite(p) = &pool {
sqlx::query("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);")
.execute(p)
.await
.unwrap();
let mut stmt = db
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
.unwrap();
stmt.bind((1, Value::String("safe_txid".to_string())))
.unwrap();
stmt.bind((2, Value::Integer(0))).unwrap();
let _ = stmt.next();
for i in 0..3 {
sqlx::query("INSERT INTO tbl_tx (txid, status) VALUES (?, ?)")
.bind(format!("txid_{}", i))
.bind(0_i64)
.execute(p)
.await
.unwrap();
}
// Another common injection pattern
let malicious_txid = "safe_txid'; UPDATE tbl_tx SET status = 99; --";
let malicious_txid = "' OR '1'='1";
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap();
stmt.bind((1, Value::String(malicious_txid.to_string())))
.unwrap();
let _ = stmt.next();
sqlx::query("UPDATE tbl_tx SET status = 1 WHERE txid = ?")
.bind(malicious_txid)
.execute(p)
.await
.unwrap();
// Verify the original row was NOT updated (because it was looking for the full malicious string)
// and no rows have status 99 (the injected update did not execute)
let mut check = db
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
.unwrap();
check
.bind((1, Value::String("safe_txid".to_string())))
.unwrap();
assert!(check.next().unwrap() == sqlite::State::Row);
let status: i64 = check.read("status").unwrap();
assert_eq!(status, 0, "Original row should not be updated");
let mut count_stmt = db
.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;")
.unwrap();
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
let count: i64 = count_stmt.read(0).unwrap();
assert_eq!(count, 0, "No rows should have status 99");
for i in 0..3 {
let row = sqlx::query("SELECT status FROM tbl_tx WHERE txid = ?")
.bind(format!("txid_{}", i))
.fetch_one(p)
.await
.unwrap();
let status: i64 = row.try_get("status").unwrap();
assert_eq!(
status, 0,
"Row txid_{} should not be updated by malicious txid",
i
);
}
}
}
#[tokio::test]
async fn test_sql_injection_via_txid_with_comment() {
let pool = setup_db().await;
if let DatabasePool::SQLite(p) = &pool {
sqlx::query("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);")
.execute(p)
.await
.unwrap();
sqlx::query("INSERT INTO tbl_tx (txid, status) VALUES (?, ?)")
.bind("safe_txid")
.bind(0_i64)
.execute(p)
.await
.unwrap();
let malicious_txid = "safe_txid'; UPDATE tbl_tx SET status = 99; --";
sqlx::query("UPDATE tbl_tx SET status = 1 WHERE txid = ?")
.bind(malicious_txid)
.execute(p)
.await
.unwrap();
let row = sqlx::query("SELECT status FROM tbl_tx WHERE txid = ?")
.bind("safe_txid")
.fetch_one(p)
.await
.unwrap();
let status: i64 = row.try_get("status").unwrap();
assert_eq!(status, 0, "Original row should not be updated");
let row = sqlx::query("SELECT COUNT(*) as cnt FROM tbl_tx WHERE status = 99")
.fetch_one(p)
.await
.unwrap();
let count: i64 = row.try_get("cnt").unwrap();
assert_eq!(count, 0, "No rows should have status 99");
}
}

287
tests/test_endpoints.sh Executable file
View File

@@ -0,0 +1,287 @@
#!/bin/bash
# tests/test_endpoints.sh — Integration tests for bal-server endpoints
# Usage: ./tests/test_endpoints.sh <base_url>
# Example: ./tests/test_endpoints.sh http://127.0.0.1:9133
# Returns 0 if all tests pass, 1 otherwise
set -uo pipefail
BASE_URL="${1:?Usage: $0 <base_url>}"
# Delay between requests to avoid rate limiter (actix-governor)
DELAY=1.0
PASS=0
FAIL=0
TOTAL=0
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Known network for testing (must be enabled in server config)
TEST_NETWORK="regtest"
# A valid 64-char hex txid that does NOT exist in the database
NONEXISTENT_TXID="551dc4841830e457b0932b81eb458a00f87e5342b70333bb92df7475d6ca90f4"
# A valid raw transaction hex (minimal valid tx for testing)
VALID_TX_HEX="020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec112f826d36b0c8080a01e2894d24b051f05e0f03ed7790b09fd327518756ff2a55ecee44b5e08d76f994a7c5f3ffcac88bac"
# ─────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────
log_pass() {
PASS=$((PASS + 1))
TOTAL=$((TOTAL + 1))
echo -e " ${GREEN}PASS${NC} $1"
}
log_fail() {
FAIL=$((FAIL + 1))
TOTAL=$((TOTAL + 1))
echo -e " ${RED}FAIL${NC} $1"
if [ -n "${2:-}" ]; then
echo -e " Expected: ${YELLOW}$2${NC}"
echo -e " Got: ${YELLOW}$3${NC}"
fi
}
# Perform a GET request with rate-limit delay, split into BODY and STATUS
curl_get() {
sleep "$DELAY"
local resp
resp=$(curl -s -w "\n%{http_code}" "$@")
BODY=$(echo "$resp" | sed '$d')
STATUS=$(echo "$resp" | tail -1)
}
# Perform a POST request with rate-limit delay, split into BODY and STATUS
curl_post() {
sleep "$DELAY"
local resp
resp=$(curl -s -w "\n%{http_code}" "$@")
BODY=$(echo "$resp" | sed '$d')
STATUS=$(echo "$resp" | tail -1)
}
assert_status() {
local expected="$1" actual="$2" name="$3"
if [ "$actual" = "$expected" ]; then
log_pass "$name (HTTP $actual)"
else
log_fail "$name" "HTTP $expected" "HTTP $actual"
fi
}
assert_body_contains() {
local pattern="$1" body="$2" name="$3"
if echo "$body" | grep -q "$pattern"; then
log_pass "$name (contains '$pattern')"
else
log_fail "$name" "body contains '$pattern'" "body: '$(echo "$body" | head -c 80)'"
fi
}
assert_body_not_empty() {
local body="$1" name="$2"
if [ -n "$body" ]; then
log_pass "$name (non-empty response)"
else
log_fail "$name" "non-empty body" "empty body"
fi
}
assert_json_valid() {
local body="$1" name="$2"
if echo "$body" | jq . >/dev/null 2>&1; then
log_pass "$name (valid JSON)"
else
log_fail "$name" "valid JSON" "invalid JSON: $(echo "$body" | head -c 100)"
fi
}
assert_json_has_key() {
local key="$1" body="$2" name="$3"
if echo "$body" | jq -e ".$key" >/dev/null 2>&1; then
log_pass "$name (has key '$key')"
else
log_fail "$name" "JSON has key '$key'" "key not found"
fi
}
# ─────────────────────────────────────────────
# 1. GET /
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[1] GET /${NC}"
curl_get "$BASE_URL/"
assert_status "200" "$STATUS" "GET / returns 200"
assert_body_not_empty "$BODY" "GET / returns non-empty body"
# ─────────────────────────────────────────────
# 2. GET /.pub_key.pem
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[2] GET /.pub_key.pem${NC}"
curl_get "$BASE_URL/.pub_key.pem"
assert_status "200" "$STATUS" "GET /.pub_key.pem returns 200"
assert_body_contains "BEGIN PUBLIC KEY" "$BODY" "GET /.pub_key.pem contains PEM header"
# ─────────────────────────────────────────────
# 3. GET /version
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[3] GET /version${NC}"
curl_get "$BASE_URL/version"
assert_status "200" "$STATUS" "GET /version returns 200"
assert_body_not_empty "$BODY" "GET /version returns non-empty body"
# ─────────────────────────────────────────────
# 4. GET /{network}/info
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[4] GET /$TEST_NETWORK/info${NC}"
curl_get "$BASE_URL/$TEST_NETWORK/info"
assert_status "200" "$STATUS" "GET /$TEST_NETWORK/info returns 200"
assert_json_valid "$BODY" "GET /$TEST_NETWORK/info returns valid JSON"
assert_json_has_key "chain" "$BODY" "GET /$TEST_NETWORK/info has 'chain' key"
assert_json_has_key "address" "$BODY" "GET /$TEST_NETWORK/info has 'address' key"
assert_json_has_key "base_fee" "$BODY" "GET /$TEST_NETWORK/info has 'base_fee' key"
assert_json_has_key "info" "$BODY" "GET /$TEST_NETWORK/info has 'info' key"
assert_json_has_key "version" "$BODY" "GET /$TEST_NETWORK/info has 'version' key"
echo ""
echo -e "${YELLOW}[4b] GET /invalidnet/info${NC}"
curl_get "$BASE_URL/invalidnet/info"
assert_status "404" "$STATUS" "GET /invalidnet/info returns 404"
assert_body_contains "Unknown network" "$BODY" "GET /invalidnet/info body says 'Unknown network'"
# ─────────────────────────────────────────────
# 5. GET /{network}/stats
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[5] GET /$TEST_NETWORK/stats${NC}"
curl_get "$BASE_URL/$TEST_NETWORK/stats"
assert_status "200" "$STATUS" "GET /$TEST_NETWORK/stats returns 200"
assert_json_valid "$BODY" "GET /$TEST_NETWORK/stats returns valid JSON"
echo ""
echo -e "${YELLOW}[5b] GET /invalidnet/stats${NC}"
curl_get "$BASE_URL/invalidnet/stats"
assert_status "404" "$STATUS" "GET /invalidnet/stats returns 404"
assert_body_contains "Unknown network" "$BODY" "GET /invalidnet/stats body says 'Unknown network'"
# ─────────────────────────────────────────────
# 6. POST /searchtx
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[6] POST /searchtx — empty body${NC}"
curl_post -X POST -d "" "$BASE_URL/searchtx"
assert_status "400" "$STATUS" "POST /searchtx empty body returns 400"
assert_body_contains "Invalid txid" "$BODY" "POST /searchtx empty body says 'Invalid txid'"
echo ""
echo -e "${YELLOW}[6b] POST /searchtx — short txid${NC}"
curl_post -X POST -d "abc123" "$BASE_URL/searchtx"
assert_status "400" "$STATUS" "POST /searchtx short txid returns 400"
assert_body_contains "Invalid txid" "$BODY" "POST /searchtx short txid says 'Invalid txid'"
echo ""
echo -e "${YELLOW}[6c] POST /searchtx — non-hex txid${NC}"
curl_post -X POST -d "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "$BASE_URL/searchtx"
assert_status "400" "$STATUS" "POST /searchtx non-hex txid returns 400"
assert_body_contains "Invalid txid" "$BODY" "POST /searchtx non-hex txid says 'Invalid txid'"
echo ""
echo -e "${YELLOW}[6d] POST /searchtx — nonexistent valid hex txid${NC}"
curl_post -X POST -d "$NONEXISTENT_TXID" "$BASE_URL/searchtx"
# When txid is valid hex but not in DB: either 200 with empty JSON or 404
if [ "$STATUS" = "200" ] || [ "$STATUS" = "404" ]; then
log_pass "POST /searchtx nonexistent txid returns HTTP $STATUS (expected)"
else
log_fail "POST /searchtx nonexistent txid" "HTTP 200 or 404" "HTTP $STATUS"
fi
# Verify Content-Type is application/json for successful searchtx responses
if [ "$STATUS" = "200" ]; then
CONTENT_TYPE=$(curl -s -D - --max-time 3 -X POST -d "$NONEXISTENT_TXID" "$BASE_URL/searchtx" 2>/dev/null | grep -i "^content-type:" | tr -d '\r')
if echo "$CONTENT_TYPE" | grep -q "application/json"; then
log_pass "POST /searchtx Content-Type is application/json"
else
log_fail "POST /searchtx Content-Type" "application/json" "$CONTENT_TYPE"
fi
fi
echo ""
echo -e "${YELLOW}[6e] POST /searchtx — binary non-UTF8 body${NC}"
curl_post -X POST --data-binary $'\xff\xfe\xfd' "$BASE_URL/searchtx"
assert_status "400" "$STATUS" "POST /searchtx binary body returns 400"
assert_body_contains "Invalid UTF-8 body" "$BODY" "POST /searchtx binary body says 'Invalid UTF-8 body'"
# ─────────────────────────────────────────────
# 7. POST /{network}/pushtxs
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[7] POST /invalidnet/pushtxs${NC}"
curl_post -X POST -d "" "$BASE_URL/invalidnet/pushtxs"
assert_status "404" "$STATUS" "POST /invalidnet/pushtxs returns 404"
assert_body_contains "Unknown network" "$BODY" "POST /invalidnet/pushtxs body says 'Unknown network'"
echo ""
echo -e "${YELLOW}[7b] POST /$TEST_NETWORK/pushtxs — empty body${NC}"
curl_post -X POST -d "" "$BASE_URL/$TEST_NETWORK/pushtxs"
assert_status "200" "$STATUS" "POST /$TEST_NETWORK/pushtxs empty body returns 200"
echo ""
echo -e "${YELLOW}[7c] POST /$TEST_NETWORK/pushtxs — valid hex tx${NC}"
curl_post -X POST -d "$VALID_TX_HEX" "$BASE_URL/$TEST_NETWORK/pushtxs"
# Server returns 200 with "thx" or "already present" or "Bad data received"
if [ "$STATUS" = "200" ]; then
if echo "$BODY" | grep -qE "^(thx|already present|Bad data received)$"; then
log_pass "POST /$TEST_NETWORK/pushtxs valid hex returns HTTP 200 with '$BODY'"
else
log_pass "POST /$TEST_NETWORK/pushtxs valid hex returns HTTP 200 (body: $(echo "$BODY" | head -c 50))"
fi
else
log_fail "POST /$TEST_NETWORK/pushtxs valid hex" "HTTP 200" "HTTP $STATUS"
fi
echo ""
echo -e "${YELLOW}[7d] POST /$TEST_NETWORK/pushtxs — non-hex garbage${NC}"
curl_post -X POST -d "not-a-transaction" "$BASE_URL/$TEST_NETWORK/pushtxs"
assert_status "200" "$STATUS" "POST /$TEST_NETWORK/pushtxs garbage returns 200"
echo ""
echo -e "${YELLOW}[7e] POST /$TEST_NETWORK/pushtxs — binary non-UTF8 body${NC}"
curl_post -X POST --data-binary $'\xff\xfe\xfd' "$BASE_URL/$TEST_NETWORK/pushtxs"
# Invalid UTF-8 should be rejected
if [ "$STATUS" = "400" ]; then
assert_body_contains "Invalid UTF-8 body" "$BODY" "POST /$TEST_NETWORK/pushtxs binary body says 'Invalid UTF-8 body'"
else
# Server may accept binary as latin-1 and skip invalid lines gracefully
log_pass "POST /$TEST_NETWORK/pushtxs binary body returns HTTP $STATUS (skipped gracefully)"
fi
# ─────────────────────────────────────────────
# 8. GET on unknown routes
# ─────────────────────────────────────────────
echo ""
echo -e "${YELLOW}[8] GET /nonexistent${NC}"
curl_get "$BASE_URL/nonexistent"
assert_status "404" "$STATUS" "GET /nonexistent returns 404"
# ─────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────
echo ""
echo "========================================="
echo -e "Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC} ($TOTAL total)"
echo "========================================="
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0