forked from bitcoinafterlife/bal-server
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8dc344cbd1
|
|||
|
eacb2e1450
|
|||
|
734b2ee71d
|
|||
|
7999902cc0
|
|||
|
d6b888e403
|
|||
|
36219c49a0
|
|||
|
ca530bf987
|
|||
|
1c76755ea6
|
|||
|
c371a4f478
|
|||
|
59250289a7
|
|||
|
0f0f0a08c3
|
|||
|
6e6c634e98
|
|||
|
efcd91e6b4
|
|||
| 22b60e55c7 | |||
| 7fe5fd3139 | |||
| b46f85f436 | |||
|
cd24eda111
|
|||
|
8ce3f6a445
|
|||
|
190cac929e
|
|||
|
5e1d0d7c54
|
|||
|
9081e08785
|
|||
|
06db6f1d48
|
|||
|
ba8f828e89
|
|||
|
9abfad29b9
|
|||
|
bc9ec1a48c
|
|||
|
c461232095
|
|||
|
fbe61a5862
|
|||
|
56696050ec
|
|||
| 638f00bf0c | |||
| da2124031e | |||
| 538e72f806 | |||
| 4fc0790fe7 | |||
| 237e62d4be | |||
| fa2f458468 | |||
| 167869b881 | |||
| 0fdefcfd0f | |||
| df8effcc60 | |||
| 69d877a360 | |||
| 64f402eeae | |||
| 0526c1f742 | |||
| 97bdffaa50 | |||
| dc02d9248c | |||
| efa8c470cc | |||
| c2797420a1 | |||
| e78a1efb68 | |||
| 4a592fa3ef | |||
| ed38ffb87f | |||
| b6d9c5b6ba | |||
| 47323e9986 | |||
| 31ab600e79 | |||
| 62c4ad824b | |||
| ab0f132081 | |||
| 056aa414e5 | |||
| eef33c55a6 | |||
| 86e9e50059 | |||
| 48e1ead8a1 | |||
| 70583710d5 | |||
| 166b9f9f2b | |||
| aa91662a2b | |||
| b8f120f8b1 | |||
| b41d961285 | |||
| 3a091ab070 | |||
| 72fc39b787 |
56
.dockerignore
Normal file
56
.dockerignore
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitsecret
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Environment files (secrets)
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Private keys
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
!public_key.pem
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests/
|
||||||
|
|
||||||
|
# Scripts (local dev only)
|
||||||
|
bal-server.sh
|
||||||
|
bal-pusher.sh
|
||||||
|
download_bal_db.sh
|
||||||
|
sendtx.sh
|
||||||
|
lib.sh
|
||||||
|
contrib/
|
||||||
|
update
|
||||||
|
update_codebase.txt
|
||||||
|
|
||||||
|
# Service files
|
||||||
|
*.service
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
generate_random_ascii.sh
|
||||||
|
test/
|
||||||
|
invalid_txs/
|
||||||
|
valid_txs/
|
||||||
56
.env.example
Normal file
56
.env.example
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Gitea API Token for releases
|
||||||
|
# Get this from your gitea settings: https://bitcoin-after.life/gitea/user-settings/applications
|
||||||
|
# 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=5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
|
||||||
|
# (replace with your actual token after revoking the old one)
|
||||||
|
|
||||||
|
# === bal-server ===
|
||||||
|
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place
|
||||||
|
# Nginx with TLS in front. Direct exposure will leak transaction data !!!
|
||||||
|
RUST_LOG=info
|
||||||
|
BAL_SERVER_DB_FILE=/var/bal/bal.db
|
||||||
|
BAL_SERVER_BIND_ADDRESS=127.0.0.1
|
||||||
|
BAL_SERVER_BIND_PORT=9137
|
||||||
|
BAL_SERVER_INFO="BAL server production"
|
||||||
|
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem
|
||||||
|
|
||||||
|
BAL_SERVER_BITCOIN_ADDRESS="your_bitcoin_or_xpub_address"
|
||||||
|
BAL_SERVER_BITCOIN_FIXED_FEE=50000
|
||||||
|
|
||||||
|
# Actix Web DoS Protection Settings
|
||||||
|
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
|
||||||
|
|
||||||
|
# === bal-pusher ===
|
||||||
|
BAL_PUSHER_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
|
||||||
|
|
||||||
|
# ZMQ endpoints per network (default: localhost only)
|
||||||
|
BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332
|
||||||
|
BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:23332
|
||||||
|
BAL_PUSHER_TESTNET_ZMQ_HASHBLOCK=tcp://127.0.0.1:24332
|
||||||
|
BAL_PUSHER_TESTNET4_ZMQ_HASHBLOCK=tcp://127.0.0.1:22332
|
||||||
|
BAL_PUSHER_SIGNET_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
|
||||||
|
|
||||||
|
# RPC endpoints per network (default: localhost only)
|
||||||
|
BAL_PUSHER_BITCOIN_RPC_URL=http://127.0.0.1:8332
|
||||||
|
BAL_PUSHER_REGTEST_RPC_URL=http://127.0.0.1:18443
|
||||||
|
BAL_PUSHER_TESTNET_RPC_URL=http://127.0.0.1:18332
|
||||||
|
BAL_PUSHER_TESTNET4_RPC_URL=http://127.0.0.1:48332
|
||||||
|
BAL_PUSHER_SIGNET_RPC_URL=http://127.0.0.1:38332
|
||||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.gitsecret/keys/random_seed
|
||||||
|
!*.secret
|
||||||
|
|
||||||
|
# Environment files - NEVER commit tokens or secrets
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.secret
|
||||||
|
|
||||||
|
|
||||||
|
# Private keys - NEVER commit to git
|
||||||
|
# Only public_key.pem should be tracked (if needed)
|
||||||
|
*.pem
|
||||||
|
!public_key.pem
|
||||||
|
data/*.pem
|
||||||
|
!data/public_key.pem
|
||||||
|
*.key
|
||||||
|
!*.secret
|
||||||
|
private_key.pem
|
||||||
|
privkey.pem
|
||||||
|
ec.key
|
||||||
|
chiave_privata.key
|
||||||
|
|
||||||
|
# Other sensitive files
|
||||||
|
bal.db
|
||||||
|
.bal.db
|
||||||
|
download_bal_db.sh
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
# Rust build artifacts
|
||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
!lib/
|
||||||
|
!contrib/
|
||||||
|
!src/
|
||||||
|
make_release.sh
|
||||||
64
AGENTS.md
Normal file
64
AGENTS.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# agents.md — Rust Maintainer & Security Auditor (opencode.ai)
|
||||||
|
# Language rule: English only. This agent's output code/comments/files must be in English.
|
||||||
|
|
||||||
|
## 0) Documentation & Knowledge Base
|
||||||
|
|
||||||
|
**Before reading, editing, or auditing any code in this repository, consult
|
||||||
|
the knowledge base in `docs/INDEX.md` to locate the relevant domain knowledge,
|
||||||
|
architecture notes, and security considerations.**
|
||||||
|
|
||||||
|
- Start here: `docs/INDEX.md` — navigable index of all documentation
|
||||||
|
- Bitcoin domain: `docs/02_glossary_and_bitcoin_domain.md`
|
||||||
|
- Architecture: `docs/03_architecture_and_data_flow.md`
|
||||||
|
- Security audit: `docs/08_security_audit.md`
|
||||||
|
|
||||||
|
**After any significant code change (new features, API changes, schema changes,
|
||||||
|
or security fixes), update the corresponding knowledge base file in `docs/`
|
||||||
|
to keep documentation in sync with the implementation.**
|
||||||
|
|
||||||
|
## 1) Purpose
|
||||||
|
Maintain and audit a Rust project with two main goals:
|
||||||
|
- **Maintenance:** keep the codebase correct, stable, and easy to evolve.
|
||||||
|
- **Security:** find and fix bugs and potential **exploits**, especially those reachable via untrusted input.
|
||||||
|
|
||||||
|
## 2) Scope (based on your dependencies)
|
||||||
|
This repo likely uses:
|
||||||
|
- **Async HTTP server:** `hyper`, `hyper-util`, `http-body-util`, `tokio`
|
||||||
|
- **HTTP client:** `reqwest` (`json`, `socks`)
|
||||||
|
- **Bitcoin components:** `bitcoin`, `bitcoincore-rpc`, `bitcoincore-rpc-json`
|
||||||
|
- **Data/encoding:** `base64`, `bs58`, `hex`, `hex-conservative`, `byteorder`
|
||||||
|
- **Crypto/TLS:** `sha2`, `openssl` (vendored)
|
||||||
|
- **Serialization:** `serde`, `serde_json`
|
||||||
|
- **Storage:** `sqlite`
|
||||||
|
- **Parsing:** `regex`
|
||||||
|
- **IPC/messaging:** `zmq`
|
||||||
|
- **Logging:** `log`, `env_logger`
|
||||||
|
|
||||||
|
Therefore, prioritize:
|
||||||
|
- untrusted input flowing into **parsing/encoding/DB/RPC/IPC**
|
||||||
|
- **SSRF/network abuse** via `reqwest`
|
||||||
|
- **panics** from `unwrap()/expect()/panic!()` in request/message paths
|
||||||
|
- **SQL injection** risks in SQLite usage
|
||||||
|
- **secret leakage** through logs/config/error messages
|
||||||
|
- **DoS** vectors: oversized bodies/messages, expensive regex, unbounded JSON, missing timeouts
|
||||||
|
|
||||||
|
## 3) Operating Principles
|
||||||
|
1. **Reproducibility first**
|
||||||
|
- Every fix or security claim must include concrete reproduction steps and exact commands.
|
||||||
|
2. **Small changes**
|
||||||
|
- Prefer minimal diffs with focused tests.
|
||||||
|
3. **CI-aligned workflow**
|
||||||
|
- If CI fails, identify the failing target/job and fix at the right layer.
|
||||||
|
4. **Security-first triage**
|
||||||
|
- If you see signs of exploitability (RCE/auth bypass/SSRF/secret leak/DoS), prioritize mitigation + regression tests.
|
||||||
|
5. **No panics on untrusted input**
|
||||||
|
- In any path reachable from HTTP/ZMQ/DB/IPC/network/CLI: eliminate `unwrap()/expect()` and replace with safe error handling.
|
||||||
|
|
||||||
|
## 4) Baseline Commands (must run before finalizing)
|
||||||
|
Run and ensure these succeed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt -- --check
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
2142
Cargo.lock
generated
2142
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
80
Cargo.toml
80
Cargo.toml
@@ -1,30 +1,58 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bal-server"
|
name = "bal_server"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
[dependencies]
|
[features]
|
||||||
bs58 = "0.4.0"
|
default = ["server", "pusher"]
|
||||||
bytes = "1.2"
|
server = ["dep:actix-web", "dep:actix-governor", "dep:actix-rt", "dep:chrono", "dep:hex-conservative"]
|
||||||
bitcoin = { version = "0.32.5" }
|
pusher = ["dep:zmq", "dep:reqwest", "dep:byteorder", "dep:base64", "dep:ed25519-dalek"]
|
||||||
bitcoincore-rpc = "0.19.0"
|
|
||||||
bitcoincore-rpc-json = "0.19.0"
|
|
||||||
byteorder = "1.5.0"
|
|
||||||
confy = "0.6.1"
|
|
||||||
chrono = "0.4.40"
|
|
||||||
env_logger = "0.11.5"
|
|
||||||
hex = "0.4.3"
|
|
||||||
hex-conservative = "0.1.1"
|
|
||||||
hyper = { version = "1.3.1", features = ["http1","server"] }
|
|
||||||
hyper-util = { version = "0.1.3", features = ["tokio"] }
|
|
||||||
http-body-util = "0.1"
|
|
||||||
log = "0.4.21"
|
|
||||||
sha2 = "0.10.8"
|
|
||||||
serde = { version = "1.0.152", features = ["derive"] }
|
|
||||||
serde_json = "1.0.116"
|
|
||||||
sqlite = "0.34.0"
|
|
||||||
regex = "1.10.4"
|
|
||||||
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] } # Keep only necessary runtime components
|
|
||||||
zmq = "0.10.0"
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bs58 = { version = "0.4.0" }
|
||||||
|
bytes = { version = "1.2" }
|
||||||
|
bitcoin = { version = "0.32.5" }
|
||||||
|
bitcoincore-rpc = { version = "0.19.0" }
|
||||||
|
bitcoincore-rpc-json = { version = "0.19.0" }
|
||||||
|
env_logger = { version = "0.11.5" }
|
||||||
|
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" }
|
||||||
|
regex = { version = "1.10.4" }
|
||||||
|
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] }
|
||||||
|
url = { version = "2" }
|
||||||
|
|
||||||
|
# server-only
|
||||||
|
actix-web = { version = "4.9.0", optional = true }
|
||||||
|
actix-governor = { version = "0.6.0", optional = true }
|
||||||
|
actix-rt = { version = "2.10.0", optional = true }
|
||||||
|
chrono = { version = "0.4.40", optional = true }
|
||||||
|
hex-conservative = { version = "0.1.1", optional = true }
|
||||||
|
|
||||||
|
# pusher-only
|
||||||
|
zmq = { version = "0.10.0", optional = true }
|
||||||
|
reqwest = { version = "0.12.24", features = ["json","socks"], optional = true }
|
||||||
|
byteorder = { version = "1.5.0", optional = true }
|
||||||
|
base64 = { version = "0.22.1", optional = true }
|
||||||
|
ed25519-dalek = { version = "2", features = ["pem", "pkcs8"], optional = true }
|
||||||
|
sha2 = { version = "0.10.8" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bal-server"
|
||||||
|
path = "src/bin/bal-server.rs"
|
||||||
|
required-features = ["server"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bal-pusher"
|
||||||
|
path = "src/bin/bal-pusher.rs"
|
||||||
|
required-features = ["pusher"]
|
||||||
|
|||||||
92
Dockerfile
Normal file
92
Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Multi-stage Dockerfile for bal-server + bal-pusher
|
||||||
|
# Security: non-root user, minimal runtime, tini as PID 1
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1: Builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM rust:1.95-bookworm AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
libzmq5-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Cache dependencies: copy Cargo.toml first, create dummy src to build deps
|
||||||
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
|
RUN mkdir -p src/bin && \
|
||||||
|
echo 'fn main() {}' > src/bin/bal-server.rs && \
|
||||||
|
echo 'fn main() {}' > src/bin/bal-pusher.rs && \
|
||||||
|
echo '' > src/lib.rs && \
|
||||||
|
echo '' > src/db.rs && \
|
||||||
|
echo '' > src/xpub.rs && \
|
||||||
|
echo '' > src/validation.rs && \
|
||||||
|
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 each binary with only its required features
|
||||||
|
COPY src/ src/
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2: Runtime
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
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 \
|
||||||
|
libzmq5 \
|
||||||
|
libsqlite3-0 \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& apt-get clean
|
||||||
|
|
||||||
|
# Copy binaries from builder
|
||||||
|
COPY --from=builder /build/target/release/bal-server /usr/local/bin/bal-server
|
||||||
|
COPY --from=builder /build/target/release/bal-pusher /usr/local/bin/bal-pusher
|
||||||
|
|
||||||
|
# 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 (bind to 127.0.0.1 via env, expose for reverse proxy)
|
||||||
|
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_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
|
||||||
|
|
||||||
|
# Health check: verify bal-server is responding
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -sf http://127.0.0.1:9137/ || exit 1
|
||||||
100
Dockerfile.release
Normal file
100
Dockerfile.release
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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_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
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -sf http://127.0.0.1:9137/ || exit 1
|
||||||
182
README.md
182
README.md
@@ -3,28 +3,172 @@
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ git clone ....
|
git clone https://bitcoin-after.life/gitea/bitcoinafterlife/bal-server.git
|
||||||
$ cd bal-server
|
cd bal-server
|
||||||
$ cargo build --release
|
openssl genpkey -algorithm ED25519 -out private_key.pem
|
||||||
$ sudo cp target/release/bal-server /usr/local/bin
|
openssl pkey -in private_key.pem -pubout -out public_key.pem
|
||||||
$ bal-server
|
cargo build --release
|
||||||
|
sudo cp target/release/bal-server target/release/bal-pusher /usr/local/bin
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Docker
|
||||||
|
|
||||||
The `bal-server` application can be configured using environment variables. The following variables are available:
|
### 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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name bal-server \
|
||||||
|
--network host \
|
||||||
|
--tmpfs /tmp:rw,noexec,nosuid \
|
||||||
|
-v /path/to/data:/var/bal:rw \
|
||||||
|
-v /path/to/.bitcoin/regtest/.cookie:/var/bal/.bitcoin/regtest/.cookie:ro \
|
||||||
|
-e BAL_SERVER_REGTEST_ADDRESS="your_xpub_or_address" \
|
||||||
|
-e BAL_SERVER_REGTEST_FIXED_FEE=50000 \
|
||||||
|
-e BAL_SERVER_INFO="BAL server" \
|
||||||
|
-e BAL_PUSHER_NETWORK=regtest \
|
||||||
|
-e BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332 \
|
||||||
|
-e BAL_PUSHER_REGTEST_COOKIE_FILE=/var/bal/.bitcoin/regtest/.cookie \
|
||||||
|
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 |
|
| Variable | Description | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `BAL_SERVER_CONFIG_FILE` | Path to the configuration file. If the file does not exist, a new one will be created. | `$HOME/.config/bal-server/default-config.toml` |
|
| `BAL_PUSHER_NETWORK` | Network to run pusher on (`bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`). | `bitcoin` |
|
||||||
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. If the file does not exist, a new one will be created. | `bal.db` |
|
| `BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK` | ZMQ endpoint for regtest blocks. | `tcp://127.0.0.1:21332` |
|
||||||
| `BAL_SERVER_BIND_ADDRESS` | Public address for listening to requests. | `127.0.0.1` |
|
| `BAL_PUSHER_REGTEST_COOKIE_FILE` | Absolute path to Bitcoin Core cookie file inside the container. | - |
|
||||||
| `BAL_SERVER_BIND_PORT` | Default port for listening to requests. | `9137` |
|
|
||||||
| `BAL_SERVER_REGTEST_ADDRESS` | Bitcoin address for the regtest environment. | - |
|
> **Note:** The container runs as a non-root `bal` user (uid 1000) with `tini` as PID 1.
|
||||||
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee for the regtest environment. | 50000 |
|
> The `/var/bal` volume stores the database. Mount Bitcoin Core's cookie file as read-only.
|
||||||
| `BAL_SERVER_SIGNET_ADDRESS` | Bitcoin address for the signet environment. | - |
|
> When using `--network host`, ensure only `127.0.0.1` is used for internal services.
|
||||||
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee for the signet environment. | 50000 |
|
> `Dockerfile.release` fetches the latest release from the Gitea server and verifies its SHA-256 checksum.
|
||||||
| `BAL_SERVER_TESTNET_ADDRESS` | Bitcoin address for the testnet environment. | - |
|
|
||||||
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee for the testnet environment. | 50000 |
|
## Configuration (bal-server)
|
||||||
| `BAL_SERVER_BITCOIN_ADDRESS` | Bitcoin address for the mainnet environment. | - |
|
|
||||||
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee for the mainnet environment. | 50000 |
|
The `bal-server` application can be configured using environment variables.
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
|
||||||
|
| `BAL_SERVER_BIND_ADDRESS` | Address to listen on. **Never bind to `0.0.0.0` in production without a reverse proxy.** | `127.0.0.1` |
|
||||||
|
| `BAL_SERVER_BIND_PORT` | Port to listen on. | `9137` |
|
||||||
|
| `BAL_SERVER_INFO` | Server info string returned by the `/` endpoint. | - |
|
||||||
|
| `BAL_SERVER_PUB_KEY_PATH` | Ed25519 public key for signature verification. | `public_key.pem` |
|
||||||
|
| `BAL_SERVER_URL` | Public URL of this server (used for stats reporting). | - |
|
||||||
|
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
|
||||||
|
| `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`). | `info` |
|
||||||
|
|
||||||
|
### Per-network addresses and fees
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BAL_SERVER_BITCOIN_ADDRESS` | xpub or address for mainnet. | - |
|
||||||
|
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee (satoshis) for mainnet. | `50000` |
|
||||||
|
| `BAL_SERVER_REGTEST_ADDRESS` | xpub or address for regtest. | - |
|
||||||
|
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee (satoshis) for regtest. | `50000` |
|
||||||
|
| `BAL_SERVER_SIGNET_ADDRESS` | xpub or address for signet. | - |
|
||||||
|
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee (satoshis) for signet. | `50000` |
|
||||||
|
| `BAL_SERVER_TESTNET_ADDRESS` | xpub or address for testnet. | - |
|
||||||
|
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee (satoshis) for testnet. | `50000` |
|
||||||
|
| `BAL_SERVER_TESTNET4_ADDRESS` | xpub or address for testnet4. | - |
|
||||||
|
| `BAL_SERVER_TESTNET4_FIXED_FEE` | Fixed fee (satoshis) for testnet4. | `50000` |
|
||||||
|
|
||||||
|
### DoS protection (Actix Web)
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BAL_SERVER_ACTIX_MAX_BODY_SIZE` | Maximum request body size in bytes. | `1048576` (1 MB) |
|
||||||
|
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | Request timeout in seconds. | `5` |
|
||||||
|
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | Rate limit: push txs requests per second. | `1` |
|
||||||
|
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | Rate limit: push txs burst size. | `3` |
|
||||||
|
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | Rate limit: search tx requests per second. | `5` |
|
||||||
|
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | Rate limit: search tx burst size. | `10` |
|
||||||
|
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | Rate limit: info requests per second. | `20` |
|
||||||
|
| `BAL_SERVER_ACTIX_INFO_BURST` | Rate limit: info burst size. | `30` |
|
||||||
|
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | Rate limit: default requests per second. | `50` |
|
||||||
|
| `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-pusher
|
||||||
|
|
||||||
|
`bal-pusher` monitors Bitcoin blocks via ZMQ and pushes time-locked transactions from the database to the Bitcoin network when their **locktime** exceeds the **median time past** (MTP).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Bitcoin Core** with ZMQ support enabled. Add to `bitcoin.conf`:
|
||||||
|
```
|
||||||
|
zmqpubhashblock=tcp://127.0.0.1:28332
|
||||||
|
```
|
||||||
|
- **Rust and Cargo**: [Rust Installation](https://www.rust-lang.org/tools/install)
|
||||||
|
- **Libraries**: `libssl-dev`, `libzmq5-dev`, `libsqlite3-dev`
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bal-pusher [bitcoin|testnet|testnet4|signet|regtest]
|
||||||
|
```
|
||||||
|
|
||||||
|
If no network is specified, defaults to `bitcoin`.
|
||||||
|
|
||||||
|
## Configuration (bal-pusher)
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BAL_PUSHER_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` |
|
||||||
|
|
||||||
|
### Per-network configuration
|
||||||
|
|
||||||
|
Each network (`bitcoin`, `regtest`, `testnet`, `testnet4`, `signet`) supports the following variables.
|
||||||
|
Replace `{NETWORK}` with the uppercase network name (e.g., `REGTEST`, `BITCOIN`).
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_ZMQ_HASHBLOCK` | ZMQ endpoint for block notifications. | `tcp://127.0.0.1:28332` (mainnet) |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_COOKIE_FILE` | Absolute path to Bitcoin Core cookie file. | `$HOME/.bitcoin/{dir}/.cookie` |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_RPC_USER` | Bitcoin Core RPC username (alternative to cookie auth). | - |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_RPC_PASSWORD` | Bitcoin Core RPC password. | - |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_HOST` | Bitcoin Core RPC host. | `http://127.0.0.1` |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_PORT` | Bitcoin Core RPC port. | `8332` (mainnet) |
|
||||||
|
| `BAL_PUSHER_{NETWORK}_DIR_PATH` | Bitcoin Core data directory subfolder. | `` (mainnet) |
|
||||||
|
|
||||||
|
Default ZMQ ports per network:
|
||||||
|
|
||||||
|
| Network | ZMQ Port | RPC Port |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `bitcoin` | 28332 | 8332 |
|
||||||
|
| `regtest` | 21332 | 18443 |
|
||||||
|
| `testnet` | 23332 | 18332 |
|
||||||
|
| `testnet4` | 22332 | 48332 |
|
||||||
|
| `signet` | 24332 | 38332 |
|
||||||
|
|||||||
119
RPC.md
Normal file
119
RPC.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# RPC Endpoint Documentation
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
This document outlines the various endpoints provided by the Will Executor Server, a specialized server designed for managing Bitcoin transactions. Each endpoint can be accessed via HTTP GET or POST
|
||||||
|
requests with specific parameters and return values.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 1. **Server Information**
|
||||||
|
- **Endpoint:** `GET /`
|
||||||
|
- **Description:** Returns general information about the server.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/`
|
||||||
|
- **Response:**
|
||||||
|
```plaintext
|
||||||
|
Will Executor Server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Server Public Key**
|
||||||
|
- **Endpoint:** `GET /.pub_key.pem`
|
||||||
|
- **Description:** Returns the public key of the server.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/.pub_key.pem`
|
||||||
|
- **Response:**
|
||||||
|
```plaintext
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAy10MSrWabdfco1c5Jo1XuohSdXSk1S0YaoEYvqZR5VE=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Server Version**
|
||||||
|
- **Endpoint:** `GET /version`
|
||||||
|
- **Description:** Returns the version of the server.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/version`
|
||||||
|
- **Response:**
|
||||||
|
```plaintext
|
||||||
|
0.2.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Network Information**
|
||||||
|
- **Endpoint:** `GET /<network>/info`
|
||||||
|
- **Description:** Returns information about a specific network.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/bitcoin/info`
|
||||||
|
- **Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chain": "bitcoin",
|
||||||
|
"address": "bc1q5z32sl8at9s3sxt7mfwe6th4jxua98a0mvg8yz",
|
||||||
|
"base_fee": 1000,
|
||||||
|
"info": "Will Executor Server",
|
||||||
|
"version": "0.2.2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. **Network Statistics**
|
||||||
|
- **Endpoint:** `GET /<network>/stats`
|
||||||
|
- **Description:** Returns statistics for a specific network.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/bitcoin/stats`
|
||||||
|
- **Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"report_date": "2025-10-21 03:10:09",
|
||||||
|
"chain": "bitcoin",
|
||||||
|
"totals": 63,
|
||||||
|
"waiting": 8,
|
||||||
|
"sent": 30,
|
||||||
|
"failed": 25,
|
||||||
|
"waiting_profit": 80000,
|
||||||
|
"sent_profit": 300000,
|
||||||
|
"missed_profit": 250000,
|
||||||
|
"unique_inputs": 38
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. **Search Transaction**
|
||||||
|
- **Endpoint:** `POST /searchtx`
|
||||||
|
- **Description:** Searches for a transaction by its ID.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/searchtx`
|
||||||
|
- **Request Data:**
|
||||||
|
```plaintext
|
||||||
|
241ac86bdbf1408198b8c6df77e88159b43a9bb3464e55197a9fed8fdd628895
|
||||||
|
```
|
||||||
|
- **Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "1",
|
||||||
|
"our_address": "bcrt1q7ajty6q3g055vvy6ryql9y3jz76x5uv806skk6",
|
||||||
|
"time": "1733755921197410086",
|
||||||
|
"our_fees": "10000",
|
||||||
|
"tx": "0200000000010281c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600100000000fdffffff81c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600200000000fdffffff031027000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. **Push Transactions**
|
||||||
|
- **Endpoint:** `POST /push`
|
||||||
|
- **Description:** Pushes one or more transactions to the network.
|
||||||
|
- **Example URL:** `https://we.bitcoin-after.life/push`
|
||||||
|
- **Request Data:**
|
||||||
|
```plaintext
|
||||||
|
0200000000010281c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600100000000fdffffff81c28321ff6bcbfcd894bf4536d9a9fb4f4b56470db487da36f8330d495528600200000000fdffffff031027000000
|
||||||
|
```
|
||||||
|
- **Response:**
|
||||||
|
- If successful, it returns the hash of each transaction:
|
||||||
|
```plaintext
|
||||||
|
thx
|
||||||
|
```
|
||||||
|
- If a transaction is already present or bad data is received, it returns an error message:
|
||||||
|
```plaintext
|
||||||
|
{
|
||||||
|
already present // or Bad data received
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- **400 Bad Request:** Returned when the request contains invalid parameters.
|
||||||
|
- **500 Internal Server Error:** Returned when the server encounters an unexpected condition.
|
||||||
|
|
||||||
|
This documentation should help you effectively interact with the Will Executor Server using its provided endpoints.
|
||||||
|
|
||||||
15
bal-pusher.sh
Normal file
15
bal-pusher.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export RUST_LOG=trace
|
||||||
|
|
||||||
|
export BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
|
||||||
|
#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 WELIST_SERVER_URL=http://localhost:8086
|
||||||
|
export WELIST_SKIP_URL_VALIDATION=true
|
||||||
|
export BAL_SERVER_URL="http://127.0.0.1:9133"
|
||||||
|
export SSL_KEY_PATH="$(pwd)/private_key.pem"
|
||||||
|
cargo run --bin=bal-pusher regtest --features=pusher
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
RUST_LOG=trace
|
RUST_LOG=info
|
||||||
BAL_SERVER_DB_FILE=/home/bal/bal.db
|
BAL_SERVER_DB_FILE="/home/bal/bal.db"
|
||||||
BAL_SERVER_INFO="BAL server test willexecutor"
|
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_ADDRESS=127.0.0.1
|
||||||
BAL_SERVER_BIND_PORT=9133
|
BAL_SERVER_BIND_PORT=9133
|
||||||
BAL_SERVER_BITCOIN_ADDRESS="your bitcoin to recive payments here"
|
BAL_SERVER_BITCOIN_ADDRESS="your bitcoin or xpub to recive payments here"
|
||||||
BAL_SERVER_BITCOIN_FIXED_FEE=50000
|
BAL_SERVER_BITCOIN_FIXED_FEE=50000
|
||||||
|
BAL_SERVER_PUB_KEY_PATH="/home/bal/public_key.pem"
|
||||||
|
|
||||||
BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4"
|
BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4"
|
||||||
BAL_SERVER_REGTEST_FEE=5000
|
BAL_SERVER_REGTEST_FEE=5000
|
||||||
@@ -12,3 +14,17 @@ BAL_SERVER_REGTEST_FEE=5000
|
|||||||
#BAL_SERVER_TESTNET_FEE=100000
|
#BAL_SERVER_TESTNET_FEE=100000
|
||||||
#BAL_SERVER_SIGNET_ADDRESS=
|
#BAL_SERVER_SIGNET_ADDRESS=
|
||||||
#BAL_SERVER_SIGNET_FEE=100000
|
#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
|
||||||
|
|||||||
25
bal-server.sh
Normal file
25
bal-server.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
WORKING_DIR=$(pwd)
|
||||||
|
if [ ! -f "$WORKING_DIR/public_key.pem" ]; then
|
||||||
|
echo "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/bal.db"
|
||||||
|
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_BITCOIN_ADDRESS="your bitcoin address or xpub to recive payments here"
|
||||||
|
#export BAL_SERVER_BITCOIN_FIXED_FEE=50000
|
||||||
|
|
||||||
|
export BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4"
|
||||||
|
export BAL_SERVER_REGTEST_FIXED_FEE=1000
|
||||||
|
#export BAL_SERVER_TESTNET_ADDRESS=
|
||||||
|
#export BAL_SERVER_TESTNET_FEE=100000
|
||||||
|
#export BAL_SERVER_SIGNET_ADDRESS=
|
||||||
|
#export BAL_SERVER_SIGNET_FEE=100000
|
||||||
|
cargo run --bin=bal-server
|
||||||
258
contrib/download_and_install_bal.sh
Normal file
258
contrib/download_and_install_bal.sh
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
###############SETTINGS################
|
||||||
|
# These settings can be overridden by environment variables or arguments
|
||||||
|
# Usage: ./download_and_install_bal.sh <xpub> <fixed_fee> <willexecutor_url> <email> [info]
|
||||||
|
# Example: ./download_and_install_bal.sh bc1q... 50000 we.example.com info@example.com
|
||||||
|
# DO NOT commit this file with hardcoded secrets!
|
||||||
|
|
||||||
|
if [ -n "$1" ]; then xpub="$1"; else
|
||||||
|
echo "Error: xpub address is required as first argument"
|
||||||
|
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
|
||||||
|
echo "Example: $0 bc1q... 50000 we.example.com info@example.com"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$2" ]; then fixed_fee="$2"; else
|
||||||
|
echo "Error: fixed_fee is required as second argument"
|
||||||
|
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$3" ]; then willexecutor_url="$3"; else
|
||||||
|
echo "Error: willexecutor_url is required as third argument"
|
||||||
|
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$4" ]; then email="$4"; else
|
||||||
|
echo "Error: email is required as fourth argument (for SSL certificate)"
|
||||||
|
echo "Usage: $0 <xpub> <fixed_fee> <willexecutor_url> <email> [info]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$5" ]; then info="$5"; else info="commercial will executor server"; fi
|
||||||
|
#######################################
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bal_server_conf=$(cat << EOF
|
||||||
|
BAL_SERVER_DB_FILE=/home/bal/bal.db
|
||||||
|
BAL_SERVER_BIND_ADDRESS=127.0.0.1
|
||||||
|
BAL_SERVER_BIND_PORT=9137
|
||||||
|
BAL_SERVER_BITCOIN_ADDRESS="$xpub"
|
||||||
|
BAL_SERVER_BITCOIN_FIXED_FEE=$fixed_fee
|
||||||
|
BAL_SERVER_INFO="$info"
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
bal_pusher_conf=$(cat << EOF
|
||||||
|
BAL_PUSHER_DB_FILE=/home/bal/bal.db
|
||||||
|
BAL_PUSHER_BITCOIN_COOKIE_FILE=/home/bitcoin/.bitcoin/.cookie
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
if ! command -v jq &> /dev/null; then
|
||||||
|
echo "Installing jq... "
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq
|
||||||
|
fi
|
||||||
|
if ! command -v curl &> /dev/null; then
|
||||||
|
echo "Installing curl... "
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v certbot &> /dev/null; then
|
||||||
|
echo "Installing certbot... "
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y certbot python3-certbot-nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v nginx &> /dev/null; then
|
||||||
|
echo "Installing nginx... "
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
################## DOWNLOAD AND INSTALL BAL #####################
|
||||||
|
url_releases="https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server/releases/latest"
|
||||||
|
|
||||||
|
url_asset="$(curl -sfL $url_releases | jq -r .assets[0].browser_download_url)"
|
||||||
|
if [ -z "$url_asset" ] || [ "$url_asset" = "null" ]; then
|
||||||
|
echo "Error: could not fetch download URL from Gitea releases"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tempdir=$(mktemp -d)
|
||||||
|
cd $tempdir
|
||||||
|
|
||||||
|
curl -sfL -O "$url_asset"
|
||||||
|
echo "$url_asset"
|
||||||
|
filename=$(basename "$url_asset")
|
||||||
|
tar -xzf $filename
|
||||||
|
|
||||||
|
dirname=$(basename "$filename" .tar.gz)
|
||||||
|
echo "dirname $dirname"
|
||||||
|
cd $dirname
|
||||||
|
sudo install -m 0755 -o root -g root -t /usr/local/bin bal-server
|
||||||
|
sudo install -m 0755 -o root -g root -t /usr/local/bin bal-pusher
|
||||||
|
|
||||||
|
id bal >/dev/null 2>&1 || sudo adduser --gecos "" --disabled-password bal
|
||||||
|
printf "$bal_server_conf" | sudo -u bal tee "/home/bal/bal-server.env" > /dev/null
|
||||||
|
sudo chmod 600 /home/bal/bal-server.env
|
||||||
|
printf "$bal_pusher_conf" | sudo -u bal tee "/home/bal/bal-pusher.env" > /dev/null
|
||||||
|
sudo chmod 600 /home/bal/bal-pusher.env
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
################## SERVICES #####################
|
||||||
|
bal_server_service=$(cat << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=bal-server daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
|
||||||
|
EnvironmentFile=/home/bal/bal-server.env
|
||||||
|
|
||||||
|
ExecStart=/usr/local/bin/bal-server
|
||||||
|
|
||||||
|
SyslogIdentifier=bal-server
|
||||||
|
|
||||||
|
Type=simple
|
||||||
|
PIDFile=/run/bal-server/bal-server.pid
|
||||||
|
Restart=always
|
||||||
|
TimeoutSec=300
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
User=bal
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
RuntimeDirectory=bal-server
|
||||||
|
RuntimeDirectoryMode=0710
|
||||||
|
|
||||||
|
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
PrivateDevices=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
bal_pusher_service=$(cat << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=bal-pusher daemon
|
||||||
|
After=bitcoind.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
|
||||||
|
EnvironmentFile=/home/bal/bal-pusher.env
|
||||||
|
|
||||||
|
ExecStart=/usr/local/bin/bal-pusher bitcoin
|
||||||
|
|
||||||
|
StandardOutput=syslog
|
||||||
|
StandardError=syslog
|
||||||
|
SyslogIdentifier=bal-pusher
|
||||||
|
|
||||||
|
|
||||||
|
Type=simple
|
||||||
|
PIDFile=/run/bal-pusher/bal-pusher.pid
|
||||||
|
Restart=always
|
||||||
|
TimeoutSec=120
|
||||||
|
RestartSec=300
|
||||||
|
KillMode=process
|
||||||
|
|
||||||
|
User=bal
|
||||||
|
Group=bitcoin
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
RuntimeDirectory=bal-pusher
|
||||||
|
RuntimeDirectoryMode=0710
|
||||||
|
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
PrivateDevices=true
|
||||||
|
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
printf "$bal_server_service" | sudo tee "/etc/systemd/system/bal-server.service" > /dev/null
|
||||||
|
printf "$bal_pusher_service" | sudo tee "/etc/systemd/system/bal-pusher.service" > /dev/null
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable bal-server.service
|
||||||
|
sudo systemctl restart bal-server.service
|
||||||
|
|
||||||
|
sudo systemctl enable bal-pusher.service
|
||||||
|
sudo systemctl restart bal-pusher.service
|
||||||
|
|
||||||
|
################## TODO SSL #####################
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
echo "Asking certificate for domain $willexecutor_url..."
|
||||||
|
sudo certbot --nginx --non-interactive --agree-tos --email $email -d $willexecutor_url
|
||||||
|
|
||||||
|
if [ -n "/etc/letsencrypt/live/$willexecutor_url/fullchain.pem" ]; then
|
||||||
|
sudo openssl x509 -in "/etc/letsencrypt/live/$willexecutor_url/fullchain.pem" -noout -text | grep -E "Issuer:|Subject:|Not Before:|Not After :"
|
||||||
|
else
|
||||||
|
echo "Error getting certificate"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
(crontab -l 2>/dev/null; echo "0 0,12 * * * /usr/bin/certbot renew --quiet") | crontab -
|
||||||
|
echo "ssl certificate installed"
|
||||||
|
sudo systemctl status nginx
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
################## NGNIX ########################
|
||||||
|
nginx_reverse_proxy=$(cat << EOF
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name $willexecutor_url;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$willexecutor_url/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$willexecutor_url/privkey.pem; # managed by Certbot
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9137;
|
||||||
|
# Include standard proxy headers from above
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $willexecutor_url;
|
||||||
|
return 301 https://$willexecutor_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
printf "$nginx_reverse_proxy" | sudo tee "/etc/nginx/sites-available/$willexecutor_url" > /dev/null
|
||||||
|
sudo ln -s "/etc/nginx/sites-available/$willexecutor_url" "/etc/nginx/sites-enabled/" || true
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
|
||||||
|
rm -r $tempdir
|
||||||
|
echo "done"
|
||||||
411
contrib/download_and_install_bitcoincore.sh
Normal file
411
contrib/download_and_install_bitcoincore.sh
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# Copyright (c) 2024-2025 Joel Torres
|
||||||
|
# Distributed under the MIT software license, see the accompanying
|
||||||
|
# file LICENSE or https://opensource.org/license/mit.
|
||||||
|
|
||||||
|
VERSION=0.1.1
|
||||||
|
usage(){
|
||||||
|
echo "\$ bash download_and_install_bitcoincore.sh <version>|update [username] [testnet] [force]"
|
||||||
|
echo "\$ bash download_and_install_bitcoincore.sh version"
|
||||||
|
echo "\$ bash download_and_install_bitcoincore.sh -h"
|
||||||
|
}
|
||||||
|
if [[ $1 == "version" ]]; then
|
||||||
|
echo "Bitcoin Core Installer v$VERSION"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ $1 == "-h" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
username=$(id -un)
|
||||||
|
if [[ "$2" != "" ]]; then
|
||||||
|
username="$2"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $3 == "testnet" ]]; then
|
||||||
|
testnet=1
|
||||||
|
fi
|
||||||
|
if [[ $4 == "force" ]]; then
|
||||||
|
force=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SOURCE_DIR/lib.sh"
|
||||||
|
PLATFORM_ARCH=$(uname -m)
|
||||||
|
|
||||||
|
if [ $(uname) == "Linux" ]; then
|
||||||
|
PLATFORM_NAME="linux-gnu"
|
||||||
|
|
||||||
|
BITCOIN_DIR=".bitcoin"
|
||||||
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
|
GLOBAL_ALIASES="/etc/profile.d/bitcoin_aliases.sh"
|
||||||
|
else
|
||||||
|
echo_e "Running script on unsupported platform, exiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
CMD_DEPENDENCIES="git gpg curl openssl"
|
||||||
|
for cmd in $CMD_DEPENDENCIES
|
||||||
|
do
|
||||||
|
if [ $(which $cmd >/dev/null 2>&1; echo $?) != 0 ]; then
|
||||||
|
echo_e "Command not found on path: $cmd, please install or add to path"
|
||||||
|
sudo apt install -y $CMD_DEPENDENCIES
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SYSTEMD_SERVICE="bitcoind.service"
|
||||||
|
BITCOIN_CONFIG_FILE="bitcoin.conf"
|
||||||
|
BITCOIN_CORE_URL="https://bitcoincore.org"
|
||||||
|
BIN_URL="$BITCOIN_CORE_URL/bin"
|
||||||
|
DOWNLOAD_URL="$BITCOIN_CORE_URL/en/download/"
|
||||||
|
if [ -n "$1" ] && [ $1 != "update" ]; then
|
||||||
|
VERSION_NUM=$1
|
||||||
|
if [[ ! $VERSION_NUM =~ ^[0-9]{1,3}.[0-9]{1,3}$ ]]; then
|
||||||
|
echo_e "Error: invalid version number"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
VERSION_NUM=$(curl -s $DOWNLOAD_URL | grep "Latest version" | sed 's/.*Latest version: \([0-9]*\.[0-9]*\).*/\1/')
|
||||||
|
fi
|
||||||
|
VERSION_NUM_FULL="bitcoin-core-$VERSION_NUM"
|
||||||
|
|
||||||
|
KEYS_REPO="guix.sigs"
|
||||||
|
KEYS_REPO_URL="https://github.com/bitcoin-core/$KEYS_REPO"
|
||||||
|
KEYS_DIR="$KEYS_REPO/builder-keys"
|
||||||
|
|
||||||
|
if command -v bitcoin-cli &> /dev/null; then
|
||||||
|
CURRENT_VERSION=$(bitcoin-cli --version | head -n1 | awk '{print $NF}')
|
||||||
|
else
|
||||||
|
CURRENT_VERSION="none"
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_bitcoin_core_running() {
|
||||||
|
echo $(pgrep bitcoind >/dev/null 2>&1; echo $?)
|
||||||
|
}
|
||||||
|
|
||||||
|
have_to_update(){
|
||||||
|
if [[ "v$VERSION_NUM" != "$CURRENT_VERSION" ]]; then
|
||||||
|
echo 0
|
||||||
|
else
|
||||||
|
echo 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_bitcoin_core_exec() {
|
||||||
|
echo_i "Starting bitcoind $1"
|
||||||
|
bitcoind="bitcoind"
|
||||||
|
if [[ $1 == "testnet" ]]; then
|
||||||
|
bitcoind="t$SYSTEMD_SERVICE"
|
||||||
|
fi
|
||||||
|
sudo systemctl restart $bitcoind
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_bitcoin_core_exec(){
|
||||||
|
bitcoind="$SYSTEMD_SERVICE"
|
||||||
|
debug_file="$BITCOIN_DIR_REAL/debug.log"
|
||||||
|
if [[ "$1" == "testnet" ]]; then
|
||||||
|
bitcoind="t$SYSTEMD_SERVICE"
|
||||||
|
debug_file="$BITCOIN_DIR_REAL/testnet3/debug.log"
|
||||||
|
fi
|
||||||
|
sudo systemctl stop $bitcoind
|
||||||
|
#while true; do
|
||||||
|
# last_line=$(tail -n 1 file.txt)
|
||||||
|
# if [[ $last_line == "*Shutdown: done*" ]]; then
|
||||||
|
# echo_i "$1 bitcoind terminated."
|
||||||
|
# break
|
||||||
|
# fi
|
||||||
|
#done
|
||||||
|
|
||||||
|
}
|
||||||
|
stop_bitcoin_core() {
|
||||||
|
stop_bitcoin_core_exec
|
||||||
|
if [[ $testnet == 1 ]]; then
|
||||||
|
stop_bitcoin_core testnet
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
start_bitcoin_core() {
|
||||||
|
start_bitcoin_core_exec
|
||||||
|
if [[ $testnet == 1 ]]; then
|
||||||
|
start_bitcoin_core_exec testnet
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
download_bitcoin_core () {
|
||||||
|
file_download_url="$BIN_URL/$VERSION_NUM_FULL/bitcoin-$VERSION_NUM-$PLATFORM_ARCH-$PLATFORM_NAME.tar.gz"
|
||||||
|
bin_hash_url="$BIN_URL/$VERSION_NUM_FULL/SHA256SUMS"
|
||||||
|
hash_sign_url="$bin_hash_url.asc"
|
||||||
|
|
||||||
|
if [ ! -d $VERSION_NUM_FULL ]; then
|
||||||
|
mkdir $VERSION_NUM_FULL
|
||||||
|
fi
|
||||||
|
|
||||||
|
for url in $file_download_url $bin_hash_url $hash_sign_url
|
||||||
|
do
|
||||||
|
echo_i "Downloading $url"
|
||||||
|
curl -O --output-dir $VERSION_NUM_FULL $url
|
||||||
|
done
|
||||||
|
|
||||||
|
echo_i "Verifying sha-256 hash"
|
||||||
|
cd $VERSION_NUM_FULL
|
||||||
|
shasum -a 256 --ignore-missing --check SHA256SUMS
|
||||||
|
if [ $? != 0 ]; then
|
||||||
|
echo_e "Installation aborted: failure on computing hashes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch .hash_verified
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_bitcoin_core () {
|
||||||
|
|
||||||
|
if [ ! -d $KEYS_REPO ]; then
|
||||||
|
echo_i "Downloading builder-keys ($KEYS_REPO_URL)"
|
||||||
|
git clone $KEYS_REPO_URL
|
||||||
|
else
|
||||||
|
echo_i "Updating builder-keys"
|
||||||
|
git -C $KEYS_REPO pull
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_i "Importing and refreshing keys"
|
||||||
|
gpg --import $KEYS_DIR/*
|
||||||
|
gpg --keyserver hkps://keys.openpgp.org --refresh-keys
|
||||||
|
|
||||||
|
echo_i "Verifying gpg signatures"
|
||||||
|
cd $VERSION_NUM_FULL
|
||||||
|
good_sign_str="Good signature"
|
||||||
|
good_sign_out=$(gpg --verify SHA256SUMS.asc 2> >(grep "$good_sign_str"))
|
||||||
|
if [[ ! $good_sign_out == *"$good_sign_str"* ]]; then
|
||||||
|
echo_e "Installation aborted: no good gpg signatures found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$good_sign_out"
|
||||||
|
echo
|
||||||
|
while true; do
|
||||||
|
read -p "The above good signatures were found. Do you trust some of these? [y/n]: " answer
|
||||||
|
case $answer in
|
||||||
|
Y|y)
|
||||||
|
touch .sign_verified; break;;
|
||||||
|
N|n)
|
||||||
|
echo_e "Installation aborted: keys not trusted"; exit 1;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
}
|
||||||
|
|
||||||
|
install_bitcoin_core () {
|
||||||
|
echo_i "Installing $VERSION_NUM_FULL"
|
||||||
|
cd $VERSION_NUM_FULL
|
||||||
|
tar xzf *.tar.gz
|
||||||
|
|
||||||
|
# Copy binaries to /usr/local/bin
|
||||||
|
extract_dir=$(find . -maxdepth 1 -type d -name 'bitcoin-*' | head -n1)
|
||||||
|
if [ -d "$extract_dir/bin" ]; then
|
||||||
|
sudo install -m 755 "$extract_dir/bin/bitcoind" /usr/local/bin/
|
||||||
|
sudo install -m 755 "$extract_dir/bin/bitcoin-cli" /usr/local/bin/
|
||||||
|
else
|
||||||
|
echo_e "Installation aborted: binary directory not found in extracted archive"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $(is_bitcoin_core_running) == 0 ]; then
|
||||||
|
echo_i "Stopping bitcoind before installing"
|
||||||
|
stop_bitcoin_core
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_s "Bitcoin Core $VERSION_NUM successfully installed!"
|
||||||
|
touch .installed
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
echo $VERSION_NUM > .version
|
||||||
|
}
|
||||||
|
install_and_start_services () {
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart $SYSTEMD_SERVICE
|
||||||
|
sudo systemctl enable $SYSTEMD_SERVICE
|
||||||
|
if [[ $testnet == 1 ]]; then
|
||||||
|
sudo systemctl restart "t$SYSTEMD_SERVICE"
|
||||||
|
sudo systemctl enable "t$SYSTEMD_SERVICE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
init_bitcoin_core_config () {
|
||||||
|
echo_i "USERNAME: $username"
|
||||||
|
warning_file="File already present not forcing update."
|
||||||
|
id $username >/dev/null 2>&1 || sudo adduser --gecos "" --disabled-password $username
|
||||||
|
sudo adduser $username debian-tor
|
||||||
|
userhome=$(getent passwd $username | cut -d: -f6)
|
||||||
|
usergroup=$(sudo -u $username id -gn)
|
||||||
|
|
||||||
|
BITCOIN_DIR_REAL="$userhome/$BITCOIN_DIR"
|
||||||
|
BITCOIN_CONFIG="$BITCOIN_DIR_REAL/$BITCOIN_CONFIG_FILE"
|
||||||
|
echo_i "GROUPNAME: $usergroup"
|
||||||
|
echo_i "BITCOIN_DIR_REAL: $BITCOIN_DIR_REAL"
|
||||||
|
echo_i "BITCOIN_CONFIG: $BITCOIN_CONFIG"
|
||||||
|
|
||||||
|
sudo -u $username mkdir -p $BITCOIN_DIR_REAL
|
||||||
|
|
||||||
|
sudo adduser $USER $usergroup
|
||||||
|
sudo ln -s $BITCOIN_DIR_REAL $HOME
|
||||||
|
bitcoinconf=$(cat << EOF
|
||||||
|
# Bitcoin daemon
|
||||||
|
server=1
|
||||||
|
|
||||||
|
|
||||||
|
# Activate v2 P2P
|
||||||
|
v2transport=1
|
||||||
|
|
||||||
|
# Connections
|
||||||
|
zmqpubhashblock=tcp://127.0.0.1:28332
|
||||||
|
zmqpubrawtx=tcp://127.0.0.1:28333
|
||||||
|
|
||||||
|
maxuploadtarget=5000
|
||||||
|
|
||||||
|
dbcache=2000
|
||||||
|
blocksonly=1
|
||||||
|
acceptnonstdtxn=0
|
||||||
|
peerbloomfilters=0
|
||||||
|
prune=550
|
||||||
|
listen=0
|
||||||
|
dnsseed=0
|
||||||
|
disablewallet=1
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ ! -e "$BITCOIN_CONFIG" ] || [[ $force == 1 ]]; then
|
||||||
|
printf "$bitcoinconf" | sudo -u $username tee "$BITCOIN_CONFIG" > /dev/null
|
||||||
|
else
|
||||||
|
echo_i "$warning_file: $BITCOIN_CONFIG"
|
||||||
|
fi
|
||||||
|
sudo -u $username chmod 640 $BITCOIN_CONFIG
|
||||||
|
|
||||||
|
serviceconf=$(cat << EOF
|
||||||
|
# /etc/systemd/system/bitcoind.service
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Bitcoin daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
|
||||||
|
# Service execution
|
||||||
|
###################
|
||||||
|
|
||||||
|
ExecStart=/usr/local/bin/bitcoind -daemon \\
|
||||||
|
-pid=/run/bitcoind/bitcoind.pid \\
|
||||||
|
-conf=$BITCOIN_CONFIG \\
|
||||||
|
-datadir=$BITCOIN_DIR_REAL \\
|
||||||
|
-startupnotify="chmod g+r $BITCOIN_DIR_REAL/.cookie"
|
||||||
|
|
||||||
|
# Process management
|
||||||
|
####################
|
||||||
|
Type=forking
|
||||||
|
PIDFile=/run/bitcoind/bitcoind.pid
|
||||||
|
Restart=on-failure
|
||||||
|
TimeoutSec=300
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
# Directory creation and permissions
|
||||||
|
####################################
|
||||||
|
User=$username
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
# /run/bitcoind
|
||||||
|
RuntimeDirectory=bitcoind
|
||||||
|
RuntimeDirectoryMode=0710
|
||||||
|
|
||||||
|
# Hardening measures
|
||||||
|
####################
|
||||||
|
# Provide a private /tmp and /var/tmp.
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
# Mount /usr, /boot/ and /etc read-only for the process.
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
# Disallow the process and all of its children to gain
|
||||||
|
# new privileges through execve().
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
# Use a new /dev namespace only populated with API pseudo devices
|
||||||
|
# such as /dev/null, /dev/zero and /dev/random.
|
||||||
|
PrivateDevices=true
|
||||||
|
|
||||||
|
# Deny the creation of writable and executable memory mappings.
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
if [[ ! -e "$SYSTEMD_DIR/$SYSTEMD_SERVICE" ]] || [[ $force == 1 ]]; then
|
||||||
|
printf "$serviceconf" | sudo tee "$SYSTEMD_DIR/$SYSTEMD_SERVICE" > /dev/null
|
||||||
|
else
|
||||||
|
echo_i "$warning_file: $SYSTEMD_DIR/$SYSTEMD_SERVICE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $testnet == 1 ]]; then
|
||||||
|
tserviceconf=$(printf "$serviceconf" | sed "s/bitcoind/tbitcoind/g")
|
||||||
|
tserviceconf=$(printf "$tserviceconf" | sed "s/Bitcoin daemon/Bitcoin Testnet daemon/")
|
||||||
|
tserviceconf=$(printf "$tserviceconf" | sed "s|tbitcoind -daemon|bitcoind -daemon -testnet|")
|
||||||
|
|
||||||
|
if [[ ! -e "$SYSTEMD_DIR/t$SYSTEMD_SERVICE" ]] || [[ $force == 1 ]]; then
|
||||||
|
printf "$tserviceconf" | sudo tee "$SYSTEMD_DIR/t$SYSTEMD_SERVICE" > /dev/null
|
||||||
|
else
|
||||||
|
echo_i "$warning_file: $SYSTEMD_DIR/t$SYSTEMD_SERVICE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#install aliases avoid duplicates
|
||||||
|
|
||||||
|
echo $GLOBAL_ALIASES
|
||||||
|
if ! grep -q "alias tbitcoind=" $GLOBAL_ALIASES ; then
|
||||||
|
printf "alias tbitcoind='bitcoind -testnet'" | sudo tee -a $GLOBAL_ALIASES > /dev/null
|
||||||
|
echo_i "adding alias"
|
||||||
|
else
|
||||||
|
echo_i "alias tbitcoind present"
|
||||||
|
fi
|
||||||
|
if ! grep -q "alias tbitcoin-cli=" $GLOBAL_ALIASES ; then
|
||||||
|
printf "alias tbitcoin-cli='bitcoin-cli -testnet'" | sudo tee -a $GLOBAL_ALIASES > /dev/null
|
||||||
|
echo_i "adding alias"
|
||||||
|
else
|
||||||
|
echo_i "alias tbitcoin-cli present"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch .config_init
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -e .version ] && [ $(cat .version) != $VERSION_NUM ] && [ -d $VERSION_NUM_FULL ]; then
|
||||||
|
rm $VERSION_NUM_FULL/.installed
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e $VERSION_NUM_FULL/.hash_verified ] &&
|
||||||
|
[ -e $VERSION_NUM_FULL/.sign_verified ] &&
|
||||||
|
[ -e $VERSION_NUM_FULL/.installed ]
|
||||||
|
then
|
||||||
|
echo_e "Bitcoin Core $VERSION_NUM already installed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [ $(have_to_update) == 0 ]; then
|
||||||
|
echo_i "New version available: v$VERSION_NUM(current: $CURRENT_VERSION)"
|
||||||
|
init_bitcoin_core_config;
|
||||||
|
|
||||||
|
if [ ! -e $VERSION_NUM_FULL/.hash_verified ]; then download_bitcoin_core; fi
|
||||||
|
if [ ! -e $VERSION_NUM_FULL/.sign_verified ]; then verify_bitcoin_core; fi
|
||||||
|
if [ ! -e $VERSION_NUM_FULL/.installed ]; then install_bitcoin_core; fi
|
||||||
|
if [ ! -e .config_init ]; then
|
||||||
|
install_and_start_services;
|
||||||
|
fi
|
||||||
|
|
||||||
|
fi
|
||||||
36
contrib/install_tor.sh
Normal file
36
contrib/install_tor.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
gpg \
|
||||||
|
wget \
|
||||||
|
lsb-release
|
||||||
|
|
||||||
|
ARCH=$(dpkg --print-architecture)
|
||||||
|
DISTRO=$(lsb_release -cs)
|
||||||
|
|
||||||
|
sudo tee /etc/apt/sources.list.d/tor.list > /dev/null <<EOF
|
||||||
|
deb [arch=$ARCH signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org $DISTRO main
|
||||||
|
deb-src [arch=$ARCH signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org $DISTRO main
|
||||||
|
EOF
|
||||||
|
|
||||||
|
wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | sudo tee /usr/share/keyrings/tor-archive-keyring.gpg >/dev/null
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt install -y tor deb.torproject.org-keyring
|
||||||
|
|
||||||
|
# Backup existing torrc before modifying
|
||||||
|
if [ -f /etc/tor/torrc ]; then
|
||||||
|
sudo cp /etc/tor/torrc /etc/tor/torrc.bak.$(date +%Y%m%d%H%M%S)
|
||||||
|
fi
|
||||||
|
sudo sed -i '/^ControlPort/d; /^CookieAuthentication/d; /^CookieAuthFileGroupReadable/d' /etc/tor/torrc
|
||||||
|
|
||||||
|
sudo tee -a /etc/tor/torrc > /dev/null << EOF
|
||||||
|
|
||||||
|
# Added by script ($(date))
|
||||||
|
ControlPort 127.0.0.1:9051
|
||||||
|
CookieAuthentication 1
|
||||||
|
CookieAuthFileGroupReadable 1
|
||||||
|
DisableDebuggerAttachment 1
|
||||||
|
EOF
|
||||||
|
sudo systemctl restart tor
|
||||||
55
contrib/nginx/bal-server.conf
Normal file
55
contrib/nginx/bal-server.conf
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Nginx reverse proxy for bal-server
|
||||||
|
# Place this file in /etc/nginx/sites-available/ and symlink to sites-enabled
|
||||||
|
# Replace BAL_DOMAIN with your actual domain
|
||||||
|
|
||||||
|
# HTTP redirect to HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name BAL_DOMAIN;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS proxy to bal-server (localhost only)
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name BAL_DOMAIN;
|
||||||
|
|
||||||
|
# Let's Encrypt certificates (managed by certbot)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/BAL_DOMAIN/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/BAL_DOMAIN/privkey.pem;
|
||||||
|
|
||||||
|
# Security headers (no HSTS to avoid preloading issues)
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Body size limit — must match Actix PayloadConfig (default 1 MB)
|
||||||
|
client_max_body_size 1m;
|
||||||
|
|
||||||
|
# Rate limiting zone (requires `limit_req_zone` in nginx.conf)
|
||||||
|
# limit_req zone=bal burst=20 nodelay;
|
||||||
|
# limit_conn addr 10;
|
||||||
|
|
||||||
|
# Proxy to bal-server on localhost
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9137;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_send_timeout 10s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: serve public_key.pem directly from Nginx (faster)
|
||||||
|
# location /.pub_key.pem {
|
||||||
|
# alias /var/bal/public_key.pem;
|
||||||
|
# add_header Content-Type text/plain;
|
||||||
|
# }
|
||||||
|
}
|
||||||
25
docker/entrypoint.sh
Normal file
25
docker/entrypoint.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
mkdir -p /var/bal/.bitcoin
|
||||||
|
chown bal:bal /var/bal /var/bal/.bitcoin 2>/dev/null || true
|
||||||
|
|
||||||
|
PUSHER_NETWORK="${BAL_PUSHER_NETWORK:-bitcoin}"
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting bal-server on ${BAL_SERVER_BIND_ADDRESS:-127.0.0.1}:${BAL_SERVER_BIND_PORT:-9137}"
|
||||||
|
echo "[entrypoint] Starting bal-pusher (network: ${PUSHER_NETWORK})"
|
||||||
|
|
||||||
|
su -s /bin/sh bal -c '/usr/local/bin/bal-server' &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
su -s /bin/sh bal -c "/usr/local/bin/bal-pusher ${PUSHER_NETWORK}" &
|
||||||
|
PUSHER_PID=$!
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "[entrypoint] Shutting down..."
|
||||||
|
kill $SERVER_PID $PUSHER_PID 2>/dev/null
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
trap cleanup TERM INT
|
||||||
|
|
||||||
|
wait
|
||||||
55
docs/01_project_overview.md
Normal file
55
docs/01_project_overview.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Project Overview
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** vision, scope, system components, and mapping to existing documentation.
|
||||||
|
- **See also:** [02_glossary_and_bitcoin_domain.md](02_glossary_and_bitcoin_domain.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md)
|
||||||
|
|
||||||
|
## Vision and Scope
|
||||||
|
|
||||||
|
`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.
|
||||||
|
2. **Store** transactions, inputs, and outputs in a structured database.
|
||||||
|
3. **Monitor** new blocks via ZMQ and push transactions to the Bitcoin network when the locktime is satisfied.
|
||||||
|
4. **Track** derived addresses and extended public keys for fee accounting.
|
||||||
|
5. **Collect and report** statistics about the service.
|
||||||
|
|
||||||
|
## System Components
|
||||||
|
|
||||||
|
The project consists of two binaries and one shared library:
|
||||||
|
|
||||||
|
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, 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) |
|
||||||
51
docs/02_glossary_and_bitcoin_domain.md
Normal file
51
docs/02_glossary_and_bitcoin_domain.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Glossary and Bitcoin Domain Knowledge
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** Bitcoin-specific concepts, protocols, and standards needed to understand this codebase.
|
||||||
|
- **See also:** [01_project_overview.md](01_project_overview.md), [04_modules_detail.md](04_modules_detail.md)
|
||||||
|
|
||||||
|
## BIP-84: Derivation Path for P2WPKH
|
||||||
|
|
||||||
|
BIP-84 defines the derivation path for native SegWit (Bech32) addresses (P2WPKH). The standard path is `m/84'/<coin_type>'/<account>'/0/<address_index>`. In this project, `xpub.rs` derives P2WPKH addresses from an xpub or zpub using the path `m/84'/coin_type'/account'/0/index`. The `bitcoin` crate's `Xpub::derive_pub` and `Secp256k1` are used in `src/xpub.rs`.
|
||||||
|
|
||||||
|
## XPub, ZPub, and Extended Public Keys
|
||||||
|
|
||||||
|
An **XPub** (Extended Public Key) is a master key that allows derivation of child public keys without revealing private keys. A **ZPub** is the Bech32-encoded equivalent for native SegWit. The codebase uses `xpub.rs` to derive addresses from these and verify their checksums. See `src/xpub.rs` for the Base58 decoding and checksum logic.
|
||||||
|
|
||||||
|
## Locktime and nLockTime
|
||||||
|
|
||||||
|
A Bitcoin transaction can include a `nLockTime` field. If it is non-zero and below `500_000_000`, it is interpreted as a **block height** before which the transaction cannot be mined. If above, it is a **Unix timestamp**. The system evaluates whether the locktime has been met by comparing it against the blockchain's median time. See `src/bin/bal-pusher.rs` for the evaluation logic.
|
||||||
|
|
||||||
|
## P2WPKH (Pay to Witness Public Key Hash)
|
||||||
|
|
||||||
|
P2WPKH is a native SegWit output format that reduces transaction size and lowers transaction fees. The addresses are Bech32 encoded (e.g., `bc1q...`). The project assumes fee collection outputs are P2WPKH and uses `bitcoin::Address::p2wpkh` for derivation in `src/xpub.rs`.
|
||||||
|
|
||||||
|
## Bitcoin Block Header (80 bytes)
|
||||||
|
|
||||||
|
A Bitcoin block header is a fixed 80-byte structure containing `version` (4 bytes), `previous block hash` (32 bytes), `merkle root` (32 bytes), `timestamp` (4 bytes), `bits` (4 bytes), and `nonce` (4 bytes). The `bal-pusher` binary uses the `hashblock` ZMQ topic and calls `getblockchaininfo` to get the `mediantime`.
|
||||||
|
|
||||||
|
## ZMQ Publisher
|
||||||
|
|
||||||
|
Bitcoin Core can publish notifications over ZeroMQ. The project listens to two topics:
|
||||||
|
- **`hashblock`**: Sends the 32-byte block hash when a new block is found. The `bal-pusher` uses this to trigger an update cycle.
|
||||||
|
- **`rawblock`**: Sends the full raw block (including the 80-byte header). This is not used by the current `bal-pusher` implementation.
|
||||||
|
|
||||||
|
The ZMQ endpoint is per-network:
|
||||||
|
- Bitcoin: `tcp://127.0.0.1:28332`
|
||||||
|
- Regtest: `tcp://127.0.0.1:21332`
|
||||||
|
- Testnet: `tcp://127.0.0.1:23332`
|
||||||
|
- Testnet4: `tcp://127.0.0.1:24332`
|
||||||
|
- Signet: `tcp://127.0.0.1:22332`
|
||||||
|
|
||||||
|
## Bitcoin Core RPC
|
||||||
|
|
||||||
|
The project communicates with a local Bitcoin Core node via the JSON-RPC interface. Key methods used are:
|
||||||
|
- `sendrawtransaction`: To broadcast a pending transaction.
|
||||||
|
- `getblockchaininfo`: To retrieve the current block height and `mediantime` (used to evaluate locktime).
|
||||||
|
- `getblock`: To retrieve block data in `bal-pusher` (for median time calculation).
|
||||||
|
|
||||||
|
Authentication is done via `bitcoincore-rpc` using either `UserPass` or `CookieFile` (the `cookie` file is stored in `~/.bitcoin/.cookie`). See `src/bin/bal-pusher.rs`.
|
||||||
|
|
||||||
|
## Mempool and P2P
|
||||||
|
|
||||||
|
Transactions are validated against mempool rules before submission. The server checks that the fee output is paid to a specific address owned by the operator. It also ensures the transaction can be deserialized using the `bitcoin::Transaction` parser from the `bitcoin` crate. See `src/bin/bal-server.rs` (`pushtxs` endpoint).
|
||||||
69
docs/03_architecture_and_data_flow.md
Normal file
69
docs/03_architecture_and_data_flow.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Architecture and Data Flow
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** high-level architecture, data flow, state machine, and error handling strategy.
|
||||||
|
- **See also:** [01_project_overview.md](01_project_overview.md), [04_modules_detail.md](04_modules_detail.md), [05_api_reference.md](05_api_reference.md), [06_database_schema.md](06_database_schema.md)
|
||||||
|
|
||||||
|
## High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
User
|
||||||
|
|
|
||||||
|
| HTTP POST (raw hex transactions)
|
||||||
|
v
|
||||||
|
+-----------------+
|
||||||
|
| bal-server | (actix-web 4.9.0 + actix-governor, async)
|
||||||
|
| (src/bin/bal-server.rs) |
|
||||||
|
+-----------------+
|
||||||
|
| SQLite insert (db.rs, Arc<Mutex<Connection>>)
|
||||||
|
v
|
||||||
|
bal.db (WAL mode)
|
||||||
|
| (transactions with status=0, waiting locktime)
|
||||||
|
|
|
||||||
|
| ZMQ (hashblock)
|
||||||
|
v
|
||||||
|
+-----------------+
|
||||||
|
| bal-pusher | (tokio, ZMQ + RPC + reqwest)
|
||||||
|
| (src/bin/bal-pusher.rs)
|
||||||
|
+-----------------+
|
||||||
|
| bitcoincore-rpc
|
||||||
|
| sendrawtransaction
|
||||||
|
v
|
||||||
|
Bitcoin Network
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow (Transaction Lifecycle)
|
||||||
|
|
||||||
|
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 server also exposes stats via the `GET /:network/stats` endpoint if `expose_stats` is enabled.
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
[Submitted] -> status=0 (waiting)
|
||||||
|
|
|
||||||
|
| locktime satisfied
|
||||||
|
v
|
||||||
|
[Push attempt] -> status=1 (sent) or status=2 (failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
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`). Error details stored in `push_err`.
|
||||||
|
|
||||||
|
## Error Handling Strategy
|
||||||
|
|
||||||
|
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 actix-web `Logger::default()` middleware logs all HTTP requests/responses.
|
||||||
212
docs/04_modules_detail.md
Normal file
212
docs/04_modules_detail.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Module Details
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** detailed analysis of each Rust module and binary, including source code references.
|
||||||
|
- **See also:** [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [09_references_and_links.md](09_references_and_links.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `lib.rs`
|
||||||
|
|
||||||
|
**Location:** `src/lib.rs`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `db.rs` (Database Interface)
|
||||||
|
|
||||||
|
**Location:** `src/db.rs`
|
||||||
|
|
||||||
|
This module contains all the logic for interacting with the SQLite database.
|
||||||
|
|
||||||
|
### Key Functions
|
||||||
|
|
||||||
|
| 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
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `xpub.rs` (Extended Public Key Utilities)
|
||||||
|
|
||||||
|
**Location:** `src/xpub.rs`
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| 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::{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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `bal-server.rs` (HTTP Server / API)
|
||||||
|
|
||||||
|
**Location:** `src/bin/bal-server.rs`
|
||||||
|
|
||||||
|
The main application binary that provides an async HTTP server.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Runtime:** `actix-web 4.9.0` with `actix-rt` (`#[actix_web::main]`).
|
||||||
|
- **Rate Limiting:** `actix-governor` middleware with token-bucket algorithm per endpoint.
|
||||||
|
- **Response Compression:** `actix_web::middleware::Compress`.
|
||||||
|
- **Request Logging:** `actix_web::middleware::Logger::default()`.
|
||||||
|
- **Shared State:** `Arc<Mutex<Connection>>` for database access, `MyConfig` for configuration.
|
||||||
|
|
||||||
|
### Configuration Structs
|
||||||
|
|
||||||
|
**`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`, per-endpoint rate limits (`pushtxs`, `searchtx`, `info`, `default`), `workers`, `max_connections`
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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 `privkey.pem` is used by the pusher to sign statistics before sending them to the `welist` server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `bal-pusher.rs` (Async Transaction Pusher)
|
||||||
|
|
||||||
|
**Location:** `src/bin/bal-pusher.rs`
|
||||||
|
|
||||||
|
This is the async daemon that monitors the blockchain and pushes pending transactions.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **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 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 (`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`).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
All configuration is via environment variables (no config files):
|
||||||
|
- `BAL_PUSHER_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).
|
||||||
|
- `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`.
|
||||||
162
docs/05_api_reference.md
Normal file
162
docs/05_api_reference.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** complete specification of the HTTP API, ZMQ messages, and RPC usage, with request/response examples.
|
||||||
|
- **See also:** [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [04_modules_detail.md](04_modules_detail.md), [06_database_schema.md](06_database_schema.md), [07_deployment_and_ops.md](07_deployment_and_ops.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API (provided by `bal-server`)
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
All endpoints are rate-limited via `actix-governor` with a token-bucket algorithm. Defaults:
|
||||||
|
|
||||||
|
| Endpoint | Rate (req/s) | Burst |
|
||||||
|
|----------|-------------|-------|
|
||||||
|
| `POST /{network}/pushtxs` | 1 | 3 |
|
||||||
|
| `POST /searchtx` | 5 | 10 |
|
||||||
|
| `GET /{network}/info` | 20 | 30 |
|
||||||
|
| All others | 50 | 100 |
|
||||||
|
|
||||||
|
Rate limits are configurable via `BAL_SERVER_ACTIX_*` environment variables.
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
- **Description:** Returns a static identification string (default: "Will Executor Server").
|
||||||
|
- **Response:** Plain text `200 OK`.
|
||||||
|
|
||||||
|
### `GET /version`
|
||||||
|
- **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 (path configurable via `BAL_SERVER_PUB_KEY_PATH`).
|
||||||
|
|
||||||
|
### `GET /:network/info`
|
||||||
|
- **Description:** Returns JSON with the server's configuration for that specific network.
|
||||||
|
- **Supported Networks:** `bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`.
|
||||||
|
- **Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"address": "bcrt1q...",
|
||||||
|
"base_fee": 50000,
|
||||||
|
"chain": "regtest",
|
||||||
|
"info": "Will Executor Server",
|
||||||
|
"version": "0.3.2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 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. Guarded by the `expose_stats` configuration flag.
|
||||||
|
- **Response (200 OK):** A JSON array of `StatsResponse` objects:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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 (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.
|
||||||
|
```
|
||||||
|
02000000000101...hex...\n
|
||||||
|
02000000000101...hex...\n
|
||||||
|
```
|
||||||
|
- **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`. The request body must contain exactly 64 hex characters.
|
||||||
|
- **Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"txid": "abc123def456..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"txid": "abc123...",
|
||||||
|
"status": 1,
|
||||||
|
"tx": "020000000...",
|
||||||
|
"our_address": "bcrt1q...",
|
||||||
|
"our_fees": 1000,
|
||||||
|
"reqid": "192.168.1.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **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`
|
||||||
|
- **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` 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`
|
||||||
|
- **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) with the error in `push_err`.
|
||||||
|
|
||||||
|
### `getblockchaininfo`
|
||||||
|
- **Method:** `getblockchaininfo` (RPC `1`)
|
||||||
|
- **Parameters:** None.
|
||||||
|
- **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`
|
||||||
|
- **Method:** `getblock` (RPC `1`)
|
||||||
|
- **Parameters:** `blockhash`, `verbosity` (set to `1` for JSON with timestamp).
|
||||||
|
- **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 Stats Signing
|
||||||
|
|
||||||
|
The pusher signs the statistics payload before sending it to the `welist` server:
|
||||||
|
1. Collects statistics from the database.
|
||||||
|
2. Serializes the stats as JSON.
|
||||||
|
3. Signs the JSON payload with the Ed25519 private key (`privkey.pem`).
|
||||||
|
4. Sends the payload with the base64-encoded signature in the `X-Signature` header.
|
||||||
|
5. The `welist` server can verify the signature using the public key served at `GET /.pub_key.pem`.
|
||||||
204
docs/06_database_schema.md
Normal file
204
docs/06_database_schema.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** the full SQL schema, data types, indexing, queries, and data lifecycle for the SQLite `bal.db` database.
|
||||||
|
- **See also:** [04_modules_detail.md](04_modules_detail.md), [05_api_reference.md](05_api_reference.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Technology
|
||||||
|
- **Engine:** `sqlite` (Rust `sqlite` crate, version 0.34.0)
|
||||||
|
- **File:** `bal.db` (default, configurable via `BAL_SERVER_DB_FILE` / `BAL_PUSHER_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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table Schema
|
||||||
|
|
||||||
|
### `tbl_tx` (Transactions)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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:** `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 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 submitted transactions. Enables identification of double-spends.
|
||||||
|
- **Constraints:** A unique index prevents duplicate entries for the same input in the same transaction.
|
||||||
|
|
||||||
|
### `tbl_out` (Transaction Outputs)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
### `tbl_xpub` (Extended Public Keys)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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/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 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. 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 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 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
|
||||||
|
|
||||||
|
- **Get Pending Transactions (by status and locktime):**
|
||||||
|
```sql
|
||||||
|
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` to find transactions ready to broadcast. The `locktime_threshold` constant distinguishes block heights from timestamps.
|
||||||
|
|
||||||
|
- **Insert Transaction (batched):**
|
||||||
|
```sql
|
||||||
|
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 `bal-server` for efficient bulk inserts.
|
||||||
|
|
||||||
|
- **Update Status:**
|
||||||
|
```sql
|
||||||
|
UPDATE tbl_tx SET status = ? WHERE txid = ?;
|
||||||
|
```
|
||||||
|
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 = ?;
|
||||||
|
```
|
||||||
|
Used by the `searchtx` endpoint.
|
||||||
|
|
||||||
|
- **Get All Addresses by XPub:**
|
||||||
|
```sql
|
||||||
|
SELECT a.address
|
||||||
|
FROM tbl_address a
|
||||||
|
JOIN tbl_xpub x ON a.xpub = x.id
|
||||||
|
WHERE x.xpub = ?;
|
||||||
|
```
|
||||||
|
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 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 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.
|
||||||
302
docs/07_deployment_and_ops.md
Normal file
302
docs/07_deployment_and_ops.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Deployment and Operations
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **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` (all prefixed `BAL_SERVER_`)
|
||||||
|
|
||||||
|
#### 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_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: pushtxs requests per second |
|
||||||
|
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: pushtxs 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 requests per second |
|
||||||
|
| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info burst size |
|
||||||
|
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second |
|
||||||
|
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | `100` | Rate limit: default burst size |
|
||||||
|
|
||||||
|
### `bal-pusher` (prefixed `BAL_PUSHER_`)
|
||||||
|
|
||||||
|
#### Core Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BAL_PUSHER_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://localhost/"` | 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) |
|
||||||
|
|
||||||
|
#### 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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Services
|
||||||
|
|
||||||
|
### `bal-server.service` (Systemd Unit)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Bal Server
|
||||||
|
After=network.target
|
||||||
|
[Service]
|
||||||
|
User=bal
|
||||||
|
Group=bal
|
||||||
|
ExecStart=/usr/local/bin/bal-server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
WorkingDirectory=/var/bal
|
||||||
|
EnvironmentFile=/var/bal/bal-server.env
|
||||||
|
ProtectSystem=full
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateDevices=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user
|
||||||
|
```
|
||||||
|
- Runs as a dedicated non-privileged `bal` user.
|
||||||
|
- Hardened with `ProtectSystem=full`, `NoNewPrivileges=true`, `PrivateDevices=true`, `MemoryDenyWriteExecute=true`.
|
||||||
|
|
||||||
|
### `bitcoind.service` (Bitcoin Core Daemon)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Bitcoin Core Daemon
|
||||||
|
After=network.target
|
||||||
|
[Service]
|
||||||
|
User=bitcoin
|
||||||
|
Group=bitcoin
|
||||||
|
ExecStart=/usr/local/bin/bitcoind ... -zmqpubhashblock=tcp://127.0.0.1:28332 ...
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=30
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user
|
||||||
|
```
|
||||||
|
- Must be started with `zmqpubhashblock` (not `zmqpubrawblock`).
|
||||||
|
- ZMQ ports must be bound to `127.0.0.1` only.
|
||||||
|
|
||||||
|
### `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)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `bal-pusher.sh` (Development Pusher Startup)
|
||||||
|
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` (Test Transaction Sender)
|
||||||
|
A helper script that wraps `bitcoin-cli` for manual testing.
|
||||||
|
|
||||||
|
### `make_release.sh` (Release Builder)
|
||||||
|
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)
|
||||||
|
Uses `scp` to pull the production `bal.db` from a remote server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nginx and SSL Configuration
|
||||||
|
|
||||||
|
The `bal-server` is a plain HTTP server. A reverse proxy (Nginx) with TLS termination is required for production.
|
||||||
|
|
||||||
|
### Template: `contrib/nginx/bal-server.conf`
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name BAL_DOMAIN;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
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: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;
|
||||||
|
}
|
||||||
|
# Uncomment for rate limiting:
|
||||||
|
# limit_req zone=pal limit=10 nodelay;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### 1. `bal-server` Bind Address
|
||||||
|
- [ ] `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 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 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.
|
||||||
|
- [ ] `private_key.pem` / `privkey.pem` not in the repository.
|
||||||
|
- [ ] `public_key.pem` readable by Nginx if served directly.
|
||||||
|
|
||||||
|
### 4. Pusher and ZMQ
|
||||||
|
- [ ] 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=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 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` with `CookieAuthentication`. The `bal-pusher` supports SOCKS5 proxy via the `reqwest` `socks` feature for `.onion` connectivity.
|
||||||
156
docs/08_security_audit.md
Normal file
156
docs/08_security_audit.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Security Audit
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** threat model, vulnerability assessment, hardening recommendations, and a security checklist.
|
||||||
|
- **See also:** [AGENTS.md](AGENTS.md), [07_deployment_and_ops.md](07_deployment_and_ops.md), [06_database_schema.md](06_database_schema.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [04_modules_detail.md](04_modules_detail.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Attackers
|
||||||
|
- **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 (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 (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)`, `fs::read_to_string` uses `match` + 500, `cfg.lock()` uses `poisoned.into_inner()` recovery.
|
||||||
|
**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).
|
||||||
|
|
||||||
|
### 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 token-bucket per endpoint (`BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST`).
|
||||||
|
- Connection limits: `workers(4)` and `max_connections(100)` via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`.
|
||||||
|
- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS`.
|
||||||
|
- ZMQ timeout: `set_rcvtimeo(5000)` prevents infinite blocking.
|
||||||
|
- RPC retry: sleep + retry on connection failure instead of panic.
|
||||||
|
|
||||||
|
### 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, and IPv6 unique-local addresses. HTTPS-only scheme enforced.
|
||||||
|
**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 8 unit tests in `src/validation.rs`.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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(¶m.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).
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
### 11. `bal-stats.rs.dontcompile` (REMOVED)
|
||||||
|
**Severity:** LOW | **Status:** Removed
|
||||||
|
**Description:** The broken HTML report generator file no longer exists in the source tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardening Recommendations
|
||||||
|
|
||||||
|
### System-Level
|
||||||
|
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. **Rate Limiting:** Implemented via `actix-governor` with per-endpoint token-bucket configuration.
|
||||||
|
2. **Input Validation:** Network enum check, txid hex validation, body size limits.
|
||||||
|
3. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt.
|
||||||
|
4. **WAL Mode:** Enabled with retry logic for concurrent access.
|
||||||
|
5. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking.
|
||||||
|
6. **Transaction Size Limits:** Configurable via rate limiting and body size.
|
||||||
|
7. **ZMQ Retry:** Reconnect logic with timeout-based detection.
|
||||||
|
8. **Fee Limits:** Per-network `fixed_fee` configuration.
|
||||||
|
9. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest).
|
||||||
|
10. **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) | 8 | SSRF URL validation unit tests |
|
||||||
154
docs/09_references_and_links.md
Normal file
154
docs/09_references_and_links.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# References and Links
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- **What this file contains:** links to source code, dependencies, and existing documentation, plus a machine-readable dependency map.
|
||||||
|
- **See also:** [INDEX.md](INDEX.md), [01_project_overview.md](01_project_overview.md), [04_modules_detail.md](04_modules_detail.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Code References
|
||||||
|
|
||||||
|
| Module/Component | File Path | Key Details |
|
||||||
|
|---|---|---|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
| 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 for testing |
|
||||||
|
| Utility scripts | `lib.sh` | Colored echo functions |
|
||||||
|
| 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` |
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `bitcoin` | `0.32.5` | Transaction parsing, `ScriptBuf`, `Address`, `Xpub`, `Transaction` |
|
||||||
|
| `bitcoincore-rpc` | `0.19.0` | RPC client (`sendrawtransaction`, `getblockchaininfo`) |
|
||||||
|
| `bitcoincore-rpc-json` | `0.19.0` | JSON types for Bitcoin RPC responses |
|
||||||
|
| `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`) |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapping to Existing Documentation
|
||||||
|
|
||||||
|
| Existing File | Description | Replaced/Managed By KB |
|
||||||
|
|---|---|---|
|
||||||
|
| `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` |
|
||||||
|
| `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) |
|
||||||
|
| `tbitcoind.service` | `systemd` unit for testnet node | `07_deployment_and_ops.md` (Systemd) |
|
||||||
|
| `bal-server.env` | `bal-server` environment variables | `07_deployment_and_ops.md` (Environment Variables) |
|
||||||
|
| `bal-pusher.env` | `bal-pusher` environment variables | `07_deployment_and_ops.md` (Environment Variables) |
|
||||||
|
| `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 | `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 `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) |
|
||||||
|
| `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/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
|
||||||
|
```
|
||||||
31
docs/INDEX.md
Normal file
31
docs/INDEX.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# `docs/` Knowledge Base
|
||||||
|
|
||||||
|
## Quick Guide for Contributors
|
||||||
|
|
||||||
|
- **I am a developer and want to understand the project** → Start with [`01_project_overview.md`](01_project_overview.md)
|
||||||
|
- **I am a developer and need to know how the system works** → Read [`03_architecture_and_data_flow.md`](03_architecture_and_data_flow.md)
|
||||||
|
- **I am a developer working on a specific module** → See [`04_modules_detail.md`](04_modules_detail.md)
|
||||||
|
- **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 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)
|
||||||
|
|
||||||
|
## Files Overview
|
||||||
|
|
||||||
|
| # | File | Purpose |
|
||||||
|
|---|------|---------|
|
||||||
|
| 1 | [`01_project_overview.md`](01_project_overview.md) | Vision, goals, components, and mapping to existing docs |
|
||||||
|
| 2 | [`02_glossary_and_bitcoin_domain.md`](02_glossary_and_bitcoin_domain.md) | Bitcoin domain knowledge: BIP-84, locktime, P2WPKH, ZMQ, block headers |
|
||||||
|
| 3 | [`03_architecture_and_data_flow.md`](03_architecture_and_data_flow.md) | High-level architecture, data flow, state machine, error handling |
|
||||||
|
| 4 | [`04_modules_detail.md`](04_modules_detail.md) | Deep dive into each Rust module and binary |
|
||||||
|
| 5 | [`05_api_reference.md`](05_api_reference.md) | Complete API docs: HTTP, ZMQ, RPC with examples |
|
||||||
|
| 6 | [`06_database_schema.md`](06_database_schema.md) | Full SQL schema, tables, queries, data lifecycle |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Note:** This knowledge base is maintained in parallel to the codebase. After any significant change to the code (new features, API changes, schema changes, or security fixes), update the corresponding file in this directory to keep documentation synchronized.
|
||||||
8
generate_keys.sh
Normal file
8
generate_keys.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
openssl pkey -in private_key.pem -pubout -out public_key.pem
|
||||||
|
chmod 600 private_key.pem
|
||||||
|
# Ensure private key is not accidentally committed to git
|
||||||
|
if grep -q "private_key.pem" .gitignore 2>/dev/null; then
|
||||||
|
echo "private_key.pem is already protected by .gitignore"
|
||||||
|
else
|
||||||
|
echo "WARNING: private_key.pem may not be in .gitignore!"
|
||||||
|
fi
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
414
src/db.rs
414
src/db.rs
@@ -1,7 +1,149 @@
|
|||||||
use sqlite::{ Connection, Value, State, Error };
|
use log::{error, info, trace, warn};
|
||||||
use log::{info, trace, error};
|
use sqlite::{Connection, Error, State, Value};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub fn create_database(db: &Connection){
|
/// 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");
|
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("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("ALTER TABLE tbl_tx ADD COLUMN push_err TEXT");
|
||||||
@@ -9,15 +151,20 @@ pub fn create_database(db: &Connection){
|
|||||||
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_inp(id, txid, in_txid, in_vout);");
|
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 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 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 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 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 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_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>{
|
pub fn get_xpub_id(db: &Connection, network: &String, xpub: &String) -> Option<i64>{
|
||||||
@@ -31,76 +178,180 @@ pub fn create_database(db: &Connection){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
pub fn insert_xpub(db: &Connection, network: &String, xpub: &String){
|
pub fn insert_xpub(db: &Connection, network: &str, xpub: &str) {
|
||||||
if xpub != "" {
|
if !xpub.is_empty() {
|
||||||
trace!("going to insert: {} xpub:{}", network, xpub);
|
trace!("going to insert: {} xpub:{}", network, xpub);
|
||||||
let mut stmt = db.prepare ("INSERT INTO tbl_xpub(network,xpub) VALUES(?, ?);").unwrap();
|
let mut stmt =
|
||||||
let _ = stmt.bind((1,Value::String(network.to_string()))).unwrap();
|
match db.prepare("INSERT OR IGNORE INTO tbl_xpub(network,xpub) VALUES(?, ?);") {
|
||||||
let _ = stmt.bind((2,Value::String(xpub.to_string()))).unwrap();
|
Ok(s) => s,
|
||||||
let _ = stmt.next();
|
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>{
|
pub fn get_last_used_address_by_ip(
|
||||||
let mut stmt = 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;").unwrap();
|
db: &Connection,
|
||||||
let _ = stmt.bind((1,Value::String(network.to_string())));
|
network: &String,
|
||||||
let _ = stmt.bind((2,Value::String(address.to_string())));
|
xpub: &String,
|
||||||
let _ = stmt.bind((3,Value::String(xpub.to_string())));
|
address: &String,
|
||||||
if let Ok(State::Row) = stmt.next(){
|
) -> Option<String> {
|
||||||
let address = stmt.read::<String,_>("address").unwrap();
|
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;") {
|
||||||
return Some(address);
|
Ok(s) => s,
|
||||||
}else{
|
Err(e) => {
|
||||||
return None;
|
error!("Failed to prepare address query: {}", e);
|
||||||
}
|
return None;
|
||||||
|
|
||||||
}
|
|
||||||
pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64,i64){
|
|
||||||
let mut stmt = db.prepare("UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = ? and xpub= ? RETURNING path_idx,id;").unwrap();
|
|
||||||
stmt.bind((1,Value::String(network.to_string()))).unwrap();
|
|
||||||
stmt.bind((2,Value::String(xpub.to_string()))).unwrap();
|
|
||||||
match stmt.next(){
|
|
||||||
Ok(State::Row) =>{
|
|
||||||
let next = stmt.read::<i64,_>("path_idx").unwrap();
|
|
||||||
let id = stmt.read::<i64,_>("id").unwrap();
|
|
||||||
return (id,next);
|
|
||||||
},Err(_)=> {
|
|
||||||
return (0,0);
|
|
||||||
},Ok(State::Done) =>{
|
|
||||||
return (0,0);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
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 save_new_address(db: &Connection,xpub: i64,address: &String, path: &String,remote_addr: &String){
|
pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64, i64) {
|
||||||
let mut stmt = db.prepare("INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);").unwrap();
|
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,
|
||||||
stmt.bind((1,Value::String(address.to_string()))).unwrap();
|
Err(e) => {
|
||||||
stmt.bind((2,Value::String(path.to_string()))).unwrap();
|
error!("Failed to prepare xpub index update: {}", e);
|
||||||
stmt.bind((3,Value::Integer(xpub))).unwrap();
|
return (0, 0);
|
||||||
stmt.bind((4,Value::String(remote_addr.to_string()))).unwrap();
|
}
|
||||||
|
};
|
||||||
let _ = stmt.next();
|
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 execute_insert(db: &Connection,
|
pub fn save_new_address(
|
||||||
sqltxs: String,
|
db: &Connection,
|
||||||
ptx: Vec<(usize, Value)>,
|
xpub: i64,
|
||||||
sqlinp: String,
|
address: &String,
|
||||||
pinp: Vec<(usize, Value)>,
|
path: &String,
|
||||||
sqlout: String,
|
remote_addr: &String,
|
||||||
pout: Vec<(usize, Value)>) -> Result<(),Error>{
|
) {
|
||||||
|
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 _ = db.execute("BEGIN TRANSACTION");
|
||||||
let mut stmt = db.prepare(sqltxs.as_str()).expect("failed to prepare sqltxs");
|
let mut stmt = match db.prepare(sqltxs.as_str()) {
|
||||||
if let Err(err) = stmt.bind::<&[(_,Value)]>(&ptx[..]) {
|
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);
|
error!("error binding transaction parameters: {}", err);
|
||||||
let _ = db.execute("ROLLBACK");
|
let _ = db.execute("ROLLBACK");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
|
|
||||||
}
|
}
|
||||||
if let Err(err) = stmt.next() {
|
if let Err(err) = stmt.next() {
|
||||||
error!("error inserting transactions {}",err);
|
error!("error inserting transactions {}", err);
|
||||||
let _ = db.execute("ROLLBACK");
|
let _ = db.execute("ROLLBACK");
|
||||||
}else{
|
} else {
|
||||||
let mut stmt = db.prepare(sqlinp.as_str()).expect("failed to prepare sqlinp");
|
let mut stmt = match db.prepare(sqlinp.as_str()) {
|
||||||
if let Err(err) = stmt.bind::<&[(_,Value)]>(&pinp[..]) {
|
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);
|
error!("error binding inputs parameters {}", err);
|
||||||
let _ = db.execute("ROLLBACK");
|
let _ = db.execute("ROLLBACK");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -109,10 +360,16 @@ pub fn execute_insert(db: &Connection,
|
|||||||
error!("error inserting inputs {}", err);
|
error!("error inserting inputs {}", err);
|
||||||
let _ = db.execute("ROLLBACK");
|
let _ = db.execute("ROLLBACK");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
|
} else {
|
||||||
}else{
|
let mut stmt = match db.prepare(sqlout.as_str()) {
|
||||||
let mut stmt = db.prepare(sqlout.as_str()).expect("failed to prepare sqlout");
|
Ok(s) => s,
|
||||||
if let Err(err) = stmt.bind::<&[(_,Value)]>(&pout[..]) {
|
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);
|
error!("error binding outs parameters {}", err);
|
||||||
let _ = db.execute("ROLLBACK");
|
let _ = db.execute("ROLLBACK");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -121,12 +378,35 @@ pub fn execute_insert(db: &Connection,
|
|||||||
error!("error inserting outs {}", err);
|
error!("error inserting outs {}", err);
|
||||||
let _ = db.execute("ROLLBACK");
|
let _ = db.execute("ROLLBACK");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = db.execute("COMMIT");
|
let _ = db.execute("COMMIT");
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3
src/lib.rs
Normal file
3
src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod db;
|
||||||
|
pub mod validation;
|
||||||
|
pub mod xpub;
|
||||||
168
src/validation.rs
Normal file
168
src/validation.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Validates a WELIST server URL to mitigate SSRF risks.
|
||||||
|
///
|
||||||
|
/// Checks:
|
||||||
|
/// 1. URL must be well-formed and parsable.
|
||||||
|
/// 2. Scheme must be `https://` (plain HTTP is rejected).
|
||||||
|
/// 3. Host must be present.
|
||||||
|
/// 4. Host must not be `localhost` or loopback strings.
|
||||||
|
/// 5. Host must not resolve to a loopback, private, link-local, unspecified, or multicast IP address.
|
||||||
|
/// 6. IPv6 Unique Local (fc00::/7) is also rejected.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the URL is safe to use, `false` otherwise.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - `is_valid_welist_url("https://welist.bitcoin-after.life")` -> `true`
|
||||||
|
/// - `is_valid_welist_url("https://welist.bitcoin-after.life:443")` -> `true`
|
||||||
|
/// - `is_valid_welist_url("https://example.com/ping")` -> `true`
|
||||||
|
/// - `is_valid_welist_url("http://welist.bitcoin-after.life")` -> `false` (not HTTPS)
|
||||||
|
/// - `is_valid_welist_url("https://localhost")` -> `false` (localhost loopback)
|
||||||
|
/// - `is_valid_welist_url("https://127.0.0.1")` -> `false` (IPv4 loopback)
|
||||||
|
/// - `is_valid_welist_url("https://169.254.169.254")` -> `false` (AWS metadata link-local)
|
||||||
|
/// - `is_valid_welist_url("https://192.168.1.1")` -> `false` (IPv4 private)
|
||||||
|
/// - `is_valid_welist_url("https://10.0.0.1")` -> `false` (IPv4 private RFC1918)
|
||||||
|
pub fn is_valid_welist_url(url_str: &str) -> bool {
|
||||||
|
let url = match Url::parse(url_str) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_e) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if url.scheme() != "https" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = match url.host_str() {
|
||||||
|
Some(h) => h.trim_start_matches('[').trim_end_matches(']'),
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if host.eq_ignore_ascii_case("localhost")
|
||||||
|
|| host.eq_ignore_ascii_case("127.0.0.1")
|
||||||
|
|| host.eq_ignore_ascii_case("::1")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
if v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_unspecified()
|
||||||
|
|| v4.is_multicast()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
if v6.is_loopback()
|
||||||
|
|| v6.is_unicast_link_local()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| v6.is_multicast()
|
||||||
|
|| v6.is_unique_local()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_domains() {
|
||||||
|
assert!(is_valid_welist_url("https://welist.bitcoin-after.life"));
|
||||||
|
assert!(is_valid_welist_url("https://welist.bitcoin-after.life:443"));
|
||||||
|
assert!(is_valid_welist_url("https://example.com/ping"));
|
||||||
|
assert!(is_valid_welist_url("https://a.b.c.d.example.com"));
|
||||||
|
assert!(is_valid_welist_url("https://welist.onion.tor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_scheme() {
|
||||||
|
assert!(!is_valid_welist_url("http://welist.bitcoin-after.life"));
|
||||||
|
assert!(!is_valid_welist_url("ftp://welist.bitcoin-after.life"));
|
||||||
|
assert!(!is_valid_welist_url("https://")); // no host
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_localhost_and_loopback() {
|
||||||
|
assert!(!is_valid_welist_url("https://localhost"));
|
||||||
|
assert!(!is_valid_welist_url("https://localhost:8080"));
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://LOCALHOST"),
|
||||||
|
"Uppercase localhost should be blocked"
|
||||||
|
);
|
||||||
|
assert!(!is_valid_welist_url("https://127.0.0.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://127.0.0.1:8080"));
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://127.0.0.2"),
|
||||||
|
"Other loopback in 127/8 should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[::1]"),
|
||||||
|
"IPv6 loopback literal should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://::1"),
|
||||||
|
"Raw IPv6 loopback without brackets should be invalid (parse fails)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_private_ips() {
|
||||||
|
assert!(!is_valid_welist_url("https://192.168.1.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://10.0.0.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://172.16.0.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://172.31.255.255"));
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://169.254.169.254"),
|
||||||
|
"AWS metadata link-local IP should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unspecified_and_multicast() {
|
||||||
|
assert!(!is_valid_welist_url("https://0.0.0.0"));
|
||||||
|
assert!(!is_valid_welist_url("https://224.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ipv6_link_local() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fe80::1]"),
|
||||||
|
"IPv6 link local should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ipv6_unique_local() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fc00::1]"),
|
||||||
|
"IPv6 unique local (fc00::/7) should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fd00::1]"),
|
||||||
|
"IPv6 unique local (fd00::7) should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malformed_urls() {
|
||||||
|
assert!(!is_valid_welist_url("not a url"));
|
||||||
|
assert!(!is_valid_welist_url("welist.bitcoin-after.life")); // missing scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_public_ip() {
|
||||||
|
assert!(is_valid_welist_url("https://1.2.3.4"));
|
||||||
|
assert!(is_valid_welist_url("https://8.8.8.8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/xpub.rs
207
src/xpub.rs
@@ -1,32 +1,149 @@
|
|||||||
use sha2::{Digest, Sha256};
|
//use bs58;
|
||||||
use bitcoin::bip32::Xpub;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use bitcoin::bip32::DerivationPath;
|
|
||||||
use bitcoin::key::Secp256k1;
|
|
||||||
use bitcoin::Address;
|
use bitcoin::Address;
|
||||||
|
use bitcoin::Network;
|
||||||
use bitcoin::ScriptBuf;
|
use bitcoin::ScriptBuf;
|
||||||
use bitcoin::WPubkeyHash;
|
use bitcoin::WPubkeyHash;
|
||||||
use bitcoin::Network;
|
use bitcoin::bip32::DerivationPath;
|
||||||
|
use bitcoin::bip32::Xpub;
|
||||||
use bitcoin::hashes::Hash;
|
use bitcoin::hashes::Hash;
|
||||||
|
use bitcoin::key::Secp256k1;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
// Mainnet (BIP44/BIP49/BIP84)
|
// Mainnet (BIP44/BIP49/BIP84)
|
||||||
enum BS58Prefix{
|
#[allow(dead_code)]
|
||||||
|
enum BS58Prefix {
|
||||||
Xpub,
|
Xpub,
|
||||||
//Ypub,
|
Ypub,
|
||||||
//Zpub,
|
Zpub,
|
||||||
//Tpub,
|
Tpub,
|
||||||
//Vpub,
|
Vpub,
|
||||||
//Upub
|
Upub,
|
||||||
}
|
}
|
||||||
const XPUB_PREFIX:[u8; 4] = [0x04, 0x88, 0xB2, 0x1E]; // xpub (Legacy P2PKH)
|
const XPUB_PREFIX: [u8; 4] = [0x04, 0x88, 0xB2, 0x1E]; // xpub (Legacy P2PKH)
|
||||||
//const YPUB_PREFIX:[u8; 4] = [0x04, 0x9D, 0x7C, 0xB2]; // ypub (Nested SegWit P2SH-P2WPKH)
|
const YPUB_PREFIX: [u8; 4] = [0x04, 0x9D, 0x7C, 0xB2]; // ypub (Nested SegWit P2SH-P2WPKH)
|
||||||
//const ZPUB_PREFIX:[u8; 4] = [0x04, 0xB2, 0x47, 0x46]; // zpub (Native SegWit P2WPKH)
|
const ZPUB_PREFIX: [u8; 4] = [0x04, 0xB2, 0x47, 0x46]; // zpub (Native SegWit P2WPKH)
|
||||||
//const TPUB_PREFIX:[u8; 4] = [0x04, 0x35, 0x87, 0xCF]; // tpub (Testnet Legacy P2PKH)
|
const TPUB_PREFIX: [u8; 4] = [0x04, 0x35, 0x87, 0xCF]; // tpub (Testnet Legacy P2PKH)
|
||||||
//const VPUB_PREFIX:[u8; 4] = [0x04, 0x5F, 0x1C, 0xF6]; // vpub (Testnet Nested SegWit)
|
const VPUB_PREFIX: [u8; 4] = [0x04, 0x5F, 0x1C, 0xF6]; // vpub (Testnet Nested SegWit)
|
||||||
//const UPUB_PREFIX:[u8; 4] = [0x04, 0x4A, 0x52, 0x62]; // upub (RegTest Nested SegWit)
|
const UPUB_PREFIX: [u8; 4] = [0x04, 0x4A, 0x52, 0x62]; // upub (RegTest Nested SegWit)
|
||||||
|
// Constants from Bitcoin Core's checksum algorithm
|
||||||
|
const INPUT_CHARSET: &[u8] = b"0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ ";
|
||||||
|
const CHECKSUM_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||||
|
|
||||||
|
// Polynomial modulo function used in checksum calculation (same as in Bitcoin Core)
|
||||||
|
fn poly_mod(mut c: u64, val: u64) -> u64 {
|
||||||
|
let c0 = c >> 35;
|
||||||
|
c = ((c & 0x7ffffffff) << 5) ^ val;
|
||||||
|
if c0 & 1 > 0 {
|
||||||
|
c ^= 0xf5dee51989
|
||||||
|
};
|
||||||
|
if c0 & 2 > 0 {
|
||||||
|
c ^= 0xa9fdca3312
|
||||||
|
};
|
||||||
|
if c0 & 4 > 0 {
|
||||||
|
c ^= 0x1bab10e32d
|
||||||
|
};
|
||||||
|
if c0 & 8 > 0 {
|
||||||
|
c ^= 0x3706b1677a
|
||||||
|
};
|
||||||
|
if c0 & 16 > 0 {
|
||||||
|
c ^= 0x644d626ffd
|
||||||
|
};
|
||||||
|
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate checksum for a descriptor string
|
||||||
|
fn calc_checksum(desc: &str) -> Result<String, String> {
|
||||||
|
// Separate descriptor from any existing checksum
|
||||||
|
let desc = match desc.split_once('#') {
|
||||||
|
Some((d, _)) => d,
|
||||||
|
None => desc,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut c: u64 = 1;
|
||||||
|
let mut cls: u64 = 0;
|
||||||
|
let mut clscount: u64 = 0;
|
||||||
|
|
||||||
|
// Process each character in the descriptor
|
||||||
|
for ch in desc.as_bytes() {
|
||||||
|
let pos = match INPUT_CHARSET.iter().position(|b| b == ch) {
|
||||||
|
Some(p) => p as u64,
|
||||||
|
None => return Err(format!("Invalid character in descriptor: {}", *ch as char)),
|
||||||
|
};
|
||||||
|
|
||||||
|
c = poly_mod(c, pos & 31);
|
||||||
|
cls = cls * 3 + (pos >> 5);
|
||||||
|
clscount += 1;
|
||||||
|
|
||||||
|
if clscount == 3 {
|
||||||
|
c = poly_mod(c, cls);
|
||||||
|
cls = 0;
|
||||||
|
clscount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clscount > 0 {
|
||||||
|
c = poly_mod(c, cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final steps in checksum calculation
|
||||||
|
for _ in 0..8 {
|
||||||
|
c = poly_mod(c, 0);
|
||||||
|
}
|
||||||
|
c ^= 1;
|
||||||
|
|
||||||
|
// Convert checksum to characters
|
||||||
|
let mut checksum = String::with_capacity(8);
|
||||||
|
for j in 0..8 {
|
||||||
|
let idx = ((c >> (5 * (7 - j))) & 31) as usize;
|
||||||
|
checksum.push(CHECKSUM_CHARSET[idx] as char);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_bitcoincore_descriptor(xpub: &str) -> String {
|
||||||
|
let fingerprint = match calculate_fingerprint(xpub) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
|
||||||
|
};
|
||||||
|
|
||||||
|
let xpub_converted = match convert_xpub(xpub) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
|
||||||
|
};
|
||||||
|
let descriptor = format!("wpkh([{}/84h/0h/0h]{}/0/*)", fingerprint, xpub_converted);
|
||||||
|
match calc_checksum(&descriptor) {
|
||||||
|
Ok(checksum) => {
|
||||||
|
let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor);
|
||||||
|
format!("{}#{}", clean_descriptor, checksum)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Error: {}", err);
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn convert_xpub(xpub: &str) -> Result<String, String> {
|
||||||
|
if xpub.len() >= 4 && (&xpub[0..4] == "xpub" || &xpub[0..4] == "ypub" || &xpub[0..4] == "zpub")
|
||||||
|
{
|
||||||
|
convert_to(xpub, BS58Prefix::Xpub)
|
||||||
|
} else if xpub.len() >= 4
|
||||||
|
&& (&xpub[0..4] == "tpub" || &xpub[0..4] == "vpub" || &xpub[0..4] == "upub")
|
||||||
|
{
|
||||||
|
convert_to(xpub, BS58Prefix::Tpub)
|
||||||
|
} else {
|
||||||
|
Err("Invalid xpub prefix: expected xpub, ypub, zpub, tpub, vpub, or upub".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn calculate_fingerprint(tpub: &str) -> Result<String, String> {
|
||||||
|
let xpub = Xpub::from_str(&convert_to(tpub, BS58Prefix::Xpub)?)
|
||||||
|
.map_err(|e| format!("Invalid xpub: {}", e))?;
|
||||||
|
let fp = xpub.fingerprint();
|
||||||
|
let _pp = xpub.parent_fingerprint;
|
||||||
|
Ok(format!("{}", fp))
|
||||||
|
}
|
||||||
|
|
||||||
fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
|
fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
let data = bs58::decode(s).into_vec().map_err(|e| e.to_string())?;
|
let data = bs58::decode(s).into_vec().map_err(|e| e.to_string())?;
|
||||||
@@ -47,27 +164,33 @@ fn base58check_encode(data: &[u8]) -> String {
|
|||||||
bs58::encode(full).into_string()
|
bs58::encode(full).into_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_to(zpub: &str,prefix: BS58Prefix) -> Result<String, String> {
|
fn convert_to(zpub: &str, prefix: BS58Prefix) -> Result<String, String> {
|
||||||
|
|
||||||
let mut data = base58check_decode(zpub)?;
|
let mut data = base58check_decode(zpub)?;
|
||||||
|
|
||||||
if data.len() < 4 {
|
if data.len() < 4 {
|
||||||
return Err("Non è una zpub valida.".to_string());
|
return Err("Non è una zpub valida.".to_string());
|
||||||
}
|
}
|
||||||
data.splice(0..4, match prefix {
|
data.splice(
|
||||||
BS58Prefix::Xpub => XPUB_PREFIX,
|
0..4,
|
||||||
//BS58Prefix::Ypub => YPUB_PREFIX,
|
match prefix {
|
||||||
//BS58Prefix::Zpub => ZPUB_PREFIX,
|
BS58Prefix::Xpub => XPUB_PREFIX,
|
||||||
//BS58Prefix::Vpub => VPUB_PREFIX,
|
BS58Prefix::Ypub => YPUB_PREFIX,
|
||||||
//BS58Prefix::Tpub => TPUB_PREFIX,
|
BS58Prefix::Zpub => ZPUB_PREFIX,
|
||||||
//BS58Prefix::Upub => UPUB_PREFIX,
|
BS58Prefix::Vpub => VPUB_PREFIX,
|
||||||
});
|
BS58Prefix::Tpub => TPUB_PREFIX,
|
||||||
|
BS58Prefix::Upub => UPUB_PREFIX,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(base58check_encode(&data))
|
Ok(base58check_encode(&data))
|
||||||
}
|
}
|
||||||
pub fn new_address_from_xpub(zpub: &str, index: i64,network: Network)-> Result<(String,String), Box<dyn std::error::Error>>{
|
pub fn new_address_from_xpub(
|
||||||
let xpub = Xpub::from_str(&convert_to(zpub,BS58Prefix::Xpub)?)?;
|
zpub: &str,
|
||||||
let path = format!("m/0/{}",index);
|
index: i64,
|
||||||
|
network: Network,
|
||||||
|
) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||||
|
let xpub = Xpub::from_str(&convert_to(zpub, BS58Prefix::Xpub)?)?;
|
||||||
|
let path = format!("m/0/{}", index);
|
||||||
let derivation_path = DerivationPath::from_str(path.as_str())?;
|
let derivation_path = DerivationPath::from_str(path.as_str())?;
|
||||||
let secp = Secp256k1::new();
|
let secp = Secp256k1::new();
|
||||||
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
|
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
|
||||||
@@ -78,20 +201,21 @@ pub fn new_address_from_xpub(zpub: &str, index: i64,network: Network)-> Result<(
|
|||||||
//let script_pubkey = ScriptBuf::new_p2sh(&redeem_script.script_hash());
|
//let script_pubkey = ScriptBuf::new_p2sh(&redeem_script.script_hash());
|
||||||
let address = Address::from_script(&redeem_script, network)?;
|
let address = Address::from_script(&redeem_script, network)?;
|
||||||
//let address = Address::from_script(&script_pubkey, network)?;
|
//let address = Address::from_script(&script_pubkey, network)?;
|
||||||
Ok((address.to_string(),path.to_string()))
|
Ok((address.to_string(), path.to_string()))
|
||||||
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>>{
|
fn main() -> Result<(), Box<dyn std::error::Error>>{
|
||||||
//let zpub = "xpub6C29v8gxCXREHUzoGNfqqFqZWxTVEmYtmZshuzfSwBKNmfYQxoizRziCkkUUA4WwJZkJs2i7nttRiC6MQG7mxZpouXeYkTZe3U52RyPAeo2";
|
match convert_to(zpub,BS58Prefix::Tpub) {
|
||||||
//let zpub = "vpub5Ut36m34VebUUjdhYaxJCjSPqk3ZR8bA2MXLmbHRQCycAxy5Q1GFPJspLkJywJjBgQnvU3rmwPKTPp1ELLWeXrve3zBufpZR4MRCCTNHzsn";
|
Ok(tpub) => println!("XPUB: {}", tpub),
|
||||||
let zpub = "zpub6qdfveGrxBQN3z8paZ88EHpCn5MGXpUoHwQmHhPbj4rPQtUjbWyCHrJFYZGVY7MsmVbDaeu4JYqRqcdLzMx78wZFEWbLrF9FG3gr2MPQC5H";
|
|
||||||
match convert_to(zpub,BS58Prefix::Xpub) {
|
|
||||||
Ok(xpub) => println!("XPUB: {}", xpub),
|
|
||||||
Err(e) => eprintln!("Errore: {}", e),
|
Err(e) => eprintln!("Errore: {}", e),
|
||||||
}
|
}
|
||||||
let xpub = Xpub::from_str(&convert_to(zpub,BS58Prefix::Xpub)?)?;
|
let fingerprint = base58check_encode(&calculate_fingerprint(zpub));
|
||||||
|
println!("ZPUB: {}, FINGERPRINT: {}",zpub,fingerprint);
|
||||||
|
|
||||||
|
let xpub = Xpub::from_str(&convert_to(zpub,BS58Prefix::Xpub)?)?;
|
||||||
|
let tpub = convert_to(zpub,BS58Prefix::Tpub)?;
|
||||||
|
let fingerprint = base58check_encode(&calculate_fingerprint(&tpub));
|
||||||
|
println!("TPUB: {}, FINGERPRINT: {}",tpub,fingerprint);
|
||||||
let derivation_path = DerivationPath::from_str("m/0/0")?;
|
let derivation_path = DerivationPath::from_str("m/0/0")?;
|
||||||
let secp = Secp256k1::new();
|
let secp = Secp256k1::new();
|
||||||
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
|
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
|
||||||
@@ -108,5 +232,4 @@ fn main() -> Result<(), Box<dyn std::error::Error>>{
|
|||||||
let address = Address::from_script(&script_pubkey, network)?;
|
let address = Address::from_script(&script_pubkey, network)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}*/
|
||||||
*/
|
|
||||||
|
|||||||
92
tests/db_path_validation.rs
Normal file
92
tests/db_path_validation.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use bal_server::db::open_db;
|
||||||
|
use sqlite::State;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_db_blocks_traversal() {
|
||||||
|
let res = open_db("../etc/passwd");
|
||||||
|
assert!(res.is_err(), "Path with '..' should be rejected");
|
||||||
|
let err = match res {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("Expected error for traversal path"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.contains("'..'"),
|
||||||
|
"Error should mention directory traversal: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.contains("forbidden"),
|
||||||
|
"Error should mention forbidden prefix: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
assert!(res.is_err(), "Symlink DB path should be rejected");
|
||||||
|
let err = match res {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("Expected error for symlink"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.contains("symlink"),
|
||||||
|
"Error should mention symlink: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(real);
|
||||||
|
let _ = fs::remove_file(link);
|
||||||
|
}
|
||||||
82
tests/input_validation_tests.rs
Normal file
82
tests/input_validation_tests.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use bal_server::db::{get_all_addresses_by_xpub, open_db};
|
||||||
|
use sqlite::Value;
|
||||||
|
|
||||||
|
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(?, ?, ?);")
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!networks.contains(&"attacker"),
|
||||||
|
"attacker should not be a valid network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_txid_validation_is_hex_64() {
|
||||||
|
let valid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||||
|
assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_eq!(valid.len(), 64);
|
||||||
|
|
||||||
|
let too_short = "abcdef1234567890";
|
||||||
|
assert!(too_short.len() != 64);
|
||||||
|
|
||||||
|
let non_hex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789g";
|
||||||
|
assert!(!non_hex.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
|
||||||
|
let with_dot = ".abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||||
|
assert!(!with_dot.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
46
tests/panic_regression_tests.rs
Normal file
46
tests/panic_regression_tests.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use sqlite::Connection;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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 result = handle.join();
|
||||||
|
assert!(result.is_err()); // Thread panicked
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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');");
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(found_value.unwrap(), "0");
|
||||||
|
}
|
||||||
154
tests/secret_leakage_tests.rs
Normal file
154
tests/secret_leakage_tests.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gitignore_protection_env() {
|
||||||
|
let gitignore =
|
||||||
|
fs::read_to_string(".gitignore").expect(".gitignore file not found in project root");
|
||||||
|
|
||||||
|
// Check that .env and .pem files are blocked
|
||||||
|
let required_patterns = vec![
|
||||||
|
".env",
|
||||||
|
"*.env",
|
||||||
|
"*.env.local",
|
||||||
|
".env.production",
|
||||||
|
".env.secret",
|
||||||
|
"*.pem",
|
||||||
|
"!public_key.pem",
|
||||||
|
"*.key",
|
||||||
|
"private_key.pem",
|
||||||
|
"privkey.pem",
|
||||||
|
"ec.key",
|
||||||
|
"chiave_privata.key",
|
||||||
|
];
|
||||||
|
|
||||||
|
for pattern in required_patterns {
|
||||||
|
let has_wildcard = gitignore.contains("*.env.local") || gitignore.contains(".env.local");
|
||||||
|
let has_env = gitignore.contains("*.env") || gitignore.contains(".env");
|
||||||
|
|
||||||
|
let is_env_local = pattern == "*.env.local" || pattern == ".env.local";
|
||||||
|
if is_env_local {
|
||||||
|
assert!(
|
||||||
|
has_wildcard,
|
||||||
|
".gitignore must contain pattern '*.env.local' or '.env.local' to protect secrets",
|
||||||
|
);
|
||||||
|
} else if pattern == ".env.production"
|
||||||
|
|| pattern == ".env.secret"
|
||||||
|
|| pattern == "*.env"
|
||||||
|
|| pattern == ".env"
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
has_env,
|
||||||
|
".gitignore must contain pattern '*.env' or '.env' to protect secrets",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
gitignore.contains(pattern),
|
||||||
|
".gitignore must contain pattern '{}' to protect secrets",
|
||||||
|
pattern
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(".gitignore properly protects .env, .pem, and .key files");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_private_key_in_git() {
|
||||||
|
let gitignore = match fs::read_to_string(".gitignore") {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
println!("WARNING: .gitignore not found: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
gitignore.contains("private_key.pem"),
|
||||||
|
".gitignore must block private_key.pem"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
gitignore.contains("privkey.pem"),
|
||||||
|
".gitignore must block privkey.pem"
|
||||||
|
);
|
||||||
|
assert!(gitignore.contains("ec.key"), ".gitignore must block ec.key");
|
||||||
|
assert!(
|
||||||
|
gitignore.contains("chiave_privata.key"),
|
||||||
|
".gitignore must block chiave_privata.key"
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.args(["ls-files", "*.pem", "*.key"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to run git ls-files");
|
||||||
|
|
||||||
|
let tracked_keys = String::from_utf8(output.stdout).unwrap();
|
||||||
|
let tracked_keys: Vec<&str> = tracked_keys.lines().collect();
|
||||||
|
|
||||||
|
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
|
||||||
|
if !tracked.contains("public_key.pem") {
|
||||||
|
panic!(
|
||||||
|
"Private key file is tracked by git: {}. Remove it with git rm --cached",
|
||||||
|
tracked
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("PASS: No private keys tracked in git (only public_key.pem allowed)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_token_in_source_files() {
|
||||||
|
let mut found_issues = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(".").unwrap().filter_map(|e| e.ok()) {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ext) = path.extension()
|
||||||
|
&& ext == "sh"
|
||||||
|
{
|
||||||
|
let content = fs::read_to_string(&path).unwrap();
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
if line.trim().starts_with('#')
|
||||||
|
|| line.to_lowercase().contains("example")
|
||||||
|
|| line.to_lowercase().contains("template")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.trim().len() >= 40 {
|
||||||
|
let hex_chars: Vec<_> = line
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect();
|
||||||
|
if (40..=64).contains(&hex_chars.len())
|
||||||
|
&& (line.to_lowercase().contains("token")
|
||||||
|
|| line.to_lowercase().contains("api")
|
||||||
|
|| line.to_lowercase().contains("secret"))
|
||||||
|
{
|
||||||
|
found_issues.push(format!(
|
||||||
|
"Potential hardcoded token in {}: line {}: {}",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
line.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_issues.is_empty() {
|
||||||
|
println!("FAIL: Found potential hardcoded tokens:");
|
||||||
|
for issue in &found_issues {
|
||||||
|
println!(" {}", issue);
|
||||||
|
}
|
||||||
|
panic!(
|
||||||
|
"Found potential hardcoded tokens in shell scripts: {:?}",
|
||||||
|
found_issues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("PASS: No hardcoded tokens found in shell scripts");
|
||||||
|
}
|
||||||
137
tests/sql_injection_tests.rs
Normal file
137
tests/sql_injection_tests.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use sqlite::{Connection, Value};
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 (?, ?);")
|
||||||
|
.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 = ?;")
|
||||||
|
.unwrap();
|
||||||
|
check
|
||||||
|
.bind((1, Value::String(format!("txid_{}", i))))
|
||||||
|
.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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);");
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Another common injection pattern
|
||||||
|
let malicious_txid = "safe_txid'; UPDATE tbl_tx SET status = 99; --";
|
||||||
|
|
||||||
|
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 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");
|
||||||
|
}
|
||||||
110
tests/ssrf_tests.rs
Normal file
110
tests/ssrf_tests.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use bal_server::validation::is_valid_welist_url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_blocks_internal_urls() {
|
||||||
|
// Blocked: internal loopback
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://127.0.0.1"),
|
||||||
|
"IPv4 loopback should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://localhost"),
|
||||||
|
"localhost hostname should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[::1]"),
|
||||||
|
"IPv6 loopback should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: private RFC1918 ranges
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://192.168.1.1"),
|
||||||
|
"RFC1918 private IP should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://10.0.0.1"),
|
||||||
|
"RFC1918 private IP should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://172.16.0.1"),
|
||||||
|
"RFC1918 private IP should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: AWS metadata link-local
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://169.254.169.254"),
|
||||||
|
"AWS metadata link-local IP should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: non-HTTPS schemes
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("http://welist.bitcoin-after.life"),
|
||||||
|
"HTTP plaintext should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("ftp://welist.bitcoin-after.life"),
|
||||||
|
"FTP scheme should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: malformed URLs
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("not a url"),
|
||||||
|
"Malformed URL should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("welist.bitcoin-after.life"),
|
||||||
|
"URL missing scheme should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allowed: valid public domain on HTTPS
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://welist.bitcoin-after.life"),
|
||||||
|
"Known production domain should be allowed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://example.com/ping"),
|
||||||
|
"Public domain on HTTPS should be allowed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allowed: valid public IP on HTTPS
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://8.8.8.8"),
|
||||||
|
"Public IP on HTTPS should be allowed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://1.2.3.4"),
|
||||||
|
"Public IP on HTTPS should be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_case_insensitive_localhost() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://LOCALHOST"),
|
||||||
|
"Uppercase localhost should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://LocalHost"),
|
||||||
|
"Mixed case localhost should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_ipv6_unique_local() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fc00::1]"),
|
||||||
|
"IPv6 unique local fc00 should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fd00::1]"),
|
||||||
|
"IPv6 unique local fd00 should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_port_presence_ok() {
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://welist.bitcoin-after.life:443"),
|
||||||
|
"HTTPS with explicit port should be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user