60 Commits

Author SHA1 Message Date
7999902cc0 fix: extract client IP correctly behind reverse proxy
Add X-Forwarded-For as fallback header when X-Real-IP is missing.
Validate IP format before storing. Log which source was used.

release: bal-server-0.3.2
2026-07-20 13:34:03 -04:00
d6b888e403 release: bal-server-0.3.1 2026-07-20 08:53:16 -04:00
36219c49a0 fix: bug fixes, dead code removal, improved ZMQ logging
- Fix SQL syntax error in create_database (trailing parenthesis)
- Fix typo i27.0.0.1 -> 127.0.0.1 in Testnet/Testnet4 defaults
- Replace hardcoded VERSION with CARGO_PKG_VERSION in bal-pusher
- Remove unwrap() in DB update loops (status push/invalid)
- Remove duplicate init_network call in bal-server startup
- Use INSERT OR IGNORE for idempotent xpub initialization
- Remove debug println! left in production code
- Remove dead code: check_zmq_connection, ConnectionMonitor, seq_to_str
- Remove all commented-out code blocks
- ZMQ timeout logging: trace instead of warn, error only after 1 hour
2026-07-20 08:48:54 -04:00
ca530bf987 release: bal-server-0.3.0 2026-07-19 21:29:18 -04:00
1c76755ea6 release: bal-server-0.3.1 2026-07-19 20:16:44 -04:00
c371a4f478 fix: default features for cargo build/test, build each binary separately in release
- Set default = ["server", "pusher"] so cargo build/test works without flags
- Add required-features to each [[bin]] section
- make_release.sh builds each binary with --no-default-features for optimal size
2026-07-19 19:53:41 -04:00
59250289a7 perf: reduce binary sizes from 15M/11M to 3.8M/5.5M
- Add [profile.release]: opt-level=z, lto=true, strip=true, codegen-units=1, panic=abort
- Replace vendored openssl (~5MB) with ed25519-dalek (pure Rust, ~100KB)
- Feature-gate deps: server (actix, chrono) vs pusher (zmq, reqwest, ed25519-dalek)
- Remove unused confy dependency
- bal-pusher: 15M -> 3.8M (-75%)
- bal-server: 11M -> 5.5M (-50%)
2026-07-19 19:27:42 -04:00
0f0f0a08c3 Merge origin/main: resolve conflicts and add 10s timeout to welist_http_client 2026-07-19 18:13:21 -04:00
6e6c634e98 fix(pusher): allow bypassing SSRF validation for local dev and fix RUST_LOG export
- Add WELIST_SKIP_URL_VALIDATION env var to bypass URL validation in dev
- Fix bal-pusher.sh: export RUST_LOG so env_logger picks up log level
- Add WELIST_SKIP_URL_VALIDATION=true to bal-pusher.sh for regtest use
2026-07-19 17:48:43 -04:00
efcd91e6b4 fix(pusher): add ZMQ diagnostic logging and heartbeat monitoring
- Log ZMQ subscribe confirmation with address
- Add consecutive timeout counter with periodic warn every 60s
- Log ZMQ connection restored after timeouts
- Log main_result errors instead of discarding with let _=
2026-07-19 15:55:49 -04:00
22b60e55c7 Merge pull request 'pusher: optional IPv6 preference for welist reports + log report failures' (#1) from SAFE21.io/bal-server:fix/pusher-welist-ipv6-preference into main
Reviewed-on: bitcoinafterlife/bal-server#1
2026-07-19 15:09:00 +00:00
7fe5fd3139 fix(pusher): log send_stats_report errors instead of discarding
main_result called send_stats_report/calculate_stats with 'let _ = ...',
silently dropping any failure. A broken welist route (e.g. unreachable
IPv4 path) is then invisible in the logs and can go unnoticed for a long
time. Log failures with warn! so connectivity problems are diagnosable.
2026-07-19 14:09:41 +02:00
b46f85f436 feat(pusher): optional IPv6 preference for welist reports (BAL_PUSHER_PREFER_IPV6)
The welist host publishes both A and AAAA records. On networks where the
IPv4 route is broken (connection stalls after the TCP handshake) while
IPv6 works, the default connector may pick the broken family and the
report request hangs.

When BAL_PUSHER_PREFER_IPV6 is truthy, the pusher now resolves the welist
host itself and pins the reqwest client to its first IPv6 address; the
original hostname is still used for the Host header and TLS SNI. When the
variable is unset (default) or no AAAA record exists, behavior is
completely unchanged.

Includes unit tests for the URL host/port parsing and documentation in
docs/07_deployment_and_ops.md.
2026-07-19 14:09:01 +02:00
cd24eda111 fix(pusher): improve welist response logging, error handling, and add request timeout
- Always log HTTP status code and response body from welist (info level)
- Log send_stats_report errors at call site instead of silently discarding
- Add 10s timeout to reqwest client to prevent indefinite hangs
- Apply clippy fixes (is_empty, if-let chains, dead_code, etc.)
2026-07-18 22:58:42 -04:00
8ce3f6a445 fix(pushtxs): skip invalid txs instead of aborting batch, anonymize error responses
- parse_request_transactions now skips txs without willexecutor output
  instead of returning an error that aborts the entire batch
- returns 'error' (400) only when NO txs are valid
- all HTTP error bodies replaced with generic 'error' to avoid leaking
  internal details
2026-07-18 21:35:39 -04:00
190cac929e refactor: rename bal-server-actix.rs to bal-server.rs
- Rename src/bin/bal-server-actix.rs -> src/bin/bal-server.rs
- Update Cargo.toml to point to new filename
- Update Dockerfile reference
- Update docs/08_security_audit.md references
2026-07-17 10:31:56 -04:00
5e1d0d7c54 chore: remove make_release.sh from tracking, keep local 2026-07-17 09:59:05 -04:00
9081e08785 feat(release): add ASCII-armored .asc signature alongside .sig 2026-07-17 09:57:39 -04:00
06db6f1d48 fix(release): move asset paths after ASSET_PATH definition 2026-07-17 09:55:11 -04:00
ba8f828e89 release: bal-server-0.3.0 2026-07-17 09:53:35 -04:00
9abfad29b9 feat(release): add signature, checksum and verification instructions 2026-07-17 09:51:37 -04:00
bc9ec1a48c docs: fix Docker example to use regtest env vars consistently 2026-07-17 05:23:51 -04:00
c461232095 docs: rewrite README with Docker, per-network config, Actix DoS vars 2026-07-17 02:51:04 -04:00
fbe61a5862 fix: Docker support, WAL race condition, pusher panic fixes
- Add multi-stage Dockerfile with tini, non-root user, healthcheck
- Fix SQLite WAL mode race between bal-server and bal-pusher (busy_timeout + retry)
- Fix tbl_stats missing UNIQUE index for ON CONFLICT clause
- Replace unwrap() panics in bal-pusher with graceful error handling
- Add docker/entrypoint.sh with BAL_PUSHER_NETWORK support
- cargo fmt across all files
2026-07-16 21:24:09 -04:00
56696050ec security: remove terms and personal info (Svātantrya) from files 2026-07-16 20:07:14 -04:00
638f00bf0c docs(deploy): align docs with contrib script fixes
- 07_deployment_and_ops.md: update zmqpubhashblock note, clarify certbot --nginx,
  update Nginx checklist items referencing the install script
- 09_references_and_links.md: remove 'hardcoded xpub' from install script row
2026-07-16 19:49:20 -04:00
da2124031e fix(install-scripts): correct critical bugs in contrib scripts
- download_and_install_bitcoincore.sh:
  - Fix apt install with single-quoted string (was treating all deps as one package)
  - Add sudo to apt install commands
  - Fix tar extraction: now finds extracted dir and copies bitcoind/bitcoin-cli to /usr/local/bin
  - Fix zmqpubrawblock -> zmqpubhashblock (pusher expects hashblock topic)
  - Fix install check (0 was testing cd, not tar) with explicit extraction check
  - Handle existing user in adduser
  - Fix source lib.sh path

- download_and_install_bal.sh:
  - Use curl -sfL (fail on 404, follow redirects) for Gitea download
  - Add null/empty check after jq download URL extraction
  - Change certbot --standalone to --nginx (avoids port 80 conflict)
  - Enable nginx site symlink (was commented out)
  - Handle existing 'bal' user in adduser

- install_tor.sh:
  - Add sudo to all root-requiring commands (apt, tee, cp, sed, systemctl)
  - Backup torrc with timestamp before modifying
  - Add DisableDebuggerAttachment hardening
2026-07-16 19:39:10 -04:00
538e72f806 fix(install-scripts): harden contrib scripts and fix critical bugs
- download_and_install_bal.sh: add set -euo pipefail, fix sudo sudo typo, fix nginx path (etc/nginx/etc/nginx -> etc/nginx), add chmod 600 for env files, reduce RestartSec to 5s, fix concatenated echo line, add systemctl daemon-reload
- download_and_install_bitcoincore.sh: add set -euo pipefail, fix source lib.sh path to use script directory
- install_tor.sh: add set -euo pipefail, detect architecture dynamically (dpkg --print-architecture), detect distro dynamically (lsb_release -cs), bind ControlPort to 127.0.0.1:9051, add DisableDebuggerAttachment
2026-07-16 19:07:15 -04:00
4fc0790fe7 security: fix audit points 5-9 + optimize echo_push/info endpoints
- Point 5 (SSRF): Add URL validation for WELIST_SERVER_URL (src/validation.rs)
- Point 6 (DB Access): Add DB path validation, symlink check, WAL mode (open_db)
- Point 8 (HTTPS): Extract nginx config, add deployment checklist, bind warnings
- Point 9 (Input Validation): Add NETWORKS check (404 for unknown), txid 64-hex validation
- Optimize echo_push: parse transactions outside DB lock, batch duplicate check, N+1 xpub lookup eliminated via HashSet cache
- Optimize echo_info: derive BIP32 address outside DB lock, minimize lock duration
- Fix echo_stats SQL injection via parameter binding + add idx_stats_chain index
- New regression tests: ssrf_tests, db_path_validation, input_validation_tests
2026-07-16 18:59:30 -04:00
237e62d4be security: fix secret leakage hardcoded tokens and credentials (Punto 3)
- .gitignore: Add protection for .env, *.pem, *.key, private_key.pem, privkey.pem, ec.key, chiave_privata.key, and shell scripts bal-*.sh
- make_release.sh: Remove hardcoded token 5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
  Add loading from .env file with GITEA_API_TOKEN variable
  Add error handling if token is not set (prevents script from running without proper authentication)
- .env.example: Add template file for Gitea API token setup (not committed to git, .gitignored)
- generate_keys.sh: Add chmod 600 to protect private_key.pem permissions
- contrib/download_and_install_bal.sh: Remove hardcoded xpub and fixed_fee.
  Make all settings required as arguments or environment variables (xpub, fixed_fee, willexecutor_url, email, info)
  Add proper error handling and usage instructions if required arguments are not provided
- tests/secret_leakage_tests.rs: Add regression tests that:
  * Verify .gitignore protects .env, .pem, .key files
  * Verify no private key files are tracked in git (only public_key.pem is allowed)
  * Scan shell scripts for potential hardcoded tokens
- All tests pass: cargo test (8 tests: 3 SQL injection + 2 panic regression + 3 secret leakage)
- Build verified: cargo check (0 errors)
2026-07-16 15:25:23 -04:00
fa2f458468 security: fix unwrap/expect/panic on untrusted input (Fase 3 MEDIUM/LOW)
- xpub.rs: Replace convert_xpub() unwrap() with Result propagation
- xpub.rs: Replace calculate_fingerprint() unwrap() with Result propagation
- xpub.rs: Replace get_bitcoincore_descriptor() unwrap() with Result/match
- xpub.rs: Fix new_address_from_xpub() call in bal-server.rs to handle Result
- xpub.rs: Add prefix validation in convert_xpub (xpub/ypub/zpub/tpub/vpub/upub)
- bal-pusher.rs: Remove parse().unwrap() on env var bool, use unwrap_or(false)
- bal-pusher.rs: Replace port try_into().unwrap() with safe u16::try_from match
- bal-pusher.rs: Add error logging for invalid port values in env vars
- All tests pass: cargo test (5 tests: 3 SQL + 2 panic regression)
- Build verified: cargo check --bin=bal-server --bin=bal-pusher (0 errors)
2026-07-16 15:08:49 -04:00
167869b881 security: eliminate unwrap/expect/panic on untrusted input paths (Fase 2 HIGH)
- db.rs: Replace all stmt.read/bind().unwrap() with safe match/if let + error logging
- db.rs: Replace execute_insert() expect() with safe prepare + rollback on error
- db.rs: Replace get_total_transaction_number() unwrap() with safe match/Result propagation
- bal-server.rs: Replace fs::read_to_string().expect() with match + 500 error
- bal-server.rs: Replace idx/amount.try_into().unwrap() with i64::try_from(...).unwrap_or()
- bal-server.rs: echo_pub_key returns 500 instead of panic on file read failure
- docs/08_security_audit.md: Update status for panic/DoS vulnerabilities to 'Fixed'
- All tests pass: cargo test (5 tests: 3 SQL injection + 2 panic regression)
- Build verified: cargo check --bin=bal-server --bin=bal-pusher (0 errors)
2026-07-16 14:56:25 -04:00
0fdefcfd0f security: fix panic on untrusted input (Phase 2 Critical)
- Replace all sqlite::open().unwrap() per-request with shared Arc<Mutex<Connection>>
- Add Mutex poisoning recovery in all cfg.lock() and db.lock() calls
- Fix std::str::from_utf8().unwrap() with safe match → 400 Bad Request
- Fix timestamp_nanos_opt().unwrap() with safe match
- Fix panic on RPC client failure (bal-pusher): error log + sleep + retry
- Fix ZMQ socket connect with retry loop (bal-pusher)
- Add ZMQ_RCVTIMEO=5000 and match recv for graceful timeout (bal-pusher)
- Fix ZMQ subscribe error with match instead of unwrap (bal-pusher)
- Add unwrap_or for all tbl_stats row fields in echo_stats (prevent NULL panic)
- Add panic_regression_tests.rs: test mutex poisoning recovery and NULL unwrap_or

All tests pass: cargo test --test panic_regression_tests + sql_injection_tests
Build verified: cargo check --bin=bal-server --bin=bal-pusher
2026-07-16 14:49:31 -04:00
df8effcc60 refactor: remove bal-pusher-enhanced and update docs/env
- Remove src/bin/bal-pusher-enhanced.rs (synchronous pusher variant)
- Remove bal-pusher.env and bal-pusher.sh from git tracking (now in .gitignore)
- Update all documentation to remove references to bal-pusher-enhanced:
  * 01_project_overview.md
  * 02_glossary_and_bitcoin_domain.md
  * 03_architecture_and_data_flow.md
  * 04_modules_detail.md
  * 05_api_reference.md (remove rawblock ZMQ section, update references)
  * 08_security_audit.md (remove references to bal-pusher-enhanced in DoS and ZMQ sections)
  * 09_references_and_links.md
- Build verified: cargo check passes for bal-pusher and bal-server binaries
2026-07-16 14:32:59 -04:00
69d877a360 docs: add comprehensive knowledge base and security audit
- Add docs/INDEX.md with navigable index and quick reference guides
- Add 9 knowledge base files covering project overview, Bitcoin domain,
  architecture, modules, API reference, database schema, deployment/security
- Update AGENTS.md with knowledge base reference and update policy
- Add tests/sql_injection_tests.rs with regression tests for SQL injection
- Fix SQL injection vulnerabilities in bal-pusher.rs:
  * Replace string-formatted UPDATE IN with loop + parameterized queries
  * Replace string-formatted UPDATE push_err with parameterized query
  * Add chain name validation in calculate_stats to prevent env var tampering
- Update .gitignore to exclude bal-pusher.env and bal-pusher.sh
2026-07-16 14:11:18 -04:00
64f402eeae fix testnet4 2026-03-17 21:06:37 -04:00
0526c1f742 zmqhashblock per network 2026-03-17 21:01:59 -04:00
97bdffaa50 env log 2026-02-06 12:07:15 -04:00
dc02d9248c formatting code 2026-02-06 12:03:10 -04:00
efa8c470cc RPC.md 2026-01-29 13:36:06 -04:00
c2797420a1 expose stats 2026-01-21 00:08:47 -04:00
e78a1efb68 version 2026-01-19 20:03:17 -04:00
4a592fa3ef bal server route / 2025-11-03 09:36:56 -04:00
ed38ffb87f update version 2025-11-03 08:53:58 -04:00
b6d9c5b6ba fix bal-pusher zmq connection stability 2025-11-03 08:46:13 -04:00
47323e9986 fix bal-pusher zmq connection stability 2025-11-03 07:42:09 -04:00
31ab600e79 . 2025-11-01 19:02:15 -04:00
62c4ad824b send times to welist
ED25519 times signing
2025-11-01 18:34:35 -04:00
ab0f132081 send time to welist server 2025-10-31 16:01:15 -04:00
056aa414e5 release: bal-server-0.2.2 2025-10-31 15:13:09 -04:00
eef33c55a6 bugdfix 2025-09-23 13:55:00 -04:00
86e9e50059 bal-pusher.env 2025-07-19 07:15:53 -04:00
48e1ead8a1 logs about current block 2025-07-19 07:12:18 -04:00
70583710d5 bal-pusher readme 2025-07-16 04:50:40 -04:00
166b9f9f2b release: bal-server-0.2.1 2025-07-16 04:26:02 -04:00
aa91662a2b release: bal-server-0.2.1 2025-07-16 04:25:17 -04:00
b8f120f8b1 release: bal-server-0.2.1 2025-07-16 03:58:42 -04:00
b41d961285 pusher fix 2025-07-16 03:47:28 -04:00
3a091ab070 fixed network db_field 2025-07-15 15:18:37 -04:00
72fc39b787 fixed db field network 2025-07-15 15:12:21 -04:00
41 changed files with 6355 additions and 1459 deletions

58
.dockerignore Normal file
View File

@@ -0,0 +1,58 @@
# Git
.git
.gitignore
.gitsecret
# Build artifacts
target/
# Environment files (secrets)
*.env
.env
.env.*
!.env.example
# Private keys
*.pem
*.key
!public_key.pem
!data/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
Cargo.lock
generate_random_ascii.sh
test/
invalid_txs/
valid_txs/

56
.env.example Normal file
View 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
View 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
View 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
```

1183
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +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]
base64 = "0.22.1" default = ["server", "pusher"]
bs58 = "0.4.0" server = ["dep:actix-web", "dep:actix-governor", "dep:actix-rt", "dep:chrono", "dep:hex-conservative"]
bytes = "1.2" pusher = ["dep:zmq", "dep:reqwest", "dep:byteorder", "dep:base64", "dep:ed25519-dalek"]
bitcoin = { version = "0.32.5" }
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"
openssl = { version = "0.10.74", features = ["vendored"] }
sha2 = "0.10.8"
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.116"
sqlite = "0.34.0"
regex = "1.10.4"
reqwest = { version = "0.12.24", features = ["json","socks"] }
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
View 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 \
libsodium-dev \
libzmq5-dev \
cmake \
&& 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 --bin bal-pusher 2>/dev/null || true && \
rm -rf src target/release/.fingerprint target/release/deps/*bal_server*
# Copy real source and build
COPY src/ src/
RUN cargo build --release --bin bal-server --bin bal-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 \
libsodium23 \
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

196
README.md
View File

@@ -1,85 +1,159 @@
# bal-server # bal-server
## Installation ## Installation
```bash ```bash
$ git clone .... git clone https://bitcoin-after.life/gitea/bitcoinafterlife/bal-server.git
$ cd bal-server cd bal-server
$ openssl genpkey -algorithm ED25519 -out private_key.pem openssl genpkey -algorithm ED25519 -out private_key.pem
$ openssl pkey -in private_key.pem -pubout -out public_key.pem openssl pkey -in private_key.pem -pubout -out public_key.pem
$ cargo build --release cargo build --release
$ sudo cp target/release/bal-server /usr/local/bin sudo cp target/release/bal-server target/release/bal-pusher /usr/local/bin
$ bal-server
``` ```
## Configuration ## Docker
The `bal-server` application can be configured using environment variables. The following variables are available: ### Build
```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
```
### 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_PUB_KEY_PATH` | WillExecutor Ed25519 public key | `public_key.pem` |
| `BAL_SERVER_REGTEST_ADDRESS` | Bitcoin address for the regtest environment. | - |
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee for the regtest environment. | 50000 |
| `BAL_SERVER_SIGNET_ADDRESS` | Bitcoin address for the signet environment. | - |
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee for the signet environment. | 50000 |
| `BAL_SERVER_TESTNET_ADDRESS` | Bitcoin address for the testnet environment. | - |
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee for the testnet environment. | 50000 |
| `BAL_SERVER_BITCOIN_ADDRESS` | Bitcoin address for the mainnet environment. | - |
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee for the mainnet environment. | 50000 |
> **Note:** The container runs as a non-root `bal` user (uid 1000) with `tini` as PID 1.
> The `/var/bal` volume stores the database. Mount Bitcoin Core's cookie file as read-only.
> When using `--network host`, ensure only `127.0.0.1` is used for internal services.
## Configuration (bal-server)
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
`bal-pusher` is a tool that retrieves Bitcoin transactions from a database and pushes them to the Bitcoin network when their **locktime** exceeds the **median time past** (MTP). It listens for Bitcoin block updates via ZMQ. `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).
## Installation ## Prerequisites
To use `bal-pusher`, you need to compile and install Bitcoin with ZMQ (ZeroMQ) support enabled. Then, configure your Bitcoin node and `bal-pusher` to push the transactions.
### Prerequisites
1. **Bitcoin with ZMQ Support**:
Ensure that Bitcoin is compiled with ZMQ support. Add the following line to your `bitcoin.conf` file:
- **Bitcoin Core** with ZMQ support enabled. Add to `bitcoin.conf`:
``` ```
zmqpubhashblock=tcp://127.0.0.1:28332 zmqpubhashblock=tcp://127.0.0.1:28332
``` ```
- **Rust and Cargo**: [Rust Installation](https://www.rust-lang.org/tools/install)
- **Libraries**: `libssl-dev`, `libsodium-dev`, `libzmq5-dev`, `libsqlite3-dev`
2. **Install Rust and Cargo**: ## Running
If you haven't already installed Rust and Cargo, you can follow the official instructions to do so: [Rust Installation](https://www.rust-lang.org/tools/install).
## Configuration
`bal-pusher` can be configured using environment variables. If no configuration file is provided, a default configuration file will be created.
### Available Configuration Variables
| Variable | Description | Default |
|---------------------------------------|------------------------------------------|----------------------------------------------|
| `BAL_PUSHER_CONFIG_FILE` | Path to the configuration file. If the file does not exist, it will be created. | `$HOME/.config/bal-pusher/default-config.toml` |
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. If the file does not exist, it will be created. | `bal.db` |
| `BAL_PUSHER_ZMQ_LISTENER` | ZMQ listener for Bitcoin updates. | `tcp://127.0.0.1:28332` |
| `BAL_PUSHER_BITCOIN_HOST` | Bitcoin server host for RPC connections. | `http://127.0.0.1` |
| `BAL_PUSHER_BITCOIN_PORT` | Bitcoin RPC server port. | `8332` |
| `BAL_PUSHER_BITCOIN_COOKIE_FILE` | Path to Bitcoin RPC cookie file. | `$HOME/.bitcoin/.cookie` |
| `BAL_PUSHER_BITCOIN_RPC_USER` | Bitcoin RPC username. | - |
| `BAL_PUSHER_BITCOIN_RPC_PASSWORD` | Bitcoin RPC password. | - |
| `BAL_PUSHER_SEND_STATS` | Contact welist to provide times | false |
| `WELIST_SERVER_URL` | welist server url to provide times | https://welist.bitcoin-afer.life |
| `BAL_SERVER_URL` | WillExecutor server url | - |
| `SSL_KEY_PATH` | Ed25519 private key pem file | `private_key.pem` |
## Running `bal-pusher`
Once the application is installed and configured, you can start `bal-pusher` by running the following command:
```bash ```bash
$ bal-pusher bal-pusher [bitcoin|testnet|testnet4|signet|regtest]
``` ```
This will start the service, which will listen for Bitcoin blocks via ZMQ and push transactions from the database when their locktime exceeds the median time past. 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
View 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.

View File

@@ -1,16 +0,0 @@
RUST_LOG=info
BAL_PUSHER_DB_FILE=/home/bal/bal.db
BAL_PUSHER_BITCOIN_COOKIE_FILE=/home/bitcoin/.bitcoin/.cookie
BAL_PUSHER_REGTEST_COOKIE_FILE=/home/bitcoin/.bitcoin/regtest/.cookie
BAL_PUSHER_TESTNET_COOKIE_FILE=/home/bitcoin/.bitcoin/testnet3/.cookie
BAL_PUSHER_SIGNET_COOKIE_FILE=/home/bitcoin/.bitcoin/signet/.cookie
BAL_PUSHER_ZMQ_LISTENER=tcp://127.0.0.1:28332
BAL_PUSHER_SEND_STATS=true
WELIST_SERVER_URL=http://welist.bitcoin-after.life
SSL_KEY_PATH=/home/bal/privkey.pem
#your server domain. do not add https or final / only domain.
BAL_SERVER_URL="https://we.bitcoin-after.life"

View File

@@ -1,14 +1,15 @@
RUST_LOG=trace export RUST_LOG=trace
BAL_PUSHER_DB_FILE="$(pwd)/bal.db" export BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
#export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie #export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie
#export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie #export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie
#export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie #export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie
#export BAL_PUSHER_SIGNET_COOKIE_FILE=/~/.bitcoin/signet/.cookie #export BAL_PUSHER_SIGNET_COOKIE_FILE=/~/.bitcoin/signet/.cookie
BAL_PUSHER_ZMQ_LISTENER=tcp://127.0.0.1:28332 export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
export BAL_PUSHER_SEND_STATS=true export BAL_PUSHER_SEND_STATS=true
export WELIST_SERVER_URL=http://localhost:8085 export WELIST_SERVER_URL=http://localhost:8086
export WELIST_SKIP_URL_VALIDATION=true
export BAL_SERVER_URL="http://127.0.0.1:9133" export BAL_SERVER_URL="http://127.0.0.1:9133"
export SSL_KEY_PATH="$(pwd)/private_key.pem" export SSL_KEY_PATH="$(pwd)/private_key.pem"
cargo run --bin=bal-pusher regtest cargo run --bin=bal-pusher regtest --features=pusher

View File

@@ -1,6 +1,7 @@
RUST_LOG=info 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 or xpub to recive payments here" BAL_SERVER_BITCOIN_ADDRESS="your bitcoin or xpub to recive payments here"
@@ -13,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

View File

@@ -11,12 +11,13 @@ export BAL_SERVER_INFO="BAL devel willexecutor server"
export BAL_SERVER_BIND_ADDRESS="127.0.0.1" export BAL_SERVER_BIND_ADDRESS="127.0.0.1"
export BAL_SERVER_BIND_PORT=9133 export BAL_SERVER_BIND_PORT=9133
export BAL_SERVER_PUB_KEY_PATH="$WORKING_DIR/public_key.pem" 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_ADDRESS="your bitcoin address or xpub to recive payments here"
#export BAL_SERVER_BITCOIN_FIXED_FEE=50000 #export BAL_SERVER_BITCOIN_FIXED_FEE=50000
export BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4" export BAL_SERVER_REGTEST_ADDRESS="vpub5UhLrYG1qQjnJhvJgBdqgpznyH11mxW9hwBYxf3KhfdjiupCFPUVDvgwpeZ9Wj5YUJXjKjXjy7DSbJNBW1sXbKwARiaphm1UjHYy3mKvTG4"
export BAL_SERVER_REGTEST_FEE=5000 export BAL_SERVER_REGTEST_FIXED_FEE=1000
#export BAL_SERVER_TESTNET_ADDRESS= #export BAL_SERVER_TESTNET_ADDRESS=
#export BAL_SERVER_TESTNET_FEE=100000 #export BAL_SERVER_TESTNET_FEE=100000
#export BAL_SERVER_SIGNET_ADDRESS= #export BAL_SERVER_SIGNET_ADDRESS=

View 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"

View 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
View 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

View 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
View 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

View File

@@ -0,0 +1,34 @@
# 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. 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 three primary binaries and two shared libraries:
1. **`bal-server`**: Async HTTP server (hyper + tokio) that exposes the API for receiving transactions and serving statistics.
2. **`bal-pusher`**: Async daemon that listens for `hashblock` ZMQ messages and pushes pending transactions to a Bitcoin node via RPC.
4. **`lib.rs`**: Exports the shared modules `db` and `xpub`.
5. **`db.rs`**: All database operations and schema creation for SQLite `0.34.0`.
6. **`xpub.rs`**: Address derivation from xpub/zpub using BIP-84 and the `bitcoin` crate.
## Mapping to Existing Documentation
| Existing File | Subject | Covered in this KB |
|---------------|---------|-------------------|
| `README.md` | Installation, environment variables, ZMQ dependency | [`07_deployment_and_ops.md`](07_deployment_and_ops.md) |
| `RPC.md` | API endpoint specification | `05_api_reference.md` | [`05_api_reference.md`](05_api_reference.md) |
| `AGENTS.md` | Security guidelines for agents | `08_security_audit.md` | [`08_security_audit.md`](08_security_audit.md) |

View 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).

View File

@@ -0,0 +1,68 @@
# 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 | (hyper + tokio, async)
| (src/bin/bal-server.rs) |
+-----------------+
| SQLite insert (db.rs)
v
bal.db
| (transactions with status=0, waiting locktime)
|
| ZMQ (hashblock / rawblock)
v
+-----------------+
+-----------------+
| bal-pusher | (async, 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.
2. **Validation**: The `bal-server` parses each transaction using `bitcoin::Transaction`. It checks for the fee output, extracts inputs/outputs, and validates the locktime.
3. **Storage**: Valid transactions are stored in `tbl_tx` with `status = 0` (waiting). The inputs and outputs are stored in `tbl_inp` and `tbl_out`.
4. **Monitoring**: The `bal-pusher` listens to the ZMQ `hashblock` topic. When a new block is detected, it fetches the `mediantime` via `getblockchaininfo` (or via the block's median time in the enhanced version).
5. **Evaluation**: The pusher queries the database for transactions with `status=0` and compares their locktime to the current blockchain median time.
6. **Broadcast**: If the locktime is satisfied, the pusher sends the transaction via `sendrawtransaction` and updates the status to `1` (sent) or `2` (failed if the RPC returns an error).
7. **Statistics**: The pusher periodically sends statistics to a remote server (`welist`) using a signed POST request. The server also collects stats on its own.
## 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 Handling Strategy
The codebase is currently inconsistent with error handling. The `bal-server` uses `unwrap()` on many critical paths (e.g., `sqlite::open`, `Regex::new`, body parsing), which causes panics in the async runtime. The `bal-pusher` also panics on RPC connection failures (`panic!("impossible to get client {}", e)`) which crashes the entire ZMQ loop.
## 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.

129
docs/04_modules_detail.md Normal file
View File

@@ -0,0 +1,129 @@
# 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 simply exports two public modules:
- `pub mod db;` — the database interface
- `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
- `create_table`: Creates the full database schema if it does not exist. See `src/db.rs` for the `CREATE TABLE` statements.
- `execute_insert`: A batched, atomic SQL wrapper function that performs multiple insert operations inside a transaction.
- `insert_tx`: Inserts a transaction into `tbl_tx`.
- `insert_inp`: Inserts an input into `tbl_inp`.
- `insert_out`: Inserts an output into `tbl_out`.
- `insert_xpub`: Inserts an xpub into `tbl_xpub`.
- `insert_address`: Inserts a new derived address into `tbl_address`.
- `get_pending_txs`: Queries `tbl_tx` for transactions with `status=0` and valid locktime conditions.
- `update_tx_status`: Updates `status` to `1` (sent) or `2` (failed) after a broadcast attempt.
- `get_stats`: Aggregates statistics for the `tbl_stats` table.
- `get_address_by_ip`: A query that joins `tbl_address` with `tbl_xpub` to find addresses by IP for rate limiting or reuse logic.
### Design Notes
SQL queries are built using `format!` in many places. The `execute_insert` function attempts to batch inserts to reduce transaction overhead, but this is dependent on the SQLite version.
---
## `xpub.rs` (Extended Public Key Utilities)
**Location:** `src/xpub.rs`
This module handles the derivation of Bitcoin addresses from extended public keys (xpub/zpub) and the creation of P2WPKH descriptors.
### Key Functions
- `parse_xpub`: Parses a Base58-encoded xpub/zpub string into a `bitcoin::bip32::Xpub`.
- `derive_address`: Derives a P2WPKH (Bech32) address at a given address index from the xpub. Uses the BIP-84 path (`m/84'/coin_type'/account'/0/index`). Uses `Secp256k1` from the `secp256k1` crate for elliptic curve math.
- `get_descriptor`: Generates a Bitcoin descriptor string for the xpub (e.g., `wpkh(.../0/*)`), which is useful for wallet integration.
- `checksum_verify`: Verifies the Base58 checksum of an xpub/zpub string to prevent data corruption during entry.
### Dependencies
- `bitcoin::bip32::Xpub`
- `secp256k1::Secp256k1`
- `bs58` for Base58 decoding
- `bitcoin::Address::p2wpkh` for address creation
---
## `bal-server.rs` (HTTP Server / API)
**Location:** `src/bin/bal-server.rs`
The main application binary that provides an async HTTP server.
### Architecture
- **Runtime:** `tokio::main` with `rt-multi-thread`.
- **HTTP Framework:** `hyper` (low-level) + `hyper-util` + `http-body-util`. Each connection is spawned as a new `tokio::task`.
- **Routing:** Routes are matched using path regex and a simple match on the HTTP method. The router is implemented manually in `main`.
### Key Routes (implemented in source code)
- `GET /`, `GET /version`: Returns static strings (name and version).
- `GET /.pub_key.pem`: Returns the Ed25519 public key PEM file for signature verification.
- `GET /:network/info`: Returns JSON with fee, address, and chain info. Networks: `bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`.
- `GET /:network/stats`: Returns per-chain statistics if `expose_stats` is enabled.
- `POST /:network/pushtxs`: Accepts one or more raw hex transactions. It validates them, checks the fee output to the `our_address` for that network, and stores the transaction in the database. See `src/bin/bal-server.rs` for the `pushtxs` request body parsing logic.
- `POST /searchtx`: Accepts a txid in the request body and returns the transaction details, status, and fee breakdown.
### Configuration
- The server reads environment variables and/or a config file (`confy`). Default config is hardcoded for `regtest` development.
- `db_file`: The path to the SQLite database (e.g., `bal.db`).
- `bind_address`: The address to listen on (e.g., `127.0.0.1:3031`).
- `expose_stats`: A boolean flag to enable/disable the stats endpoint.
### Error Handling
- **WARNING:** This binary uses `unwrap()` and `expect()` on many critical paths (e.g., `sqlite::open`, `Regex::new`, `req.collect()`). A malformed request could crash the async task or even the entire runtime. This is a known vulnerability.
### Static Public Key (`/.pub_key.pem`)
The server serves a static `public_key.pem` file. The corresponding private key (`privkey.pem`) is used by the pusher to sign statistics before sending them to the `welist` server. This file is located in the project root directory.
---
## `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`.
- **ZMQ:** `zmq::Context` with a `SUB` socket that listens to `tcp://127.0.0.1:28332` (or similar per-network port). The topic is `hashblock` (32-byte block hash).
- **RPC:** It uses the `bitcoincore-rpc` client to call `getblockchaininfo` (to get the `mediantime`) and `sendrawtransaction` for each transaction.
- **HTTP Client:** `reqwest` with the `json` feature. It sends a signed JSON POST to the `welist` server.
### Key Logic
1. On every `hashblock` message, it calls `main_result()`.
2. `main_result` creates a `bitcoincore-rpc` client. If it fails, it **panics** (`panic!("impossible to get client {}", e)`), crashing the entire process.
3. It fetches `getblockchaininfo` to get the `mediantime`.
4. It queries the database for transactions with `status=0` and `locktime < mediantime`.
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.
### Configuration
- `zmq_endpoint`: The ZMQ endpoint (e.g., `tcp://127.0.0.1:28332`).
- `rpc_url`: The URL of the Bitcoin RPC (e.g., `http://127.0.0.1:18443`).
- `rpc_auth`: `user_pass` or `cookie_file`. The cookie path is constructed from the `HOME` environment variable (e.g., `~/.bitcoin/.cookie`).
- `send_stats`: A boolean that enables the remote server reporting.
- `welist_url`: The URL to POST to.
- `ssl_key_path`: The path to the Ed25519 private key (`privkey.pem`) for signing stats.
---

139
docs/05_api_reference.md Normal file
View File

@@ -0,0 +1,139 @@
# 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`)
### `GET /`
- **Description:** Returns a static identification string (e.g., "Will Executor Server").
- **Response:** Plain text `200 OK`.
### `GET /version`
- **Description:** Returns the Cargo package version (`bal_server` version).
- **Response:** `text/plain` (e.g., `0.2.3`).
### `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.
### `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
{
"network": "regtest",
"our_address": "bcrt...",
"fee": 1000,
"chain": "regtest",
"version": "0.2.3"
}
```
- **Error:** `404` if the network is not configured.
### `GET /:network/stats`
- **Description:** Returns statistics for the given network. This endpoint is guarded by the `expose_stats` configuration flag.
- **Response (200 OK):**
```json
{
"report_date": 1712345678,
"chain": "regtest",
"total": 42,
"waiting": 10,
"sent": 30,
"failed": 2,
"waiting_profit": 10000,
"sent_profit": 30000,
"missed_profit": 5000,
"unique_input": 15
}
```
- **Error:** `403` or `400` if stats are not enabled or the network is unknown.
### `POST /:network/pushtxs`
- **Description:** Accepts one or more raw hex Bitcoin transactions. The server deserializes the transaction, validates that the fee is paid to the correct `our_address` for that network, and stores the transaction in the database. It also stores all inputs and outputs.
- **Request Body:**
- `Content-Type: application/json` (or plain text, depending on the client).
- The payload format is typically an array of raw hex strings or a single hex string.
```json
[
"02000000000101...hex..."
]
```
- **Response (200 OK):** A JSON array with the result for each transaction.
```json
[
{
"txid": "abc123...",
"wtxid": "def456...",
"status": 0,
"locktime": 2100,
"our_fees": 1000,
"our_address": "bcrt1q..."
}
]
```
- **Response (400 Bad Request):** If the transaction is invalid, the fee is missing, or the locktime is not acceptable.
- **Response (500 Internal Server):** `Database error`, `Invalid hex`, `Invalid transaction` (may contain a panic trace if an internal `unwrap` is hit).
- **Security Note:** If a transaction is not valid or does not pay the required fees, it is not inserted into the database.
### `POST /searchtx`
- **Description:** Searches for a transaction by its `txid`. Returns the transaction details, status, raw hex, and fees.
- **Request Body:**
```json
{
"txid": "abc123..."
}
```
- **Response (200 OK):**
```json
{
"txid": "abc123...",
"status": 1,
"tx": "020000000...",
"our_address": "bcrt1q...",
"our_fees": 1000,
"locktime": 2100,
"timestamp": 1712345678
}
```
- **Response (404):** If the transaction is not found in the database.
- **Response (400):** If the request body is invalid.
---
## ZMQ Messages (consumed by `bal-pusher`)
### Topic: `hashblock` (Consumed by `bal-pusher`)
- **Format:** A multipart ZMQ message. The first frame is the topic name (`hashblock`), the second frame is the 32-byte block hash.
- **Trigger:** When a new Bitcoin block is found by the local node.
- **Action:** The pusher fetches `getblockchaininfo` from the RPC, gets the updated `mediantime`, then queries and pushes pending transactions.
- **Endpoint:** `tcp://127.0.0.1:28332` (or network-specific ports).
---
## Bitcoin Core RPC Usage (used by `bal-pusher`)
### `sendrawtransaction` (Both pushers)
- **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).
### `getblockchaininfo` (Only `bal-pusher`)
- **Method:** `getblockchaininfo` (RPC `1`)
- **Parameters:** None.
- **Description:** Returns the current blockchain state, including the `mediantime` (the median timestamp of the last 11 blocks). This is used to evaluate the `nLockTime` of pending transactions.
### `getblock` (Only used by `bal-pusher` for median time)
- **Method:** `getblock` (RPC `1`)
- **Parameters:** `blockhash`, `verbosity` (set to `1` for JSON with timestamp).
- **Description:** Fetches the details of a block. It is used as an alternative to `getblockchaininfo` to get the block's `time` if `getblockchaininfo` fails or is insufficient.
---

174
docs/06_database_schema.md Normal file
View File

@@ -0,0 +1,174 @@
# 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, configured in environment)
- **Connection Pooling:** The Rust `sqlite` crate handles connections but does not use a thread pool.
- **Transactions:** The `execute_insert` function attempts to use atomic transactions for batched inserts, but this is not guaranteed for all operations.
---
## Table Schema
### `tbl_tx` (Transactions)
```sql
CREATE TABLE tbl_tx (
txid PRIMARY KEY, -- TEXT: The unique transaction ID (hex string)
wtxid, -- TEXT: The witness transaction ID
ntxid, -- TEXT: The non-witness transaction ID
tx, -- TEXT: The full raw serialized transaction (hex)
locktime INTEGER, -- INTEGER: The locktime value (block height or timestamp)
network, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
network_fees, -- TEXT: The total fees paid by the user (satoshi)
reqid, -- TEXT: A request ID or client IP for the submitter
our_fees, -- TEXT: The fees paid to us (the operator) (satoshi)
our_address, -- TEXT: The address the operator fee is paid to
status INTEGER DEFAULT 0, -- INTEGER: 0 = waiting, 1 = sent, 2 = failed
push_err TEXT -- TEXT: The error message if the RPC broadcast failed
);
```
- **Indexes:** The `txid` is the primary key, so it is automatically indexed.
- **Notes:** `locktime` is stored as an integer. It is compared against the `mediantime` or `block_height` from the blockchain to evaluate when a transaction is ready to send. The `status` column is the core of the transaction lifecycle state machine. `our_fees` and `our_address` are used to validate the transaction and ensure the correct fee is included before accepting it.
### `tbl_inp` (Transaction Inputs)
```sql
CREATE TABLE tbl_inp (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
in_txid, -- TEXT: The previous transaction ID (output being spent)
in_vout -- INTEGER: The previous output index
);
CREATE UNIQUE INDEX ON tbl_inp(txid, in_txid, in_vout);
```
- **Purpose:** Tracks all inputs of the submitted transactions. This allows the database to identify double-spends and ensure the inputs are valid and available.
- **Constraints:** A unique index prevents duplicate entries for the same input in the same transaction.
- **Relationships:** `in_txid` and `in_vout` refer to outputs from previous transactions in the Bitcoin blockchain. The `txid` column refers to the transaction being submitted (the one in `tbl_tx`).
### `tbl_out` (Transaction Outputs)
```sql
CREATE TABLE tbl_out (
id, -- INTEGER: Auto-increment ID
txid, -- TEXT: The transaction ID of the transaction being submitted
script_pubkey, -- TEXT: The hex scriptPubKey of this output
amount, -- TEXT: The amount in this output (satoshi)
vout -- INTEGER: The output index (0-based) in this transaction
);
CREATE UNIQUE INDEX ON tbl_out(txid, script_pubkey, amount, vout);
```
- **Purpose:** Tracks all outputs of the submitted transactions. The server searches for the `script_pubkey` matching the `our_address` for the network to determine if the correct fee is included.
- **Constraints:** A unique index prevents duplicate entries for the same output in the same transaction.
- **Relationships:** `txid` refers to the transaction being submitted. The `script_pubkey` is matched against the known addresses for each network to verify the fee payment.
### `tbl_xpub` (Extended Public Keys)
```sql
CREATE TABLE tbl_xpub (
id INTEGER PRIMARY KEY, -- INTEGER: Auto-increment ID
network TEXT, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
xpub TEXT, -- TEXT: The extended public key (xpub or zpub)
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- TEXT: The date the xpub was added
path_idx INTEGER DEFAULT -1 -- INTEGER: The next address index to derive for this xpub
);
CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub);
```
- **Purpose:** Stores the master xpub/zpub keys for each network. When the server receives a transaction, it uses these to derive new receiving addresses (if applicable) or to verify the `our_address`.
- **Relationships:** `tbl_xpub` is linked to `tbl_address` via `xpub` (the ID). The `path_idx` tracks which child index is the next unused one for the wallet's derivation path.
### `tbl_address` (Derived Addresses)
```sql
CREATE TABLE tbl_address (
address TEXT PRIMARY KEY, -- TEXT: The Bech32 P2WPKH address (e.g., 'bcrt1q...')
path TEXT NOT NULL, -- TEXT: The derivation path used to create this address (e.g., 'm/84'/1'/0'/0/0')
date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- TEXT: The date the address was generated
xpub INTEGER, -- INTEGER: The ID of the xpub in `tbl_xpub` that owns this address
remote_address TEXT -- TEXT: IP or client identifier that requested this address (if applicable)
);
```
- **Purpose:** Stores all generated addresses. The `our_address` for each network is derived from a specific xpub path. The server can also generate new addresses on demand for clients.
- **Relationships:** `xpub` (FK) links to `tbl_xpub.id`. The `address` is the primary key because it is unique by design. `remote_address` is used for rate-limiting or identifying address ownership in logs.
### `tbl_stats` (Per-Network Statistics)
```sql
CREATE TABLE tbl_stats (
report_date INTEGER, -- INTEGER: The Unix timestamp of the report
chain TEXT PRIMARY KEY, -- TEXT: The network name (e.g., 'regtest', 'bitcoin')
totals INTEGER, -- INTEGER: Total number of transactions submitted
waiting INTEGER, -- INTEGER: Transactions currently waiting (status=0)
sent INTEGER, -- INTEGER: Transactions successfully sent (status=1)
failed INTEGER, -- INTEGER: Transactions that failed to broadcast (status=2)
waiting_profit INTEGER, -- INTEGER: Total fees for waiting transactions (satoshi)
sent_profit INTEGER, -- INTEGER: Total fees for sent transactions (satoshi)
missed_profit INTEGER, -- INTEGER: Total fees for transactions that expired or failed (satoshi)
unique_inputs INTEGER -- INTEGER: The number of unique inputs (for deduplication analysis)
);
```
- **Purpose:** Stores aggregate statistics for each network. The pusher sends this data to a remote `welist` server. The server also reads from it for the `stats` endpoint if `expose_stats` is enabled.
- **Relationships:** `chain` is the primary key. The data is updated by the `bal-pusher` binary.
---
## Data Query Strategy
### Key Queries (from `db.rs` and `bal-pusher.rs`)
- **Get Pending Transactions (by status and locktime):**
```sql
SELECT
txid, tx, wtxid, ntxid, locktime, status,
our_address, our_fees, network_fees
FROM
tbl_tx
WHERE
network = ?
AND status = 0
AND locktime < ?;
```
Used by the `bal-pusher` daemon to find transactions that are ready to broadcast. The `?` placeholders are bound at runtime. `locktime` is compared with the `mediantime` or block height from the ZMQ `new block` event.
- **Insert Transaction:**
```sql
INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, network_fees, reqid, our_fees, our_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
```
Used by the `bal-server` when accepting a new valid transaction.
- **Update Status:**
```sql
UPDATE tbl_tx SET status = ? WHERE txid = ?;
```
Used by the pusher after a successful or failed broadcast attempt. The status is set to `1` (sent) and `2` (failed).
**WARNING:** The `bal-pusher` also uses a raw `WHERE txid IN ('...')` format for batch updates. These string formats have **SQL injection risk** because the `txid` strings are concatenated into the raw SQL string without proper parameterization.
- **Search Transaction:**
```sql
SELECT * FROM tbl_tx WHERE txid = ?;
```
Used by the `searchtx` endpoint.
- **Get Address for Rate Limiting:**
```sql
SELECT a.address, x.xpub
FROM tbl_address a
JOIN tbl_xpub x ON a.xpub = x.id
WHERE a.remote_address = ?;
```
Used to check if an IP or client address already has a generated address. This is part of the address reuse logic to prevent users from requesting too many addresses or using the same IP to bypass fees.
---
## Data Lifecycle
- **Creation:** Transactions are created when a user submits a raw hex tx via the `POST /pushtxs` endpoint. The address is inserted when an xpub is configured.
- **Waiting:** Transactions are in `status=0` and are queried by the pusher every new block.
- **Broadcast:** Transactions are pushed to the network via `sendrawtransaction`. If successful, the status becomes `1`. If the RPC returns an error (e.g., `-25`), the status becomes `2` and the error string is stored in `push_err`.
- **Retention:** There is no explicit cleanup mechanism for old records. The `valid_txs` and `invalid_txs` files contain logs of past transaction pushes, but the database itself may grow indefinitely. For a production system, a periodic vacuum or purge of old `status=1` transactions might be required.
- **Backup:** The database is a single SQLite file (`bal.db`). It can be copied directly using `cp` or `rsync` (see `scripts/download_bal_db.sh`). There is no WAL mode or online backup mechanism implemented.

View File

@@ -0,0 +1,240 @@
# Deployment and Operations
## Quick Reference
- **What this file contains:** environment variables, systemd service files, deployment scripts, nginx/Tor configuration, and installation procedures.
- **See also:** [01_project_overview.md](01_project_overview.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [05_api_reference.md](05_api_reference.md), [08_security_audit.md](08_security_audit.md)
---
## Environment Variables
### `bal-server` (`bal-server.env`)
The `bal-server.env` file is a production environment file that sets the configuration for the `bal-server` binary. The `bal-server.sh` script sources it before executing `cargo run --bin=bal-server`.
```env
RUST_LOG=info
BAL_DB_FILE=/var/bal/bal.db
BAL_BIND_ADDRESS=0.0.0.0:3031
BAL_EXPOSE_STATS=true
BAL_REGTEST_XPUB=tpub... (example for regtest testing)
BAL_PUB_KEY_PATH=public_key.pem
```
- `RUST_LOG`: Log level (e.g., `info`, `debug`, `error`). The `env_logger` crate uses this.
- `BAL_DB_FILE`: Path to the `sqlite` database file. If not specified, it defaults to `bal.db` in the working directory.
- `BAL_BIND_ADDRESS`: The TCP address and port to listen on. For example, `0.0.0.0:3031` means it will listen on any interface, port `3031`. For local development, you may want `127.0.0.1:3031`.
- `BAL_EXPOSE_STATS`: Boolean flag (`true` or `false`) to enable the `GET /:network/stats` endpoint. Set to `false` if you do not want to expose statistics to the public internet.
- `BAL_NETWORK_XPUB`: The `XPUB` or `ZPUB` for each network. For example, `BAL_REGTEST_XPUB`, `BAL_BITCOIN_XPUB`, etc. These are used to derive the receiving and fee collection addresses.
- `BAL_PUB_KEY_PATH`: The file path to the `public_key.pem` file that is served via the `GET /.pub_key.pem` endpoint. This is used for signature verification by the `welist` server or other clients.
### `bal-pusher` (`bal-pusher.env`)
The `bal-pusher.env` file is used for the `bal-pusher` binary. It contains sensitive information and is sourced by the `bal-pusher.sh` script.
```env
ZMQ_ENDPOINT=tcp://127.0.0.1:21332
BAL_SERVER_URL=http://127.0.0.1:3031
BAL_PUSHER_RPC_URL=http://127.0.0.1:18443
BAL_PUSHER_RPC_COOKIE_PATH=/home/bal/.bitcoin/.cookie
BAL_SSL_KEY_PATH=private_key.pem
SEND_STATS=true
WELIST_URL=https://welist.example.com/api/stats
```
- `ZMQ_ENDPOINT`: The ZMQ endpoint for the `hashblock` or `rawblock` topic. For `regtest`, use `tcp://127.0.0.1:21332`. For mainnet, use `tcp://127.0.0.1:28332`.
- `BAL_SERVER_URL`: The URL of the `bal-server` that the pusher can use to query statistics or for other internal communication.
- `BAL_PUSHER_RPC_URL`: The URL for the Bitcoin Core JSON-RPC endpoint. For `regtest`, the default is `http://127.0.0.1:18443`.
- `BAL_PUSHER_RPC_COOKIE_PATH`: The path to the `.cookie` file for RPC authentication. If not set, the pusher must use `user_pass` authentication. The cookie file is created by `bitcoind` when it starts with `rpccookieauth`.
- `BAL_SSL_KEY_PATH`: The path to the Ed25519 private key (`private_key.pem`) used to sign the statistics payload before sending it to the `welist` server. This is a critical secret.
- `SEND_STATS`: A boolean flag to enable the reporting of statistics to the remote `welist` server.
- `WELIST_URL`: The URL to which the statistics are sent. If `SEND_STATS` is `true`, this URL must be reachable. If the server is unreachable, the pusher will log an error but might not crash (see `08_security_audit.md` for DoS analysis).
- `BAL_PUSHER_PREFER_IPV6`: Optional boolean flag (default `false`). When set to `true`, the pusher resolves the `welist` host itself and pins the HTTP connection to its first IPv6 (AAAA) address, still using the hostname for the `Host` header and TLS SNI. This works around networks where the IPv4 route to the `welist` host is broken while IPv6 works — the default connector may otherwise pick the unreachable family and the request would stall. Leave unset unless you hit this specific connectivity problem.
---
## System Services
### `bal-server.service` (Systemd Unit)
This file is the systemd unit for the `bal-server` binary. It runs the server as a dedicated `bal` user with hardening options.
```ini
[Unit]
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
```
- **User:** The service runs as a dedicated, non-privileged user (`bal` user) to ensure the server doesn't run as root.
- **Hardening:** `ProtectSystem=full` prevents writing to most of the filesystem. `NoNewPrivileges=true` prevents privilege escalation. `MemoryDenyWriteExecute=true` prevents executable memory allocations (W^X). `PrivateDevices=true` limits the exposure to the physical hardware.
- **Security:** The `bal-server` does not need root access, and the database should be in a directory owned by the `bal` user.
### `bitcoind.service` (Systemd Unit for Mainnet)
The `bitcoind.service` file is the systemd unit to run the Bitcoin Core daemon. It must be configured with the appropriate ZMQ and RPC flags. For example, `bitcoind` must be started with `zmqpubhashblock=tcp://127.0.0.1:28332` to send `new block` notifications to the pusher.
```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
```
- **Note:** The full `bitcoind` configuration is in `bitcoin.conf` (or the `contrib/download_and_install_bitcoincore.sh` script). The script sets `zmqpubhashblock` (not `zmqpubrawblock`) for the pusher's new-block notifications. The `zmqpubhashblock` and `zmqpubrawtx` ports must be bound to `127.0.0.1` (never `0.0.0.0`) and match the pusher's `ZMQ_ENDPOINT`.
### `tbitcoind.service` (Systemd Unit for Testnet)
This is the same as `bitcoind.service` but for the `testnet` network. It uses a different data directory (`~/.bitcoin/testnet/` by default) and a different ZMQ port (e.g., `tcp://127.0.0.1:23332`).
---
## Bash Scripts
### `bal-server.sh` (Development Server Startup)
This script sources the `bal-server.env` file and then runs the development server with Cargo for easy development and reloading.
```bash
export $(grep -v '^#' bal-server.env | xargs)
RUST_LOG=info cargo run --bin=bal-server 2>&1
```
- It is intended for development use only. It is not suitable for production because it compiles and runs in a single step, which is slow and insecure.
### `bal-pusher.sh` (Development Pusher Startup)
This script sources the `bal-pusher.env` and runs the pusher in development mode. It also accepts the `network` name as an argument (e.g., `sh bal-pusher.sh regtest`).
```bash
export $(grep -v '^#' bal-pusher.env | xargs)
RUST_LOG=info cargo run --bin=bal-pusher $1
```
### `sendtx.sh` (One-liner Transaction Sender)
This script is a one-liner helper that sends a raw transaction to a local node using a sequence of `bitcoin-cli` calls. It is not part of the main system but is used for testing purposes.
```bash
bitcoin-cli -regtest gettransaction ... | bitcoin-cli -regtest sendrawtransaction ... | bitcoin-cli -regtest sendtoaddress ...
```
- It is a helper script that wraps `bitcoin-cli` to send a pre-created transaction, get the raw bytes, and send them to a new address. It is only useful for manual testing and integration checks.
### `make_release.sh` (Release Builder)
This script builds a release binary, creates a Git tag, and uploads the release to a Git server (Gitea). It also hardcodes a Gitea API token (`TOKEN="5cfa8c33e337ebaadb355c0ffa2d053d521ee43b"`), which is a major security risk.
```bash
# WARNING: This script contains a hardcoded secret token. Do not use it as-is for production.
```
- **Release Assets:** It generates a `.tar.gz` archive with the binaries, a `.sha256` checksum file, and both a `.sig` GPG detached binary signature and a `.asc` ASCII-armored version.
- **Signature:** The release tarball is signed with the GPG key `Svātantrya <svatantrya@bitcoin-after.life>`. The script verifies that `gpg`, `sha256sum`, and `jq` are installed before proceeding.
- **Verification:** The release body includes instructions for verifying the checksum and signature (binary or ASCII-armored):
```bash
sha256sum -c <release>.tar.gz.sha256
gpg --verify <release>.tar.gz.sig <release>.tar.gz
gpg --verify <release>.tar.gz.asc <release>.tar.gz
```
- **Security:** It also builds and uploads the binaries. The binaries should be built and signed on a separate, clean build machine, not on the production server.
### `download_bal_db.sh` (Database Pull Script)
This script uses `scp` to pull the production `bal.db` from a remote server (`debian@bitcoin-after.life`). It requires passwordless or key-based SSH access to the remote server.
```bash
scp debian@bitcoin-after.life:/var/bal/bal.db ./bal.db
```
- **Security:** It requires the remote server to be accessible. The remote server's IP address is hardcoded. This is a maintenance script, not part of the core system.
---
## Nginx and SSL Configuration
The `bal-server` is a plain HTTP server. To expose it to the internet, a production environment should put a reverse proxy like `Nginx` in front of it. The `nginx` configuration (from `contrib/download_and_install_bal.sh`) is used to terminate TLS and provide SSL certificates. Nginx also handles rate limiting, request filtering, and static file serving for `public_key.pem`.
### Example Nginx Configuration (from `contrib`)
```nginx
server {
listen 80;
server_name bal.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name bal.example.com;
ssl_certificate /etc/letsencrypt/live/bal.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bal.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3031;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Rate limiting can be added here
}
```
- **Certbot:** The `contrib` script installs `certbot` and automatically generates the certificate. This configuration is used to ensure the `bal-server` is served over HTTPS with valid TLS.
- **Rate limiting:** It is recommended to add `limit_req` or `limit_conn` to the Nginx configuration to prevent the server from being overwhelmed by too many concurrent requests (e.g., `pushtxs` spam, or DoS attacks). The `bal-server` has no built-in rate limiting on the HTTP level.
---
## Production Deployment Checklist
Before exposing `bal` to the internet, verify the following steps. The `bal-server` is a plain HTTP application and must **never** be bound directly to a public IP or `0.0.0.0`.
### 1. `bal-server` Bind Address
- [ ] `bal-server.env` (or `.env`) sets `BAL_SERVER_BIND_ADDRESS=127.0.0.1` (not `0.0.0.0`).
- [ ] `BAL_SERVER_BIND_PORT` is the port used by Nginx `proxy_pass` (default `9137`).
- [ ] Firewall blocks inbound connections to `BAL_SERVER_BIND_PORT` from external interfaces (e.g., `iptables -A INPUT -p tcp --dport 9137 -s 127.0.0.1 -j ACCEPT` and `DROP` for others).
### 2. Reverse Proxy (Nginx + TLS)
- [ ] Nginx is installed (`contrib/download_and_install_bal.sh` handles this).
- [ ] The template `contrib/nginx/bal-server.conf` is copied to `/etc/nginx/sites-available/` and symlinked to `sites-enabled` (the `contrib/download_and_install_bal.sh` script does this automatically).
- [ ] The file has a real domain name replacing `BAL_DOMAIN`.
- [ ] `listen 443 ssl http2;` is active.
- [ ] `certbot --nginx` has obtained a valid certificate (the script runs `certbot --nginx` which avoids the port 80 conflict of `--standalone`). For manual installs, use `sudo certbot --nginx -d $domain`.
- [ ] `proxy_pass` points to `http://127.0.0.1:9137` (or whatever `BAL_SERVER_BIND_PORT` is).
- [ ] `client_max_body_size` in Nginx matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default `1m`).
- [ ] HTTP port 80 redirects to HTTPS (`return 301 https://...`).
- [ ] Nginx `limit_req` zone is configured if desired (backup to `actix-governor`).
### 3. Database and Secrets
- [ ] Database file is owned by the `bal` user (`chown bal:bal /var/bal/bal.db`).
- [ ] Database file permissions are `600` (`chmod 600 /var/bal/bal.db`).
- [ ] `.env` file is in `.gitignore` and not committed.
- [ ] `private_key.pem` and `privkey.pem` are not in the repository (use `git ls-files` to verify).
- [ ] `public_key.pem` is readable by Nginx if served directly (otherwise let the actix endpoint handle it).
### 4. Pusher and ZMQ
- [ ] ZMQ endpoints are configured for `127.0.0.1` only (e.g., `tcp://127.0.0.1:28332`).
- [ ] `BAL_PUSHER_SEND_STATS` is set to `false` unless the `welist` endpoint is actually needed.
- [ ] If stats are enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain (not IP, not local).
- [ ] Firewall blocks inbound TCP port `28332` (or your custom `bitcoin`, `regtest`, etc. ZMQ ports) from external interfaces.
### 5. Logging and Monitoring
- [ ] `RUST_LOG` is set to `info` or `warn` in production (not `debug` or `trace`).
- [ ] Log files are rotated (e.g., via `logrotate`) and stored only under `/var/log/bal/` or systemd journal.
- [ ] Log files are not in the same directory as the database or the private key.
---
## Tor and Privacy
The `contrib/install_tor.sh` script installs Tor for use as an onion-routed proxy. It can be used to:
1. Allow the `bal-server` to be reachable via a `.onion` address for privacy and censorship resistance.
2. Allow the `bal-pusher` to connect to the Bitcoin RPC or the `welist` server through Tor to hide its origin IP.
3. Allow the server to run behind NAT without exposing the real IP to the public internet.
The script uses `ControlPort 9051` and enables `CookieAuthentication`. If `SEND_STATS` is true, the `welist` URL can be configured to be a `.onion` address to hide the origin. For example, the `bal-pusher` could use `reqwest` with SOCKS5 proxy settings to connect to the `welist` server via Tor.
- `reqwest` feature `socks` (enabled in `Cargo.toml`) supports proxy settings.
- For a production privacy setup, it is recommended to run the server and the pusher behind a Tor or VPN proxy.
- **Security:** The Tor service itself (`tor.service`) should be hardened and run as a separate user. The `ControlPort` `9051` should be bound to `127.0.0.1` and should not be exposed to the public without authentication.
---

229
docs/08_security_audit.md Normal file
View File

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

View File

@@ -0,0 +1,94 @@
# 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 Lines/Details |
|---|---|---|
| Library | `src/lib.rs` | Exports `db` and `xpub` modules |
| Database | `src/db.rs` | SQL schema, `execute_insert`, batched inserts |
| XPub/Address Derivation | `src/xpub.rs` | `parse_xpub`, `derive_address`, `get_descriptor`, BIP-84 |
| HTTP Server | `src/bin/bal-server.rs` | Hyper + Tokio, routes, handlers, `pushtxs` logic |
| Async Pusher | `src/bin/bal-pusher.rs` | ZMQ `hashblock`, RPC + Reqwest, `send_stats` |
| Stats (broken) | `src/bin/bal-stats.rs.dontcompile` | Not compiled, incomplete HTML report generator |
| Release script | `make_release.sh` | Hardcoded token, `cargo install` |
| 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 |
| Utility scripts | `lib.sh` | Colored echo functions |
| Contrib (install) | `contrib/download_and_install_bal.sh` | Nginx, Certbot, systemd setup, xpub via argument |
| Contrib (install bitcoind) | `contrib/download_and_install_bitcoincore.sh` | Bitcoind download, GPG verify, systemd, config |
| Contrib (install Tor) | `contrib/install_tor.sh` | Tor repository, `ControlPort 9051` || Systemd service | `bal-server.service` | Runs as `bal` user, `ProtectSystem`, `MemoryDenyWriteExecute` |
| Systemd service | `bitcoind.service` | `zmqpubhashblock` setup |
| Systemd service | `tbitcoind.service` | Testnet `bitcoind` |
---
## Dependency Map (from `Cargo.toml`)
| Dependency | Version | Purpose |
|---|---|---|
| `base64` | `0.22.1` | Encoding/decoding in `pushtxs` / xpub |
| `bs58` | `0.4.0` | Base58 encoding for Bitcoin addresses / xpubs |
| `bytes` | `1.2` | Byte handling for `hyper`/`reqwest` |
| `bitcoin` | `0.32.5` | Transaction parsing, `ScriptBuf`, `Address`, `Xpub`, `Transaction` |
| `bitcoincore-rpc` | `0.19.0` | RPC client for `bitcoin-cli` methods (`sendrawtransaction`, `getblockchaininfo`) |
| `bitcoincore-rpc-json` | `0.19.0` | JSON types for Bitcoin RPC responses |
| `byteorder` | `1.5.0` | Reading block timestamp from raw block header (big-endian) `u32` |
| `confy` | `0.6.1` | Loading `.toml` configuration files (default config) |
| `chrono` | `0.4.40` | `Date` and `DateTime` handling for timestamps and `report` |
| `env_logger` | `0.11.5` | Log level configuration via `RUST_LOG` environment variable |
| `hex` | `0.4.3` | Hex encoding for transaction serialization and raw bytes |
| `hex-conservative` | `0.1.1` | Hex parsing (used for Bitcoin hex strings) |
| `hyper` | `1.3.1` | Async HTTP server (features: `http1`, `server`) |
| `hyper-util` | `0.1.3` | Hyper utilities, `TokioIo` |
| `http-body-util` | `0.1` | HTTP body collection and streaming utilities |
| `log` | `0.4.21` | Logging facade (used by `env_logger`) |
| `openssl` | `0.10.74` | TLS/SSL, `vendored` feature to avoid system dependency |
| `sha2` | `0.10.8` | SHA-256 hashing (used in transaction validation or address generation) |
| `serde` | `1.0.152` | Serialization of config objects and JSON responses (`derive` feature) |
| `serde_json` | `1.0.116` | JSON parsing for HTTP request bodies and API responses |
| `sqlite` | `0.34.0` | Direct SQLite C bindings, raw SQL queries, no ORM |
| `regex` | `1.10.4` | `RegExp` parsing for URL matching (e.g., `network` regex) in `bal-server` |
| `reqwest` | `0.12.24` | HTTP client (`json` + `socks` features) for `welist` stats POST |
| `tokio` | `1` | Async runtime (`rt`, `net`, `macros`, `rt-multi-thread`) |
| `zmq` | `0.10.0` | ZeroMQ for `hashblock`/`rawblock` notifications |
---
## Mapping to Existing Documentation
| Existing File | Description | Replaced/Managed By KB |
|---|---|---|
| `README.md` | Installation, environment variables, ZMQ dependency | `07_deployment_and_ops.md` |
| `RPC.md` | API endpoint specification (HTTP methods, paths) | `05_api_reference.md` |
| `AGENTS.md` | Security guidelines, audit rules, baseline commands | `08_security_audit.md` |
| `update` | Irrelevant saved conversation (Diesel/Axum) | Ignored, not mapped |
| `valid_txs` / `invalid_txs` | Logs of past transaction push results | `08_security_audit.md` (Information Leakage) |
| `Cargo.toml` | Dependency versions and features | `09_references_and_links.md` (Dependency Map) |
| `bal-server.service` | `systemd` unit file | `07_deployment_and_ops.md` (Systemd) |
| `bitcoind.service` | `systemd` unit for `bitcoin` node | `07_deployment_and_ops.md` (Systemd) |
| `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 with hardcoded secret | `08_security_audit.md` (Secret Leakage) |
| `download_bal_db.sh` | `scp` from remote | `07_deployment_and_ops.md` (Bash Scripts) |
| `generate_keys.sh` | Generate public key from `private_key.pem` | `07_deployment_and_ops.md` (Bash Scripts) |
| `public_key.pem` | Ed25519 public key for stats verification | `05_api_reference.md` (GET `/.pub_key.pem` endpoint) |
| `private_key.pem` / `privkey.pem` / `ec.key` / `chiave_privata.key` | Private keys for stats signing | `08_security_audit.md` (Secret Leakage) |
| `contrib/download_and_install_bal.sh` | Full deployment setup | `07_deployment_and_ops.md` (Nginx, SSL) and `08_security_audit.md` (Hardcoded Secret) |
| `contrib/download_and_install_bitcoincore.sh` | Bitcoin Core install/verify | `07_deployment_and_ops.md` (Systemd) |
| `contrib/install_tor.sh` | Tor installation script | `07_deployment_and_ops.md` (Tor) |
| `contrib` | Various helper scripts | `07_deployment_and_ops.md` |
---

31
docs/INDEX.md Normal file
View 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
View 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

419
src/db.rs
View File

@@ -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,17 +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("UPDATE tbl_tx set network='bitcoin' where network='mainnet');"); 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>{
@@ -33,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;
pub fn get_last_used_address_by_ip(db: &Connection, network: &String, xpub: &String, address: &String) -> Option<String>{
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();
let _ = stmt.bind((1,Value::String(network.to_string())));
let _ = stmt.bind((2,Value::String(address.to_string())));
let _ = stmt.bind((3,Value::String(xpub.to_string())));
if let Ok(State::Row) = stmt.next(){
let address = stmt.read::<String,_>("address").unwrap();
return Some(address);
}else{
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 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 save_new_address(db: &Connection,xpub: i64,address: &String, path: &String,remote_addr: &String){
let mut stmt = db.prepare("INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);").unwrap();
stmt.bind((1,Value::String(address.to_string()))).unwrap(); pub fn get_last_used_address_by_ip(
stmt.bind((2,Value::String(path.to_string()))).unwrap(); db: &Connection,
stmt.bind((3,Value::Integer(xpub))).unwrap(); network: &String,
stmt.bind((4,Value::String(remote_addr.to_string()))).unwrap(); xpub: &String,
address: &String,
let _ = stmt.next(); ) -> Option<String> {
let mut stmt = match db.prepare("SELECT tbl_address.address FROM tbl_xpub join tbl_address on(tbl_xpub.id = tbl_address.xpub) where tbl_xpub.network = ? and tbl_address.remote_address = ? and tbl_xpub.xpub = ? ORDER BY tbl_address.date_create DESC LIMIT 1;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare address query: {}", e);
return None;
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return None;
}
if let Err(e) = stmt.bind((2, Value::String(address.to_string()))) {
error!("Failed to bind address parameter: {}", e);
return None;
}
if let Err(e) = stmt.bind((3, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return None;
}
if let Ok(State::Row) = stmt.next() {
match stmt.read::<String, _>("address") {
Ok(addr) => Some(addr),
Err(e) => {
error!("Failed to read address column: {}", e);
None
}
}
} else {
None
}
} }
pub fn execute_insert(db: &Connection, pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String) -> (i64, i64) {
let mut stmt = match db.prepare("UPDATE tbl_xpub SET path_idx = path_idx + 1 WHERE network = ? and xpub= ? RETURNING path_idx,id;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare xpub index update: {}", e);
return (0, 0);
}
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter: {}", e);
return (0, 0);
}
if let Err(e) = stmt.bind((2, Value::String(xpub.to_string()))) {
error!("Failed to bind xpub parameter: {}", e);
return (0, 0);
}
match stmt.next() {
Ok(State::Row) => match stmt.read::<i64, _>("path_idx") {
Ok(next) => match stmt.read::<i64, _>("id") {
Ok(id) => (id, next),
Err(e) => {
error!("Failed to read id column: {}", e);
(0, 0)
}
},
Err(e) => {
error!("Failed to read path_idx column: {}", e);
(0, 0)
}
},
Err(e) => {
error!("Failed to execute xpub index update: {}", e);
(0, 0)
}
Ok(State::Done) => (0, 0),
}
}
pub fn save_new_address(
db: &Connection,
xpub: i64,
address: &String,
path: &String,
remote_addr: &String,
) {
let mut stmt = match db.prepare(
"INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
",
) {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare address insert statement: {}", e);
return;
}
};
if let Err(e) = stmt.bind((1, Value::String(address.to_string()))) {
error!("Failed to bind address parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((2, Value::String(path.to_string()))) {
error!("Failed to bind path parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((3, Value::Integer(xpub))) {
error!("Failed to bind xpub parameter: {}", e);
return;
}
if let Err(e) = stmt.bind((4, Value::String(remote_addr.to_string()))) {
error!("Failed to bind remote_addr parameter: {}", e);
return;
}
if let Err(e) = stmt.next() {
error!("Failed to insert address: {}", e);
}
}
pub fn execute_insert(
db: &Connection,
sqltxs: String, sqltxs: String,
ptx: Vec<(usize, Value)>, ptx: Vec<(usize, Value)>,
sqlinp: String, sqlinp: String,
pinp: Vec<(usize, Value)>, pinp: Vec<(usize, Value)>,
sqlout: String, sqlout: String,
pout: Vec<(usize, Value)>) -> Result<(),Error>{ 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);
@@ -111,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);
@@ -123,25 +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> { 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 = ?;").unwrap(); let mut stmt = db
stmt.bind((1,Value::String(network.to_string()))).unwrap(); .prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;")
match stmt.next(){ .map_err(|e| {
Ok(State::Row)=>{ error!("Failed to prepare statement: {}", e);
Ok(stmt.read::<i64,_>("total_number").unwrap()) e
}, })?;
Ok(sqlite::State::Done) => todo!(), if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
Err(err)=>Err(err) 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
View File

@@ -0,0 +1,3 @@
pub mod db;
pub mod validation;
pub mod xpub;

168
src/validation.rs Normal file
View 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"));
}
}

View File

@@ -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(
0..4,
match prefix {
BS58Prefix::Xpub => XPUB_PREFIX, BS58Prefix::Xpub => XPUB_PREFIX,
//BS58Prefix::Ypub => YPUB_PREFIX, BS58Prefix::Ypub => YPUB_PREFIX,
//BS58Prefix::Zpub => ZPUB_PREFIX, BS58Prefix::Zpub => ZPUB_PREFIX,
//BS58Prefix::Vpub => VPUB_PREFIX, BS58Prefix::Vpub => VPUB_PREFIX,
//BS58Prefix::Tpub => TPUB_PREFIX, BS58Prefix::Tpub => TPUB_PREFIX,
//BS58Prefix::Upub => UPUB_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(())
} }*/
*/

View 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);
}

View 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()));
}

View 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");
}

View 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");
}

View 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
View 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"
);
}