Compare commits
24 Commits
56696050ec
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
7999902cc0
|
|||
|
d6b888e403
|
|||
|
36219c49a0
|
|||
|
ca530bf987
|
|||
|
1c76755ea6
|
|||
|
c371a4f478
|
|||
|
59250289a7
|
|||
|
0f0f0a08c3
|
|||
|
6e6c634e98
|
|||
|
efcd91e6b4
|
|||
| 22b60e55c7 | |||
| 7fe5fd3139 | |||
| b46f85f436 | |||
|
cd24eda111
|
|||
|
8ce3f6a445
|
|||
|
190cac929e
|
|||
|
5e1d0d7c54
|
|||
|
9081e08785
|
|||
|
06db6f1d48
|
|||
|
ba8f828e89
|
|||
|
9abfad29b9
|
|||
|
bc9ec1a48c
|
|||
|
c461232095
|
|||
|
fbe61a5862
|
58
.dockerignore
Normal file
58
.dockerignore
Normal file
@@ -0,0 +1,58 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitsecret
|
||||
|
||||
# Build artifacts
|
||||
target/
|
||||
|
||||
# Environment files (secrets)
|
||||
*.env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Private keys
|
||||
*.pem
|
||||
*.key
|
||||
!public_key.pem
|
||||
!data/public_key.pem
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Tests
|
||||
tests/
|
||||
|
||||
# Scripts (local dev only)
|
||||
bal-server.sh
|
||||
bal-pusher.sh
|
||||
download_bal_db.sh
|
||||
sendtx.sh
|
||||
lib.sh
|
||||
contrib/
|
||||
update
|
||||
update_codebase.txt
|
||||
|
||||
# Service files
|
||||
*.service
|
||||
|
||||
# IDE
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Misc
|
||||
Cargo.lock
|
||||
generate_random_ascii.sh
|
||||
test/
|
||||
invalid_txs/
|
||||
valid_txs/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,9 +8,6 @@
|
||||
.env.production
|
||||
.env.secret
|
||||
|
||||
# Shell scripts that load env vars (contain secrets, local only)
|
||||
bal-pusher.sh
|
||||
bal-server.sh
|
||||
|
||||
# Private keys - NEVER commit to git
|
||||
# Only public_key.pem should be tracked (if needed)
|
||||
@@ -39,3 +36,4 @@ Cargo.lock
|
||||
!lib/
|
||||
!contrib/
|
||||
!src/
|
||||
make_release.sh
|
||||
|
||||
275
Cargo.lock
generated
275
Cargo.lock
generated
@@ -312,7 +312,7 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "bal_server"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"actix-governor",
|
||||
"actix-rt",
|
||||
@@ -325,12 +325,11 @@ dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"confy",
|
||||
"ed25519-dalek",
|
||||
"env_logger",
|
||||
"hex",
|
||||
"hex-conservative 0.1.1",
|
||||
"log",
|
||||
"openssl",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -364,6 +363,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bech32"
|
||||
version = "0.11.0"
|
||||
@@ -592,16 +597,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
|
||||
|
||||
[[package]]
|
||||
name = "confy"
|
||||
version = "0.6.1"
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45b1f4c00870f07dc34adcac82bb6a72cc5aabca8536ba1797e01df51d2ce9a0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
@@ -747,6 +746,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
@@ -760,6 +786,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -809,7 +846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
@@ -824,27 +861,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -856,6 +872,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -916,6 +956,12 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.4"
|
||||
@@ -1578,16 +1624,6 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -1759,15 +1795,6 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.110"
|
||||
@@ -1776,17 +1803,10 @@ checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1810,6 +1830,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1828,6 +1857,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.30"
|
||||
@@ -1994,17 +2033,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex"
|
||||
version = "1.10.4"
|
||||
@@ -2326,6 +2354,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -2373,6 +2410,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sqlite"
|
||||
version = "0.34.0"
|
||||
@@ -2499,26 +2546,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
@@ -3032,15 +3059,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -3068,21 +3086,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.5"
|
||||
@@ -3116,12 +3119,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.5"
|
||||
@@ -3134,12 +3131,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.5"
|
||||
@@ -3152,12 +3143,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.5"
|
||||
@@ -3182,12 +3167,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.5"
|
||||
@@ -3200,12 +3179,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.5"
|
||||
@@ -3218,12 +3191,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.5"
|
||||
@@ -3236,12 +3203,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.5"
|
||||
|
||||
46
Cargo.toml
46
Cargo.toml
@@ -1,42 +1,58 @@
|
||||
[package]
|
||||
name = "bal_server"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[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]
|
||||
base64 = { version = "0.22.1" }
|
||||
bs58 = { version = "0.4.0" }
|
||||
bytes = { version = "1.2" }
|
||||
bitcoin = { version = "0.32.5" }
|
||||
bitcoincore-rpc = { version = "0.19.0" }
|
||||
bitcoincore-rpc-json = { version = "0.19.0" }
|
||||
byteorder = { version = "1.5.0" }
|
||||
confy = { version = "0.6.1" }
|
||||
chrono = { version = "0.4.40" }
|
||||
env_logger = { version = "0.11.5" }
|
||||
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" }
|
||||
openssl = { version = "0.10.74", features = ["vendored"] }
|
||||
sha2 = { version = "0.10.8" }
|
||||
serde = { version = "1.0.152", features = ["derive"] }
|
||||
serde_json = { version = "1.0.116" }
|
||||
sqlite = { version = "0.34.0" }
|
||||
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"] }
|
||||
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]]
|
||||
name = "bal-server"
|
||||
path = "src/bin/bal-server-actix.rs"
|
||||
path = "src/bin/bal-server.rs"
|
||||
required-features = ["server"]
|
||||
|
||||
[[bin]]
|
||||
name = "bal-pusher"
|
||||
path = "src/bin/bal-pusher.rs"
|
||||
|
||||
required-features = ["pusher"]
|
||||
|
||||
92
Dockerfile
Normal file
92
Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
||||
# =============================================================================
|
||||
# Multi-stage Dockerfile for bal-server + bal-pusher
|
||||
# Security: non-root user, minimal runtime, tini as PID 1
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Builder
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM rust:1.95-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libsodium-dev \
|
||||
libzmq5-dev \
|
||||
cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies: copy Cargo.toml first, create dummy src to build deps
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
RUN mkdir -p src/bin && \
|
||||
echo 'fn main() {}' > src/bin/bal-server.rs && \
|
||||
echo 'fn main() {}' > src/bin/bal-pusher.rs && \
|
||||
echo '' > src/lib.rs && \
|
||||
echo '' > src/db.rs && \
|
||||
echo '' > src/xpub.rs && \
|
||||
echo '' > src/validation.rs && \
|
||||
cargo build --release --bin bal-server --bin bal-pusher 2>/dev/null || true && \
|
||||
rm -rf src target/release/.fingerprint target/release/deps/*bal_server*
|
||||
|
||||
# Copy real source and build
|
||||
COPY src/ src/
|
||||
RUN cargo build --release --bin bal-server --bin bal-pusher && \
|
||||
strip target/release/bal-server target/release/bal-pusher
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
# Install runtime dependencies + tini for PID 1
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
libsodium23 \
|
||||
libzmq5 \
|
||||
libsqlite3-0 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Copy binaries from builder
|
||||
COPY --from=builder /build/target/release/bal-server /usr/local/bin/bal-server
|
||||
COPY --from=builder /build/target/release/bal-pusher /usr/local/bin/bal-pusher
|
||||
|
||||
# Create dedicated non-root user
|
||||
RUN groupadd -g 1000 bal && \
|
||||
useradd -u 1000 -g bal -s /usr/sbin/nologin -M bal && \
|
||||
mkdir -p /var/bal /var/bal/.bitcoin && \
|
||||
chown -R bal:bal /var/bal && \
|
||||
chmod 700 /var/bal
|
||||
|
||||
# Copy entrypoint
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Use tini as PID 1 for proper signal handling
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# Data directory (mount as volume)
|
||||
VOLUME ["/var/bal"]
|
||||
|
||||
# bal-server port (bind to 127.0.0.1 via env, expose for reverse proxy)
|
||||
EXPOSE 9137
|
||||
|
||||
# Default environment (override at runtime)
|
||||
ENV RUST_LOG=info \
|
||||
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
|
||||
BAL_SERVER_BIND_PORT=9137 \
|
||||
BAL_SERVER_DB_FILE=/var/bal/bal.db \
|
||||
BAL_PUSHER_DB_FILE=/var/bal/bal.db \
|
||||
BAL_SERVER_URL=http://127.0.0.1:9137 \
|
||||
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
|
||||
SSL_KEY_PATH=/var/bal/private_key.pem
|
||||
|
||||
# Health check: verify bal-server is responding
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -sf http://127.0.0.1:9137/ || exit 1
|
||||
202
README.md
202
README.md
@@ -1,85 +1,159 @@
|
||||
# bal-server
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ git clone ....
|
||||
$ cd bal-server
|
||||
$ openssl genpkey -algorithm ED25519 -out private_key.pem
|
||||
$ openssl pkey -in private_key.pem -pubout -out public_key.pem
|
||||
$ cargo build --release
|
||||
$ sudo cp target/release/bal-server /usr/local/bin
|
||||
$ bal-server
|
||||
git clone https://bitcoin-after.life/gitea/bitcoinafterlife/bal-server.git
|
||||
cd bal-server
|
||||
openssl genpkey -algorithm ED25519 -out private_key.pem
|
||||
openssl pkey -in private_key.pem -pubout -out public_key.pem
|
||||
cargo build --release
|
||||
sudo cp target/release/bal-server target/release/bal-pusher /usr/local/bin
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Docker
|
||||
|
||||
The `bal-server` application can be configured using environment variables. The following variables are available:
|
||||
### Build
|
||||
|
||||
```bash
|
||||
docker build -t bal-server .
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name bal-server \
|
||||
--network host \
|
||||
--tmpfs /tmp:rw,noexec,nosuid \
|
||||
-v /path/to/data:/var/bal:rw \
|
||||
-v /path/to/.bitcoin/regtest/.cookie:/var/bal/.bitcoin/regtest/.cookie:ro \
|
||||
-e BAL_SERVER_REGTEST_ADDRESS="your_xpub_or_address" \
|
||||
-e BAL_SERVER_REGTEST_FIXED_FEE=50000 \
|
||||
-e BAL_SERVER_INFO="BAL server" \
|
||||
-e BAL_PUSHER_NETWORK=regtest \
|
||||
-e BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332 \
|
||||
-e BAL_PUSHER_REGTEST_COOKIE_FILE=/var/bal/.bitcoin/regtest/.cookie \
|
||||
bal-server
|
||||
```
|
||||
|
||||
### Docker environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_SERVER_CONFIG_FILE` | Path to the configuration file. If the file does not exist, a new one will be created. | `$HOME/.config/bal-server/default-config.toml` |
|
||||
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. If the file does not exist, a new one will be created. | `bal.db` |
|
||||
| `BAL_SERVER_BIND_ADDRESS` | Public address for listening to requests. | `127.0.0.1` |
|
||||
| `BAL_SERVER_BIND_PORT` | Default port for listening to requests. | `9137` |
|
||||
| `BAL_SERVER_PUB_KEY_PATH` | WillExecutor Ed25519 public key | `public_key.pem` |
|
||||
| `BAL_SERVER_REGTEST_ADDRESS` | Bitcoin address for the regtest environment. | - |
|
||||
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee for the regtest environment. | 50000 |
|
||||
| `BAL_SERVER_SIGNET_ADDRESS` | Bitcoin address for the signet environment. | - |
|
||||
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee for the signet environment. | 50000 |
|
||||
| `BAL_SERVER_TESTNET_ADDRESS` | Bitcoin address for the testnet environment. | - |
|
||||
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee for the testnet environment. | 50000 |
|
||||
| `BAL_SERVER_BITCOIN_ADDRESS` | Bitcoin address for the mainnet environment. | - |
|
||||
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee for the mainnet environment. | 50000 |
|
||||
| `BAL_PUSHER_NETWORK` | Network to run pusher on (`bitcoin`, `testnet`, `testnet4`, `signet`, `regtest`). | `bitcoin` |
|
||||
| `BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK` | ZMQ endpoint for regtest blocks. | `tcp://127.0.0.1:21332` |
|
||||
| `BAL_PUSHER_REGTEST_COOKIE_FILE` | Absolute path to Bitcoin Core cookie file inside the container. | - |
|
||||
|
||||
> **Note:** The container runs as a non-root `bal` user (uid 1000) with `tini` as PID 1.
|
||||
> The `/var/bal` volume stores the database. Mount Bitcoin Core's cookie file as read-only.
|
||||
> When using `--network host`, ensure only `127.0.0.1` is used for internal services.
|
||||
|
||||
## Configuration (bal-server)
|
||||
|
||||
The `bal-server` application can be configured using environment variables.
|
||||
|
||||
### General
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
|
||||
| `BAL_SERVER_BIND_ADDRESS` | Address to listen on. **Never bind to `0.0.0.0` in production without a reverse proxy.** | `127.0.0.1` |
|
||||
| `BAL_SERVER_BIND_PORT` | Port to listen on. | `9137` |
|
||||
| `BAL_SERVER_INFO` | Server info string returned by the `/` endpoint. | - |
|
||||
| `BAL_SERVER_PUB_KEY_PATH` | Ed25519 public key for signature verification. | `public_key.pem` |
|
||||
| `BAL_SERVER_URL` | Public URL of this server (used for stats reporting). | - |
|
||||
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
|
||||
| `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`). | `info` |
|
||||
|
||||
### Per-network addresses and fees
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_SERVER_BITCOIN_ADDRESS` | xpub or address for mainnet. | - |
|
||||
| `BAL_SERVER_BITCOIN_FIXED_FEE` | Fixed fee (satoshis) for mainnet. | `50000` |
|
||||
| `BAL_SERVER_REGTEST_ADDRESS` | xpub or address for regtest. | - |
|
||||
| `BAL_SERVER_REGTEST_FIXED_FEE` | Fixed fee (satoshis) for regtest. | `50000` |
|
||||
| `BAL_SERVER_SIGNET_ADDRESS` | xpub or address for signet. | - |
|
||||
| `BAL_SERVER_SIGNET_FIXED_FEE` | Fixed fee (satoshis) for signet. | `50000` |
|
||||
| `BAL_SERVER_TESTNET_ADDRESS` | xpub or address for testnet. | - |
|
||||
| `BAL_SERVER_TESTNET_FIXED_FEE` | Fixed fee (satoshis) for testnet. | `50000` |
|
||||
| `BAL_SERVER_TESTNET4_ADDRESS` | xpub or address for testnet4. | - |
|
||||
| `BAL_SERVER_TESTNET4_FIXED_FEE` | Fixed fee (satoshis) for testnet4. | `50000` |
|
||||
|
||||
### DoS protection (Actix Web)
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_SERVER_ACTIX_MAX_BODY_SIZE` | Maximum request body size in bytes. | `1048576` (1 MB) |
|
||||
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | Request timeout in seconds. | `5` |
|
||||
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | Rate limit: push txs requests per second. | `1` |
|
||||
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | Rate limit: push txs burst size. | `3` |
|
||||
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | Rate limit: search tx requests per second. | `5` |
|
||||
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | Rate limit: search tx burst size. | `10` |
|
||||
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | Rate limit: info requests per second. | `20` |
|
||||
| `BAL_SERVER_ACTIX_INFO_BURST` | Rate limit: info burst size. | `30` |
|
||||
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | Rate limit: default requests per second. | `50` |
|
||||
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | Rate limit: default burst size. | `100` |
|
||||
| `BAL_SERVER_ACTIX_WORKERS` | Number of Actix worker threads. | `4` |
|
||||
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | Maximum concurrent connections. | `100` |
|
||||
|
||||
---
|
||||
|
||||
# bal-pusher
|
||||
|
||||
`bal-pusher` is a tool that retrieves Bitcoin transactions from a database and pushes them to the Bitcoin network when their **locktime** exceeds the **median time past** (MTP). It listens for Bitcoin block updates via ZMQ.
|
||||
`bal-pusher` monitors Bitcoin blocks via ZMQ and pushes time-locked transactions from the database to the Bitcoin network when their **locktime** exceeds the **median time past** (MTP).
|
||||
|
||||
## Installation
|
||||
## Prerequisites
|
||||
|
||||
To use `bal-pusher`, you need to compile and install Bitcoin with ZMQ (ZeroMQ) support enabled. Then, configure your Bitcoin node and `bal-pusher` to push the transactions.
|
||||
- **Bitcoin Core** with ZMQ support enabled. Add to `bitcoin.conf`:
|
||||
```
|
||||
zmqpubhashblock=tcp://127.0.0.1:28332
|
||||
```
|
||||
- **Rust and Cargo**: [Rust Installation](https://www.rust-lang.org/tools/install)
|
||||
- **Libraries**: `libssl-dev`, `libsodium-dev`, `libzmq5-dev`, `libsqlite3-dev`
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Bitcoin with ZMQ Support**:
|
||||
Ensure that Bitcoin is compiled with ZMQ support. Add the following line to your `bitcoin.conf` file:
|
||||
|
||||
```
|
||||
zmqpubhashblock=tcp://127.0.0.1:28332
|
||||
```
|
||||
|
||||
2. **Install Rust and Cargo**:
|
||||
If you haven't already installed Rust and Cargo, you can follow the official instructions to do so: [Rust Installation](https://www.rust-lang.org/tools/install).
|
||||
|
||||
## Configuration
|
||||
|
||||
`bal-pusher` can be configured using environment variables. If no configuration file is provided, a default configuration file will be created.
|
||||
|
||||
### Available Configuration Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---------------------------------------|------------------------------------------|----------------------------------------------|
|
||||
| `BAL_PUSHER_CONFIG_FILE` | Path to the configuration file. If the file does not exist, it will be created. | `$HOME/.config/bal-pusher/default-config.toml` |
|
||||
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. If the file does not exist, it will be created. | `bal.db` |
|
||||
| `BAL_PUSHER_ZMQ_LISTENER` | ZMQ listener for Bitcoin updates. | `tcp://127.0.0.1:28332` |
|
||||
| `BAL_PUSHER_BITCOIN_HOST` | Bitcoin server host for RPC connections. | `http://127.0.0.1` |
|
||||
| `BAL_PUSHER_BITCOIN_PORT` | Bitcoin RPC server port. | `8332` |
|
||||
| `BAL_PUSHER_BITCOIN_COOKIE_FILE` | Path to Bitcoin RPC cookie file. | `$HOME/.bitcoin/.cookie` |
|
||||
| `BAL_PUSHER_BITCOIN_RPC_USER` | Bitcoin RPC username. | - |
|
||||
| `BAL_PUSHER_BITCOIN_RPC_PASSWORD` | Bitcoin RPC password. | - |
|
||||
| `BAL_PUSHER_SEND_STATS` | Contact welist to provide times | false |
|
||||
| `WELIST_SERVER_URL` | welist server url to provide times | https://welist.bitcoin-afer.life |
|
||||
| `BAL_SERVER_URL` | WillExecutor server url | - |
|
||||
| `SSL_KEY_PATH` | Ed25519 private key pem file | `private_key.pem` |
|
||||
|
||||
|
||||
## Running `bal-pusher`
|
||||
|
||||
Once the application is installed and configured, you can start `bal-pusher` by running the following command:
|
||||
## Running
|
||||
|
||||
```bash
|
||||
$ bal-pusher [bitcoin|testnet|regtest|]
|
||||
bal-pusher [bitcoin|testnet|testnet4|signet|regtest]
|
||||
```
|
||||
|
||||
This will start the service, which will listen for Bitcoin blocks via ZMQ and push transactions from the database when their locktime exceeds the median time past.
|
||||
If no network is specified, defaults to `bitcoin`.
|
||||
|
||||
## Configuration (bal-pusher)
|
||||
|
||||
### General
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
|
||||
| `BAL_PUSHER_SEND_STATS` | Send stats to welist server. | `false` |
|
||||
| `BAL_SERVER_URL` | URL of bal-server (for stats reporting). | - |
|
||||
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
|
||||
| `WELIST_SERVER_URL` | Welist server URL. | `https://welist.bitcoin-after.life` |
|
||||
|
||||
### Per-network configuration
|
||||
|
||||
Each network (`bitcoin`, `regtest`, `testnet`, `testnet4`, `signet`) supports the following variables.
|
||||
Replace `{NETWORK}` with the uppercase network name (e.g., `REGTEST`, `BITCOIN`).
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_PUSHER_{NETWORK}_ZMQ_HASHBLOCK` | ZMQ endpoint for block notifications. | `tcp://127.0.0.1:28332` (mainnet) |
|
||||
| `BAL_PUSHER_{NETWORK}_COOKIE_FILE` | Absolute path to Bitcoin Core cookie file. | `$HOME/.bitcoin/{dir}/.cookie` |
|
||||
| `BAL_PUSHER_{NETWORK}_RPC_USER` | Bitcoin Core RPC username (alternative to cookie auth). | - |
|
||||
| `BAL_PUSHER_{NETWORK}_RPC_PASSWORD` | Bitcoin Core RPC password. | - |
|
||||
| `BAL_PUSHER_{NETWORK}_HOST` | Bitcoin Core RPC host. | `http://127.0.0.1` |
|
||||
| `BAL_PUSHER_{NETWORK}_PORT` | Bitcoin Core RPC port. | `8332` (mainnet) |
|
||||
| `BAL_PUSHER_{NETWORK}_DIR_PATH` | Bitcoin Core data directory subfolder. | `` (mainnet) |
|
||||
|
||||
Default ZMQ ports per network:
|
||||
|
||||
| Network | ZMQ Port | RPC Port |
|
||||
| --- | --- | --- |
|
||||
| `bitcoin` | 28332 | 8332 |
|
||||
| `regtest` | 21332 | 18443 |
|
||||
| `testnet` | 23332 | 18332 |
|
||||
| `testnet4` | 22332 | 48332 |
|
||||
| `signet` | 24332 | 38332 |
|
||||
|
||||
15
bal-pusher.sh
Normal file
15
bal-pusher.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
export RUST_LOG=trace
|
||||
|
||||
export BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
|
||||
#export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie
|
||||
#export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie
|
||||
#export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie
|
||||
#export BAL_PUSHER_SIGNET_COOKIE_FILE=/~/.bitcoin/signet/.cookie
|
||||
|
||||
export BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
|
||||
export BAL_PUSHER_SEND_STATS=true
|
||||
export WELIST_SERVER_URL=http://localhost:8086
|
||||
export WELIST_SKIP_URL_VALIDATION=true
|
||||
export BAL_SERVER_URL="http://127.0.0.1:9133"
|
||||
export SSL_KEY_PATH="$(pwd)/private_key.pem"
|
||||
cargo run --bin=bal-pusher regtest --features=pusher
|
||||
25
docker/entrypoint.sh
Normal file
25
docker/entrypoint.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /var/bal/.bitcoin
|
||||
chown bal:bal /var/bal /var/bal/.bitcoin 2>/dev/null || true
|
||||
|
||||
PUSHER_NETWORK="${BAL_PUSHER_NETWORK:-bitcoin}"
|
||||
|
||||
echo "[entrypoint] Starting bal-server on ${BAL_SERVER_BIND_ADDRESS:-127.0.0.1}:${BAL_SERVER_BIND_PORT:-9137}"
|
||||
echo "[entrypoint] Starting bal-pusher (network: ${PUSHER_NETWORK})"
|
||||
|
||||
su -s /bin/sh bal -c '/usr/local/bin/bal-server' &
|
||||
SERVER_PID=$!
|
||||
|
||||
su -s /bin/sh bal -c "/usr/local/bin/bal-pusher ${PUSHER_NETWORK}" &
|
||||
PUSHER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "[entrypoint] Shutting down..."
|
||||
kill $SERVER_PID $PUSHER_PID 2>/dev/null
|
||||
wait
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait
|
||||
@@ -45,6 +45,7 @@ WELIST_URL=https://welist.example.com/api/stats
|
||||
- `BAL_SSL_KEY_PATH`: The path to the Ed25519 private key (`private_key.pem`) used to sign the statistics payload before sending it to the `welist` server. This is a critical secret.
|
||||
- `SEND_STATS`: A boolean flag to enable the reporting of statistics to the remote `welist` server.
|
||||
- `WELIST_URL`: The URL to which the statistics are sent. If `SEND_STATS` is `true`, this URL must be reachable. If the server is unreachable, the pusher will log an error but might not crash (see `08_security_audit.md` for DoS analysis).
|
||||
- `BAL_PUSHER_PREFER_IPV6`: Optional boolean flag (default `false`). When set to `true`, the pusher resolves the `welist` host itself and pins the HTTP connection to its first IPv6 (AAAA) address, still using the hostname for the `Host` header and TLS SNI. This works around networks where the IPv4 route to the `welist` host is broken while IPv6 works — the default connector may otherwise pick the unreachable family and the request would stall. Leave unset unless you hit this specific connectivity problem.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,6 +134,14 @@ This script builds a release binary, creates a Git tag, and uploads the release
|
||||
```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)
|
||||
|
||||
@@ -87,7 +87,7 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
||||
- 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-actix.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)
|
||||
**Migration:** Server replaced `hyper` custom server with `actix-web` (see `src/bin/bal-server.rs`). All handlers migrated with `Arc<Mutex<Connection>>` shared DB. Old `bal-server.rs` (Hyper) removed. `bal-pusher` enhanced with ZMQ timeout (`ZMQ_RCVTIMEO` 5000ms) and RPC retry logic. **Priority:** High. (Mitigated)
|
||||
|
||||
### 5. SSRF / Network Abuse via `reqwest` (MEDIUM)
|
||||
**Location:** `src/bin/bal-pusher.rs`.
|
||||
@@ -121,7 +121,7 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
||||
- 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-actix.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).
|
||||
- ✅ **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.
|
||||
|
||||
154
make_release.sh
154
make_release.sh
@@ -1,154 +0,0 @@
|
||||
#!/bin/bash
|
||||
#author: <your-name>
|
||||
|
||||
source lib.sh
|
||||
usage() {
|
||||
echo_w "./make_release <version> <message>"
|
||||
}
|
||||
|
||||
if [ -n "$1" ]; then release=$1; else usage; exit; fi
|
||||
|
||||
if [ -n "$2" ]; then message=$2; else
|
||||
# Create temporary file using mktemp
|
||||
TEMPFILE=$(mktemp)
|
||||
vi $TEMPFILE
|
||||
message=$(cat $TEMPFILE)
|
||||
rm $TEMPFILE
|
||||
fi
|
||||
|
||||
echo_i $message
|
||||
|
||||
# Load secrets from .env file (not committed to git)
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
TOKEN="${GITEA_API_TOKEN}"
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo_e "Error: GITEA_API_TOKEN is not set in .env file."
|
||||
echo_e "Please create a .env file with: GITEA_API_TOKEN=your_token_here"
|
||||
exit 1
|
||||
fi
|
||||
OWNER="bitcoinafterlife"
|
||||
basename=$(basename $(pwd))
|
||||
REPO=$basename
|
||||
TAG="v$release"
|
||||
binpath="target/release/$basename"
|
||||
release_name="$basename-$release"
|
||||
dest="releases/$release"
|
||||
arch=$(uname -m)
|
||||
platform="linux-gnu"
|
||||
destbin="$dest/$arch"
|
||||
destsrc="$dest/src"
|
||||
assetname="$release_name""_$arch""_$platform"
|
||||
|
||||
asset_tar_gz="$assetname.tar.gz"
|
||||
ASSET_PATH="$destbin/$assetname.tar.gz"
|
||||
|
||||
giteahost="https://bitcoin-after.life/gitea"
|
||||
url_releases="$giteahost/api/v1/repos/$OWNER/$REPO/releases"
|
||||
|
||||
echo_i() {
|
||||
echo -e "\033[1m==> $1\033[0m"
|
||||
}
|
||||
|
||||
echo_e() {
|
||||
echo -e "\033[31;1m$1\033[0m"
|
||||
}
|
||||
|
||||
echo_s() {
|
||||
echo -e "\033[32;1m$1\033[0m"
|
||||
}
|
||||
echo_w() {
|
||||
echo -e "\033[33;1m$1\033[0m"
|
||||
}
|
||||
|
||||
|
||||
prepare_release(){
|
||||
mkdir -p "$destbin/$assetname"
|
||||
if ! cargo build --release; then
|
||||
echo_w "error building release"
|
||||
exit 1
|
||||
fi
|
||||
ls -l $binpath
|
||||
cp target/release/bal-server \
|
||||
target/release/bal-pusher \
|
||||
README.md \
|
||||
"$destbin/$assetname"
|
||||
(
|
||||
cd "$destbin"
|
||||
echo_w $ASSET_PATH
|
||||
echo "ls $(pwd)"
|
||||
ls
|
||||
echo "ls $(pwd)/$assetname"
|
||||
ls "$(pwd)/$assetname"
|
||||
ls $assetname
|
||||
tar -czf "$asset_tar_gz" "$assetname"
|
||||
)
|
||||
}
|
||||
|
||||
push_tag() {
|
||||
git commit -am"release: $release_name"
|
||||
git push
|
||||
#git tag -a "$TAG" -m"release: $release_name"
|
||||
#git push origin --tags
|
||||
}
|
||||
|
||||
# Configurazioni
|
||||
post_release() {
|
||||
if [ -z "$1" ]; then
|
||||
echo_e "no data to release"
|
||||
exit 1
|
||||
else
|
||||
echo "data: $1"
|
||||
fi
|
||||
echo "token:$TOKEN"
|
||||
echo url_releases: $url_releases
|
||||
RELEASE="$(curl -s -X POST \
|
||||
-H "accept: application/json" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$1" \
|
||||
$url_releases
|
||||
)"
|
||||
echo $RELEASE
|
||||
}
|
||||
|
||||
add_asset_release() {
|
||||
if [ -z "$1" ]; then
|
||||
echo_e "error add_asset_release"
|
||||
exit 1
|
||||
fi
|
||||
echo $ASSET_PATH
|
||||
ls -l $ASSET_PATH
|
||||
pwd
|
||||
curl -X POST \
|
||||
-H "accept: application/json" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "attachment=@$ASSET_PATH" \
|
||||
"$url_releases/$1/assets"
|
||||
}
|
||||
|
||||
# Estrae l'ID della release
|
||||
release_data=$(cat <<EOF
|
||||
{
|
||||
"tag_name":"$TAG",
|
||||
"name":"$release_name",
|
||||
"body":"Release: $release_name enjoy\n$message"
|
||||
}
|
||||
|
||||
EOF
|
||||
)
|
||||
|
||||
prepare_release
|
||||
echo_s "prepare release done"
|
||||
push_tag
|
||||
echo_s "push tag done"
|
||||
echo "$release_data"
|
||||
post_release "$release_data"
|
||||
echo_s "prepare release done"
|
||||
echo $RELEASE
|
||||
id_release=
|
||||
add_asset_release $(echo $RELEASE | jq .id)
|
||||
echo_s "done"
|
||||
@@ -5,8 +5,7 @@ use bitcoin::Network;
|
||||
use bitcoincore_rpc::{Auth, Client, Error, RpcApi, bitcoin};
|
||||
use bitcoincore_rpc_json::GetBlockchainInfoResult;
|
||||
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use hex;
|
||||
use ed25519_dalek::{Signer as _, SigningKey, pkcs8::DecodePrivateKey};
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -15,24 +14,19 @@ use sqlite::{Connection, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::error::Error as StdError;
|
||||
use std::io::Cursor;
|
||||
use std::str;
|
||||
use std::{thread, time::Duration};
|
||||
use zmq::{Context, DEALER, DONTWAIT, Socket};
|
||||
use zmq::{Context, Socket};
|
||||
|
||||
use bal_server::db::open_db;
|
||||
use bal_server::validation::is_valid_welist_url;
|
||||
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 std::fs;
|
||||
use std::time::Instant;
|
||||
use std::net::SocketAddr;
|
||||
use url::Url;
|
||||
|
||||
const LOCKTIME_THRESHOLD: i64 = 5000000;
|
||||
const VERSION: &str = "0.0.2";
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MyConfig {
|
||||
db_file: String,
|
||||
@@ -90,7 +84,7 @@ fn get_network_params(cfg: &MyConfig, network: Network) -> &NetworkParams {
|
||||
fn get_network_params_default(network: Network) -> NetworkParams {
|
||||
match network {
|
||||
Network::Testnet => NetworkParams {
|
||||
host: "http://i27.0.0.1".to_string(),
|
||||
host: "http://127.0.0.1".to_string(),
|
||||
port: 18332,
|
||||
dir_path: "testnet3/".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(),
|
||||
},
|
||||
Network::Testnet4 => NetworkParams {
|
||||
host: "http://i27.0.0.1".to_string(),
|
||||
host: "http://127.0.0.1".to_string(),
|
||||
port: 48332,
|
||||
dir_path: "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>> {
|
||||
if network.cookie_file != "" {
|
||||
if !network.cookie_file.is_empty() {
|
||||
Ok(network.cookie_file.clone())
|
||||
} else {
|
||||
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(
|
||||
url: &String,
|
||||
url: &str,
|
||||
network: &NetworkParams,
|
||||
) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> {
|
||||
if network.rpc_user != "" {
|
||||
if !network.rpc_user.is_empty() {
|
||||
match Client::new(
|
||||
&url[..],
|
||||
url,
|
||||
Auth::UserPass(network.rpc_user.to_string(), network.rpc_pass.to_string()),
|
||||
) {
|
||||
Ok(client) => match client.get_blockchain_info() {
|
||||
@@ -180,57 +174,37 @@ fn get_client_from_username(
|
||||
}
|
||||
}
|
||||
fn get_client_from_cookie(
|
||||
url: &String,
|
||||
url: &str,
|
||||
network: &NetworkParams,
|
||||
) -> Result<(Client, GetBlockchainInfoResult), Box<dyn StdError>> {
|
||||
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(bcinfo) => Ok((client, bcinfo)),
|
||||
Err(err) => Err(err.into()),
|
||||
},
|
||||
Err(err) => Err(err.into()),
|
||||
},
|
||||
Err(err) => Err(err.into()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
fn get_client(
|
||||
network: &NetworkParams,
|
||||
) -> 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}");
|
||||
match get_client_from_username(&url, network) {
|
||||
Ok(client) => Ok(client),
|
||||
Err(_) => match get_client_from_cookie(&url, &network) {
|
||||
Err(_) => match get_client_from_cookie(&url, network) {
|
||||
Ok(client) => Ok(client),
|
||||
Err(err) => Err(err.into()),
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
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) {
|
||||
Ok((rpc, bcinfo)) => {
|
||||
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!("height time: {}",bcinfo.median_time);
|
||||
info!("blocks: {}", bcinfo.blocks);
|
||||
@@ -247,7 +221,13 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
|
||||
info!("db open {}", &cfg.db_file);
|
||||
|
||||
let sqlquery = "SELECT * FROM tbl_tx WHERE network = :network AND status = :status AND ( locktime < :bestblock_height OR locktime > :locktime_threshold AND locktime < :bestblock_time);";
|
||||
let query_tx = db.prepare(sqlquery).unwrap().into_iter();
|
||||
let query_tx = match db.prepare(sqlquery) {
|
||||
Ok(q) => q.into_iter(),
|
||||
Err(e) => {
|
||||
warn!("tbl_tx not ready yet (tables may not exist): {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
trace!("query_tx: {}", sqlquery);
|
||||
trace!(":locktime_threshold: {}", LOCKTIME_THRESHOLD);
|
||||
trace!(":bestblock_time: {}", average_time);
|
||||
@@ -257,45 +237,38 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
|
||||
//let query_tx = db.prepare("SELECT * FROM tbl_tx where status = :status").unwrap().into_iter();
|
||||
let mut pushed_txs: Vec<String> = Vec::new();
|
||||
let mut invalid_txs: std::collections::HashMap<String, String> = HashMap::new();
|
||||
for row in query_tx
|
||||
.bind::<&[(_, Value)]>(
|
||||
&[
|
||||
(":locktime_threshold", (LOCKTIME_THRESHOLD as i64).into()),
|
||||
(":bestblock_time", (average_time as i64).into()),
|
||||
(":bestblock_height", (bcinfo.blocks as i64).into()),
|
||||
(":network", network_params.db_field.clone().into()),
|
||||
(":status", 0.into()),
|
||||
][..],
|
||||
)
|
||||
.unwrap()
|
||||
.map(|row| row.unwrap())
|
||||
{
|
||||
for row_result in match query_tx.bind::<&[(_, Value)]>(
|
||||
&[
|
||||
(":locktime_threshold", LOCKTIME_THRESHOLD.into()),
|
||||
(":bestblock_time", (average_time as i64).into()),
|
||||
(":bestblock_height", (bcinfo.blocks as i64).into()),
|
||||
(":network", network_params.db_field.clone().into()),
|
||||
(":status", 0.into()),
|
||||
][..],
|
||||
) {
|
||||
Ok(bound) => bound,
|
||||
Err(e) => {
|
||||
error!("Failed to bind query parameters: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
} {
|
||||
let row = match row_result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to read row: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let tx = row.read::<&str, _>("tx");
|
||||
let txid = row.read::<&str, _>("txid");
|
||||
let locktime = row.read::<i64, _>("locktime");
|
||||
info!("to be pushed: {}: {}", txid, locktime);
|
||||
match rpc.send_raw_transaction(tx) {
|
||||
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);
|
||||
pushed_txs.push(txid.to_string());
|
||||
}
|
||||
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);
|
||||
//store err in invalid_txs
|
||||
invalid_txs.insert(txid.to_string(), err.to_string());
|
||||
@@ -305,19 +278,44 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
|
||||
|
||||
for txid in &pushed_txs {
|
||||
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
||||
let mut stmt = db.prepare(sql).unwrap();
|
||||
stmt.bind((1, Value::String(txid.clone()))).unwrap();
|
||||
let _ = stmt.next();
|
||||
match db.prepare(sql) {
|
||||
Ok(mut stmt) => {
|
||||
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 {
|
||||
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
|
||||
let mut stmt = db.prepare(sql).unwrap();
|
||||
stmt.bind((1, Value::String(txerr.clone()))).unwrap();
|
||||
stmt.bind((2, Value::String(txid.clone()))).unwrap();
|
||||
let _ = stmt.next();
|
||||
match db.prepare(sql) {
|
||||
Ok(mut stmt) => {
|
||||
if let Err(e) = stmt.bind((1, Value::String(txerr.clone()))) {
|
||||
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) => {
|
||||
error!("impossible to get client: {}, retrying on next block", erx);
|
||||
@@ -375,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) {
|
||||
error!("error inserting creating stats table {err}");
|
||||
} else {
|
||||
@@ -407,6 +380,84 @@ ON CONFLICT(chain) DO UPDATE SET
|
||||
}
|
||||
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(
|
||||
cfg: &MyConfig,
|
||||
bcinfo: GetBlockchainInfoResult,
|
||||
@@ -415,14 +466,18 @@ async fn send_stats_report(
|
||||
debug!("sending report to welist");
|
||||
let welist_url = env::var("WELIST_SERVER_URL")
|
||||
.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!(
|
||||
"Invalid or unsafe WELIST_SERVER_URL: {}. Skipping stats report.",
|
||||
welist_url
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let client = rClient::new();
|
||||
let client = welist_http_client(&welist_url).await;
|
||||
let url = format!("{}/ping", welist_url);
|
||||
debug!("welist url: {}", url);
|
||||
let chain = bcinfo.chain.to_string().to_lowercase();
|
||||
@@ -431,7 +486,7 @@ async fn send_stats_report(
|
||||
cfg.url, chain, bcinfo.blocks, bcinfo.median_time, bcinfo.best_block_hash
|
||||
);
|
||||
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
|
||||
.post(url)
|
||||
.header("User-Agent", format!("bal-pusher/{}", VERSION))
|
||||
@@ -446,32 +501,23 @@ async fn send_stats_report(
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
warn!(
|
||||
"Non-success response: {} {}",
|
||||
response.status(),
|
||||
response.status().canonical_reason().unwrap_or("")
|
||||
);
|
||||
}
|
||||
|
||||
let body = &(response.text().await?);
|
||||
info!("Report to welist({})\tSent: {}", welist_url, body);
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
info!(
|
||||
"Report to welist({}) status={} body={}",
|
||||
welist_url, status, body
|
||||
);
|
||||
} else {
|
||||
debug!("Not sending stats");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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();
|
||||
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
|
||||
general_purpose::STANDARD.encode(signature.to_bytes())
|
||||
}
|
||||
|
||||
fn parse_env(cfg: &mut MyConfig) {
|
||||
@@ -490,144 +536,47 @@ fn parse_env_netconfig(cfg_lock: &mut MyConfig, chain: &str) -> NetworkParams {
|
||||
"testnet4" => &mut cfg_lock.testnet4,
|
||||
&_ => &mut cfg_lock.mainnet,
|
||||
};
|
||||
match env::var(format!("BAL_PUSHER_{}_HOST", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
cfg.host = value;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_HOST", chain.to_uppercase())) {
|
||||
cfg.host = value;
|
||||
}
|
||||
match env::var(format!("BAL_PUSHER_{}_PORT", chain.to_uppercase())) {
|
||||
Ok(value) => match value.parse::<u64>() {
|
||||
Ok(value) => match u16::try_from(value) {
|
||||
Ok(port) => cfg.port = port,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Port value {} exceeds u16 range for chain {}: {}",
|
||||
value, chain, e
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(_) => {}
|
||||
},
|
||||
Err(_) => {}
|
||||
}
|
||||
match env::var(format!("BAL_PUSHER_{}_DIR_PATH", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
cfg.dir_path = value;
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_PORT", chain.to_uppercase()))
|
||||
&& let Ok(port_num) = value.parse::<u64>()
|
||||
{
|
||||
if let Ok(port) = u16::try_from(port_num) {
|
||||
cfg.port = port;
|
||||
} else {
|
||||
error!(
|
||||
"Port value {} exceeds u16 range for chain {}",
|
||||
port_num, chain
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
match env::var(format!("BAL_PUSHER_{}_DB_FIELD", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
cfg.db_field = value;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_DIR_PATH", chain.to_uppercase())) {
|
||||
cfg.dir_path = value;
|
||||
}
|
||||
match env::var(format!("BAL_PUSHER_{}_COOKIE_FILE", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
cfg.cookie_file = value;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_DB_FIELD", chain.to_uppercase())) {
|
||||
cfg.db_field = value;
|
||||
}
|
||||
match env::var(format!("BAL_PUSHER_{}_RPC_USER", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
cfg.rpc_user = value;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_COOKIE_FILE", chain.to_uppercase())) {
|
||||
cfg.cookie_file = value;
|
||||
}
|
||||
match env::var(format!("BAL_PUSHER_{}_RPC_PASSWORD", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
cfg.rpc_pass = value;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_RPC_USER", chain.to_uppercase())) {
|
||||
cfg.rpc_user = value;
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())
|
||||
);
|
||||
match env::var(format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())) {
|
||||
Ok(value) => {
|
||||
println!("value:{}", value);
|
||||
cfg.zmq_listener = value;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_RPC_PASSWORD", chain.to_uppercase())) {
|
||||
cfg.rpc_pass = value;
|
||||
}
|
||||
if let Ok(value) = env::var(format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())) {
|
||||
cfg.zmq_listener = value;
|
||||
}
|
||||
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]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::init();
|
||||
let mut cfg = MyConfig::default();
|
||||
|
||||
let dbfile = env::var("BAL_PUSHER_DB_FILE").unwrap();
|
||||
parse_env(&mut cfg);
|
||||
let mut args = std::env::args();
|
||||
let _exe_name = args.next().unwrap();
|
||||
@@ -662,31 +611,48 @@ async fn main() -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
match socket.set_subscribe(b"") {
|
||||
Ok(_) => {}
|
||||
Ok(_) => {
|
||||
info!("ZMQ subscribed to all topics on {}", zmq_address);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("ZMQ subscribe failed: {}, exiting", e);
|
||||
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..");
|
||||
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
|
||||
let mut consecutive_timeouts: u32 = 0;
|
||||
loop {
|
||||
let message = match socket.recv_multipart(0) {
|
||||
Ok(m) => m,
|
||||
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;
|
||||
}
|
||||
};
|
||||
if consecutive_timeouts > 0 {
|
||||
info!(
|
||||
"ZMQ connection restored after {} consecutive timeouts",
|
||||
consecutive_timeouts
|
||||
);
|
||||
}
|
||||
consecutive_timeouts = 0;
|
||||
let topic = message[0].clone();
|
||||
let body = message[1].clone();
|
||||
let seq = message[2].clone();
|
||||
last_seq = seq;
|
||||
debug!(
|
||||
"ZMQ:GET TOPIC: {}",
|
||||
String::from_utf8(topic.clone()).expect("invalid topic")
|
||||
@@ -694,18 +660,52 @@ async fn main() -> std::io::Result<()> {
|
||||
trace!("ZMQ:GET BODY: {}", hex::encode(&body));
|
||||
if topic == b"hashblock" {
|
||||
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
|
||||
}
|
||||
}
|
||||
fn seq_to_str(seq: &Vec<u8>) -> String {
|
||||
if seq.len() == 4 {
|
||||
let mut rdr = Cursor::new(seq);
|
||||
let sequence = rdr
|
||||
.read_u32::<LittleEndian>()
|
||||
.expect("Failed to read integer");
|
||||
return sequence.to_string();
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use chrono::Utc;
|
||||
use hex_conservative::FromHex;
|
||||
use log::{debug, error, info, trace};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use sqlite::State;
|
||||
use sqlite::{Connection, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -119,6 +118,7 @@ pub struct StatsResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[expect(dead_code)]
|
||||
struct ActixConfig {
|
||||
max_body_size: usize,
|
||||
timeout_secs: u64,
|
||||
@@ -208,7 +208,7 @@ async fn echo_pub_key(data: web::Data<AppState>) -> impl Responder {
|
||||
"Failed to read public key file {}: {}",
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
path: web::Path<String>,
|
||||
data: web::Data<AppState>,
|
||||
@@ -224,26 +256,15 @@ async fn echo_info(
|
||||
) -> impl Responder {
|
||||
let param = path.into_inner();
|
||||
if !NETWORKS.contains(¶m.as_str()) {
|
||||
return HttpResponse::NotFound().body("Unknown network");
|
||||
return HttpResponse::NotFound().body("error");
|
||||
}
|
||||
info!("echo info!!!{}", param);
|
||||
let netconfig = data.cfg.get_net_config(¶m);
|
||||
if !netconfig.enabled {
|
||||
debug!("network disabled {}", param);
|
||||
return HttpResponse::BadRequest().body("network disabled");
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
let remote_addr = req
|
||||
.headers()
|
||||
.get("X-Real-IP")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|xff| xff.split(',').next())
|
||||
.map(|ip| ip.trim().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
req.connection_info()
|
||||
.peer_addr()
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
});
|
||||
let remote_addr = extract_client_ip(&req);
|
||||
let address = match netconfig.xpub {
|
||||
false => {
|
||||
let address = netconfig.address.to_string();
|
||||
@@ -257,7 +278,7 @@ async fn echo_info(
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
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(
|
||||
@@ -266,31 +287,28 @@ async fn echo_info(
|
||||
&netconfig.address,
|
||||
&remote_addr,
|
||||
) {
|
||||
Some(address) => return HttpResponse::Ok().json(InfoResponse {
|
||||
address,
|
||||
base_fee: netconfig.fixed_fee,
|
||||
chain: netconfig.network.to_string(),
|
||||
info: data.cfg.info.to_string(),
|
||||
version: VERSION.to_string(),
|
||||
}),
|
||||
None => {
|
||||
let next = get_next_address_index(&db, &netconfig.name, &netconfig.address);
|
||||
next
|
||||
Some(address) => {
|
||||
return HttpResponse::Ok().json(InfoResponse {
|
||||
address,
|
||||
base_fee: netconfig.fixed_fee,
|
||||
chain: netconfig.network.to_string(),
|
||||
info: data.cfg.info.to_string(),
|
||||
version: VERSION.to_string(),
|
||||
});
|
||||
}
|
||||
None => get_next_address_index(&db, &netconfig.name, &netconfig.address),
|
||||
}
|
||||
}; // lock released
|
||||
|
||||
// Derive address (CPU-bound, no lock held)
|
||||
let derived = match new_address_from_xpub(
|
||||
&netconfig.address, next_idx.1, netconfig.network
|
||||
) {
|
||||
Ok(address) => address,
|
||||
Err(e) => {
|
||||
error!("Failed to derive address from xpub: {}", e);
|
||||
return HttpResponse::BadRequest()
|
||||
.body(format!("Failed to derive address: {}", e));
|
||||
}
|
||||
};
|
||||
let derived =
|
||||
match new_address_from_xpub(&netconfig.address, next_idx.1, netconfig.network) {
|
||||
Ok(address) => address,
|
||||
Err(e) => {
|
||||
error!("Failed to derive address from xpub: {}", e);
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
};
|
||||
|
||||
// Lock #2: save the newly derived address
|
||||
{
|
||||
@@ -298,7 +316,7 @@ async fn echo_info(
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
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);
|
||||
@@ -321,30 +339,30 @@ async fn echo_info(
|
||||
debug!("echo info reply: {}", json_data);
|
||||
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 {
|
||||
let param = path.into_inner();
|
||||
if !NETWORKS.contains(¶m.as_str()) {
|
||||
return HttpResponse::NotFound().body("Unknown network");
|
||||
return HttpResponse::NotFound().body("error");
|
||||
}
|
||||
info!("echo stats!!! {}", data.cfg.expose_stats);
|
||||
let netconfig = data.cfg.get_net_config(¶m);
|
||||
if !netconfig.enabled {
|
||||
debug!("network disabled {}", param);
|
||||
return HttpResponse::BadRequest().body("network disabled");
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
if !data.cfg.expose_stats {
|
||||
return HttpResponse::Forbidden().body("Stats not exposed");
|
||||
return HttpResponse::Forbidden().body("error");
|
||||
}
|
||||
let mut stats: Vec<StatsResponse> = vec![];
|
||||
let db = match data.db.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
error!("DB mutex poisoned in echo_stats");
|
||||
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
};
|
||||
let mut stmt = match db.prepare(
|
||||
@@ -353,24 +371,56 @@ async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl
|
||||
Ok(s) => s,
|
||||
Err(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()))) {
|
||||
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() {
|
||||
let report_date = stmt.read("report_date").unwrap_or("0".to_string());
|
||||
let chain = stmt.read("chain").unwrap_or("?".to_string());
|
||||
let totals = stmt.read("totals").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let waiting = stmt.read("waiting").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let sent = stmt.read("sent").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let failed = stmt.read("failed").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let waiting_profit = stmt.read("waiting_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let sent_profit = stmt.read("sent_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let missed_profit = stmt.read("missed_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let unique_inputs = stmt.read("unique_inputs").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||
let totals = stmt
|
||||
.read("totals")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let waiting = stmt
|
||||
.read("waiting")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let sent = stmt
|
||||
.read("sent")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let failed = stmt
|
||||
.read("failed")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let waiting_profit = stmt
|
||||
.read("waiting_profit")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let sent_profit = stmt
|
||||
.read("sent_profit")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let missed_profit = stmt
|
||||
.read("missed_profit")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let unique_inputs = stmt
|
||||
.read("unique_inputs")
|
||||
.unwrap_or("0".to_string())
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
stats.push(StatsResponse {
|
||||
report_date,
|
||||
chain,
|
||||
@@ -384,13 +434,8 @@ async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl
|
||||
unique_inputs,
|
||||
});
|
||||
}
|
||||
match serde_json::to_string(&stats) {
|
||||
Ok(json_data) => {
|
||||
debug!("echo info reply: {}", json_data);
|
||||
HttpResponse::Ok().json(stats)
|
||||
}
|
||||
Err(err) => HttpResponse::InternalServerError().body(format!("error:{}", err)),
|
||||
}
|
||||
debug!("echo stats reply for chain: {}", netconfig.name);
|
||||
HttpResponse::Ok().json(stats)
|
||||
}
|
||||
|
||||
async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
|
||||
@@ -398,32 +443,33 @@ async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
|
||||
let strbody = match std::str::from_utf8(&body) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().body("Invalid UTF-8 body");
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
};
|
||||
info!("{}", strbody);
|
||||
|
||||
if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return HttpResponse::BadRequest().body("Invalid txid");
|
||||
if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
|
||||
let db = match data.db.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
error!("DB mutex poisoned in echo_search");
|
||||
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
};
|
||||
let mut statement = match db.prepare("SELECT * FROM tbl_tx WHERE txid = ? LIMIT 1") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to prepare statement: {}", e);
|
||||
return HttpResponse::InternalServerError().body("Database error");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
};
|
||||
if let Err(e) = statement.bind((1, strbody)) {
|
||||
error!("Failed to bind parameter: {}", e);
|
||||
return HttpResponse::InternalServerError().body("Database error");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
|
||||
if let Ok(State::Row) = statement.next() {
|
||||
@@ -469,11 +515,14 @@ async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
|
||||
}
|
||||
}
|
||||
match serde_json::to_string(&response_data) {
|
||||
Ok(json_data) => HttpResponse::Ok().json(json_data),
|
||||
Err(_) => HttpResponse::BadRequest().body("Bad data received"),
|
||||
Ok(json_data) => {
|
||||
debug!("echo search reply: {}", json_data);
|
||||
HttpResponse::Ok().json(&response_data)
|
||||
}
|
||||
Err(_) => HttpResponse::BadRequest().body("error"),
|
||||
}
|
||||
} else {
|
||||
HttpResponse::BadRequest().body("Bad data received")
|
||||
HttpResponse::BadRequest().body("error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,20 +534,19 @@ struct ParsedTx {
|
||||
ntxid: String,
|
||||
raw_hex: String, // the original line
|
||||
locktime: String,
|
||||
inputs: Vec<(String, String)>, // (in_txid, in_vout)
|
||||
outputs: Vec<(usize, String, u64)> // (idx, script_pubkey, amount_sat)
|
||||
inputs: Vec<(String, String)>, // (in_txid, in_vout)
|
||||
outputs: Vec<(usize, String, u64)>, // (idx, script_pubkey, amount_sat)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
strbody: &str,
|
||||
_req_time: i64,
|
||||
netconfig: &NetConfig,
|
||||
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 union_tx = true;
|
||||
|
||||
for line in strbody.split('\n') {
|
||||
if line.is_empty() {
|
||||
@@ -560,7 +608,7 @@ fn parse_request_transactions(
|
||||
if known_addresses.contains(&address) {
|
||||
address.clone()
|
||||
} else {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
netconfig.address.clone()
|
||||
@@ -579,13 +627,8 @@ fn parse_request_transactions(
|
||||
}
|
||||
|
||||
if !found {
|
||||
error!("willexecutor output not found for tx {}", txid);
|
||||
return Err(HttpResponse::BadRequest().body("Bad data received"));
|
||||
}
|
||||
if !union_tx {
|
||||
// This is only used for SQL building later; we track it in the caller
|
||||
} else {
|
||||
union_tx = false;
|
||||
trace!("willexecutor output not found for tx {}, skipping", txid);
|
||||
continue;
|
||||
}
|
||||
result.push((
|
||||
ParsedTx {
|
||||
@@ -602,7 +645,7 @@ fn parse_request_transactions(
|
||||
));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
result
|
||||
}
|
||||
|
||||
async fn echo_push(
|
||||
@@ -614,24 +657,24 @@ async fn echo_push(
|
||||
let strbody = match std::str::from_utf8(&body) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().body("Invalid UTF-8 body");
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
};
|
||||
|
||||
let param = path.into_inner();
|
||||
if !NETWORKS.contains(¶m.as_str()) {
|
||||
return HttpResponse::NotFound().body("Unknown network");
|
||||
return HttpResponse::NotFound().body("error");
|
||||
}
|
||||
let netconfig = data.cfg.get_net_config(¶m);
|
||||
if !netconfig.enabled {
|
||||
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() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!("Invalid timestamp");
|
||||
return HttpResponse::BadRequest().body("Invalid timestamp");
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -641,7 +684,7 @@ async fn echo_push(
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
error!("DB mutex poisoned acquiring addresses in echo_push");
|
||||
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
};
|
||||
if netconfig.xpub {
|
||||
@@ -649,7 +692,7 @@ async fn echo_push(
|
||||
Ok(addrs) => addrs,
|
||||
Err(e) => {
|
||||
error!("Failed to load addresses from xpub: {}", e);
|
||||
return HttpResponse::InternalServerError().body("Database error");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -658,14 +701,9 @@ async fn echo_push(
|
||||
}; // lock released here
|
||||
|
||||
// Parse all transactions (CPU-bound, no DB needed)
|
||||
let parsed = match parse_request_transactions(
|
||||
strbody, req_time, netconfig, &known_addresses,
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let parsed = parse_request_transactions(strbody, req_time, netconfig, &known_addresses);
|
||||
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();
|
||||
@@ -676,14 +714,14 @@ async fn echo_push(
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
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) {
|
||||
Ok(dups) => dups,
|
||||
Err(e) => {
|
||||
error!("Duplicate check failed: {}", e);
|
||||
return HttpResponse::InternalServerError().body("Database error");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
}
|
||||
}; // lock released here
|
||||
@@ -699,7 +737,7 @@ async fn echo_push(
|
||||
Ok(g) => g,
|
||||
Err(_p) => {
|
||||
error!("DB mutex poisoned in echo_push insert phase");
|
||||
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||
return HttpResponse::InternalServerError().body("error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -763,9 +801,15 @@ async fn echo_push(
|
||||
}
|
||||
sqlouts.push_str(" SELECT ?, ?, ?, ?");
|
||||
pouts.push((lineout, Value::String(parsed.txid.clone())));
|
||||
pouts.push((lineout + 1, Value::Integer(i64::try_from(*idx).unwrap_or(-1))));
|
||||
pouts.push((
|
||||
lineout + 1,
|
||||
Value::Integer(i64::try_from(*idx).unwrap_or(-1)),
|
||||
));
|
||||
pouts.push((lineout + 2, Value::String(script.clone())));
|
||||
pouts.push((lineout + 3, Value::Integer(i64::try_from(*amount).unwrap_or(0))));
|
||||
pouts.push((
|
||||
lineout + 3,
|
||||
Value::Integer(i64::try_from(*amount).unwrap_or(0)),
|
||||
));
|
||||
lineout += 4;
|
||||
}
|
||||
}
|
||||
@@ -780,7 +824,7 @@ async fn echo_push(
|
||||
|
||||
if let Err(err) = execute_insert(&db, sqltxs, ptx, sqlinps, pinps, sqlouts, pouts) {
|
||||
error!("execute_insert failed: {}", err);
|
||||
return HttpResponse::BadRequest().body("Bad data received");
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
} // lock released
|
||||
|
||||
@@ -862,7 +906,7 @@ async fn main() -> std::io::Result<()> {
|
||||
let db = match open_db(&cfg.db_file) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
return Err(std::io::Error::other(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -877,15 +921,6 @@ async fn main() -> std::io::Result<()> {
|
||||
cfg: cfg.clone(),
|
||||
});
|
||||
|
||||
// Initialize networks
|
||||
{
|
||||
let db = data.db.lock().unwrap();
|
||||
for network in NETWORKS {
|
||||
let netconfig = data.cfg.get_net_config(network);
|
||||
insert_xpub(&db, &netconfig.name.to_string(), &netconfig.address);
|
||||
}
|
||||
}
|
||||
|
||||
let bind_address = data.cfg.bind_address.clone();
|
||||
let bind_port = data.cfg.bind_port;
|
||||
|
||||
95
src/db.rs
95
src/db.rs
@@ -1,7 +1,9 @@
|
||||
use log::{error, info, trace};
|
||||
use log::{error, info, trace, warn};
|
||||
use sqlite::{Connection, Error, State, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Check which txids are already present in the database in a single batch query.
|
||||
/// Returns a HashSet of txids that already exist (duplicates).
|
||||
@@ -10,28 +12,28 @@ pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSe
|
||||
if txids.is_empty() {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
|
||||
|
||||
// Build a single query with all txids using IN clause placeholders
|
||||
// SQLite supports up to 1000 parameters per statement, so we chunk for safety
|
||||
let mut duplicates = HashSet::new();
|
||||
let chunk_size = 500; // Safe chunk size for SQLite parameters
|
||||
|
||||
|
||||
for chunk in txids.chunks(chunk_size) {
|
||||
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||
let sql = format!("SELECT txid FROM tbl_tx WHERE txid IN ({})", placeholders);
|
||||
let mut stmt = db.prepare(sql)?;
|
||||
|
||||
|
||||
for (i, txid) in chunk.iter().enumerate() {
|
||||
stmt.bind((i + 1, Value::String(txid.clone())))?;
|
||||
}
|
||||
|
||||
|
||||
while let Ok(State::Row) = stmt.next() {
|
||||
if let Ok(txid) = stmt.read::<String, _>("txid") {
|
||||
duplicates.insert(txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(duplicates)
|
||||
}
|
||||
|
||||
@@ -61,12 +63,10 @@ pub fn open_db(path: &str) -> Result<Connection, String> {
|
||||
];
|
||||
for prefix in &forbidden {
|
||||
if path_str.starts_with(prefix) {
|
||||
return Err(
|
||||
format!(
|
||||
"Absolute database path under {} is forbidden",
|
||||
prefix
|
||||
)
|
||||
);
|
||||
return Err(format!(
|
||||
"Absolute database path under {} is forbidden",
|
||||
prefix
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,24 +74,48 @@ pub fn open_db(path: &str) -> Result<Connection, String> {
|
||||
// If file exists, must be a regular file (not a symlink, device, etc.)
|
||||
if p.exists() {
|
||||
if p.is_symlink() {
|
||||
return Err(
|
||||
"Database path must not be a symlink".to_string()
|
||||
);
|
||||
return Err("Database path must not be a symlink".to_string());
|
||||
}
|
||||
let metadata = std::fs::metadata(p)
|
||||
.map_err(|e| format!("Cannot access database file metadata: {}", e))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(
|
||||
"Database path must point to a regular file, not a directory or device".to_string()
|
||||
"Database path must point to a regular file, not a directory or device".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = sqlite::open(path)
|
||||
.map_err(|e| format!("Failed to open SQLite database: {}", e))?;
|
||||
let conn = sqlite::open(path).map_err(|e| format!("Failed to open SQLite database: {}", e))?;
|
||||
|
||||
// Set busy timeout BEFORE WAL mode so SQLite waits instead of failing immediately.
|
||||
// This handles the race where two processes (server + pusher) open the same DB
|
||||
// and both try to enable WAL mode concurrently.
|
||||
conn.execute("PRAGMA busy_timeout = 5000;")
|
||||
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
|
||||
|
||||
// Retry WAL mode up to 5 times (handles concurrent open from bal-pusher).
|
||||
let mut wal_ok = false;
|
||||
for attempt in 0..5 {
|
||||
match conn.execute("PRAGMA journal_mode = WAL;") {
|
||||
Ok(_) => {
|
||||
wal_ok = true;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"WAL mode attempt {}/5 failed: {}, retrying in 100ms...",
|
||||
attempt + 1,
|
||||
e
|
||||
);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wal_ok {
|
||||
// WAL might already be enabled by another process; this is not fatal.
|
||||
warn!("Could not set WAL mode after retries — may already be enabled by another process");
|
||||
}
|
||||
|
||||
conn.execute("PRAGMA journal_mode = WAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL mode: {}", e))?;
|
||||
conn.execute("PRAGMA synchronous = NORMAL;")
|
||||
.map_err(|e| format!("Failed to set synchronous NORMAL: {}", e))?;
|
||||
|
||||
@@ -102,13 +126,15 @@ pub fn open_db(path: &str) -> Result<Connection, String> {
|
||||
/// in-memory lookup during transaction validation (replaces N+1 query).
|
||||
pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?"
|
||||
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?",
|
||||
)?;
|
||||
stmt.bind((1, Value::String(xpub.to_string())))?;
|
||||
let mut addresses = HashSet::new();
|
||||
while let Ok(State::Row) = stmt.next() {
|
||||
match stmt.read::<String, _>("address") {
|
||||
Ok(addr) => { addresses.insert(addr); }
|
||||
Ok(addr) => {
|
||||
addresses.insert(addr);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read address column: {}", e);
|
||||
}
|
||||
@@ -134,9 +160,11 @@ pub fn create_database(db: &Connection) {
|
||||
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_address (address TEXT PRIMARY_KEY, path TEXT NOT NULL, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, xpub INTEGER,remote_address TEXT);");
|
||||
|
||||
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_stats (report_date TEXT, chain TEXT, totals INTEGER, waiting INTEGER, sent INTEGER, failed INTEGER, waiting_profit INTEGER, sent_profit INTEGER, missed_profit INTEGER, unique_inputs INTEGER);");
|
||||
let _ = db.execute("CREATE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);");
|
||||
// UNIQUE index required for ON CONFLICT(chain) DO UPDATE in calculate_stats
|
||||
let _ = db.execute("DROP INDEX IF EXISTS idx_stats_chain;");
|
||||
let _ = db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_chain ON tbl_stats(chain);");
|
||||
|
||||
let _ = db.execute("UPDATE tbl_tx set network='bitcoin' where network='mainnet');");
|
||||
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>{
|
||||
@@ -150,16 +178,17 @@ pub fn create_database(db: &Connection) {
|
||||
}
|
||||
}
|
||||
*/
|
||||
pub fn insert_xpub(db: &Connection, network: &String, xpub: &String) {
|
||||
if xpub != "" {
|
||||
pub fn insert_xpub(db: &Connection, network: &str, xpub: &str) {
|
||||
if !xpub.is_empty() {
|
||||
trace!("going to insert: {} xpub:{}", network, xpub);
|
||||
let mut stmt = match db.prepare("INSERT INTO tbl_xpub(network,xpub) VALUES(?, ?);") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to prepare xpub insert statement: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut stmt =
|
||||
match db.prepare("INSERT OR IGNORE INTO tbl_xpub(network,xpub) VALUES(?, ?);") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to prepare xpub insert statement: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
|
||||
error!("Failed to bind network parameter for xpub insert: {}", e);
|
||||
return;
|
||||
|
||||
32
src/xpub.rs
32
src/xpub.rs
@@ -11,6 +11,7 @@ use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
|
||||
// Mainnet (BIP44/BIP49/BIP84)
|
||||
#[allow(dead_code)]
|
||||
enum BS58Prefix {
|
||||
Xpub,
|
||||
Ypub,
|
||||
@@ -102,44 +103,29 @@ fn calc_checksum(desc: &str) -> Result<String, String> {
|
||||
Ok(checksum)
|
||||
}
|
||||
|
||||
pub fn get_bitcoincore_descriptor(xpub: &String) -> String {
|
||||
pub fn get_bitcoincore_descriptor(xpub: &str) -> String {
|
||||
let fingerprint = match calculate_fingerprint(xpub) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
|
||||
};
|
||||
|
||||
let 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) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
|
||||
};
|
||||
let descriptor = format!("wpkh([{}/84h/0h/0h]{}/0/*)", fingerprint, xpub_converted);
|
||||
let descriptor = match calc_checksum(&descriptor) {
|
||||
match calc_checksum(&descriptor) {
|
||||
Ok(checksum) => {
|
||||
let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor);
|
||||
format!("{}#{}", clean_descriptor, checksum)
|
||||
}
|
||||
Err(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")
|
||||
{
|
||||
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());
|
||||
}
|
||||
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[..] {
|
||||
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 {
|
||||
let checksum = &Sha256::digest(&Sha256::digest(data))[0..4];
|
||||
let checksum = &Sha256::digest(Sha256::digest(data))[0..4];
|
||||
let full = [data, checksum].concat();
|
||||
bs58::encode(full).into_string()
|
||||
}
|
||||
@@ -205,7 +191,7 @@ pub fn new_address_from_xpub(
|
||||
) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||
let xpub = Xpub::from_str(&convert_to(zpub, BS58Prefix::Xpub)?)?;
|
||||
let path = format!("m/0/{}", index);
|
||||
let derivation_path = DerivationPath::from_str(&path.as_str())?;
|
||||
let derivation_path = DerivationPath::from_str(path.as_str())?;
|
||||
let secp = Secp256k1::new();
|
||||
let derived_xpub = xpub.derive_pub(&secp, &derivation_path)?;
|
||||
let public_key = derived_xpub.public_key;
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
use bal_server::db::open_db;
|
||||
use sqlite::State;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_open_db_blocks_traversal() {
|
||||
let res = open_db("../etc/passwd");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Path with '..' should be rejected"
|
||||
);
|
||||
assert!(res.is_err(), "Path with '..' should be rejected");
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for traversal path"),
|
||||
};
|
||||
assert!(err.contains("'..'"), "Error should mention directory traversal: {}", err);
|
||||
assert!(
|
||||
err.contains("'..'"),
|
||||
"Error should mention directory traversal: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_blocks_forbidden_absolute() {
|
||||
for path in ["/etc/passwd", "/proc/self/mem", "/dev/null", "/usr/bin/ls"] {
|
||||
let res = open_db(path);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Absolute path {} should be rejected", path
|
||||
);
|
||||
assert!(res.is_err(), "Absolute path {} should be rejected", path);
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for forbidden path {}", path),
|
||||
};
|
||||
assert!(
|
||||
err.contains("forbidden"),
|
||||
"Error should mention forbidden prefix: {}", err
|
||||
"Error should mention forbidden prefix: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,10 +39,7 @@ fn test_open_db_allows_relative() {
|
||||
let test_path = "tmp_test_bal.db";
|
||||
let _ = fs::remove_file(test_path);
|
||||
let res = open_db(test_path);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"Valid relative path should be allowed"
|
||||
);
|
||||
assert!(res.is_ok(), "Valid relative path should be allowed");
|
||||
let db = res.unwrap();
|
||||
drop(db);
|
||||
let _ = fs::remove_file(test_path);
|
||||
@@ -61,10 +56,7 @@ fn test_open_db_wal_pragmas_set() {
|
||||
let mut stmt = db.prepare("PRAGMA journal_mode;").unwrap();
|
||||
if let Ok(State::Row) = stmt.next() {
|
||||
let mode: String = stmt.read(0).unwrap();
|
||||
assert_eq!(
|
||||
mode, "wal",
|
||||
"SQLite journal mode should be WAL"
|
||||
);
|
||||
assert_eq!(mode, "wal", "SQLite journal mode should be WAL");
|
||||
} else {
|
||||
panic!("Could not read journal_mode pragma");
|
||||
}
|
||||
@@ -81,20 +73,18 @@ fn test_open_db_rejects_symlink() {
|
||||
let _ = fs::remove_file(real);
|
||||
let _ = fs::remove_file(link);
|
||||
fs::File::create(real).unwrap();
|
||||
fs::soft_link(real, link).unwrap();
|
||||
std::os::unix::fs::symlink(real, link).unwrap();
|
||||
|
||||
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");
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for symlink"),
|
||||
};
|
||||
assert!(
|
||||
err.contains("symlink"),
|
||||
"Error should mention symlink: {}", err
|
||||
"Error should mention symlink: {}",
|
||||
err
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(real);
|
||||
|
||||
@@ -10,15 +10,21 @@ fn setup_db_with_xpub() -> sqlite::Connection {
|
||||
"CREATE TABLE tbl_address (address TEXT PRIMARY KEY, path TEXT, xpub INTEGER, remote_address TEXT);"
|
||||
);
|
||||
// Insert test xpub
|
||||
let mut stmt = db.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);").unwrap();
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);")
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::Integer(1))).unwrap();
|
||||
stmt.bind((2, Value::String("testnet".to_string()))).unwrap();
|
||||
stmt.bind((3, Value::String("tpub_test".to_string()))).unwrap();
|
||||
stmt.bind((2, Value::String("testnet".to_string())))
|
||||
.unwrap();
|
||||
stmt.bind((3, Value::String("tpub_test".to_string())))
|
||||
.unwrap();
|
||||
let _ = stmt.next();
|
||||
drop(stmt);
|
||||
// Insert test addresses
|
||||
for addr in ["addr1", "addr2", "addr3"] {
|
||||
let mut stmt = db.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);").unwrap();
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);")
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::String(addr.to_string()))).unwrap();
|
||||
stmt.bind((2, Value::String("m/0/1".to_string()))).unwrap();
|
||||
stmt.bind((3, Value::Integer(1))).unwrap();
|
||||
@@ -53,7 +59,10 @@ fn test_network_unknown_returns_404() {
|
||||
for n in networks {
|
||||
assert!(networks.contains(&n), "{} should be a valid network", n);
|
||||
}
|
||||
assert!(!networks.contains(&"attacker"), "attacker should not be a valid network");
|
||||
assert!(
|
||||
!networks.contains(&"attacker"),
|
||||
"attacker should not be a valid network"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,11 +36,8 @@ fn test_db_null_unwrap_or() {
|
||||
|
||||
let mut found_value = None;
|
||||
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
|
||||
let row: HashMap<_, _> = pairs
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.map(|s| s)))
|
||||
.collect();
|
||||
let totals = row["totals"].clone().unwrap_or("0").to_string();
|
||||
let row: HashMap<_, _> = pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect();
|
||||
let totals = row["totals"].unwrap_or("0").to_string();
|
||||
found_value = Some(totals);
|
||||
true
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_gitignore_protection_env() {
|
||||
@@ -23,30 +22,20 @@ fn test_gitignore_protection_env() {
|
||||
];
|
||||
|
||||
for pattern in required_patterns {
|
||||
let has_exact = gitignore.contains(&pattern);
|
||||
let has_wildcard = gitignore.contains(&format!("*.env.local"))
|
||||
|| gitignore.contains(&format!(".env.local"));
|
||||
let has_wildcard = gitignore.contains("*.env.local") || gitignore.contains(".env.local");
|
||||
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";
|
||||
if is_env_local {
|
||||
assert!(
|
||||
has_wildcard,
|
||||
".gitignore must contain pattern '*.env.local' or '.env.local' to protect secrets",
|
||||
);
|
||||
} else if pattern == ".env.production" || pattern == ".env.secret" {
|
||||
assert!(
|
||||
gitignore.contains(pattern),
|
||||
".gitignore must contain pattern '{}' to protect secrets",
|
||||
pattern
|
||||
);
|
||||
} else if pattern == "*.env" {
|
||||
assert!(
|
||||
has_env,
|
||||
".gitignore must contain pattern '*.env' or '.env' to protect secrets",
|
||||
);
|
||||
} else if pattern == ".env" {
|
||||
} else if pattern == ".env.production"
|
||||
|| pattern == ".env.secret"
|
||||
|| pattern == "*.env"
|
||||
|| pattern == ".env"
|
||||
{
|
||||
assert!(
|
||||
has_env,
|
||||
".gitignore must contain pattern '*.env' or '.env' to protect secrets",
|
||||
@@ -65,7 +54,6 @@ fn test_gitignore_protection_env() {
|
||||
|
||||
#[test]
|
||||
fn test_no_private_key_in_git() {
|
||||
// Check that .gitignore includes private_key.pem
|
||||
let gitignore = match fs::read_to_string(".gitignore") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -88,20 +76,17 @@ fn test_no_private_key_in_git() {
|
||||
".gitignore must block chiave_privata.key"
|
||||
);
|
||||
|
||||
// Check that no private key files are tracked by git
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["ls-files", "*.pem", "*.key"])
|
||||
.args(["ls-files", "*.pem", "*.key"])
|
||||
.output()
|
||||
.expect("Failed to run git ls-files");
|
||||
|
||||
let tracked_keys = String::from_utf8(output.stdout).unwrap();
|
||||
let tracked_keys: Vec<&str> = tracked_keys.lines().collect();
|
||||
|
||||
// Only non-empty entries and only public_key.pem should be tracked
|
||||
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
|
||||
if !tracked.contains("public_key.pem") {
|
||||
assert!(
|
||||
false,
|
||||
panic!(
|
||||
"Private key file is tracked by git: {}. Remove it with git rm --cached",
|
||||
tracked
|
||||
);
|
||||
@@ -113,47 +98,41 @@ fn test_no_private_key_in_git() {
|
||||
|
||||
#[test]
|
||||
fn test_no_token_in_source_files() {
|
||||
// Scan source files for hardcoded tokens
|
||||
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()) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "sh" {
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
// Skip comments and example/template files
|
||||
if line.trim().starts_with("#")
|
||||
|| line.to_lowercase().contains("example")
|
||||
|| line.to_lowercase().contains("template")
|
||||
if let Some(ext) = path.extension()
|
||||
&& ext == "sh"
|
||||
{
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
if line.trim().starts_with('#')
|
||||
|| line.to_lowercase().contains("example")
|
||||
|| line.to_lowercase().contains("template")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if line.trim().len() >= 40 {
|
||||
let hex_chars: Vec<_> = line
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
if (40..=64).contains(&hex_chars.len())
|
||||
&& (line.to_lowercase().contains("token")
|
||||
|| line.to_lowercase().contains("api")
|
||||
|| line.to_lowercase().contains("secret"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Check for 40-64 hex chars that could be API tokens (not in .env.example comments)
|
||||
if line.trim().len() >= 40 {
|
||||
let hex_chars = line
|
||||
.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()
|
||||
));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
println!(" {}", issue);
|
||||
}
|
||||
assert!(
|
||||
false,
|
||||
panic!(
|
||||
"Found potential hardcoded tokens in shell scripts: {:?}",
|
||||
found_issues
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user