18 Commits

Author SHA1 Message Date
8dc344cbd1 docker: add release Dockerfile, fix build deps and feature-split builds
- Add Dockerfile.release: downloads latest release from Gitea, verifies SHA-256
- Fix Dockerfile: remove unused libsodium/cmake, add libsqlite3-dev
- Split build per binary with --no-default-features --features server/pusher
- Remove Cargo.lock from .dockerignore for reproducible builds
- Update README and knowledge base with Dockerfile.release docs
2026-07-20 17:36:58 -04:00
eacb2e1450 docs: update knowledge base to match current codebase
- Fix framework references (actix-web, not hyper)
- Update all env var names (BAL_SERVER_*/BAL_PUSHER_* prefix)
- Add validation.rs module documentation
- Fix function signatures in xpub.rs and db.rs
- Update API response formats (InfoResponse, StatsResponse)
- Fix database schema (date_creation/date_update, push_err, tbl_stats)
- Mark fixed vulnerabilities with current status
- Add Docker support and actix tuning documentation
- Remove outdated references (confy, bal-stats.rs.dontcompile)
- Add regression test summary table
2026-07-20 15:43:20 -04:00
734b2ee71d release: bal-server-0.3.2 2026-07-20 13:37:19 -04:00
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
23 changed files with 1467 additions and 1213 deletions

View File

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

4
.gitignore vendored
View File

@@ -8,9 +8,6 @@
.env.production .env.production
.env.secret .env.secret
# Shell scripts that load env vars (contain secrets, local only)
bal-pusher.sh
bal-server.sh
# Private keys - NEVER commit to git # Private keys - NEVER commit to git
# Only public_key.pem should be tracked (if needed) # Only public_key.pem should be tracked (if needed)
@@ -39,3 +36,4 @@ Cargo.lock
!lib/ !lib/
!contrib/ !contrib/
!src/ !src/
make_release.sh

275
Cargo.lock generated
View File

@@ -312,7 +312,7 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]] [[package]]
name = "bal_server" name = "bal_server"
version = "0.3.0" version = "0.3.2"
dependencies = [ dependencies = [
"actix-governor", "actix-governor",
"actix-rt", "actix-rt",
@@ -325,12 +325,11 @@ dependencies = [
"byteorder", "byteorder",
"bytes", "bytes",
"chrono", "chrono",
"confy", "ed25519-dalek",
"env_logger", "env_logger",
"hex", "hex",
"hex-conservative 0.1.1", "hex-conservative 0.1.1",
"log", "log",
"openssl",
"regex", "regex",
"reqwest", "reqwest",
"serde", "serde",
@@ -364,6 +363,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bech32" name = "bech32"
version = "0.11.0" version = "0.11.0"
@@ -592,16 +597,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]] [[package]]
name = "confy" name = "const-oid"
version = "0.6.1" version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45b1f4c00870f07dc34adcac82bb6a72cc5aabca8536ba1797e01df51d2ce9a0" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
dependencies = [
"directories",
"serde",
"thiserror",
"toml",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
@@ -747,6 +746,33 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "5.5.3" version = "5.5.3"
@@ -760,6 +786,17 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -809,7 +846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer 0.12.1", "block-buffer 0.12.1",
"const-oid", "const-oid 0.10.2",
"crypto-common 0.2.2", "crypto-common 0.2.2",
] ]
@@ -824,27 +861,6 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -856,6 +872,30 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -916,6 +956,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.4" version = "0.1.4"
@@ -1578,16 +1624,6 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.9.0",
"libc",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@@ -1759,15 +1795,6 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.4+3.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.110" version = "0.9.110"
@@ -1776,17 +1803,10 @@ checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1810,6 +1830,15 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1828,6 +1857,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.30" version = "0.3.30"
@@ -1994,17 +2033,6 @@ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
] ]
[[package]]
name = "redox_users"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891"
dependencies = [
"getrandom 0.2.14",
"libredox",
"thiserror",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.10.4" version = "1.10.4"
@@ -2326,6 +2354,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -2373,6 +2410,16 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "sqlite" name = "sqlite"
version = "0.34.0" version = "0.34.0"
@@ -2499,26 +2546,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "thiserror"
version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.44"
@@ -3032,15 +3059,6 @@ dependencies = [
"windows-link 0.1.1", "windows-link 0.1.1",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -3068,21 +3086,6 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.5" version = "0.52.5"
@@ -3116,12 +3119,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.5" version = "0.52.5"
@@ -3134,12 +3131,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.5" version = "0.52.5"
@@ -3152,12 +3143,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.5" version = "0.52.5"
@@ -3182,12 +3167,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.5" version = "0.52.5"
@@ -3200,12 +3179,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.5" version = "0.52.5"
@@ -3218,12 +3191,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.5" version = "0.52.5"
@@ -3236,12 +3203,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.5" version = "0.52.5"

View File

@@ -1,42 +1,58 @@
[package] [package]
name = "bal_server" name = "bal_server"
version = "0.3.0" version = "0.3.2"
edition = "2024" 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
[features]
default = ["server", "pusher"]
server = ["dep:actix-web", "dep:actix-governor", "dep:actix-rt", "dep:chrono", "dep:hex-conservative"]
pusher = ["dep:zmq", "dep:reqwest", "dep:byteorder", "dep:base64", "dep:ed25519-dalek"]
[dependencies] [dependencies]
base64 = { version = "0.22.1" }
bs58 = { version = "0.4.0" } bs58 = { version = "0.4.0" }
bytes = { version = "1.2" } bytes = { version = "1.2" }
bitcoin = { version = "0.32.5" } bitcoin = { version = "0.32.5" }
bitcoincore-rpc = { version = "0.19.0" } bitcoincore-rpc = { version = "0.19.0" }
bitcoincore-rpc-json = { version = "0.19.0" } bitcoincore-rpc-json = { version = "0.19.0" }
byteorder = { version = "1.5.0" }
confy = { version = "0.6.1" }
chrono = { version = "0.4.40" }
env_logger = { version = "0.11.5" } env_logger = { version = "0.11.5" }
hex = { version = "0.4.3" } hex = { version = "0.4.3" }
hex-conservative = { version = "0.1.1" }
actix-web = { version = "4.9.0" }
actix-governor = { version = "0.6.0" }
log = { version = "0.4.21" } log = { version = "0.4.21" }
openssl = { version = "0.10.74", features = ["vendored"] }
sha2 = { version = "0.10.8" }
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
serde_json = { version = "1.0.116" } serde_json = { version = "1.0.116" }
sqlite = { version = "0.34.0" } sqlite = { version = "0.34.0" }
regex = { version = "1.10.4" } regex = { version = "1.10.4" }
reqwest = { version = "0.12.24", features = ["json","socks"] }
actix-rt = { version = "2.10.0" }
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] } tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] }
url = { version = "2" } url = { version = "2" }
zmq = { version = "0.10.0" }
# 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]] [[bin]]
name = "bal-server" name = "bal-server"
path = "src/bin/bal-server.rs" path = "src/bin/bal-server.rs"
required-features = ["server"]
[[bin]] [[bin]]
name = "bal-pusher" name = "bal-pusher"
path = "src/bin/bal-pusher.rs" path = "src/bin/bal-pusher.rs"
required-features = ["pusher"]

View File

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

100
Dockerfile.release Normal file
View File

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

View File

@@ -13,7 +13,15 @@ sudo cp target/release/bal-server target/release/bal-pusher /usr/local/bin
## Docker ## Docker
### Build ### Quick Start (release download)
Download the latest pre-built release — no Rust toolchain needed:
```bash
docker build -f Dockerfile.release -t bal-server .
```
### Build from source
```bash ```bash
docker build -t bal-server . docker build -t bal-server .
@@ -37,6 +45,12 @@ docker run -d \
bal-server bal-server
``` ```
### Pin a specific version
```bash
docker build -f Dockerfile.release --build-arg BAL_VERSION=v0.3.2 -t bal-server:0.3.2 .
```
### Docker environment variables ### Docker environment variables
| Variable | Description | Default | | Variable | Description | Default |
@@ -48,6 +62,7 @@ docker run -d \
> **Note:** The container runs as a non-root `bal` user (uid 1000) with `tini` as PID 1. > **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. > 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. > When using `--network host`, ensure only `127.0.0.1` is used for internal services.
> `Dockerfile.release` fetches the latest release from the Gitea server and verifies its SHA-256 checksum.
## Configuration (bal-server) ## Configuration (bal-server)
@@ -111,7 +126,7 @@ The `bal-server` application can be configured using environment variables.
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) - **Rust and Cargo**: [Rust Installation](https://www.rust-lang.org/tools/install)
- **Libraries**: `libssl-dev`, `libsodium-dev`, `libzmq5-dev`, `libsqlite3-dev` - **Libraries**: `libssl-dev`, `libzmq5-dev`, `libsqlite3-dev`
## Running ## Running

15
bal-pusher.sh Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -8,18 +8,31 @@
## HTTP API (provided by `bal-server`) ## HTTP API (provided by `bal-server`)
### Rate Limiting
All endpoints are rate-limited via `actix-governor` with a token-bucket algorithm. Defaults:
| Endpoint | Rate (req/s) | Burst |
|----------|-------------|-------|
| `POST /{network}/pushtxs` | 1 | 3 |
| `POST /searchtx` | 5 | 10 |
| `GET /{network}/info` | 20 | 30 |
| All others | 50 | 100 |
Rate limits are configurable via `BAL_SERVER_ACTIX_*` environment variables.
### `GET /` ### `GET /`
- **Description:** Returns a static identification string (e.g., "Will Executor Server"). - **Description:** Returns a static identification string (default: "Will Executor Server").
- **Response:** Plain text `200 OK`. - **Response:** Plain text `200 OK`.
### `GET /version` ### `GET /version`
- **Description:** Returns the Cargo package version (`bal_server` version). - **Description:** Returns the Cargo package version.
- **Response:** `text/plain` (e.g., `0.2.3`). - **Response:** `text/plain` (e.g., `0.3.2`).
### `GET /.pub_key.pem` ### `GET /.pub_key.pem`
- **Description:** Returns the static Ed25519 public key PEM file for signature verification of remote stats. - **Description:** Returns the static Ed25519 public key PEM file for signature verification of remote stats.
- **Response:** `text/plain` with the PEM file content. - **Response:** `text/plain` with the PEM file content.
- **File:** `public_key.pem` in the project root. - **File:** `public_key.pem` in the project root (path configurable via `BAL_SERVER_PUB_KEY_PATH`).
### `GET /:network/info` ### `GET /:network/info`
- **Description:** Returns JSON with the server's configuration for that specific network. - **Description:** Returns JSON with the server's configuration for that specific network.
@@ -27,67 +40,62 @@
- **Response (200 OK):** - **Response (200 OK):**
```json ```json
{ {
"network": "regtest", "address": "bcrt1q...",
"our_address": "bcrt...", "base_fee": 50000,
"fee": 1000,
"chain": "regtest", "chain": "regtest",
"version": "0.2.3" "info": "Will Executor Server",
"version": "0.3.2"
} }
``` ```
- **Error:** `404` if the network is not configured. - In xpub mode, the `address` field contains a freshly derived P2WPKH address unique to the requesting IP.
- **Error:** `404` if the network is not configured or unknown.
### `GET /:network/stats` ### `GET /:network/stats`
- **Description:** Returns statistics for the given network. This endpoint is guarded by the `expose_stats` configuration flag. - **Description:** Returns statistics for the given network. Guarded by the `expose_stats` configuration flag.
- **Response (200 OK):** - **Response (200 OK):** A JSON array of `StatsResponse` objects:
```json ```json
{ [
"report_date": 1712345678, {
"chain": "regtest", "report_date": "2024-07-20T12:00:00Z",
"total": 42, "chain": "regtest",
"waiting": 10, "totals": 42,
"sent": 30, "waiting": 10,
"failed": 2, "sent": 30,
"waiting_profit": 10000, "failed": 2,
"sent_profit": 30000, "waiting_profit": 10000,
"missed_profit": 5000, "sent_profit": 30000,
"unique_input": 15 "missed_profit": 5000,
} "unique_inputs": 15
}
]
``` ```
- **Error:** `403` or `400` if stats are not enabled or the network is unknown. - **Error:** `403` or `400` if stats are not enabled or the network is unknown.
### `POST /:network/pushtxs` ### `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. - **Description:** Accepts one or more raw hex Bitcoin transactions (newline-separated). The server deserializes each transaction, validates that the fee is paid to the correct `our_address` for that network, and stores valid transactions in the database.
- **Request Body:** - **Request Body:** Newline-separated raw hex transactions.
- `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. 02000000000101...hex...\n
```json 02000000000101...hex...\n
[
{
"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 (200 OK):** A JSON object with the results for the batch:
- **Response (500 Internal Server):** `Database error`, `Invalid hex`, `Invalid transaction` (may contain a panic trace if an internal `unwrap` is hit). ```json
- **Security Note:** If a transaction is not valid or does not pay the required fees, it is not inserted into the database. {
"accepted": 2,
"rejected": 0,
"details": [...]
}
```
- **Response (400 Bad Request):** If all transactions are invalid, the fee is missing, or the locktime is not acceptable.
- **Response (413 Payload Too Limited):** If the request body exceeds the configured max size (default 1 MiB).
- **Security Note:** Invalid transactions or those not paying the required fees are not inserted into the database.
### `POST /searchtx` ### `POST /searchtx`
- **Description:** Searches for a transaction by its `txid`. Returns the transaction details, status, raw hex, and fees. - **Description:** Searches for a transaction by its `txid`. The request body must contain exactly 64 hex characters.
- **Request Body:** - **Request Body:**
```json ```json
{ {
"txid": "abc123..." "txid": "abc123def456..."
} }
``` ```
- **Response (200 OK):** - **Response (200 OK):**
@@ -98,42 +106,57 @@
"tx": "020000000...", "tx": "020000000...",
"our_address": "bcrt1q...", "our_address": "bcrt1q...",
"our_fees": 1000, "our_fees": 1000,
"locktime": 2100, "reqid": "192.168.1.1"
"timestamp": 1712345678
} }
``` ```
- **Response (404):** If the transaction is not found in the database. - **Response (400 Bad Request):** If the txid is not exactly 64 hex characters.
- **Response (400):** If the request body is invalid. - **Response (404 Not Found):** If the transaction is not found in the database.
--- ---
## ZMQ Messages (consumed by `bal-pusher`) ## ZMQ Messages (consumed by `bal-pusher`)
### Topic: `hashblock` (Consumed by `bal-pusher`) ### Topic: `hashblock`
- **Format:** A multipart ZMQ message. The first frame is the topic name (`hashblock`), the second frame is the 32-byte block hash. - **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. - **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. - **Action:** The pusher fetches `getblockchaininfo` from the RPC, gets the updated `mediantime` and `blocks` height, then queries and pushes pending transactions.
- **Endpoint:** `tcp://127.0.0.1:28332` (or network-specific ports). - **Endpoint:** Per-network (e.g., `tcp://127.0.0.1:28332` for mainnet).
- **Timeout:** 5 seconds (`ZMQ_RCVTIMEO`). The pusher logs a warning after ~720 consecutive timeouts (~1 hour).
--- ---
## Bitcoin Core RPC Usage (used by `bal-pusher`) ## Bitcoin Core RPC Usage (used by `bal-pusher`)
### `sendrawtransaction` (Both pushers) ### `sendrawtransaction`
- **Method:** `sendrawtransaction` (RPC `2`) - **Method:** `sendrawtransaction` (RPC `2`)
- **Parameters:** `hexstring` (the raw hex of the transaction to broadcast). - **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`). - **Description:** Broadcasts the transaction to the Bitcoin network. If the transaction is invalid (e.g., `bad-txns-inputs-missingorspent`), the RPC will return an error with a negative code (e.g., `-25`).
- **Error Handling:** The pusher catches these errors, logs them, and updates the database status to `2` (failed). - **Error Handling:** The pusher catches these errors, logs them, and updates the database status to `2` (failed) with the error in `push_err`.
### `getblockchaininfo` (Only `bal-pusher`) ### `getblockchaininfo`
- **Method:** `getblockchaininfo` (RPC `1`) - **Method:** `getblockchaininfo` (RPC `1`)
- **Parameters:** None. - **Parameters:** None.
- **Description:** Returns the current blockchain state, including the `mediantime` (the median timestamp of the last 11 blocks). This is used to evaluate the `nLockTime` of pending transactions. - **Description:** Returns the current blockchain state, including `mediantime` (median timestamp of the last 11 blocks) and `blocks` (best block height). Used to evaluate `nLockTime` of pending transactions.
### `getblock`
### `getblock` (Only used by `bal-pusher` for median time)
- **Method:** `getblock` (RPC `1`) - **Method:** `getblock` (RPC `1`)
- **Parameters:** `blockhash`, `verbosity` (set to `1` for JSON with timestamp). - **Parameters:** `blockhash`, `verbosity` (set to `1` for JSON with timestamp).
- **Description:** Fetches the details of a block. It is used as an alternative to `getblockchaininfo` to get the block's `time` if `getblockchaininfo` fails or is insufficient. - **Description:** Fetches block details. Used as an alternative for median time calculation.
### RPC Authentication
Authentication is done via `bitcoincore-rpc` using either:
- **`UserPass`**: `BAL_PUSHER_{NETWORK}_RPC_USER` and `BAL_PUSHER_{NETWORK}_RPC_PASSWORD`.
- **`CookieFile`**: `$HOME/.bitcoin/{dir_path}.cookie` or custom path via `BAL_PUSHER_{NETWORK}_COOKIE_FILE`.
The client tries username/password auth first, then falls back to cookie file auth.
--- ---
## Ed25519 Stats Signing
The pusher signs the statistics payload before sending it to the `welist` server:
1. Collects statistics from the database.
2. Serializes the stats as JSON.
3. Signs the JSON payload with the Ed25519 private key (`privkey.pem`).
4. Sends the payload with the base64-encoded signature in the `X-Signature` header.
5. The `welist` server can verify the signature using the public key served at `GET /.pub_key.pem`.

View File

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

View File

@@ -1,57 +1,143 @@
# Deployment and Operations # Deployment and Operations
## Quick Reference ## Quick Reference
- **What this file contains:** environment variables, systemd service files, deployment scripts, nginx/Tor configuration, and installation procedures. - **What this file contains:** environment variables, systemd service files, deployment scripts, nginx/Tor configuration, Docker support, and installation procedures.
- **See also:** [01_project_overview.md](01_project_overview.md), [03_architecture_and_data_flow.md](03_architecture_and_data_flow.md), [05_api_reference.md](05_api_reference.md), [08_security_audit.md](08_security_audit.md) - **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 ## Environment Variables
### `bal-server` (`bal-server.env`) ### `bal-server` (all prefixed `BAL_SERVER_`)
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 #### Core Settings
RUST_LOG=info
BAL_DB_FILE=/var/bal/bal.db | Variable | Default | Description |
BAL_BIND_ADDRESS=0.0.0.0:3031 |----------|---------|-------------|
BAL_EXPOSE_STATS=true | `BAL_SERVER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
BAL_REGTEST_XPUB=tpub... (example for regtest testing) | `BAL_SERVER_BIND_ADDRESS` | `"127.0.0.1"` | TCP address to bind to (**never use `0.0.0.0` in production**) |
BAL_PUB_KEY_PATH=public_key.pem | `BAL_SERVER_BIND_PORT` | `9137` | TCP port to listen on |
| `BAL_SERVER_EXPOSE_STATS` | `false` | Enable/disable the `GET /:network/stats` endpoint |
| `BAL_SERVER_PUB_KEY_PATH` | `"public_key.pem"` | Path to the Ed25519 public key PEM file |
| `BAL_SERVER_INFO` | `"Will Executor Server"` | String returned by `GET /` |
#### Per-Network Settings
For each network (`regtest`, `testnet`, `testnet4`, `signet`, `bitcoin`):
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_{NETWORK}_ADDRESS` | (empty) | The xpub/zpub/ypub or fixed address for fee collection |
| `BAL_SERVER_{NETWORK}_FIXED_FEE` | `50000` | Minimum fee in satoshis required for transaction acceptance |
Example: `BAL_SERVER_REGTEST_ADDRESS=tpub...`, `BAL_SERVER_BITCOIN_FIXED_FEE=50000`.
#### Actix-Web Tuning
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_SERVER_ACTIX_MAX_BODY_SIZE` | `1048576` (1 MiB) | Maximum HTTP request body size |
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | `5` | Request timeout in seconds |
| `BAL_SERVER_ACTIX_WORKERS` | `4` | Number of actix-web worker threads |
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | `100` | Maximum concurrent connections |
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: pushtxs requests per second |
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: pushtxs burst size |
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | `5` | Rate limit: searchtx requests per second |
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | `10` | Rate limit: searchtx burst size |
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info requests per second |
| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info burst size |
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second |
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | `100` | Rate limit: default burst size |
### `bal-pusher` (prefixed `BAL_PUSHER_`)
#### Core Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `BAL_PUSHER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
| `BAL_PUSHER_BITCOIN_DIR` | `""` | Bitcoin data directory (for cookie file path resolution) |
| `BAL_PUSHER_SEND_STATS` | `false` | Enable/disable remote stats reporting |
| `BAL_SERVER_URL` | `"http://localhost/"` | URL of the bal-server for internal communication |
| `SSL_KEY_PATH` | `"privkey.pem"` | Path to Ed25519 private key for signing stats |
| `BAL_PUSHER_PREFER_IPV6` | `false` | Pin HTTP connection to first IPv6 address (for broken IPv4 routes) |
| `WELIST_SERVER_URL` | `"https://welist.bitcoin-after.life"` | URL to POST signed stats to (validated against SSRF) |
| `WELIST_SKIP_URL_VALIDATION` | `false` | Bypass SSRF URL validation (for testing only) |
#### Per-Network Settings
For each network (`regtest`, `testnet`, `testnet4`, `signet`, `bitcoin`):
| Variable | Default (regtest) | Description |
|----------|-------------------|-------------|
| `BAL_PUSHER_{NETWORK}_HOST` | `"127.0.0.1"` | Bitcoin Core RPC host |
| `BAL_PUSHER_{NETWORK}_PORT` | `18443` | Bitcoin Core RPC port |
| `BAL_PUSHER_{NETWORK}_DIR_PATH` | `".bitcoin"` | Relative directory under `$HOME` for cookie file |
| `BAL_PUSHER_{NETWORK}_DB_FIELD` | (empty) | Database field name for this network |
| `BAL_PUSHER_{NETWORK}_COOKIE_FILE` | (empty) | Absolute path to cookie file (overrides `DIR_PATH`) |
| `BAL_PUSHER_{NETWORK}_RPC_USER` | (empty) | RPC username (if using user/pass auth) |
| `BAL_PUSHER_{NETWORK}_RPC_PASSWORD` | (empty) | RPC password (if using user/pass auth) |
| `BAL_PUSHER_{NETWORK}_ZMQ_HASHBLOCK` | `"tcp://127.0.0.1:21332"` | ZMQ hashblock endpoint |
Default ports per network:
| Network | RPC Port | ZMQ Port |
|---------|----------|----------|
| bitcoin | 8332 | 28332 |
| regtest | 18443 | 21332 |
| testnet | 18332 | 23332 |
| testnet4 | 48332 | 24332 |
| signet | 18332 | 22332 |
---
## Docker
The project provides two Dockerfiles:
### `Dockerfile.release` — Download pre-built release (recommended for production)
Downloads the latest release from the Gitea server. No Rust toolchain needed. Fast builds.
```bash
# Latest release
docker build -f Dockerfile.release -t bal-server .
# Specific version
docker build -f Dockerfile.release --build-arg BAL_VERSION=v0.3.2 -t bal-server:0.3.2 .
``` ```
- `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`) - Fetches `.tar.gz` from `https://bitcoin-after.life/gitea/api/v1/repos/bitcoinafterlife/bal-server/releases/latest`.
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. - Verifies SHA-256 checksum if available.
- Single-stage image (`debian:bookworm-slim`), minimal size.
- `BAL_VERSION` build arg: set to a tag (e.g., `v0.3.2`) to pin a specific release.
```env ### `Dockerfile` — Build from source
ZMQ_ENDPOINT=tcp://127.0.0.1:21332
BAL_SERVER_URL=http://127.0.0.1:3031 Multi-stage build with the Rust toolchain. Use for development or custom builds.
BAL_PUSHER_RPC_URL=http://127.0.0.1:18443
BAL_PUSHER_RPC_COOKIE_PATH=/home/bal/.bitcoin/.cookie - **Builder stage:** `rust:1.95-bookworm` with full build. Each binary is compiled with only its required features (`--no-default-features --features server` / `--features pusher`).
BAL_SSL_KEY_PATH=private_key.pem - **Runtime stage:** `debian:bookworm-slim` with minimal runtime.
SEND_STATS=true - **User:** Non-root `bal` user (uid 1000).
WELIST_URL=https://welist.example.com/api/stats - **PID 1:** `tini` for proper signal handling.
- **Healthcheck:** `curl -f http://localhost:9137/ || exit 1`.
### Run (both Dockerfiles)
```bash
docker run -d \
--name bal-server \
-v /var/bal:/var/bal \
--env-file bal-server.env \
-p 127.0.0.1:9137:9137 \
bal-server
``` ```
- `ZMQ_ENDPOINT`: The ZMQ endpoint for the `hashblock` or `rawblock` topic. For `regtest`, use `tcp://127.0.0.1:21332`. For mainnet, use `tcp://127.0.0.1:28332`.
- `BAL_SERVER_URL`: The URL of the `bal-server` that the pusher can use to query statistics or for other internal communication.
- `BAL_PUSHER_RPC_URL`: The URL for the Bitcoin Core JSON-RPC endpoint. For `regtest`, the default is `http://127.0.0.1:18443`.
- `BAL_PUSHER_RPC_COOKIE_PATH`: The path to the `.cookie` file for RPC authentication. If not set, the pusher must use `user_pass` authentication. The cookie file is created by `bitcoind` when it starts with `rpccookieauth`.
- `BAL_SSL_KEY_PATH`: The path to the Ed25519 private key (`private_key.pem`) used to sign the statistics payload before sending it to the `welist` server. This is a critical secret.
- `SEND_STATS`: A boolean flag to enable the reporting of statistics to the remote `welist` server.
- `WELIST_URL`: The URL to which the statistics are sent. If `SEND_STATS` is `true`, this URL must be reachable. If the server is unreachable, the pusher will log an error but might not crash (see `08_security_audit.md` for DoS analysis).
--- ---
## System Services ## System Services
### `bal-server.service` (Systemd Unit) ### `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 ```ini
[Unit] [Unit]
@@ -73,12 +159,10 @@ MemoryDenyWriteExecute=true
[Install] [Install]
WantedBy=multi-user WantedBy=multi-user
``` ```
- **User:** The service runs as a dedicated, non-privileged user (`bal` user) to ensure the server doesn't run as root. - Runs as a dedicated non-privileged `bal` user.
- **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. - Hardened with `ProtectSystem=full`, `NoNewPrivileges=true`, `PrivateDevices=true`, `MemoryDenyWriteExecute=true`.
- **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) ### `bitcoind.service` (Bitcoin Core Daemon)
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 ```ini
[Unit] [Unit]
@@ -93,147 +177,126 @@ RestartSec=30
[Install] [Install]
WantedBy=multi-user WantedBy=multi-user
``` ```
- **Note:** The full `bitcoind` configuration is in `bitcoin.conf` (or the `contrib/download_and_install_bitcoincore.sh` script). The script sets `zmqpubhashblock` (not `zmqpubrawblock`) for the pusher's new-block notifications. The `zmqpubhashblock` and `zmqpubrawtx` ports must be bound to `127.0.0.1` (never `0.0.0.0`) and match the pusher's `ZMQ_ENDPOINT`. - Must be started with `zmqpubhashblock` (not `zmqpubrawblock`).
- ZMQ ports must be bound to `127.0.0.1` only.
### `tbitcoind.service` (Systemd Unit for Testnet) ### `tbitcoind.service` (Testnet Bitcoind)
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`). Same as `bitcoind.service` but for testnet with a different data directory and ZMQ port (e.g., `tcp://127.0.0.1:23332`).
--- ---
## Bash Scripts ## Bash Scripts
### `bal-server.sh` (Development Server Startup) ### `bal-server.sh` (Development Server Startup)
This script sources the `bal-server.env` file and then runs the development server with Cargo for easy development and reloading. Sources `bal-server.env` and runs the development server:
```bash ```bash
export $(grep -v '^#' bal-server.env | xargs) export $(grep -v '^#' bal-server.env | xargs)
RUST_LOG=info cargo run --bin=bal-server 2>&1 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) ### `bal-pusher.sh` (Development Pusher Startup)
This script sources the `bal-pusher.env` and runs the pusher in development mode. It also accepts the `network` name as an argument (e.g., `sh bal-pusher.sh regtest`). Sources `bal-pusher.env` and runs the pusher with a network argument:
```bash ```bash
export $(grep -v '^#' bal-pusher.env | xargs) export $(grep -v '^#' bal-pusher.env | xargs)
RUST_LOG=info cargo run --bin=bal-pusher $1 RUST_LOG=info cargo run --bin=bal-pusher $1
``` ```
### `sendtx.sh` (One-liner Transaction Sender) ### `sendtx.sh` (Test 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. A helper script that wraps `bitcoin-cli` for manual testing.
```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) ### `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. Builds release binaries, creates Git tags, and uploads to Gitea. Signs the release tarball with GPG. Release assets include `.tar.gz`, `.sha256`, `.sig`, and `.asc` files. Token is loaded from `.env` (not hardcoded).
```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) ### `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. Uses `scp` to pull the production `bal.db` from a 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 ## Nginx and SSL Configuration
The `bal-server` is a plain HTTP server. To expose it to the internet, a production environment should put a reverse proxy like `Nginx` in front of it. The `nginx` configuration (from `contrib/download_and_install_bal.sh`) is used to terminate TLS and provide SSL certificates. Nginx also handles rate limiting, request filtering, and static file serving for `public_key.pem`. The `bal-server` is a plain HTTP server. A reverse proxy (Nginx) with TLS termination is required for production.
### Example Nginx Configuration (from `contrib`) ### Template: `contrib/nginx/bal-server.conf`
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name bal.example.com; server_name BAL_DOMAIN;
return 301 https://$server_name$request_uri; return 301 https://$server_name$request_uri;
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name bal.example.com; server_name BAL_DOMAIN;
ssl_certificate /etc/letsencrypt/live/bal.example.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/BAL_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bal.example.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/BAL_DOMAIN/privkey.pem;
client_max_body_size 1m;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy no-referrer;
location / { location / {
proxy_pass http://127.0.0.1:3031; proxy_pass http://127.0.0.1:9137;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
# Rate limiting can be added here # Uncomment for rate limiting:
# limit_req zone=pal limit=10 nodelay;
} }
``` ```
- **Certbot:** The `contrib` script installs `certbot` and automatically generates the certificate. This configuration is used to ensure the `bal-server` is served over HTTPS with valid TLS.
- **Rate limiting:** It is recommended to add `limit_req` or `limit_conn` to the Nginx configuration to prevent the server from being overwhelmed by too many concurrent requests (e.g., `pushtxs` spam, or DoS attacks). The `bal-server` has no built-in rate limiting on the HTTP level. Key points:
- `client_max_body_size` must match `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- `certbot --nginx` obtains the certificate automatically.
- Security headers: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
--- ---
## Production Deployment Checklist ## 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 ### 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_ADDRESS=127.0.0.1` (never `0.0.0.0`).
- [ ] `BAL_SERVER_BIND_PORT` is the port used by Nginx `proxy_pass` (default `9137`). - [ ] `BAL_SERVER_BIND_PORT` matches 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). - [ ] Firewall blocks inbound connections to `BAL_SERVER_BIND_PORT` from external interfaces.
### 2. Reverse Proxy (Nginx + TLS) ### 2. Reverse Proxy (Nginx + TLS)
- [ ] Nginx is installed (`contrib/download_and_install_bal.sh` handles this). - [ ] Nginx 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). - [ ] `contrib/nginx/bal-server.conf` template copied to `/etc/nginx/sites-available/`.
- [ ] The file has a real domain name replacing `BAL_DOMAIN`. - [ ] Real domain name replacing `BAL_DOMAIN`.
- [ ] `listen 443 ssl http2;` is active. - [ ] `listen 443 ssl http2` 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`. - [ ] `certbot --nginx` has obtained a valid certificate.
- [ ] `proxy_pass` points to `http://127.0.0.1:9137` (or whatever `BAL_SERVER_BIND_PORT` is). - [ ] `proxy_pass` points to `http://127.0.0.1:9137`.
- [ ] `client_max_body_size` in Nginx matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default `1m`). - [ ] `client_max_body_size` matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- [ ] HTTP port 80 redirects to HTTPS (`return 301 https://...`). - [ ] HTTP port 80 redirects to HTTPS.
- [ ] Nginx `limit_req` zone is configured if desired (backup to `actix-governor`). - [ ] Security headers configured.
### 3. Database and Secrets ### 3. Database and Secrets
- [ ] Database file is owned by the `bal` user (`chown bal:bal /var/bal/bal.db`). - [ ] Database file owned by `bal` user (`chown bal:bal /var/bal/bal.db`).
- [ ] Database file permissions are `600` (`chmod 600 /var/bal/bal.db`). - [ ] Database file permissions `600` (`chmod 600 /var/bal/bal.db`).
- [ ] `.env` file is in `.gitignore` and not committed. - [ ] `.env` files in `.gitignore` and not committed.
- [ ] `private_key.pem` and `privkey.pem` are not in the repository (use `git ls-files` to verify). - [ ] `private_key.pem` / `privkey.pem` not in the repository.
- [ ] `public_key.pem` is readable by Nginx if served directly (otherwise let the actix endpoint handle it). - [ ] `public_key.pem` readable by Nginx if served directly.
### 4. Pusher and ZMQ ### 4. Pusher and ZMQ
- [ ] ZMQ endpoints are configured for `127.0.0.1` only (e.g., `tcp://127.0.0.1:28332`). - [ ] ZMQ endpoints configured for `127.0.0.1` only.
- [ ] `BAL_PUSHER_SEND_STATS` is set to `false` unless the `welist` endpoint is actually needed. - [ ] `BAL_PUSHER_SEND_STATS=false` unless `welist` endpoint is needed.
- [ ] If stats are enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain (not IP, not local). - [ ] If stats enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain.
- [ ] Firewall blocks inbound TCP port `28332` (or your custom `bitcoin`, `regtest`, etc. ZMQ ports) from external interfaces. - [ ] Firewall blocks inbound ZMQ ports from external interfaces.
### 5. Logging and Monitoring ### 5. Logging and Monitoring
- [ ] `RUST_LOG` is set to `info` or `warn` in production (not `debug` or `trace`). - [ ] `RUST_LOG=info` or `warn` in production (not `debug`/`trace`).
- [ ] Log files are rotated (e.g., via `logrotate`) and stored only under `/var/log/bal/` or systemd journal. - [ ] Log files rotated and stored under `/var/log/bal/` or systemd journal.
- [ ] Log files are not in the same directory as the database or the private key. - [ ] Log files not in the same directory as the database or private key.
--- ---
## Tor and Privacy ## Tor and Privacy
The `contrib/install_tor.sh` script installs Tor for use as an onion-routed proxy. It can be used to: The `contrib/install_tor.sh` script installs Tor for onion-routed proxy use:
1. Allow the `bal-server` to be reachable via a `.onion` address for privacy and censorship resistance. 1. `bal-server` can be reachable via a `.onion` address.
2. Allow the `bal-pusher` to connect to the Bitcoin RPC or the `welist` server through Tor to hide its origin IP. 2. `bal-pusher` can connect to Bitcoin RPC or `welist` through Tor.
3. Allow the server to run behind NAT without exposing the real IP to the public internet. 3. The server can run behind NAT without exposing the real IP.
The script uses `ControlPort 9051` and enables `CookieAuthentication`. If `SEND_STATS` is true, the `welist` URL can be configured to be a `.onion` address to hide the origin. For example, the `bal-pusher` could use `reqwest` with SOCKS5 proxy settings to connect to the `welist` server via Tor. The script uses `ControlPort 9051` with `CookieAuthentication`. The `bal-pusher` supports SOCKS5 proxy via the `reqwest` `socks` feature for `.onion` connectivity.
- `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.
---

View File

@@ -9,221 +9,148 @@
## Threat Model ## Threat Model
### Assets ### 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. 1. **`bal.db` (SQLite database):** Contains all transaction details, user IP addresses, and stats data. Single unencrypted file on disk. WAL mode enabled for concurrent access safety.
2. **Private Keys (`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. 2. **Private Keys (`privkey.pem`):** Used to sign statistics payloads for the `welist` server. Located in `.gitignore`.
3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access 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. 3. **Bitcoin Node (`bitcoind`) Access:** The `bal-pusher` has RPC access. Compromise allows arbitrary transaction broadcasting.
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. 4. **Server Availability (`bal-server`):** Public-facing HTTP endpoint. DoS attacks threaten service availability.
### Attackers ### 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. - **Remote Anonymous Users:** Can interact with the API via public HTTP. No credentials required.
- **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. - **Network Man-in-the-Middle (MITM):** TLS termination is via Nginx reverse proxy. The `bal-server` itself is plain HTTP.
- **Local/Insider Threats:** If the server is compromised (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. - **Local/Insider Threats:** If the server is compromised, the attacker can access `bal.db`, private keys, and env files.
---
## Vulnerability Assessment ## Vulnerability Assessment
### 1. SQL Injection (HIGH) ### 1. SQL Injection (FIXED)
**Location:** `src/bin/bal-server.rs` (e.g., `echo_stats`, `echo_push` handlers), `src/db.rs`. **Severity:** HIGH | **Status:** Fixed
**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. **Location:** `src/db.rs`, `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Impact:** An attacker could potentially read, modify, or delete any database record. **Description:** SQL queries previously used `format!()` for string interpolation. All queries now use parameterized statements (`?` with `bind()`).
**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. **Mitigation Applied:** All SQL queries rewritten with prepared statements. `execute_insert` uses parameterized batch inserts. `check_duplicate_txids` uses parameterized `IN` clauses. `echo_stats` handler uses prepared statements for chain filtering.
**Status:** Fixed (Vulnerability 1 & 2 in `bal-pusher.rs` patched in commit). **Regression Tests:** `tests/sql_injection_tests.rs` (3 tests).
**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) ### 2. Panic on Untrusted Input (FIXED)
**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)`). **Severity:** HIGH | **Status:** Fixed
**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. **Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
- 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. **Description:** All `unwrap()`/`expect()` calls on critical paths have been replaced with safe error handling.
**Impact:** A single malformed request can crash the entire server or the pusher daemon, leading to a full Denial of Service (DoS). **Mitigation Applied:**
**Mitigation:** - `bal-server`: `from_utf8` returns 400, `sqlite::open` uses `open_db()` with validation, all handlers return proper HTTP status codes.
- 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. - `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, `fs::read_to_string` uses `match` + 500, `cfg.lock()` uses `poisoned.into_inner()` recovery.
- 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. **Regression Tests:** `tests/panic_regression_tests.rs` (2 tests).
- 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 (FIXED)
**Severity:** HIGH | **Status:** Fixed
**Location:** `make_release.sh`, `.gitignore`
**Description:** `make_release.sh` now loads `TOKEN` from `.env` (`.env.example` provided). Private keys (`.pem`, `.key`) are in `.gitignore`. `generate_keys.sh` sets `chmod 600` on generated keys.
**Mitigation Applied:** Secrets removed from scripts and repository. `.gitignore` protects `.env`, `*.pem`, `*.key` files.
**Regression Tests:** `tests/secret_leakage_tests.rs` (3 tests).
### 3. Secret Leakage (HIGH) ### 4. Denial of Service (DoS) (FIXED)
**Location:** `make_release.sh`, `contrib/download_and_install_bal.sh`, `private_key.pem`, `privkey.pem`, `ec.key`, `chiave_privata.key`. **Severity:** HIGH | **Status:** Fixed
**Description:** **Location:** `src/bin/bal-server.rs` (HTTP), `src/bin/bal-pusher.rs` (ZMQ)
- 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. **Description:** All DoS vectors mitigated via actix-web migration.
- 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. **Mitigation Applied:**
- 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. - Body size limit: `PayloadConfig::default().limit(max_body_size)` via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB).
**Impact:** An attacker could gain unauthorized access to the CI/CD pipeline, the release server, or the `welist` statistics service. - Rate limiting: `actix-governor` with token-bucket per endpoint (`BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST`).
**Mitigation:** - Connection limits: `workers(4)` and `max_connections(100)` via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`.
- 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`. - Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS`.
- Remove hardcoded secrets from the scripts. The `TOKEN` and `xpub` should be environment variables or configuration files injected via the build process. - ZMQ timeout: `set_rcvtimeo(5000)` prevents infinite blocking.
- Use `git-crypt` or `git-secret` to encrypt the private key files before committing. - RPC retry: sleep + retry on connection failure instead of panic.
**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) ### 5. SSRF / Network Abuse via `reqwest` (FIXED)
**Location:** `src/bin/bal-server.rs` (HTTP request body), `src/bin/bal-pusher.rs` (ZMQ). **Severity:** MEDIUM | **Status:** Fixed
**Description:** **Location:** `src/bin/bal-pusher.rs`, `src/validation.rs`
- 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. **Description:** URL validation prevents redirecting requests to internal/private IPs.
- 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. **Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, and IPv6 unique-local addresses. HTTPS-only scheme enforced.
- 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. **Regression Tests:** `tests/ssrf_tests.rs` (integration) + 8 unit tests in `src/validation.rs`.
- 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) ### 6. Insecure Database Access (FIXED)
**Location:** `src/bin/bal-pusher.rs`. **Severity:** MEDIUM | **Status:** Fixed
**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. **Location:** `src/db.rs`, `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`
**Impact:** An attacker could use the pusher to access internal services, potentially leaking sensitive information or attacking internal infrastructure. **Description:** Database path validation and WAL mode for concurrent access.
**Mitigation:** **Mitigation Applied:**
- **Implemented:** Added strict URL validation `bal_server::validation::is_valid_wELIST_url` (see `src/validation.rs`). It checks: - `open_db()` rejects `..` traversal, forbidden system directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`), symlinks, and non-regular files.
- URL must be well-formed and parsable. - WAL mode (`PRAGMA journal_mode=WAL`) with retry logic (up to 5 attempts).
- Scheme must be `https://` (plain HTTP is rejected). - `busy_timeout=5000` for concurrent access.
- Host must not be `localhost`, `127.0.0.1`, `::1`, or any loopback/private/link-local/multicast/unspecified IP address. - `bal-server` uses `Arc<Mutex<Connection>>` for thread-safe shared access.
- 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. **Regression Tests:** `tests/db_path_validation.rs` (5 tests).
- 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) ### 7. ZMQ Authentication and Encryption (OPEN)
**Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`. **Severity:** MEDIUM | **Status:** Open
**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. **Location:** `src/bin/bal-pusher.rs`
- 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`). **Description:** ZMQ connection is plaintext TCP. No authentication (ZAP), no encryption (ZMQ_CURVE). If the ZMQ port is exposed, any attacker can subscribe to topics.
**Impact:** **Mitigation (Operational):**
- The database file could be placed on a path that causes a file system vulnerability or a crash of the server. - Bind ZMQ to `127.0.0.1` only.
- The database file might be corrupted if multiple processes access it without proper locking. - Firewall blocks external access to ZMQ ports.
**Mitigation:** - If public ZMQ is required, use ZMQ_CURVE with public-key cryptography.
-**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) ### 8. Missing HTTPS / Insecure Server Communication (FIXED)
**Location:** `src/bin/bal-pusher.rs`. **Severity:** HIGH | **Status:** Fixed (Infrastructure)
**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. **Location:** Nginx configuration, `contrib/nginx/bal-server.conf`
**Impact:** **Description:** TLS termination via Nginx reverse proxy. The `bal-server` intentionally does not implement TLS.
- 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. **Mitigation Applied:**
- 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. - Dedicated Nginx template with `listen 443 ssl http2`, Let's Encrypt paths.
**Mitigation:** - Security headers: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
- 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. - `client_max_body_size` matching `BAL_SERVER_ACTIX_MAX_BODY_SIZE`.
- 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. - HTTP 80 redirect to HTTPS.
- If not using ZMQ_CURVE, use `zmqpubhashblock` with a firewall that blocks the public port for port 28332. - Deployment checklist ensures no accidental plain HTTP exposure.
- 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) ### 9. Missing Input Validation (FIXED)
**Location:** `src/bin/bal-server.rs` (TCP server), Nginx configuration. **Severity:** MEDIUM | **Status:** Fixed
**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. **Location:** `src/bin/bal-server.rs`
**Impact:** **Description:** Network, txid, and content validation.
- If the server is directly exposed to the internet, the transaction data is sent in plaintext, making it vulnerable to sniffing and MitM attacks. **Mitigation Applied:**
- 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. - Network validation: `NETWORKS.contains(&param.as_str())` before processing. Unknown networks return 404.
**Mitigation:** - Txid validation: `echo_search` requires exactly 64 ASCII hex characters. Non-hex or wrong length returns 400.
- **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: - XPub address caching: `get_all_addresses_by_xpub` loads all addresses once per batch (O(1) lookup), eliminating N+1 queries.
- `listen 443 ssl http2;` with Let's encrypt paths - Content-Length: Handled by actix-web `PayloadConfig` size limit.
- `proxy_pass` to `http://127.0.0.1:9137` only **Regression Tests:** `tests/input_validation_tests.rs` (4 tests).
- `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) ### 10. Information Leakage (MITIGATED)
**Location:** `src/bin/bal-server.rs` (e.g., `pushtxs` endpoint). **Severity:** LOW | **Status:** Mitigated
**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. **Location:** Application logs
**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. **Description:** Raw file logging (`valid_txs`/`invalid_txs`) has been removed. `info!`/`warn!` macros may still log txid and details in application logs.
**Mitigation:** **Mitigation (Operational):** Set production `RUST_LOG` to `warn` or higher. Log files restricted with `chmod 600`.
- 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) ### 11. `bal-stats.rs.dontcompile` (REMOVED)
**Severity:** LOW | **Status:** Removed
**Description:** The broken HTML report generator file no longer exists in the source tree.
### 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 ## Hardening Recommendations
### System-Level ### 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. 1. Run as non-root user with systemd hardening (`ProtectSystem=full`, `NoNewPrivileges`, `PrivateDevices`, `MemoryDenyWriteExecute`).
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`. 2. Use firewall to block all inbound ports except HTTPS (443) and SSH (22).
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`. 3. Use VPN or Tor for `welist` connections if on public network.
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. 4. Run in a container or chroot for isolation.
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. 5. Enable SELinux or AppArmor profiles for the binaries.
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. 6. Use read-only filesystem for the server binary.
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 ### 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. 1. **Rate Limiting:** Implemented via `actix-governor` with per-endpoint token-bucket configuration.
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. 2. **Input Validation:** Network enum check, txid hex validation, body size limits.
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. 3. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt.
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. 4. **WAL Mode:** Enabled with retry logic for concurrent access.
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. 5. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking.
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. 6. **Transaction Size Limits:** Configurable via rate limiting and body size.
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. 7. **ZMQ Retry:** Reconnect logic with timeout-based detection.
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. 8. **Fee Limits:** Per-network `fixed_fee` configuration.
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. 9. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest).
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 10. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction.
---
## Regression Test Suite
| Test File | Tests | Coverage |
|-----------|-------|----------|
| `tests/sql_injection_tests.rs` | 3 | Parameterized queries, injection prevention |
| `tests/panic_regression_tests.rs` | 2 | Mutex poisoning recovery, NULL value handling |
| `tests/ssrf_tests.rs` | 4+ | URL validation, internal IP blocking |
| `tests/secret_leakage_tests.rs` | 3 | .gitignore, no tracked secrets, no hardcoded tokens |
| `tests/input_validation_tests.rs` | 4 | Address caching, network validation, txid hex validation |
| `tests/db_path_validation.rs` | 5 | Path traversal, forbidden dirs, symlinks, WAL pragma, valid paths |
| `src/validation.rs` (inline) | 8 | SSRF URL validation unit tests |

View File

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

View File

@@ -5,8 +5,7 @@ use bitcoin::Network;
use bitcoincore_rpc::{Auth, Client, Error, RpcApi, bitcoin}; use bitcoincore_rpc::{Auth, Client, Error, RpcApi, bitcoin};
use bitcoincore_rpc_json::GetBlockchainInfoResult; use bitcoincore_rpc_json::GetBlockchainInfoResult;
use byteorder::{LittleEndian, ReadBytesExt}; use ed25519_dalek::{Signer as _, SigningKey, pkcs8::DecodePrivateKey};
use hex;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -15,24 +14,19 @@ use sqlite::{Connection, Value};
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::error::Error as StdError; use std::error::Error as StdError;
use std::io::Cursor;
use std::str; use std::str;
use std::{thread, time::Duration}; use std::{thread, time::Duration};
use zmq::{Context, DEALER, DONTWAIT, Socket}; use zmq::{Context, Socket};
use bal_server::db::open_db; use bal_server::db::open_db;
use bal_server::validation::is_valid_welist_url; use bal_server::validation::is_valid_welist_url;
use base64::{Engine as _, engine::general_purpose}; use base64::{Engine as _, engine::general_purpose};
use openssl::hash::MessageDigest;
use openssl::pkey::PKey;
use openssl::sign::Signer;
use openssl::sign::Verifier;
use reqwest::Client as rClient; use reqwest::Client as rClient;
use std::fs; use std::net::SocketAddr;
use std::time::Instant; use url::Url;
const LOCKTIME_THRESHOLD: i64 = 5000000; const LOCKTIME_THRESHOLD: i64 = 5000000;
const VERSION: &str = "0.0.2"; const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct MyConfig { struct MyConfig {
db_file: String, db_file: String,
@@ -90,7 +84,7 @@ fn get_network_params(cfg: &MyConfig, network: Network) -> &NetworkParams {
fn get_network_params_default(network: Network) -> NetworkParams { fn get_network_params_default(network: Network) -> NetworkParams {
match network { match network {
Network::Testnet => NetworkParams { Network::Testnet => NetworkParams {
host: "http://i27.0.0.1".to_string(), host: "http://127.0.0.1".to_string(),
port: 18332, port: 18332,
dir_path: "testnet3/".to_string(), dir_path: "testnet3/".to_string(),
db_field: "testnet".to_string(), db_field: "testnet".to_string(),
@@ -100,7 +94,7 @@ fn get_network_params_default(network: Network) -> NetworkParams {
zmq_listener: "tcp://127.0.0.1:23332".to_string(), zmq_listener: "tcp://127.0.0.1:23332".to_string(),
}, },
Network::Testnet4 => NetworkParams { Network::Testnet4 => NetworkParams {
host: "http://i27.0.0.1".to_string(), host: "http://127.0.0.1".to_string(),
port: 48332, port: 48332,
dir_path: "testnet4/".to_string(), dir_path: "testnet4/".to_string(),
db_field: "testnet4".to_string(), db_field: "testnet4".to_string(),
@@ -143,7 +137,7 @@ fn get_network_params_default(network: Network) -> NetworkParams {
} }
fn get_cookie_filename(network: &NetworkParams) -> Result<String, Box<dyn StdError>> { fn get_cookie_filename(network: &NetworkParams) -> Result<String, Box<dyn StdError>> {
if network.cookie_file != "" { if !network.cookie_file.is_empty() {
Ok(network.cookie_file.clone()) Ok(network.cookie_file.clone())
} else { } else {
match env::var_os("HOME") { match env::var_os("HOME") {
@@ -161,12 +155,12 @@ fn get_cookie_filename(network: &NetworkParams) -> Result<String, Box<dyn StdErr
} }
} }
fn get_client_from_username( fn get_client_from_username(
url: &String, url: &str,
network: &NetworkParams, network: &NetworkParams,
) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> { ) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> {
if network.rpc_user != "" { if !network.rpc_user.is_empty() {
match Client::new( match Client::new(
&url[..], url,
Auth::UserPass(network.rpc_user.to_string(), network.rpc_pass.to_string()), Auth::UserPass(network.rpc_user.to_string(), network.rpc_pass.to_string()),
) { ) {
Ok(client) => match client.get_blockchain_info() { Ok(client) => match client.get_blockchain_info() {
@@ -180,57 +174,37 @@ fn get_client_from_username(
} }
} }
fn get_client_from_cookie( fn get_client_from_cookie(
url: &String, url: &str,
network: &NetworkParams, network: &NetworkParams,
) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> { ) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> {
match get_cookie_filename(network) { match get_cookie_filename(network) {
Ok(cookie) => match Client::new(&url[..], Auth::CookieFile(cookie.into())) { Ok(cookie) => match Client::new(url, Auth::CookieFile(cookie.into())) {
Ok(client) => match client.get_blockchain_info() { Ok(client) => match client.get_blockchain_info() {
Ok(bcinfo) => Ok((client, bcinfo)), Ok(bcinfo) => Ok((client, bcinfo)),
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
}, },
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
}, },
Err(err) => Err(err.into()), Err(err) => Err(err),
} }
} }
fn get_client( fn get_client(
network: &NetworkParams, network: &NetworkParams,
) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> { ) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> {
let url = format!("{}:{}/", network.host, &network.port); let url = format!("{}:{}/", network.host, network.port);
debug!("trying to connect to bitcoin daemon:{url}"); debug!("trying to connect to bitcoin daemon:{url}");
match get_client_from_username(&url, network) { match get_client_from_username(&url, network) {
Ok(client) => Ok(client), Ok(client) => Ok(client),
Err(_) => match get_client_from_cookie(&url, &network) { Err(_) => match get_client_from_cookie(&url, network) {
Ok(client) => Ok(client), Ok(client) => Ok(client),
Err(err) => Err(err.into()), Err(err) => Err(err),
}, },
} }
} }
async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(), Error> { async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(), Error> {
/*let url = args.next().expect("Usage: <rpc_url> <username> <password>");
let user = args.next().expect("no user given");
let pass = args.next().expect("no pass given");
*/
//let network = Network::Regtest
match get_client(network_params) { match get_client(network_params) {
Ok((rpc, bcinfo)) => { Ok((rpc, bcinfo)) => {
info!("connected"); info!("connected");
//let best_block_hash = rpc.get_best_block_hash()?;
//info!("best block hash: {}", best_block_hash);
//let bestblockcount = rpc.get_block_count()?;
//info!("best block height: {}", bestblockcount);
//let best_block_hash_by_height = rpc.get_block_hash(bestblockcount)?;
//info!("best block hash by height: {}", best_block_hash_by_height);
//assert_eq!(best_block_hash_by_height, best_block_hash);
//let from_block= std::cmp::max(0, bestblockcount - 11);
//let mut time_sum:u64=0;
//for i in from_block..bestblockcount{
// let hash = rpc.get_block_hash(i).unwrap();
// let block: bitcoin::Block = rpc.get_by_id(&hash).unwrap();
// time_sum += <u32 as Into<u64>>::into(block.header.time);
//}
//let average_time = time_sum/11;
info!("median time: {}", bcinfo.median_time); info!("median time: {}", bcinfo.median_time);
//info!("height time: {}",bcinfo.median_time); //info!("height time: {}",bcinfo.median_time);
info!("blocks: {}", bcinfo.blocks); info!("blocks: {}", bcinfo.blocks);
@@ -265,7 +239,7 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
let mut invalid_txs: std::collections::HashMap<String, String> = HashMap::new(); let mut invalid_txs: std::collections::HashMap<String, String> = HashMap::new();
for row_result in match query_tx.bind::<&[(_, Value)]>( for row_result in match query_tx.bind::<&[(_, Value)]>(
&[ &[
(":locktime_threshold", (LOCKTIME_THRESHOLD as i64).into()), (":locktime_threshold", LOCKTIME_THRESHOLD.into()),
(":bestblock_time", (average_time as i64).into()), (":bestblock_time", (average_time as i64).into()),
(":bestblock_height", (bcinfo.blocks as i64).into()), (":bestblock_height", (bcinfo.blocks as i64).into()),
(":network", network_params.db_field.clone().into()), (":network", network_params.db_field.clone().into()),
@@ -291,26 +265,10 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
info!("to be pushed: {}: {}", txid, locktime); info!("to be pushed: {}: {}", txid, locktime);
match rpc.send_raw_transaction(tx) { match rpc.send_raw_transaction(tx) {
Ok(o) => { Ok(o) => {
/*let mut file = OpenOptions::new()
.append(true) // Set the append option
.create(true) // Create the file if it doesn't exist
.open("valid_txs")?;
let data = format!("{}\t:\t{}\t:\t{}\n",txid,average_time,locktime);
file.write_all(data.as_bytes())?;
drop(file);
*/
info!("tx: {} pusshata PUSHED\n{}", txid, o); info!("tx: {} pusshata PUSHED\n{}", txid, o);
pushed_txs.push(txid.to_string()); pushed_txs.push(txid.to_string());
} }
Err(err) => { Err(err) => {
/*let mut file = OpenOptions::new()
.append(true) // Set the append option
.create(true) // Create the file if it doesn't exist
.open("/home/bal/invalid_txs")?;
let data = format!("{}:\t{}\t:\t{}\t:\t{}\n",txid,err,average_time,locktime);
file.write_all(data.as_bytes())?;
drop(file);
*/
warn!("Error: {}\n{}", err, txid); warn!("Error: {}\n{}", err, txid);
//store err in invalid_txs //store err in invalid_txs
invalid_txs.insert(txid.to_string(), err.to_string()); invalid_txs.insert(txid.to_string(), err.to_string());
@@ -320,19 +278,44 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
for txid in &pushed_txs { for txid in &pushed_txs {
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?"; let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap(); match db.prepare(sql) {
stmt.bind((1, Value::String(txid.clone()))).unwrap(); Ok(mut stmt) => {
let _ = stmt.next(); if let Err(e) = stmt.bind((1, Value::String(txid.clone()))) {
error!("Failed to bind txid for status update: {}", e);
continue;
}
let _ = stmt.next();
}
Err(e) => {
error!("Failed to prepare status update: {}", e);
}
}
} }
for (txid, txerr) in &invalid_txs { for (txid, txerr) in &invalid_txs {
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?"; let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
let mut stmt = db.prepare(sql).unwrap(); match db.prepare(sql) {
stmt.bind((1, Value::String(txerr.clone()))).unwrap(); Ok(mut stmt) => {
stmt.bind((2, Value::String(txid.clone()))).unwrap(); if let Err(e) = stmt.bind((1, Value::String(txerr.clone()))) {
let _ = stmt.next(); error!("Failed to bind txerr for error update: {}", e);
continue;
}
if let Err(e) = stmt.bind((2, Value::String(txid.clone()))) {
error!("Failed to bind txid for error update: {}", e);
continue;
}
let _ = stmt.next();
}
Err(e) => {
error!("Failed to prepare error update: {}", e);
}
}
}
if let Err(e) = send_stats_report(cfg, bcinfo).await {
error!("send_stats_report failed: {}", e);
}
if let Err(e) = calculate_stats(&db, network_params.db_field.clone()).await {
warn!("calculate_stats failed: {e}");
} }
let _ = send_stats_report(cfg, bcinfo).await;
let _ = calculate_stats(&db, network_params.db_field.clone()).await;
} }
Err(erx) => { Err(erx) => {
error!("impossible to get client: {}, retrying on next block", erx); error!("impossible to get client: {}, retrying on next block", erx);
@@ -390,31 +373,6 @@ ON CONFLICT(chain) DO UPDATE SET
" "
); );
/*
let sql = format!("CREATE TABLE tbl_stats AS
SELECT
CURRENT_TIMESTAMP AS report_date,
'{chain}' as chain,
(SELECT COUNT(*) FROM tbl_tx WHERE network ='{chain}') AS totals,
(SELECT COUNT(*) FROM tbl_tx WHERE status = 0 AND network ='{chain}') AS waiting,
(SELECT COUNT(*) FROM tbl_tx WHERE status = 1 AND network ='{chain}') AS sent,
(SELECT COUNT(*) FROM tbl_tx WHERE status = 2 AND network ='{chain}') AS failed,
(SELECT SUM(our_fees) FROM tbl_tx WHERE status = 0 AND network ='{chain}') AS waiting_profit,
(SELECT SUM(our_fees) OR 0 FROM tbl_tx WHERE status = 1 AND network ='{chain}') AS sent_profit,
(SELECT SUM(our_fees) FROM tbl_tx WHERE status = 2 AND network ='{chain}') AS missed_profit,
(SELECT COUNT(*) FROM tbl_inp JOIN tbl_tx ON(tbl_inp.txid = tbl_tx.txid) WHERE tbl_tx.status=0 AND tbl_tx.network ='{chain}') AS unique_inputs;
");
let sql = "UPDATE tbl_stats set
totals = (SELECT COUNT(*) FROM tbl_tx WHERE network ='{chain}'),
waiting = (SELECT COUNT(*) FROM tbl_tx WHERE status = 0 AND network ='{chain}'),
sent = (SELECT COUNT(*) FROM tbl_tx WHERE status = 1 AND network ='{chain}'),
failed = (SELECT COUNT(*) FROM tbl_tx WHERE status = 1 AND network ='{chain}'),
waiting_profit = (SELECT SUM(our_fees) FROM tbl_tx WHERE status = 0 AND network ='{chain}'),
sent_profit = (SELECT SUM(our_fees) FROM tbl_tx WHERE status = 0 AND network ='{chain}'),
missed_profit = (SELECT SUM(our_fees) FROM tbl_tx WHERE status = 0 AND network ='{chain}')
unique_inputs = (SELECT COUNT(*) FROM tbl_inp JOIN tbl_tx ON(tbl_inp.txid = tbl_tx.txid) WHERE tbl_tx.status=0 AND tbl_tx.network ='{chain}')
WHERE chain = '{chain}'
*/
if let Err(err) = db.execute(&sql) { if let Err(err) = db.execute(&sql) {
error!("error inserting creating stats table {err}"); error!("error inserting creating stats table {err}");
} else { } else {
@@ -422,6 +380,84 @@ ON CONFLICT(chain) DO UPDATE SET
} }
Ok(()) Ok(())
} }
/// Parse the `(host, port)` pair from a base URL like `https://host[:port]`.
///
/// Falls back to the scheme's well-known default port (443 for `https`,
/// 80 for plain `http`), or to 443 when the scheme is unknown.
fn parse_host_port(base_url: &str) -> Option<(String, u16)> {
let url = Url::parse(base_url).ok()?;
let host = url
.host_str()?
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
let port = url.port_or_known_default().unwrap_or(443);
Some((host, port))
}
/// Resolve `host:port` and return the first IPv6 (AAAA) address, if any.
///
/// Returns `None` when the host has no IPv6 address.
async fn resolve_first_ipv6(host: &str, port: u16) -> Option<SocketAddr> {
use std::net::ToSocketAddrs;
let host = host.to_string();
tokio::task::spawn_blocking(move || {
format!("{}:{}", host, port)
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.find(|a| a.is_ipv6()))
})
.await
.ok()
.flatten()
}
/// Build the HTTP client used for welist reports.
///
/// When `BAL_PUSHER_PREFER_IPV6` is truthy, the welist host is resolved and
/// the client is pinned to its first IPv6 address (the original hostname is
/// still used 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 broken family and the request
/// stalls. When the variable is unset (the default), behavior is unchanged.
async fn welist_http_client(welist_url: &str) -> rClient {
let prefer_ipv6 = env::var("BAL_PUSHER_PREFER_IPV6")
.unwrap_or("false".to_string())
.parse::<bool>()
.unwrap_or(false);
if !prefer_ipv6 {
return new_welist_client();
}
let (host, port) = match parse_host_port(welist_url) {
Some(hp) => hp,
None => {
warn!("BAL_PUSHER_PREFER_IPV6: cannot parse '{welist_url}', using default resolver");
return new_welist_client();
}
};
match resolve_first_ipv6(&host, port).await {
Some(addr) => {
debug!("BAL_PUSHER_PREFER_IPV6: pinning {host} to {addr}");
rClient::builder()
.timeout(Duration::from_secs(10))
.resolve(&host, addr)
.build()
.unwrap_or_else(|_| new_welist_client())
}
None => {
debug!("BAL_PUSHER_PREFER_IPV6: no IPv6 address for {host}, using default resolver");
new_welist_client()
}
}
}
fn new_welist_client() -> rClient {
rClient::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| rClient::new())
}
async fn send_stats_report( async fn send_stats_report(
cfg: &MyConfig, cfg: &MyConfig,
bcinfo: GetBlockchainInfoResult, bcinfo: GetBlockchainInfoResult,
@@ -430,14 +466,18 @@ async fn send_stats_report(
debug!("sending report to welist"); debug!("sending report to welist");
let welist_url = env::var("WELIST_SERVER_URL") let welist_url = env::var("WELIST_SERVER_URL")
.unwrap_or("https://welist.bitcoin-after.life".to_string()); .unwrap_or("https://welist.bitcoin-after.life".to_string());
if !is_valid_welist_url(&welist_url) { let skip_validation = env::var("WELIST_SKIP_URL_VALIDATION")
.unwrap_or("false".to_string())
.parse::<bool>()
.unwrap_or(false);
if !skip_validation && !is_valid_welist_url(&welist_url) {
warn!( warn!(
"Invalid or unsafe WELIST_SERVER_URL: {}. Skipping stats report.", "Invalid or unsafe WELIST_SERVER_URL: {}. Skipping stats report.",
welist_url welist_url
); );
return Ok(()); return Ok(());
} }
let client = rClient::new(); let client = welist_http_client(&welist_url).await;
let url = format!("{}/ping", welist_url); let url = format!("{}/ping", welist_url);
debug!("welist url: {}", url); debug!("welist url: {}", url);
let chain = bcinfo.chain.to_string().to_lowercase(); let chain = bcinfo.chain.to_string().to_lowercase();
@@ -446,7 +486,7 @@ async fn send_stats_report(
cfg.url, chain, bcinfo.blocks, bcinfo.median_time, bcinfo.best_block_hash cfg.url, chain, bcinfo.blocks, bcinfo.median_time, bcinfo.best_block_hash
); );
trace!("message to be sent: {}", message); trace!("message to be sent: {}", message);
let sign = sign_message(cfg.ssl_key_path.as_str(), &message.as_str()); let sign = sign_message(cfg.ssl_key_path.as_str(), message.as_str());
let response = client let response = client
.post(url) .post(url)
.header("User-Agent", format!("bal-pusher/{}", VERSION)) .header("User-Agent", format!("bal-pusher/{}", VERSION))
@@ -461,32 +501,23 @@ async fn send_stats_report(
})) }))
.send() .send()
.await?; .await?;
if !response.status().is_success() { let status = response.status();
warn!( let body = response.text().await?;
"Non-success response: {} {}", info!(
response.status(), "Report to welist({}) status={} body={}",
response.status().canonical_reason().unwrap_or("") welist_url, status, body
); );
}
let body = &(response.text().await?);
info!("Report to welist({})\tSent: {}", welist_url, body);
} else { } else {
debug!("Not sending stats"); debug!("Not sending stats");
} }
Ok(()) Ok(())
} }
fn sign_message(private_key_path: &str, message: &str) -> String { fn sign_message(private_key_path: &str, message: &str) -> String {
let key_data = fs::read(private_key_path).unwrap(); let signing_key =
SigningKey::read_pkcs8_pem_file(private_key_path).expect("failed to parse private key PEM");
let signature = signing_key.sign(message.as_bytes());
let private_key = PKey::private_key_from_pem(&key_data).unwrap(); general_purpose::STANDARD.encode(signature.to_bytes())
let mut signer = Signer::new_without_digest(&private_key).unwrap();
let signature = signer.sign_oneshot_to_vec(message.as_bytes()).unwrap();
let signature_b64 = general_purpose::STANDARD.encode(&signature);
signature_b64
} }
fn parse_env(cfg: &mut MyConfig) { fn parse_env(cfg: &mut MyConfig) {
@@ -505,144 +536,47 @@ fn parse_env_netconfig(cfg_lock: &mut MyConfig, chain: &str) -> NetworkParams {
"testnet4" => &mut cfg_lock.testnet4, "testnet4" => &mut cfg_lock.testnet4,
&_ => &mut cfg_lock.mainnet, &_ => &mut cfg_lock.mainnet,
}; };
match env::var(format!("BAL_PUSHER_{}_HOST", chain.to_uppercase())) { if let Ok(value) = env::var(format!("BAL_PUSHER_{}_HOST", chain.to_uppercase())) {
Ok(value) => { cfg.host = value;
cfg.host = value;
}
Err(_) => {}
} }
match env::var(format!("BAL_PUSHER_{}_PORT", chain.to_uppercase())) { if let Ok(value) = env::var(format!("BAL_PUSHER_{}_PORT", chain.to_uppercase()))
Ok(value) => match value.parse::<u64>() { && let Ok(port_num) = value.parse::<u64>()
Ok(value) => match u16::try_from(value) { {
Ok(port) => cfg.port = port, if let Ok(port) = u16::try_from(port_num) {
Err(e) => { cfg.port = port;
error!( } else {
"Port value {} exceeds u16 range for chain {}: {}", error!(
value, chain, e "Port value {} exceeds u16 range for chain {}",
); port_num, chain
} );
},
Err(_) => {}
},
Err(_) => {}
}
match env::var(format!("BAL_PUSHER_{}_DIR_PATH", chain.to_uppercase())) {
Ok(value) => {
cfg.dir_path = value;
} }
Err(_) => {}
} }
match env::var(format!("BAL_PUSHER_{}_DB_FIELD", chain.to_uppercase())) { if let Ok(value) = env::var(format!("BAL_PUSHER_{}_DIR_PATH", chain.to_uppercase())) {
Ok(value) => { cfg.dir_path = value;
cfg.db_field = value;
}
Err(_) => {}
} }
match env::var(format!("BAL_PUSHER_{}_COOKIE_FILE", chain.to_uppercase())) { if let Ok(value) = env::var(format!("BAL_PUSHER_{}_DB_FIELD", chain.to_uppercase())) {
Ok(value) => { cfg.db_field = value;
cfg.cookie_file = value;
}
Err(_) => {}
} }
match env::var(format!("BAL_PUSHER_{}_RPC_USER", chain.to_uppercase())) { if let Ok(value) = env::var(format!("BAL_PUSHER_{}_COOKIE_FILE", chain.to_uppercase())) {
Ok(value) => { cfg.cookie_file = value;
cfg.rpc_user = value;
}
Err(_) => {}
} }
match env::var(format!("BAL_PUSHER_{}_RPC_PASSWORD", chain.to_uppercase())) { if let Ok(value) = env::var(format!("BAL_PUSHER_{}_RPC_USER", chain.to_uppercase())) {
Ok(value) => { cfg.rpc_user = value;
cfg.rpc_pass = value;
}
Err(_) => {}
} }
println!( if let Ok(value) = env::var(format!("BAL_PUSHER_{}_RPC_PASSWORD", chain.to_uppercase())) {
"{}", cfg.rpc_pass = value;
format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase()) }
); if let Ok(value) = env::var(format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())) {
match env::var(format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())) { cfg.zmq_listener = value;
Ok(value) => {
println!("value:{}", value);
cfg.zmq_listener = value;
}
Err(_) => {}
} }
cfg.clone() cfg.clone()
} }
fn check_zmq_connection(endpoint: &str) -> bool {
trace!("check zmq connection");
let context = Context::new();
let socket = match context.socket(DEALER) {
Ok(sock) => sock,
Err(_) => return false,
};
if socket.connect(endpoint).is_err() {
return false;
}
// Try to send an empty message non-blocking
socket.send("", DONTWAIT).is_ok()
}
// Add this struct to monitor connection health
struct ConnectionMonitor {
last_message_time: Instant,
timeout: Duration,
consecutive_timeouts: u32,
max_consecutive_timeouts: u32,
}
impl ConnectionMonitor {
fn new(timeout_secs: u64, max_timeouts: u32) -> Self {
Self {
last_message_time: Instant::now(),
timeout: Duration::from_secs(timeout_secs),
consecutive_timeouts: 0,
max_consecutive_timeouts: max_timeouts,
}
}
fn update(&mut self) {
self.last_message_time = Instant::now();
self.consecutive_timeouts = 0;
}
fn check_connection(&mut self) -> ConnectionStatus {
let elapsed = self.last_message_time.elapsed();
if elapsed > self.timeout {
self.consecutive_timeouts += 1;
if self.consecutive_timeouts >= self.max_consecutive_timeouts {
ConnectionStatus::Lost(elapsed)
} else {
ConnectionStatus::Warning(elapsed)
}
} else {
ConnectionStatus::Healthy
}
}
fn reset(&mut self) {
self.consecutive_timeouts = 0;
self.last_message_time = Instant::now();
}
}
enum ConnectionStatus {
Healthy,
Warning(Duration),
Lost(Duration),
}
#[tokio::main] #[tokio::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
env_logger::init(); env_logger::init();
let mut cfg = MyConfig::default(); let mut cfg = MyConfig::default();
let dbfile = env::var("BAL_PUSHER_DB_FILE").unwrap();
parse_env(&mut cfg); parse_env(&mut cfg);
let mut args = std::env::args(); let mut args = std::env::args();
let _exe_name = args.next().unwrap(); let _exe_name = args.next().unwrap();
@@ -677,31 +611,48 @@ async fn main() -> std::io::Result<()> {
} }
match socket.set_subscribe(b"") { match socket.set_subscribe(b"") {
Ok(_) => {} Ok(_) => {
info!("ZMQ subscribed to all topics on {}", zmq_address);
}
Err(e) => { Err(e) => {
error!("ZMQ subscribe failed: {}, exiting", e); error!("ZMQ subscribe failed: {}, exiting", e);
return Ok(()); return Ok(());
} }
} }
let _ = main_result(&cfg, network_params).await; if let Err(e) = main_result(&cfg, network_params).await {
error!("main_result failed on startup: {}", e);
}
info!("waiting new blocks.."); info!("waiting new blocks..");
let mut last_seq: Vec<u8> = [0; 4].to_vec();
let mut counter = 0;
let max = 100;
socket.set_rcvtimeo(5000).unwrap(); // 5 seconds timeout socket.set_rcvtimeo(5000).unwrap(); // 5 seconds timeout
let mut consecutive_timeouts: u32 = 0;
loop { loop {
let message = match socket.recv_multipart(0) { let message = match socket.recv_multipart(0) {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
warn!("ZMQ recv timeout or error: {}, retrying...", e); consecutive_timeouts += 1;
if consecutive_timeouts.is_multiple_of(720) {
error!(
"No ZMQ messages for {}s ({} consecutive timeouts), is bitcoind ZMQ active on {}?",
consecutive_timeouts * 5,
consecutive_timeouts,
zmq_address
);
} else {
trace!("ZMQ recv timeout or error: {}, retrying...", e);
}
continue; continue;
} }
}; };
if consecutive_timeouts > 0 {
info!(
"ZMQ connection restored after {} consecutive timeouts",
consecutive_timeouts
);
}
consecutive_timeouts = 0;
let topic = message[0].clone(); let topic = message[0].clone();
let body = message[1].clone(); let body = message[1].clone();
let seq = message[2].clone();
last_seq = seq;
debug!( debug!(
"ZMQ:GET TOPIC: {}", "ZMQ:GET TOPIC: {}",
String::from_utf8(topic.clone()).expect("invalid topic") String::from_utf8(topic.clone()).expect("invalid topic")
@@ -709,18 +660,52 @@ async fn main() -> std::io::Result<()> {
trace!("ZMQ:GET BODY: {}", hex::encode(&body)); trace!("ZMQ:GET BODY: {}", hex::encode(&body));
if topic == b"hashblock" { if topic == b"hashblock" {
info!("NEW BLOCK: {}", hex::encode(&body)); info!("NEW BLOCK: {}", hex::encode(&body));
let _ = main_result(&cfg, network_params).await; if let Err(e) = main_result(&cfg, network_params).await {
error!("main_result failed on new block: {}", e);
}
} }
thread::sleep(Duration::from_millis(100)); // Sleep for 100ms thread::sleep(Duration::from_millis(100)); // Sleep for 100ms
} }
} }
fn seq_to_str(seq: &Vec<u8>) -> String {
if seq.len() == 4 { #[cfg(test)]
let mut rdr = Cursor::new(seq); mod tests {
let sequence = rdr use super::*;
.read_u32::<LittleEndian>()
.expect("Failed to read integer"); #[test]
return sequence.to_string(); fn parse_host_port_https_default_port() {
assert_eq!(
parse_host_port("https://welist.bitcoin-after.life"),
Some(("welist.bitcoin-after.life".to_string(), 443))
);
}
#[test]
fn parse_host_port_explicit_port_and_path() {
assert_eq!(
parse_host_port("https://example.com:8443/ping"),
Some(("example.com".to_string(), 8443))
);
}
#[test]
fn parse_host_port_http_default_port() {
assert_eq!(
parse_host_port("http://example.com"),
Some(("example.com".to_string(), 80))
);
}
#[test]
fn parse_host_port_ipv6_literal_brackets_stripped() {
assert_eq!(
parse_host_port("https://[2a13:2c0::1]:443"),
Some(("2a13:2c0::1".to_string(), 443))
);
}
#[test]
fn parse_host_port_invalid_url() {
assert_eq!(parse_host_port("not a url"), None);
} }
"Unknown".to_string()
} }

View File

@@ -7,7 +7,6 @@ use chrono::Utc;
use hex_conservative::FromHex; use hex_conservative::FromHex;
use log::{debug, error, info, trace}; use log::{debug, error, info, trace};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json;
use sqlite::State; use sqlite::State;
use sqlite::{Connection, Value}; use sqlite::{Connection, Value};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -119,6 +118,7 @@ pub struct StatsResponse {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[expect(dead_code)]
struct ActixConfig { struct ActixConfig {
max_body_size: usize, max_body_size: usize,
timeout_secs: u64, timeout_secs: u64,
@@ -208,7 +208,7 @@ async fn echo_pub_key(data: web::Data<AppState>) -> impl Responder {
"Failed to read public key file {}: {}", "Failed to read public key file {}: {}",
data.cfg.pub_key_path, e data.cfg.pub_key_path, e
); );
HttpResponse::InternalServerError().body("Failed to read public key file") HttpResponse::InternalServerError().body("error")
} }
} }
} }
@@ -217,6 +217,38 @@ async fn echo_version() -> impl Responder {
HttpResponse::Ok().body(VERSION) HttpResponse::Ok().body(VERSION)
} }
fn is_valid_ip(ip: &str) -> bool {
ip.parse::<std::net::IpAddr>().is_ok()
}
fn extract_client_ip(req: &actix_web::HttpRequest) -> String {
if let Some(val) = req.headers().get("X-Real-IP")
&& let Ok(s) = val.to_str()
{
let ip = s.split(',').next().unwrap_or(s).trim();
if is_valid_ip(ip) {
debug!("client IP from X-Real-IP: {}", ip);
return ip.to_string();
}
}
if let Some(val) = req.headers().get("X-Forwarded-For")
&& let Ok(s) = val.to_str()
{
let ip = s.split(',').next().unwrap_or(s).trim();
if is_valid_ip(ip) {
debug!("client IP from X-Forwarded-For: {}", ip);
return ip.to_string();
}
}
let fallback = req
.connection_info()
.peer_addr()
.unwrap_or("unknown")
.to_string();
debug!("client IP from peer_addr fallback: {}", fallback);
fallback
}
async fn echo_info( async fn echo_info(
path: web::Path<String>, path: web::Path<String>,
data: web::Data<AppState>, data: web::Data<AppState>,
@@ -224,26 +256,15 @@ async fn echo_info(
) -> impl Responder { ) -> impl Responder {
let param = path.into_inner(); let param = path.into_inner();
if !NETWORKS.contains(&param.as_str()) { if !NETWORKS.contains(&param.as_str()) {
return HttpResponse::NotFound().body("Unknown network"); return HttpResponse::NotFound().body("error");
} }
info!("echo info!!!{}", param); info!("echo info!!!{}", param);
let netconfig = data.cfg.get_net_config(&param); let netconfig = data.cfg.get_net_config(&param);
if !netconfig.enabled { if !netconfig.enabled {
debug!("network disabled {}", param); debug!("network disabled {}", param);
return HttpResponse::BadRequest().body("network disabled"); return HttpResponse::BadRequest().body("error");
} }
let remote_addr = req let remote_addr = extract_client_ip(&req);
.headers()
.get("X-Real-IP")
.and_then(|value| value.to_str().ok())
.and_then(|xff| xff.split(',').next())
.map(|ip| ip.trim().to_string())
.unwrap_or_else(|| {
req.connection_info()
.peer_addr()
.unwrap_or("unknown")
.to_string()
});
let address = match netconfig.xpub { let address = match netconfig.xpub {
false => { false => {
let address = netconfig.address.to_string(); let address = netconfig.address.to_string();
@@ -257,7 +278,7 @@ async fn echo_info(
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned in echo_info (lookup phase)"); error!("DB mutex poisoned in echo_info (lookup phase)");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
match get_last_used_address_by_ip( match get_last_used_address_by_ip(
@@ -275,10 +296,7 @@ async fn echo_info(
version: VERSION.to_string(), version: VERSION.to_string(),
}); });
} }
None => { None => get_next_address_index(&db, &netconfig.name, &netconfig.address),
let next = get_next_address_index(&db, &netconfig.name, &netconfig.address);
next
}
} }
}; // lock released }; // lock released
@@ -288,8 +306,7 @@ async fn echo_info(
Ok(address) => address, Ok(address) => address,
Err(e) => { Err(e) => {
error!("Failed to derive address from xpub: {}", e); error!("Failed to derive address from xpub: {}", e);
return HttpResponse::BadRequest() return HttpResponse::BadRequest().body("error");
.body(format!("Failed to derive address: {}", e));
} }
}; };
@@ -299,7 +316,7 @@ async fn echo_info(
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned in echo_info (save phase)"); error!("DB mutex poisoned in echo_info (save phase)");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
save_new_address(&db, next_idx.0, &derived.0, &derived.1, &remote_addr); save_new_address(&db, next_idx.0, &derived.0, &derived.1, &remote_addr);
@@ -322,30 +339,30 @@ async fn echo_info(
debug!("echo info reply: {}", json_data); debug!("echo info reply: {}", json_data);
HttpResponse::Ok().json(info) HttpResponse::Ok().json(info)
} }
Err(err) => HttpResponse::InternalServerError().body(format!("error:{}", err)), Err(_err) => HttpResponse::InternalServerError().body("error"),
} }
} }
async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl Responder { async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
let param = path.into_inner(); let param = path.into_inner();
if !NETWORKS.contains(&param.as_str()) { if !NETWORKS.contains(&param.as_str()) {
return HttpResponse::NotFound().body("Unknown network"); return HttpResponse::NotFound().body("error");
} }
info!("echo stats!!! {}", data.cfg.expose_stats); info!("echo stats!!! {}", data.cfg.expose_stats);
let netconfig = data.cfg.get_net_config(&param); let netconfig = data.cfg.get_net_config(&param);
if !netconfig.enabled { if !netconfig.enabled {
debug!("network disabled {}", param); debug!("network disabled {}", param);
return HttpResponse::BadRequest().body("network disabled"); return HttpResponse::BadRequest().body("error");
} }
if !data.cfg.expose_stats { if !data.cfg.expose_stats {
return HttpResponse::Forbidden().body("Stats not exposed"); return HttpResponse::Forbidden().body("error");
} }
let mut stats: Vec<StatsResponse> = vec![]; let mut stats: Vec<StatsResponse> = vec![];
let db = match data.db.lock() { let db = match data.db.lock() {
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned in echo_stats"); error!("DB mutex poisoned in echo_stats");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
let mut stmt = match db.prepare( let mut stmt = match db.prepare(
@@ -354,12 +371,12 @@ async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
error!("Failed to prepare stats query: {}", e); error!("Failed to prepare stats query: {}", e);
return HttpResponse::InternalServerError().body("Database error"); return HttpResponse::InternalServerError().body("error");
} }
}; };
if let Err(e) = stmt.bind((1, Value::String(netconfig.name.clone()))) { if let Err(e) = stmt.bind((1, Value::String(netconfig.name.clone()))) {
error!("Failed to bind chain in stats query: {}", e); error!("Failed to bind chain in stats query: {}", e);
return HttpResponse::InternalServerError().body("Database error"); return HttpResponse::InternalServerError().body("error");
} }
while let Ok(State::Row) = stmt.next() { while let Ok(State::Row) = stmt.next() {
let report_date = stmt.read("report_date").unwrap_or("0".to_string()); let report_date = stmt.read("report_date").unwrap_or("0".to_string());
@@ -417,13 +434,8 @@ async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl
unique_inputs, unique_inputs,
}); });
} }
match serde_json::to_string(&stats) { debug!("echo stats reply for chain: {}", netconfig.name);
Ok(json_data) => { HttpResponse::Ok().json(stats)
debug!("echo info reply: {}", json_data);
HttpResponse::Ok().json(stats)
}
Err(err) => HttpResponse::InternalServerError().body(format!("error:{}", err)),
}
} }
async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder { async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
@@ -431,33 +443,33 @@ async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
let strbody = match std::str::from_utf8(&body) { let strbody = match std::str::from_utf8(&body) {
Ok(s) => s, Ok(s) => s,
Err(_) => { Err(_) => {
return HttpResponse::BadRequest().body("Invalid UTF-8 body"); return HttpResponse::BadRequest().body("error");
} }
}; };
info!("{}", strbody); info!("{}", strbody);
if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit()) if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit())
{ {
return HttpResponse::BadRequest().body("Invalid txid"); return HttpResponse::BadRequest().body("error");
} }
let db = match data.db.lock() { let db = match data.db.lock() {
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned in echo_search"); error!("DB mutex poisoned in echo_search");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
let mut statement = match db.prepare("SELECT * FROM tbl_tx WHERE txid = ? LIMIT 1") { let mut statement = match db.prepare("SELECT * FROM tbl_tx WHERE txid = ? LIMIT 1") {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
error!("Failed to prepare statement: {}", e); error!("Failed to prepare statement: {}", e);
return HttpResponse::InternalServerError().body("Database error"); return HttpResponse::InternalServerError().body("error");
} }
}; };
if let Err(e) = statement.bind((1, strbody)) { if let Err(e) = statement.bind((1, strbody)) {
error!("Failed to bind parameter: {}", e); error!("Failed to bind parameter: {}", e);
return HttpResponse::InternalServerError().body("Database error"); return HttpResponse::InternalServerError().body("error");
} }
if let Ok(State::Row) = statement.next() { if let Ok(State::Row) = statement.next() {
@@ -503,11 +515,14 @@ async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
} }
} }
match serde_json::to_string(&response_data) { match serde_json::to_string(&response_data) {
Ok(json_data) => HttpResponse::Ok().json(json_data), Ok(json_data) => {
Err(_) => HttpResponse::BadRequest().body("Bad data received"), debug!("echo search reply: {}", json_data);
HttpResponse::Ok().json(&response_data)
}
Err(_) => HttpResponse::BadRequest().body("error"),
} }
} else { } else {
HttpResponse::BadRequest().body("Bad data received") HttpResponse::BadRequest().body("error")
} }
} }
@@ -524,15 +539,14 @@ struct ParsedTx {
} }
/// Parse all transactions from the request body **without** needing the DB lock. /// Parse all transactions from the request body **without** needing the DB lock.
/// Returns `Ok(parsed_txs)` if at least one tx was valid, or `Err(HttpResponse)` for early failure. /// Skips transactions that don't have a valid willexecutor output.
fn parse_request_transactions( fn parse_request_transactions(
strbody: &str, strbody: &str,
_req_time: i64, _req_time: i64,
netconfig: &NetConfig, netconfig: &NetConfig,
known_addresses: &HashSet<String>, known_addresses: &HashSet<String>,
) -> Result<Vec<(ParsedTx, String, u64)>, HttpResponse> { ) -> Vec<(ParsedTx, String, u64)> {
let mut result: Vec<(ParsedTx, String, u64)> = Vec::new(); let mut result: Vec<(ParsedTx, String, u64)> = Vec::new();
let mut union_tx = true;
for line in strbody.split('\n') { for line in strbody.split('\n') {
if line.is_empty() { if line.is_empty() {
@@ -613,13 +627,8 @@ fn parse_request_transactions(
} }
if !found { if !found {
error!("willexecutor output not found for tx {}", txid); trace!("willexecutor output not found for tx {}, skipping", txid);
return Err(HttpResponse::BadRequest().body("Bad data received")); continue;
}
if !union_tx {
// This is only used for SQL building later; we track it in the caller
} else {
union_tx = false;
} }
result.push(( result.push((
ParsedTx { ParsedTx {
@@ -636,7 +645,7 @@ fn parse_request_transactions(
)); ));
} }
Ok(result) result
} }
async fn echo_push( async fn echo_push(
@@ -648,24 +657,24 @@ async fn echo_push(
let strbody = match std::str::from_utf8(&body) { let strbody = match std::str::from_utf8(&body) {
Ok(s) => s, Ok(s) => s,
Err(_) => { Err(_) => {
return HttpResponse::BadRequest().body("Invalid UTF-8 body"); return HttpResponse::BadRequest().body("error");
} }
}; };
let param = path.into_inner(); let param = path.into_inner();
if !NETWORKS.contains(&param.as_str()) { if !NETWORKS.contains(&param.as_str()) {
return HttpResponse::NotFound().body("Unknown network"); return HttpResponse::NotFound().body("error");
} }
let netconfig = data.cfg.get_net_config(&param); let netconfig = data.cfg.get_net_config(&param);
if !netconfig.enabled { if !netconfig.enabled {
trace!("network not enabled {}", &netconfig.name); trace!("network not enabled {}", &netconfig.name);
return HttpResponse::BadRequest().body("Network not enabled"); return HttpResponse::BadRequest().body("error");
} }
let req_time = match Utc::now().timestamp_nanos_opt() { let req_time = match Utc::now().timestamp_nanos_opt() {
Some(t) => t, Some(t) => t,
None => { None => {
error!("Invalid timestamp"); error!("Invalid timestamp");
return HttpResponse::BadRequest().body("Invalid timestamp"); return HttpResponse::BadRequest().body("error");
} }
}; };
@@ -675,7 +684,7 @@ async fn echo_push(
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned acquiring addresses in echo_push"); error!("DB mutex poisoned acquiring addresses in echo_push");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
if netconfig.xpub { if netconfig.xpub {
@@ -683,7 +692,7 @@ async fn echo_push(
Ok(addrs) => addrs, Ok(addrs) => addrs,
Err(e) => { Err(e) => {
error!("Failed to load addresses from xpub: {}", e); error!("Failed to load addresses from xpub: {}", e);
return HttpResponse::InternalServerError().body("Database error"); return HttpResponse::InternalServerError().body("error");
} }
} }
} else { } else {
@@ -692,12 +701,9 @@ async fn echo_push(
}; // lock released here }; // lock released here
// Parse all transactions (CPU-bound, no DB needed) // Parse all transactions (CPU-bound, no DB needed)
let parsed = match parse_request_transactions(strbody, req_time, netconfig, &known_addresses) { let parsed = parse_request_transactions(strbody, req_time, netconfig, &known_addresses);
Ok(v) => v,
Err(resp) => return resp,
};
if parsed.is_empty() { if parsed.is_empty() {
return HttpResponse::Ok().body("thx"); return HttpResponse::BadRequest().body("error");
} }
let all_txids: Vec<String> = parsed.iter().map(|(p, _, _)| p.txid.clone()).collect(); let all_txids: Vec<String> = parsed.iter().map(|(p, _, _)| p.txid.clone()).collect();
@@ -708,14 +714,14 @@ async fn echo_push(
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned in echo_push duplicate check"); error!("DB mutex poisoned in echo_push duplicate check");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
match check_duplicate_txids(&db, &all_txids) { match check_duplicate_txids(&db, &all_txids) {
Ok(dups) => dups, Ok(dups) => dups,
Err(e) => { Err(e) => {
error!("Duplicate check failed: {}", e); error!("Duplicate check failed: {}", e);
return HttpResponse::InternalServerError().body("Database error"); return HttpResponse::InternalServerError().body("error");
} }
} }
}; // lock released here }; // lock released here
@@ -731,7 +737,7 @@ async fn echo_push(
Ok(g) => g, Ok(g) => g,
Err(_p) => { Err(_p) => {
error!("DB mutex poisoned in echo_push insert phase"); error!("DB mutex poisoned in echo_push insert phase");
return HttpResponse::InternalServerError().body("DB mutex poisoned"); return HttpResponse::InternalServerError().body("error");
} }
}; };
@@ -818,7 +824,7 @@ async fn echo_push(
if let Err(err) = execute_insert(&db, sqltxs, ptx, sqlinps, pinps, sqlouts, pouts) { if let Err(err) = execute_insert(&db, sqltxs, ptx, sqlinps, pinps, sqlouts, pouts) {
error!("execute_insert failed: {}", err); error!("execute_insert failed: {}", err);
return HttpResponse::BadRequest().body("Bad data received"); return HttpResponse::BadRequest().body("error");
} }
} // lock released } // lock released
@@ -900,7 +906,7 @@ async fn main() -> std::io::Result<()> {
let db = match open_db(&cfg.db_file) { let db = match open_db(&cfg.db_file) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
return Err(std::io::Error::new(std::io::ErrorKind::Other, e)); return Err(std::io::Error::other(e));
} }
}; };
@@ -915,15 +921,6 @@ async fn main() -> std::io::Result<()> {
cfg: cfg.clone(), cfg: cfg.clone(),
}); });
// Initialize networks
{
let db = data.db.lock().unwrap();
for network in NETWORKS {
let netconfig = data.cfg.get_net_config(network);
insert_xpub(&db, &netconfig.name.to_string(), &netconfig.address);
}
}
let bind_address = data.cfg.bind_address.clone(); let bind_address = data.cfg.bind_address.clone();
let bind_port = data.cfg.bind_port; let bind_port = data.cfg.bind_port;

View File

@@ -164,7 +164,7 @@ pub fn create_database(db: &Connection) {
let _ = db.execute("DROP INDEX IF EXISTS idx_stats_chain;"); 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("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');"); 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>{
@@ -178,16 +178,17 @@ 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 = match db.prepare("INSERT INTO tbl_xpub(network,xpub) VALUES(?, ?);") { let mut stmt =
Ok(s) => s, match db.prepare("INSERT OR IGNORE INTO tbl_xpub(network,xpub) VALUES(?, ?);") {
Err(e) => { Ok(s) => s,
error!("Failed to prepare xpub insert statement: {}", e); Err(e) => {
return; error!("Failed to prepare xpub insert statement: {}", e);
} return;
}; }
};
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) { if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
error!("Failed to bind network parameter for xpub insert: {}", e); error!("Failed to bind network parameter for xpub insert: {}", e);
return; return;

View File

@@ -11,6 +11,7 @@ use sha2::{Digest, Sha256};
use std::str::FromStr; use std::str::FromStr;
// Mainnet (BIP44/BIP49/BIP84) // Mainnet (BIP44/BIP49/BIP84)
#[allow(dead_code)]
enum BS58Prefix { enum BS58Prefix {
Xpub, Xpub,
Ypub, Ypub,
@@ -102,44 +103,29 @@ fn calc_checksum(desc: &str) -> Result<String, String> {
Ok(checksum) Ok(checksum)
} }
pub fn get_bitcoincore_descriptor(xpub: &String) -> String { pub fn get_bitcoincore_descriptor(xpub: &str) -> String {
let fingerprint = match calculate_fingerprint(xpub) { let fingerprint = match calculate_fingerprint(xpub) {
Ok(f) => f, Ok(f) => f,
Err(_) => return String::new(), // Invalid xpub, return empty descriptor Err(_) => return String::new(), // Invalid xpub, return empty descriptor
}; };
let mut bip = 84;
let cpub = xpub.to_string();
match &xpub[0..4] {
"vpub" => {
bip = 84;
}
"zpub" => {
bip = 84;
}
&_ => {
bip = 84;
}
};
let xpub_converted = match convert_xpub(xpub) { let xpub_converted = match convert_xpub(xpub) {
Ok(c) => c, Ok(c) => c,
Err(_) => return String::new(), // Invalid xpub, return empty descriptor Err(_) => return String::new(), // Invalid xpub, return empty descriptor
}; };
let descriptor = format!("wpkh([{}/84h/0h/0h]{}/0/*)", fingerprint, xpub_converted); let descriptor = format!("wpkh([{}/84h/0h/0h]{}/0/*)", fingerprint, xpub_converted);
let descriptor = match calc_checksum(&descriptor) { match calc_checksum(&descriptor) {
Ok(checksum) => { Ok(checksum) => {
let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor); let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor);
format!("{}#{}", clean_descriptor, checksum) format!("{}#{}", clean_descriptor, checksum)
} }
Err(err) => { Err(err) => {
eprintln!("Error: {}", err); eprintln!("Error: {}", err);
"".to_string() String::new()
} }
}; }
descriptor
//format!("{}#{}",descriptor,checksum)
} }
fn convert_xpub(xpub: &String) -> Result<String, String> { fn convert_xpub(xpub: &str) -> Result<String, String> {
if xpub.len() >= 4 && (&xpub[0..4] == "xpub" || &xpub[0..4] == "ypub" || &xpub[0..4] == "zpub") if xpub.len() >= 4 && (&xpub[0..4] == "xpub" || &xpub[0..4] == "ypub" || &xpub[0..4] == "zpub")
{ {
convert_to(xpub, BS58Prefix::Xpub) convert_to(xpub, BS58Prefix::Xpub)
@@ -165,7 +151,7 @@ fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
return Err("Data troppo corta".to_string()); return Err("Data troppo corta".to_string());
} }
let (payload, checksum) = data.split_at(data.len() - 4); let (payload, checksum) = data.split_at(data.len() - 4);
let hash = Sha256::digest(&Sha256::digest(payload)); let hash = Sha256::digest(Sha256::digest(payload));
if hash[0..4] != checksum[..] { if hash[0..4] != checksum[..] {
return Err("Checksum invalido".to_string()); return Err("Checksum invalido".to_string());
} }
@@ -173,7 +159,7 @@ fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
} }
fn base58check_encode(data: &[u8]) -> String { fn base58check_encode(data: &[u8]) -> String {
let checksum = &Sha256::digest(&Sha256::digest(data))[0..4]; let checksum = &Sha256::digest(Sha256::digest(data))[0..4];
let full = [data, checksum].concat(); let full = [data, checksum].concat();
bs58::encode(full).into_string() bs58::encode(full).into_string()
} }
@@ -205,7 +191,7 @@ pub fn new_address_from_xpub(
) -> Result<(String, String), Box<dyn std::error::Error>> { ) -> Result<(String, String), Box<dyn std::error::Error>> {
let xpub = Xpub::from_str(&convert_to(zpub, BS58Prefix::Xpub)?)?; let xpub = Xpub::from_str(&convert_to(zpub, BS58Prefix::Xpub)?)?;
let path = format!("m/0/{}", index); 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)?;
let public_key = derived_xpub.public_key; let public_key = derived_xpub.public_key;

View File

@@ -1,7 +1,6 @@
use bal_server::db::open_db; use bal_server::db::open_db;
use sqlite::State; use sqlite::State;
use std::fs; use std::fs;
use std::path::Path;
#[test] #[test]
fn test_open_db_blocks_traversal() { fn test_open_db_blocks_traversal() {
@@ -74,7 +73,7 @@ fn test_open_db_rejects_symlink() {
let _ = fs::remove_file(real); let _ = fs::remove_file(real);
let _ = fs::remove_file(link); let _ = fs::remove_file(link);
fs::File::create(real).unwrap(); fs::File::create(real).unwrap();
fs::soft_link(real, link).unwrap(); std::os::unix::fs::symlink(real, link).unwrap();
let res = open_db(link); let res = open_db(link);
assert!(res.is_err(), "Symlink DB path should be rejected"); assert!(res.is_err(), "Symlink DB path should be rejected");

View File

@@ -36,11 +36,8 @@ fn test_db_null_unwrap_or() {
let mut found_value = None; let mut found_value = None;
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| { let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
let row: HashMap<_, _> = pairs let row: HashMap<_, _> = pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect();
.into_iter() let totals = row["totals"].unwrap_or("0").to_string();
.map(|(k, v)| (k.to_string(), v.map(|s| s)))
.collect();
let totals = row["totals"].clone().unwrap_or("0").to_string();
found_value = Some(totals); found_value = Some(totals);
true true
}); });

View File

@@ -1,5 +1,4 @@
use std::fs; use std::fs;
use std::path::Path;
#[test] #[test]
fn test_gitignore_protection_env() { fn test_gitignore_protection_env() {
@@ -23,30 +22,20 @@ fn test_gitignore_protection_env() {
]; ];
for pattern in required_patterns { for pattern in required_patterns {
let has_exact = gitignore.contains(&pattern); let has_wildcard = gitignore.contains("*.env.local") || gitignore.contains(".env.local");
let has_wildcard = gitignore.contains(&format!("*.env.local"))
|| gitignore.contains(&format!(".env.local"));
let has_env = gitignore.contains("*.env") || gitignore.contains(".env"); let has_env = gitignore.contains("*.env") || gitignore.contains(".env");
// For .env.local, either .env.local or *.env.local is acceptable
let is_env_local = pattern == "*.env.local" || pattern == ".env.local"; let is_env_local = pattern == "*.env.local" || pattern == ".env.local";
if is_env_local { if is_env_local {
assert!( assert!(
has_wildcard, has_wildcard,
".gitignore must contain pattern '*.env.local' or '.env.local' to protect secrets", ".gitignore must contain pattern '*.env.local' or '.env.local' to protect secrets",
); );
} else if pattern == ".env.production" || pattern == ".env.secret" { } else if pattern == ".env.production"
assert!( || pattern == ".env.secret"
gitignore.contains(pattern), || pattern == "*.env"
".gitignore must contain pattern '{}' to protect secrets", || pattern == ".env"
pattern {
);
} else if pattern == "*.env" {
assert!(
has_env,
".gitignore must contain pattern '*.env' or '.env' to protect secrets",
);
} else if pattern == ".env" {
assert!( assert!(
has_env, has_env,
".gitignore must contain pattern '*.env' or '.env' to protect secrets", ".gitignore must contain pattern '*.env' or '.env' to protect secrets",
@@ -65,7 +54,6 @@ fn test_gitignore_protection_env() {
#[test] #[test]
fn test_no_private_key_in_git() { fn test_no_private_key_in_git() {
// Check that .gitignore includes private_key.pem
let gitignore = match fs::read_to_string(".gitignore") { let gitignore = match fs::read_to_string(".gitignore") {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -88,20 +76,17 @@ fn test_no_private_key_in_git() {
".gitignore must block chiave_privata.key" ".gitignore must block chiave_privata.key"
); );
// Check that no private key files are tracked by git
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(&["ls-files", "*.pem", "*.key"]) .args(["ls-files", "*.pem", "*.key"])
.output() .output()
.expect("Failed to run git ls-files"); .expect("Failed to run git ls-files");
let tracked_keys = String::from_utf8(output.stdout).unwrap(); let tracked_keys = String::from_utf8(output.stdout).unwrap();
let tracked_keys: Vec<&str> = tracked_keys.lines().collect(); let tracked_keys: Vec<&str> = tracked_keys.lines().collect();
// Only non-empty entries and only public_key.pem should be tracked
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) { for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
if !tracked.contains("public_key.pem") { if !tracked.contains("public_key.pem") {
assert!( panic!(
false,
"Private key file is tracked by git: {}. Remove it with git rm --cached", "Private key file is tracked by git: {}. Remove it with git rm --cached",
tracked tracked
); );
@@ -113,47 +98,41 @@ fn test_no_private_key_in_git() {
#[test] #[test]
fn test_no_token_in_source_files() { fn test_no_token_in_source_files() {
// Scan source files for hardcoded tokens
let mut found_issues = Vec::new(); let mut found_issues = Vec::new();
// Scan .sh files for hardcoded 40-char hex strings
for entry in fs::read_dir(".").unwrap().filter_map(|e| e.ok()) { for entry in fs::read_dir(".").unwrap().filter_map(|e| e.ok()) {
let path = entry.path(); let path = entry.path();
if !path.is_file() { if !path.is_file() {
continue; continue;
} }
if let Some(ext) = path.extension() { if let Some(ext) = path.extension()
if ext == "sh" { && ext == "sh"
let content = fs::read_to_string(&path).unwrap(); {
for (line_num, line) in content.lines().enumerate() { let content = fs::read_to_string(&path).unwrap();
// Skip comments and example/template files for (line_num, line) in content.lines().enumerate() {
if line.trim().starts_with("#") if line.trim().starts_with('#')
|| line.to_lowercase().contains("example") || line.to_lowercase().contains("example")
|| line.to_lowercase().contains("template") || 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"))
{ {
continue; found_issues.push(format!(
} "Potential hardcoded token in {}: line {}: {}",
// Check for 40-64 hex chars that could be API tokens (not in .env.example comments) path.display(),
if line.trim().len() >= 40 { line_num + 1,
let hex_chars = line line.trim()
.trim() ));
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<Vec<_>>();
if hex_chars.len() >= 40 && hex_chars.len() <= 64 {
// Check if it looks like it's part of a TOKEN assignment
if 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()
));
}
}
} }
} }
} }
@@ -165,8 +144,7 @@ fn test_no_token_in_source_files() {
for issue in &found_issues { for issue in &found_issues {
println!(" {}", issue); println!(" {}", issue);
} }
assert!( panic!(
false,
"Found potential hardcoded tokens in shell scripts: {:?}", "Found potential hardcoded tokens in shell scripts: {:?}",
found_issues found_issues
); );