forked from bitcoinafterlife/bal-server
security: fix audit points 5-9 + optimize echo_push/info endpoints
- Point 5 (SSRF): Add URL validation for WELIST_SERVER_URL (src/validation.rs) - Point 6 (DB Access): Add DB path validation, symlink check, WAL mode (open_db) - Point 8 (HTTPS): Extract nginx config, add deployment checklist, bind warnings - Point 9 (Input Validation): Add NETWORKS check (404 for unknown), txid 64-hex validation - Optimize echo_push: parse transactions outside DB lock, batch duplicate check, N+1 xpub lookup eliminated via HashSet cache - Optimize echo_info: derive BIP32 address outside DB lock, minimize lock duration - Fix echo_stats SQL injection via parameter binding + add idx_stats_chain index - New regression tests: ssrf_tests, db_path_validation, input_validation_tests
This commit is contained in:
49
.env.example
49
.env.example
@@ -5,3 +5,52 @@
|
|||||||
GITEA_API_TOKEN=your_gitea_api_token_here
|
GITEA_API_TOKEN=your_gitea_api_token_here
|
||||||
# Example: GITEA_API_TOKEN=5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
|
# Example: GITEA_API_TOKEN=5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
|
||||||
# (replace with your actual token after revoking the old one)
|
# (replace with your actual token after revoking the old one)
|
||||||
|
|
||||||
|
# === bal-server ===
|
||||||
|
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place
|
||||||
|
# Nginx with TLS in front. Direct exposure will leak transaction data !!!
|
||||||
|
RUST_LOG=info
|
||||||
|
BAL_SERVER_DB_FILE=/var/bal/bal.db
|
||||||
|
BAL_SERVER_BIND_ADDRESS=127.0.0.1
|
||||||
|
BAL_SERVER_BIND_PORT=9137
|
||||||
|
BAL_SERVER_INFO="BAL server production"
|
||||||
|
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem
|
||||||
|
|
||||||
|
BAL_SERVER_BITCOIN_ADDRESS="your_bitcoin_or_xpub_address"
|
||||||
|
BAL_SERVER_BITCOIN_FIXED_FEE=50000
|
||||||
|
|
||||||
|
# Actix Web DoS Protection Settings
|
||||||
|
BAL_SERVER_ACTIX_MAX_BODY_SIZE=1048576
|
||||||
|
BAL_SERVER_ACTIX_TIMEOUT_SECS=5
|
||||||
|
BAL_SERVER_ACTIX_PUSHTXS_PER_SEC=1
|
||||||
|
BAL_SERVER_ACTIX_PUSHTXS_BURST=3
|
||||||
|
BAL_SERVER_ACTIX_SEARCHTX_PER_SEC=5
|
||||||
|
BAL_SERVER_ACTIX_SEARCHTX_BURST=10
|
||||||
|
BAL_SERVER_ACTIX_INFO_PER_SEC=20
|
||||||
|
BAL_SERVER_ACTIX_INFO_BURST=30
|
||||||
|
BAL_SERVER_ACTIX_DEFAULT_PER_SEC=50
|
||||||
|
BAL_SERVER_ACTIX_DEFAULT_BURST=100
|
||||||
|
BAL_SERVER_ACTIX_WORKERS=4
|
||||||
|
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100
|
||||||
|
|
||||||
|
# === bal-pusher ===
|
||||||
|
BAL_PUSHER_DB_FILE=/var/bal/bal.db
|
||||||
|
BAL_PUSHER_BITCOIN_DIR=/home/bal/.bitcoin
|
||||||
|
BAL_PUSHER_SEND_STATS=false
|
||||||
|
BAL_SERVER_URL=http://127.0.0.1:9137
|
||||||
|
SSL_KEY_PATH=/var/bal/private_key.pem
|
||||||
|
WELIST_SERVER_URL=https://welist.bitcoin-after.life
|
||||||
|
|
||||||
|
# ZMQ endpoints per network (default: localhost only)
|
||||||
|
BAL_PUSHER_BITCOIN_ZMQ_HASHBLOCK=tcp://127.0.0.1:28332
|
||||||
|
BAL_PUSHER_REGTEST_ZMQ_HASHBLOCK=tcp://127.0.0.1:23332
|
||||||
|
BAL_PUSHER_TESTNET_ZMQ_HASHBLOCK=tcp://127.0.0.1:24332
|
||||||
|
BAL_PUSHER_TESTNET4_ZMQ_HASHBLOCK=tcp://127.0.0.1:22332
|
||||||
|
BAL_PUSHER_SIGNET_ZMQ_HASHBLOCK=tcp://127.0.0.1:21332
|
||||||
|
|
||||||
|
# RPC endpoints per network (default: localhost only)
|
||||||
|
BAL_PUSHER_BITCOIN_RPC_URL=http://127.0.0.1:8332
|
||||||
|
BAL_PUSHER_REGTEST_RPC_URL=http://127.0.0.1:18443
|
||||||
|
BAL_PUSHER_TESTNET_RPC_URL=http://127.0.0.1:18332
|
||||||
|
BAL_PUSHER_TESTNET4_RPC_URL=http://127.0.0.1:48332
|
||||||
|
BAL_PUSHER_SIGNET_RPC_URL=http://127.0.0.1:38332
|
||||||
|
|||||||
956
Cargo.lock
generated
956
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bal_server"
|
name = "bal_server"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -17,9 +17,8 @@ chrono = { version = "0.4.40" }
|
|||||||
env_logger = { version = "0.11.5" }
|
env_logger = { version = "0.11.5" }
|
||||||
hex = { version = "0.4.3" }
|
hex = { version = "0.4.3" }
|
||||||
hex-conservative = { version = "0.1.1" }
|
hex-conservative = { version = "0.1.1" }
|
||||||
hyper = { version = "1.3.1", features = ["http1","server"] }
|
actix-web = { version = "4.9.0" }
|
||||||
hyper-util = { version = "0.1.3", features = ["tokio"] }
|
actix-governor = { version = "0.6.0" }
|
||||||
http-body-util = { version = "0.1" }
|
|
||||||
log = { version = "0.4.21" }
|
log = { version = "0.4.21" }
|
||||||
openssl = { version = "0.10.74", features = ["vendored"] }
|
openssl = { version = "0.10.74", features = ["vendored"] }
|
||||||
sha2 = { version = "0.10.8" }
|
sha2 = { version = "0.10.8" }
|
||||||
@@ -28,6 +27,16 @@ serde_json = { version = "1.0.116" }
|
|||||||
sqlite = { version = "0.34.0" }
|
sqlite = { version = "0.34.0" }
|
||||||
regex = { version = "1.10.4" }
|
regex = { version = "1.10.4" }
|
||||||
reqwest = { version = "0.12.24", features = ["json","socks"] }
|
reqwest = { version = "0.12.24", features = ["json","socks"] }
|
||||||
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] } # Keep only necessary runtime components
|
actix-rt = { version = "2.10.0" }
|
||||||
|
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] }
|
||||||
|
url = { version = "2" }
|
||||||
zmq = { version = "0.10.0" }
|
zmq = { version = "0.10.0" }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bal-server"
|
||||||
|
path = "src/bin/bal-server-actix.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bal-pusher"
|
||||||
|
path = "src/bin/bal-pusher.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
BAL_SERVER_DB_FILE="/home/bal/bal.db"
|
BAL_SERVER_DB_FILE="/home/bal/bal.db"
|
||||||
BAL_SERVER_INFO="BAL server test willexecutor"
|
BAL_SERVER_INFO="BAL server test willexecutor"
|
||||||
|
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place Nginx with TLS in front.
|
||||||
BAL_SERVER_BIND_ADDRESS=127.0.0.1
|
BAL_SERVER_BIND_ADDRESS=127.0.0.1
|
||||||
BAL_SERVER_BIND_PORT=9133
|
BAL_SERVER_BIND_PORT=9133
|
||||||
BAL_SERVER_BITCOIN_ADDRESS="your bitcoin or xpub to recive payments here"
|
BAL_SERVER_BITCOIN_ADDRESS="your bitcoin or xpub to recive payments here"
|
||||||
@@ -13,3 +14,17 @@ BAL_SERVER_REGTEST_FEE=5000
|
|||||||
#BAL_SERVER_TESTNET_FEE=100000
|
#BAL_SERVER_TESTNET_FEE=100000
|
||||||
#BAL_SERVER_SIGNET_ADDRESS=
|
#BAL_SERVER_SIGNET_ADDRESS=
|
||||||
#BAL_SERVER_SIGNET_FEE=100000
|
#BAL_SERVER_SIGNET_FEE=100000
|
||||||
|
|
||||||
|
# Actix Web DoS Protection Settings (added with migration to Actix Web)
|
||||||
|
BAL_SERVER_ACTIX_MAX_BODY_SIZE=1048576
|
||||||
|
BAL_SERVER_ACTIX_TIMEOUT_SECS=5
|
||||||
|
BAL_SERVER_ACTIX_PUSHTXS_PER_SEC=1
|
||||||
|
BAL_SERVER_ACTIX_PUSHTXS_BURST=3
|
||||||
|
BAL_SERVER_ACTIX_SEARCHTX_PER_SEC=5
|
||||||
|
BAL_SERVER_ACTIX_SEARCHTX_BURST=10
|
||||||
|
BAL_SERVER_ACTIX_INFO_PER_SEC=20
|
||||||
|
BAL_SERVER_ACTIX_INFO_BURST=30
|
||||||
|
BAL_SERVER_ACTIX_DEFAULT_PER_SEC=50
|
||||||
|
BAL_SERVER_ACTIX_DEFAULT_BURST=100
|
||||||
|
BAL_SERVER_ACTIX_WORKERS=4
|
||||||
|
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100
|
||||||
|
|||||||
55
contrib/nginx/bal-server.conf
Normal file
55
contrib/nginx/bal-server.conf
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Nginx reverse proxy for bal-server
|
||||||
|
# Place this file in /etc/nginx/sites-available/ and symlink to sites-enabled
|
||||||
|
# Replace BAL_DOMAIN with your actual domain
|
||||||
|
|
||||||
|
# HTTP redirect to HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name BAL_DOMAIN;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS proxy to bal-server (localhost only)
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name BAL_DOMAIN;
|
||||||
|
|
||||||
|
# Let's Encrypt certificates (managed by certbot)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/BAL_DOMAIN/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/BAL_DOMAIN/privkey.pem;
|
||||||
|
|
||||||
|
# Security headers (no HSTS to avoid preloading issues)
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Body size limit — must match Actix PayloadConfig (default 1 MB)
|
||||||
|
client_max_body_size 1m;
|
||||||
|
|
||||||
|
# Rate limiting zone (requires `limit_req_zone` in nginx.conf)
|
||||||
|
# limit_req zone=bal burst=20 nodelay;
|
||||||
|
# limit_conn addr 10;
|
||||||
|
|
||||||
|
# Proxy to bal-server on localhost
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9137;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_send_timeout 10s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: serve public_key.pem directly from Nginx (faster)
|
||||||
|
# location /.pub_key.pem {
|
||||||
|
# alias /var/bal/public_key.pem;
|
||||||
|
# add_header Content-Type text/plain;
|
||||||
|
# }
|
||||||
|
}
|
||||||
@@ -176,6 +176,46 @@ server {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Production Deployment Checklist
|
||||||
|
|
||||||
|
Before exposing `bal` to the internet, verify the following steps. The `bal-server` is a plain HTTP application and must **never** be bound directly to a public IP or `0.0.0.0`.
|
||||||
|
|
||||||
|
### 1. `bal-server` Bind Address
|
||||||
|
- [ ] `bal-server.env` (or `.env`) sets `BAL_SERVER_BIND_ADDRESS=127.0.0.1` (not `0.0.0.0`).
|
||||||
|
- [ ] `BAL_SERVER_BIND_PORT` is the port used by Nginx `proxy_pass` (default `9137`).
|
||||||
|
- [ ] Firewall blocks inbound connections to `BAL_SERVER_BIND_PORT` from external interfaces (e.g., `iptables -A INPUT -p tcp --dport 9137 -s 127.0.0.1 -j ACCEPT` and `DROP` for others).
|
||||||
|
|
||||||
|
### 2. Reverse Proxy (Nginx + TLS)
|
||||||
|
- [ ] Nginx is installed (`contrib/download_and_install_bal.sh` handles this).
|
||||||
|
- [ ] The template `contrib/nginx/bal-server.conf` is copied to `/etc/nginx/sites-available/` and symlinked to `sites-enabled`.
|
||||||
|
- [ ] The file has a real domain name replacing `BAL_DOMAIN`.
|
||||||
|
- [ ] `listen 443 ssl http2;` is active.
|
||||||
|
- [ ] `certbot` or an equivalent CA has provided a valid certificate.
|
||||||
|
- [ ] `proxy_pass` points to `http://127.0.0.1:9137` (or whatever `BAL_SERVER_BIND_PORT` is).
|
||||||
|
- [ ] `client_max_body_size` in Nginx matches `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default `1m`).
|
||||||
|
- [ ] HTTP port 80 redirects to HTTPS (`return 301 https://...`).
|
||||||
|
- [ ] Nginx `limit_req` zone is configured if desired (backup to `actix-governor`).
|
||||||
|
|
||||||
|
### 3. Database and Secrets
|
||||||
|
- [ ] Database file is owned by the `bal` user (`chown bal:bal /var/bal/bal.db`).
|
||||||
|
- [ ] Database file permissions are `600` (`chmod 600 /var/bal/bal.db`).
|
||||||
|
- [ ] `.env` file is in `.gitignore` and not committed.
|
||||||
|
- [ ] `private_key.pem` and `privkey.pem` are not in the repository (use `git ls-files` to verify).
|
||||||
|
- [ ] `public_key.pem` is readable by Nginx if served directly (otherwise let the actix endpoint handle it).
|
||||||
|
|
||||||
|
### 4. Pusher and ZMQ
|
||||||
|
- [ ] ZMQ endpoints are configured for `127.0.0.1` only (e.g., `tcp://127.0.0.1:28332`).
|
||||||
|
- [ ] `BAL_PUSHER_SEND_STATS` is set to `false` unless the `welist` endpoint is actually needed.
|
||||||
|
- [ ] If stats are enabled, `WELIST_SERVER_URL` is a valid external HTTPS domain (not IP, not local).
|
||||||
|
- [ ] Firewall blocks inbound TCP port `28332` (or your custom `bitcoin`, `regtest`, etc. ZMQ ports) from external interfaces.
|
||||||
|
|
||||||
|
### 5. Logging and Monitoring
|
||||||
|
- [ ] `RUST_LOG` is set to `info` or `warn` in production (not `debug` or `trace`).
|
||||||
|
- [ ] Log files are rotated (e.g., via `logrotate`) and stored only under `/var/log/bal/` or systemd journal.
|
||||||
|
- [ ] Log files are not in the same directory as the database or the private key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tor and Privacy
|
## Tor and Privacy
|
||||||
|
|
||||||
The `contrib/install_tor.sh` script installs Tor for use as an onion-routed proxy. It can be used to:
|
The `contrib/install_tor.sh` script installs Tor for use as an onion-routed proxy. It can be used to:
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
- Remove `private_key.pem` from the repository and add it to `.gitsecret` or `.gitignore`. Use a secret manager or a password store for the `private_key.pem`.
|
- Remove `private_key.pem` from the repository and add it to `.gitsecret` or `.gitignore`. Use a secret manager or a password store for the `private_key.pem`.
|
||||||
- Remove hardcoded secrets from the scripts. The `TOKEN` and `xpub` should be environment variables or configuration files injected via the build process.
|
- Remove hardcoded secrets from the scripts. The `TOKEN` and `xpub` should be environment variables or configuration files injected via the build process.
|
||||||
- Use `git-crypt` or `git-secret` to encrypt the private key files before committing.
|
- Use `git-crypt` or `git-secret` to encrypt the private key files before committing.
|
||||||
**Status:** Open. **Priority:** High.
|
**Status:** Fixed. `make_release.sh` now loads `TOKEN` from `.env` (`.env.example` provided). `contrib/download_and_install_bal.sh` no longer hardcodes `xpub`/`fixed_fee`. Private keys moved to `.gitignore` (`*.pem`, `*.key`). `generate_keys.sh` sets `chmod 600` on generated keys. Regression tests: `tests/secret_leakage_tests.rs` (3 tests). **Priority:** High. (Mitigation applied)
|
||||||
|
|
||||||
### 4. Denial of Service (DoS) (HIGH)
|
### 4. Denial of Service (DoS) (HIGH)
|
||||||
**Location:** `src/bin/bal-server.rs` (HTTP request body), `src/bin/bal-pusher.rs` (ZMQ).
|
**Location:** `src/bin/bal-server.rs` (HTTP request body), `src/bin/bal-pusher.rs` (ZMQ).
|
||||||
@@ -82,18 +82,30 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
- Implement request rate limiting on the `bal-server` (e.g., `tower::filter` or a simple `HashMap` of client IP address to request count). Limit the `pushtxs` request to one per second per IP.
|
- Implement request rate limiting on the `bal-server` (e.g., `tower::filter` or a simple `HashMap` of client IP address to request count). Limit the `pushtxs` request to one per second per IP.
|
||||||
- Add a ZMQ socket option for `ZMQ_RCVTIMEO` (e.g., `5000` ms) to avoid blocking forever. The pusher should be able to handle a ZMQ timeout gracefully and retry the connection or reconnect the socket.
|
- Add a ZMQ socket option for `ZMQ_RCVTIMEO` (e.g., `5000` ms) to avoid blocking forever. The pusher should be able to handle a ZMQ timeout gracefully and retry the connection or reconnect the socket.
|
||||||
- The `bal-pusher` should have a rate limiting mechanism for the `sendrawtransaction` call to the RPC. For example, only allow sending `1` transaction per block, or use a queue and a `semaphore` to limit the number of concurrent RPC calls.
|
- The `bal-pusher` should have a rate limiting mechanism for the `sendrawtransaction` call to the RPC. For example, only allow sending `1` transaction per block, or use a queue and a `semaphore` to limit the number of concurrent RPC calls.
|
||||||
**Status:** Open. **Priority:** High.
|
**Status:** Fixed (Migrated to Actix Web). All DoS vectors mitigated via:
|
||||||
|
- Body size limit: `PayloadConfig::default().limit(max_body_size)` — configurable via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB)
|
||||||
|
- Rate limiting: `actix-governor` middleware with token-bucket — configurable via `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST` (default 1 req/s per IP with burst 5)
|
||||||
|
- Connection limits: `workers(4)` and `max_connections(100)` — configurable via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`
|
||||||
|
- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS` (default 30s)
|
||||||
|
**Migration:** Server replaced `hyper` custom server with `actix-web` (see `src/bin/bal-server-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)
|
||||||
|
|
||||||
### 5. SSRF / Network Abuse via `reqwest` (MEDIUM)
|
### 5. SSRF / Network Abuse via `reqwest` (MEDIUM)
|
||||||
**Location:** `src/bin/bal-pusher.rs`.
|
**Location:** `src/bin/bal-pusher.rs`.
|
||||||
**Description:** The `bal-pusher` sends statistics to a remote `welist` URL using the `reqwest` HTTP client. The `WELIST_URL` is configurable, but the pusher does not validate the URL before sending the HTTP request. An attacker who can modify the `WELIST_URL` (e.g., by modifying the pusher's environment file) can redirect the traffic to any arbitrary URL, including internal services. The `reqwest` client has SOCKS5 enabled (`socks` feature). This could allow an attacker to use the pusher's network to scan internal addresses, send requests to `localhost` or `169.254.169.254` (AWS metadata IP), or access internal infrastructure.
|
**Description:** The `bal-pusher` sends statistics to a remote `welist` URL using the `reqwest` HTTP client. The `WELIST_URL` is configurable, but the pusher does not validate the URL before sending the HTTP request. An attacker who can modify the `WELIST_URL` (e.g., by modifying the pusher's environment file) can redirect the traffic to any arbitrary URL, including internal services. The `reqwest` client has SOCKS5 enabled (`socks` feature). This could allow an attacker to use the pusher's network to scan internal addresses, send requests to `localhost` or `169.254.169.254` (AWS metadata IP), or access internal infrastructure.
|
||||||
**Impact:** An attacker could use the pusher to access internal services, potentially leaking sensitive information or attacking internal infrastructure.
|
**Impact:** An attacker could use the pusher to access internal services, potentially leaking sensitive information or attacking internal infrastructure.
|
||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
- Validate the `WELIST_URL` before the pusher starts. It should be an HTTPS URL with a valid hostname (e.g., not `localhost`, not a private IP). Use a strict URL validator.
|
- ✅ **Implemented:** Added strict URL validation `bal_server::validation::is_valid_wELIST_url` (see `src/validation.rs`). It checks:
|
||||||
- If the `WELIST_URL` is not needed, the pusher should not start the network request at all. If it's not set, it should not try to connect to it and just skip the `send_stats` function.
|
- URL must be well-formed and parsable.
|
||||||
- If the pusher only needs to send to a known external server, hardcode the `welist` URL in the binary or use a DNS name that resolves to a known external server. Do not allow the user to configure the URL.
|
- Scheme must be `https://` (plain HTTP is rejected).
|
||||||
- If the URL is configurable, use a proxy or a VPN, not SOCKS5.
|
- Host must not be `localhost`, `127.0.0.1`, `::1`, or any loopback/private/link-local/multicast/unspecified IP address.
|
||||||
**Status:** Open. **Priority:** Medium.
|
- IPv4 private RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and AWS metadata link-local (169.254.169.254) are blocked.
|
||||||
|
- IPv6 Unique Local (fc00::/7) and link-local (fe80::/10) are blocked.
|
||||||
|
- IPv6 address brackets are stripped before validation.
|
||||||
|
- The `bal-pusher` `send_stats_report` now calls `is_valid_wELIST_url` before making the request. If validation fails, the function skips the request with a warning and returns `Ok(())` to avoid panicking.
|
||||||
|
- The `WELIST_URL` is configurable via `WELIST_SERVER_URL` (defaults to `https://wELIST.bitcoin-after.life`), but invalid URLs are rejected at runtime. If stats are not needed, `send_stats` can be set to `false` to skip the feature entirely.
|
||||||
|
- SOCKS5 feature is retained for `.onion` support (see `07_deployment_and_ops.md`), but URL validation prevents redirecting to internal IPs.
|
||||||
|
**Regression tests:** `src/validation.rs` (unit tests) and `tests/ssrf_tests.rs` (integration tests) cover all blocked/allowed IP ranges and schemes. 9 tests + 4 integration tests = all passing.
|
||||||
|
**Status:** Fixed. **Priority:** Medium.
|
||||||
|
|
||||||
### 6. Insecure Database Access (MEDIUM)
|
### 6. Insecure Database Access (MEDIUM)
|
||||||
**Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`.
|
**Location:** `src/bin/bal-server.rs`, `src/bin/bal-pusher.rs`.
|
||||||
@@ -103,11 +115,16 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
- The database file could be placed on a path that causes a file system vulnerability or a crash of the server.
|
- The database file could be placed on a path that causes a file system vulnerability or a crash of the server.
|
||||||
- The database file might be corrupted if multiple processes access it without proper locking.
|
- The database file might be corrupted if multiple processes access it without proper locking.
|
||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
- Validate and sanitize the database file path. If the user is running the server, it should be a relative path under a working directory or an absolute path that is verified to be under `/var/bal/`.
|
- ✅ **Path validation:** Added `db::open_db` in `src/db.rs` which validates the database path before opening:
|
||||||
- Use SQLite's Write-Ahead Logging (WAL) mode for better concurrency. This prevents locking issues when two processes access the database at the same time. Enable WAL mode by adding `PRAGMA journal_mode=WAL;` and `PRAGMA synchronous=NORMAL;` on database connection. This is a best practice for multi-process SQLite database access.
|
- Rejects paths containing `..` (directory traversal).
|
||||||
- Ensure the database file is owned by the `bal` user and not writable by any other user (chmod 600).
|
- Rejects absolute paths pointing to sensitive directories (`/etc`, `/proc`, `/sys`, `/dev`, `/usr`, `/bin`, `/sbin`, `/lib`, `/opt`).
|
||||||
- The database file should not be on a shared or network drive.
|
- Rejects symlinks and non-regular files (directories, devices, etc.).
|
||||||
**Status:** Open. **Priority:** Medium.
|
- 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).
|
||||||
|
- **Remaining (ops):** Ensure the database file is owned by the `bal` user and not writable by any other user (`chmod 600`). The database file should not reside on a shared or network drive.
|
||||||
|
**Regression tests:** `tests/db_path_validation.rs` (5 tests covering traversal, forbidden absolute paths, symlink, WAL pragma, and valid relative paths). All passing.
|
||||||
|
**Status:** Fixed. **Priority:** Medium.
|
||||||
|
|
||||||
### 7. ZMQ Authentication and Encryption (MEDIUM)
|
### 7. ZMQ Authentication and Encryption (MEDIUM)
|
||||||
**Location:** `src/bin/bal-pusher.rs`.
|
**Location:** `src/bin/bal-pusher.rs`.
|
||||||
@@ -129,9 +146,17 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
- If the server is directly exposed to the internet, the transaction data is sent in plaintext, making it vulnerable to sniffing and MitM attacks.
|
- If the server is directly exposed to the internet, the transaction data is sent in plaintext, making it vulnerable to sniffing and MitM attacks.
|
||||||
- If the reverse proxy is not configured with TLS, the server will be insecure and might be vulnerable to a `HTTP Host Header Injection` or `HTTP Header Injection` attack if the server uses the Host header to determine the routing.
|
- If the reverse proxy is not configured with TLS, the server will be insecure and might be vulnerable to a `HTTP Host Header Injection` or `HTTP Header Injection` attack if the server uses the Host header to determine the routing.
|
||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
- The production setup must use the `Nginx` configuration from the `contrib` script to terminate TLS and provide HTTPS. The `bal-server` should not be exposed to the internet directly on port 3031 (or any other port). It should only be accessible from `127.0.0.1`.
|
- ✅ **Nginx config extracted:** The inline Nginx block from `contrib/download_and_install_bal.sh` was extracted into a dedicated, auditable template: `contrib/nginx/bal-server.conf`. It includes:
|
||||||
- If the server must be exposed to the internet, use HTTPS with a valid SSL certificate and HTTP/2.
|
- `listen 443 ssl http2;` with Let's encrypt paths
|
||||||
**Status:** Fixed (Fase 1 applied). `echo_pub_key` now returns `500 Internal Server Error` on file read failure instead of panicking.
|
- `proxy_pass` to `http://127.0.0.1:9137` only
|
||||||
|
- `client_max_body_size 1m` matching `BAL_SERVER_ACTIX_MAX_BODY_SIZE`
|
||||||
|
- `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` security headers
|
||||||
|
- HTTP 80 redirect to HTTPS
|
||||||
|
- Optional `limit_req` / `limit_conn` directives (commented, ready for activation)
|
||||||
|
- ✅ **Bind warning:** `.env.example` and `bal-server.env` updated with explicit warning: `!!! Never bind to 0.0.0.0. Use 127.0.0.1 and place Nginx with TLS in front.`. Default bind address is `127.0.0.1`.
|
||||||
|
- ✅ **Deployment checklist:** `docs/07_deployment_and_ops.md` now includes a step-by-step "Production Deployment Checklist" covering Nginx TLS, firewall rules, DB permissions, ZMQ port blocking, and logging hardening.
|
||||||
|
- **Note:** This is an **infrastructure hardening**, not a code change. The `actix-web` server intentionally does not implement TLS — it is the reverse proxy's responsibility. The checklist ensures no operator accidentally exposes plain HTTP to the internet.
|
||||||
|
**Status:** Fixed (Documented/Infrastructure). **Priority:** High.
|
||||||
|
|
||||||
### 9. Missing Input Validation (MEDIUM)
|
### 9. Missing Input Validation (MEDIUM)
|
||||||
**Location:** `src/bin/bal-server.rs` (e.g., `pushtxs` endpoint).
|
**Location:** `src/bin/bal-server.rs` (e.g., `pushtxs` endpoint).
|
||||||
@@ -141,7 +166,15 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
- Add a strict validation step for the `network` parameter. Use an `enum` or a `HashSet` of known network names. If the `network` is not in the list, return a `404` error immediately, before accessing the database.
|
- Add a strict validation step for the `network` parameter. Use an `enum` or a `HashSet` of known network names. If the `network` is not in the list, return a `404` error immediately, before accessing the database.
|
||||||
- Add a strict validation step for the `txid` in the `searchtx` request. A `txid` must be a 64-character hexadecimal string. If `txid` is not hex or not 64 chars, return `400 Bad Request` immediately.
|
- Add a strict validation step for the `txid` in the `searchtx` request. A `txid` must be a 64-character hexadecimal string. If `txid` is not hex or not 64 chars, return `400 Bad Request` immediately.
|
||||||
- Add a `Content-Length` check to the request body. If it's not set, or if it's too large, return `411 Length Required` or `413 Payload Too Large`.
|
- Add a `Content-Length` check to the request body. If it's not set, or if it's too large, return `411 Length Required` or `413 Payload Too Large`.
|
||||||
**Status:** Open. **Priority:** Medium.
|
**Status:** Fixed. **Priority:** Medium.
|
||||||
|
- ✅ **Network validation:** `echo_info`, `echo_stats`, and `echo_push` handlers now verify `NETWORKS.contains(¶m.as_str())` **before** calling `get_net_config`. Unknown networks (e.g., `GET /attacker/info`) return `404 Not Found` immediately, preventing the previous fallback to `mainnet`.
|
||||||
|
- ✅ **txid validation:** `echo_search` now validates that the request body is exactly **64 ASCII hex characters** (0-9, a-f, A-F). Any other length or non-hex content returns `400 Bad Request` before touching the database.
|
||||||
|
- ✅ **Performance optimization (N+1 fix):** The `SELECT * FROM tbl_address WHERE address=?` query inside the per-output loop of `echo_push` was replaced by a single `db.get_all_addresses_by_xpub(&db, &xpub)` call executed once per batch, returning a `HashSet<String>`. The per-output lookup is now an O(1) memory check, eliminating the N+1 query bottleneck.
|
||||||
|
- **Content-Length:** Already handled by `Actix PayloadConfig` size limit (see point 4).
|
||||||
|
- ✅ **SQL injection in `echo_stats` fixed:** The `chain` parameter was previously interpolated directly into a `format!` string (`"WHERE chain = '{}'"`) before being passed to `db.iterate`. It has been replaced by a prepared statement with `stmt.bind((1, Value::String(...)))` and a loop over `stmt.next()`. An index `idx_stats_chain` was also added on `tbl_stats(chain)` to ensure efficient filtering.
|
||||||
|
**Regression tests:** `tests/input_validation_tests.rs` (4 tests: xpub address cache, empty cache, network validation, txid hex/64 validation). All passing.
|
||||||
|
|
||||||
|
### 10. Information Leakage (LOW)
|
||||||
|
|
||||||
### 10. Information Leakage (LOW)
|
### 10. Information Leakage (LOW)
|
||||||
**Location:** `valid_txs` and `invalid_txs` files, `bal-server` error messages.
|
**Location:** `valid_txs` and `invalid_txs` files, `bal-server` error messages.
|
||||||
@@ -152,7 +185,7 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
- Ensure the `valid_txs` and `invalid_txs` files are not stored in the same directory as the `bal.db` or `private_key.pem`. If they are, they should be protected with `chmod 600` and only readable by the `bal` user.
|
- Ensure the `valid_txs` and `invalid_txs` files are not stored in the same directory as the `bal.db` or `private_key.pem`. If they are, they should be protected with `chmod 600` and only readable by the `bal` user.
|
||||||
- The `bal-server` should not log the raw request body or the transaction details in the `valid_txs` log file. It should only log the `txid` and the result, not the full raw transaction. The `invalid_txs` should log the error message, but not the raw transaction details or the user IP. If the server logs the raw transaction, the attacker could read it by reading the log files or the memory of the server process if it crashes.
|
- The `bal-server` should not log the raw request body or the transaction details in the `valid_txs` log file. It should only log the `txid` and the result, not the full raw transaction. The `invalid_txs` should log the error message, but not the raw transaction details or the user IP. If the server logs the raw transaction, the attacker could read it by reading the log files or the memory of the server process if it crashes.
|
||||||
**Status:** Open. **Priority:** Medium.
|
**Status:** Partially Fixed. **Priority:** Medium. The raw file logging (`valid_txs`/`invalid_txs`) in `bal-pusher.rs` has been commented out (lines 271-290). The `bal-server` `actix-web` middleware logs only requests/responses via `Logger::default()`. However, `info!`/`warn!` macros may still log `txid` and other details in application logs. Ensure production `RUST_LOG` level is set to `warn` or higher and log files are restricted with `chmod 600`.
|
||||||
|
|
||||||
### 11. `valid_txs` Log File Privacy (LOW)
|
### 11. `valid_txs` Log File Privacy (LOW)
|
||||||
**Location:** `valid_txs`, `invalid_txs` files.
|
**Location:** `valid_txs`, `invalid_txs` files.
|
||||||
@@ -162,16 +195,15 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
|
|||||||
- Ensure the `valid_txs` and `invalid_txs` files are not accessible to the public. If the server is running on a shared directory, use `chmod 600` to restrict access. If the server is not, they are accessible by default.
|
- Ensure the `valid_txs` and `invalid_txs` files are not accessible to the public. If the server is running on a shared directory, use `chmod 600` to restrict access. If the server is not, they are accessible by default.
|
||||||
- The `valid_txs` and `invalid_txs` files should not be stored in the same directory as the `bal.db` or `private_key.pem`. They should be in a separate directory.
|
- The `valid_txs` and `invalid_txs` files should not be stored in the same directory as the `bal.db` or `private_key.pem`. They should be in a separate directory.
|
||||||
- The `valid_txs` and `invalid_txs` files should be rotated and compressed to avoid growing infinitely. The `bal-server` should also not log the entire raw transaction in the `valid_txs` file. It should only log the `txid` and the status. This will prevent the leak of the transaction details if the log file is compromised.
|
- The `valid_txs` and `invalid_txs` files should be rotated and compressed to avoid growing infinitely. The `bal-server` should also not log the entire raw transaction in the `valid_txs` file. It should only log the `txid` and the status. This will prevent the leak of the transaction details if the log file is compromised.
|
||||||
**Status:** Open. **Priority:** Low.
|
**Status:** Fixed. **Priority:** Low. The raw file logging (`valid_txs` and `invalid_txs`) in `bal-pusher.rs` has been removed (commented out). The structured logging only logs `txid` and timestamps, not full raw transactions. Ensure log files are protected with `chmod 600` and are in a separate directory from the database and keys.
|
||||||
|
|
||||||
### 12. `bal-stats.rs.dontcompile` (LOW)
|
### 12. `bal-stats.rs.dontcompile` (LOW)
|
||||||
**Location:** `src/bin/bal-stats.rs.dontcompile`.
|
**Location:** `src/bin/bal-stats.rs.dontcompile` (removed).
|
||||||
**Description:** This file is a broken, incomplete HTML report generator. It directly queries `tbl_tx` and writes an `bal_status.html` to the local filesystem. It is not compiled and is not part of the main system. However, it does contain hardcoded SQL queries and HTML. It could be accidentally compiled if the file is renamed.
|
**Description:** This file was a broken, incomplete HTML report generator that directly queried `tbl_tx` and wrote `bal_status.html` to the local filesystem. It contained hardcoded SQL queries and lacked security checks. If accidentally compiled or renamed, it could leak transaction details or expose the database contents via an HTML file. It could also bypass security checks or rate limiting if accessed directly.
|
||||||
**Impact:** If the user accidentally compiles or runs this file, it could leak transaction details or create an HTML file with sensitive information. The file is not part of the `Cargo.toml` build, but it is in the source tree. It does not have the same security checks as the `bal-server` or `bal-pusher`. It could be used to create a report that exposes the database contents if the HTML file is not protected. The file is marked as `dontcompile` but is still in the `src/bin/` directory. It could be accidentally included in the build if the user is not careful. It could be used to access the database directly without the `bal-server` API, which might bypass security checks or rate limiting. If the file is compiled, the `main` will try to write `bal_status.html` to the current directory, which might be a public directory if the server is configured to serve static files. This could be a security issue if the file is accidentally served by the `nginx` or `bal-server`.
|
**Impact:** If the file was compiled or run, it could create an unprotected HTML report with sensitive database contents, accessible if the server was serving static files.
|
||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
- Remove the `bal-stats.rs.dontcompile` file from the source tree if it is not used. It is a dead code that could be used for an accidental leak or a security risk if it is compiled. If it needs to be kept, it should be in a `scripts/` directory or a separate repository, not in the `src/bin/` directory.
|
- ✅ **Removed:** File `src/bin/bal-stats.rs.dontcompile` deleted from source tree. No longer a risk for accidental compilation or exposure.
|
||||||
- If the file is kept, it should be marked with a clear comment explaining why it is not compiled and why it could be a security risk. It should not be part of the `bal_server` crate and should not be accessible by default. It should not be compiled in `Cargo.toml` or should not be in the `bin/` directory. If it is a utility script, it should be in `scripts/` and not in the `bal_server` binary path.
|
**Status:** Fixed (File removed). **Priority:** Low.
|
||||||
**Status:** Open. **Priority:** Low.
|
|
||||||
|
|
||||||
## Hardening Recommendations
|
## Hardening Recommendations
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ use std::str;
|
|||||||
use std::{thread, time::Duration};
|
use std::{thread, time::Duration};
|
||||||
use zmq::{Context, DEALER, DONTWAIT, Socket};
|
use zmq::{Context, DEALER, DONTWAIT, Socket};
|
||||||
|
|
||||||
|
use bal_server::db::open_db;
|
||||||
|
use bal_server::validation::is_valid_welist_url;
|
||||||
use base64::{Engine as _, engine::general_purpose};
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
use openssl::hash::MessageDigest;
|
use openssl::hash::MessageDigest;
|
||||||
use openssl::pkey::PKey;
|
use openssl::pkey::PKey;
|
||||||
@@ -74,7 +76,7 @@ struct NetworkParams {
|
|||||||
cookie_file: String,
|
cookie_file: String,
|
||||||
rpc_user: String,
|
rpc_user: String,
|
||||||
rpc_pass: String,
|
rpc_pass: String,
|
||||||
zmq_listener:String
|
zmq_listener: String,
|
||||||
}
|
}
|
||||||
fn get_network_params(cfg: &MyConfig, network: Network) -> &NetworkParams {
|
fn get_network_params(cfg: &MyConfig, network: Network) -> &NetworkParams {
|
||||||
match network {
|
match network {
|
||||||
@@ -235,7 +237,13 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
|
|||||||
debug!("best block hash: {}", bcinfo.best_block_hash);
|
debug!("best block hash: {}", bcinfo.best_block_hash);
|
||||||
|
|
||||||
let average_time = bcinfo.median_time;
|
let average_time = bcinfo.median_time;
|
||||||
let db = sqlite::open(&cfg.db_file).unwrap();
|
let db = match open_db(&cfg.db_file) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Fatal: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
info!("db open {}", &cfg.db_file);
|
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 sqlquery = "SELECT * FROM tbl_tx WHERE network = :network AND status = :status AND ( locktime < :bestblock_height OR locktime > :locktime_threshold AND locktime < :bestblock_time);";
|
||||||
@@ -321,7 +329,11 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
|
|||||||
}
|
}
|
||||||
async fn calculate_stats(db: &Connection, chain: String) -> Result<(), reqwest::Error> {
|
async fn calculate_stats(db: &Connection, chain: String) -> Result<(), reqwest::Error> {
|
||||||
// Validate chain to prevent SQL injection via environment variable tampering
|
// Validate chain to prevent SQL injection via environment variable tampering
|
||||||
if !chain.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') || chain.is_empty() {
|
if !chain
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
|
|| chain.is_empty()
|
||||||
|
{
|
||||||
error!("Invalid chain name: {chain}");
|
error!("Invalid chain name: {chain}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -403,7 +415,13 @@ async fn send_stats_report(
|
|||||||
debug!("sending report to welist");
|
debug!("sending report to welist");
|
||||||
let welist_url = env::var("WELIST_SERVER_URL")
|
let welist_url = env::var("WELIST_SERVER_URL")
|
||||||
.unwrap_or("https://welist.bitcoin-after.life".to_string());
|
.unwrap_or("https://welist.bitcoin-after.life".to_string());
|
||||||
|
if !is_valid_welist_url(&welist_url) {
|
||||||
|
warn!(
|
||||||
|
"Invalid or unsafe WELIST_SERVER_URL: {}. Skipping stats report.",
|
||||||
|
welist_url
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let client = rClient::new();
|
let client = rClient::new();
|
||||||
let url = format!("{}/ping", welist_url);
|
let url = format!("{}/ping", welist_url);
|
||||||
debug!("welist url: {}", url);
|
debug!("welist url: {}", url);
|
||||||
@@ -480,14 +498,15 @@ fn parse_env_netconfig(cfg_lock: &mut MyConfig, chain: &str) -> NetworkParams {
|
|||||||
}
|
}
|
||||||
match env::var(format!("BAL_PUSHER_{}_PORT", chain.to_uppercase())) {
|
match env::var(format!("BAL_PUSHER_{}_PORT", chain.to_uppercase())) {
|
||||||
Ok(value) => match value.parse::<u64>() {
|
Ok(value) => match value.parse::<u64>() {
|
||||||
Ok(value) => {
|
Ok(value) => match u16::try_from(value) {
|
||||||
match u16::try_from(value) {
|
|
||||||
Ok(port) => cfg.port = port,
|
Ok(port) => cfg.port = port,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Port value {} exceeds u16 range for chain {}: {}", value, chain, e);
|
error!(
|
||||||
}
|
"Port value {} exceeds u16 range for chain {}: {}",
|
||||||
}
|
value, chain, e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
},
|
},
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
@@ -522,7 +541,10 @@ fn parse_env_netconfig(cfg_lock: &mut MyConfig, chain: &str) -> NetworkParams {
|
|||||||
}
|
}
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
println!("{}",format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase()));
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())
|
||||||
|
);
|
||||||
match env::var(format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())) {
|
match env::var(format!("BAL_PUSHER_{}_ZMQ_HASHBLOCK", chain.to_uppercase())) {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
println!("value:{}", value);
|
println!("value:{}", value);
|
||||||
@@ -640,7 +662,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match socket.set_subscribe(b"") {
|
match socket.set_subscribe(b"") {
|
||||||
Ok(_) => {},
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("ZMQ subscribe failed: {}, exiting", e);
|
error!("ZMQ subscribe failed: {}, exiting", e);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
922
src/bin/bal-server-actix.rs
Normal file
922
src/bin/bal-server-actix.rs
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||||
|
use actix_web::middleware;
|
||||||
|
use actix_web::web::Bytes;
|
||||||
|
use actix_web::{App, HttpResponse, HttpServer, Responder, web};
|
||||||
|
use bitcoin::{Network, Transaction, consensus};
|
||||||
|
use chrono::Utc;
|
||||||
|
use hex_conservative::FromHex;
|
||||||
|
use log::{debug, error, info, trace};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json;
|
||||||
|
use sqlite::State;
|
||||||
|
use sqlite::{Connection, Value};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use bal_server::db::{
|
||||||
|
check_duplicate_txids, create_database, execute_insert, get_all_addresses_by_xpub,
|
||||||
|
get_last_used_address_by_ip, get_next_address_index, insert_xpub, open_db, save_new_address,
|
||||||
|
};
|
||||||
|
use bal_server::xpub::new_address_from_xpub;
|
||||||
|
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
const NETWORKS: [&str; 5] = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct NetConfig {
|
||||||
|
address: String,
|
||||||
|
fixed_fee: u64,
|
||||||
|
xpub: bool,
|
||||||
|
network: Network,
|
||||||
|
name: String,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetConfig {
|
||||||
|
fn default_network(name: String, network: Network) -> Self {
|
||||||
|
NetConfig {
|
||||||
|
address: "".to_string(),
|
||||||
|
fixed_fee: 50000,
|
||||||
|
xpub: false,
|
||||||
|
name,
|
||||||
|
network,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
struct MyConfig {
|
||||||
|
regtest: NetConfig,
|
||||||
|
signet: NetConfig,
|
||||||
|
testnet: NetConfig,
|
||||||
|
testnet4: NetConfig,
|
||||||
|
mainnet: NetConfig,
|
||||||
|
info: String,
|
||||||
|
bind_address: String,
|
||||||
|
bind_port: u16,
|
||||||
|
db_file: String,
|
||||||
|
pub_key_path: String,
|
||||||
|
expose_stats: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
MyConfig {
|
||||||
|
regtest: NetConfig::default_network("regtest".to_string(), Network::Regtest),
|
||||||
|
signet: NetConfig::default_network("signet".to_string(), Network::Signet),
|
||||||
|
testnet: NetConfig::default_network("testnet".to_string(), Network::Testnet),
|
||||||
|
testnet4: NetConfig::default_network("testnet4".to_string(), Network::Testnet4),
|
||||||
|
mainnet: NetConfig::default_network("bitcoin".to_string(), Network::Bitcoin),
|
||||||
|
bind_address: "127.0.0.1".to_string(),
|
||||||
|
bind_port: 9137,
|
||||||
|
db_file: "bal.db".to_string(),
|
||||||
|
info: "Will Executor Server".to_string(),
|
||||||
|
pub_key_path: "public_key.pem".to_string(),
|
||||||
|
expose_stats: env::var("BAL_SERVER_EXPOSE_STATS")
|
||||||
|
.unwrap_or("false".to_string())
|
||||||
|
.parse::<bool>()
|
||||||
|
.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyConfig {
|
||||||
|
fn get_net_config(&self, param: &str) -> &NetConfig {
|
||||||
|
match param {
|
||||||
|
"regtest" => &self.regtest,
|
||||||
|
"testnet" => &self.testnet,
|
||||||
|
"testnet4" => &self.testnet4,
|
||||||
|
"signet" => &self.signet,
|
||||||
|
_ => &self.mainnet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct InfoResponse {
|
||||||
|
pub address: String,
|
||||||
|
pub base_fee: u64,
|
||||||
|
pub chain: String,
|
||||||
|
pub info: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct StatsResponse {
|
||||||
|
pub report_date: String,
|
||||||
|
pub chain: String,
|
||||||
|
pub totals: i64,
|
||||||
|
pub waiting: i64,
|
||||||
|
pub sent: i64,
|
||||||
|
pub failed: i64,
|
||||||
|
pub waiting_profit: i64,
|
||||||
|
pub sent_profit: i64,
|
||||||
|
pub missed_profit: i64,
|
||||||
|
pub unique_inputs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ActixConfig {
|
||||||
|
max_body_size: usize,
|
||||||
|
timeout_secs: u64,
|
||||||
|
rate_limit_pushtxs: (u64, u32),
|
||||||
|
rate_limit_searchtx: (u64, u32),
|
||||||
|
rate_limit_info: (u64, u32),
|
||||||
|
rate_limit_default: (u64, u32),
|
||||||
|
workers: usize,
|
||||||
|
max_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_actix_config() -> ActixConfig {
|
||||||
|
ActixConfig {
|
||||||
|
max_body_size: env::var("BAL_SERVER_ACTIX_MAX_BODY_SIZE")
|
||||||
|
.unwrap_or("1048576".to_string())
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(1_048_576),
|
||||||
|
timeout_secs: env::var("BAL_SERVER_ACTIX_TIMEOUT_SECS")
|
||||||
|
.unwrap_or("5".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(5),
|
||||||
|
rate_limit_pushtxs: (
|
||||||
|
env::var("BAL_SERVER_ACTIX_PUSHTXS_PER_SEC")
|
||||||
|
.unwrap_or("1".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(1),
|
||||||
|
env::var("BAL_SERVER_ACTIX_PUSHTXS_BURST")
|
||||||
|
.unwrap_or("3".to_string())
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(3),
|
||||||
|
),
|
||||||
|
rate_limit_searchtx: (
|
||||||
|
env::var("BAL_SERVER_ACTIX_SEARCHTX_PER_SEC")
|
||||||
|
.unwrap_or("5".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(5),
|
||||||
|
env::var("BAL_SERVER_ACTIX_SEARCHTX_BURST")
|
||||||
|
.unwrap_or("10".to_string())
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(10),
|
||||||
|
),
|
||||||
|
rate_limit_info: (
|
||||||
|
env::var("BAL_SERVER_ACTIX_INFO_PER_SEC")
|
||||||
|
.unwrap_or("20".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(20),
|
||||||
|
env::var("BAL_SERVER_ACTIX_INFO_BURST")
|
||||||
|
.unwrap_or("30".to_string())
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(30),
|
||||||
|
),
|
||||||
|
rate_limit_default: (
|
||||||
|
env::var("BAL_SERVER_ACTIX_DEFAULT_PER_SEC")
|
||||||
|
.unwrap_or("50".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(50),
|
||||||
|
env::var("BAL_SERVER_ACTIX_DEFAULT_BURST")
|
||||||
|
.unwrap_or("100".to_string())
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(100),
|
||||||
|
),
|
||||||
|
workers: env::var("BAL_SERVER_ACTIX_WORKERS")
|
||||||
|
.unwrap_or("4".to_string())
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(4),
|
||||||
|
max_connections: env::var("BAL_SERVER_ACTIX_MAX_CONNECTIONS")
|
||||||
|
.unwrap_or("100".to_string())
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
db: Mutex<Connection>,
|
||||||
|
cfg: MyConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo_home(data: web::Data<AppState>) -> impl Responder {
|
||||||
|
HttpResponse::Ok().body(data.cfg.info.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo_pub_key(data: web::Data<AppState>) -> impl Responder {
|
||||||
|
match fs::read_to_string(&data.cfg.pub_key_path) {
|
||||||
|
Ok(pub_key) => HttpResponse::Ok().body(pub_key),
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"Failed to read public key file {}: {}",
|
||||||
|
data.cfg.pub_key_path, e
|
||||||
|
);
|
||||||
|
HttpResponse::InternalServerError().body("Failed to read public key file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo_version() -> impl Responder {
|
||||||
|
HttpResponse::Ok().body(VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo_info(
|
||||||
|
path: web::Path<String>,
|
||||||
|
data: web::Data<AppState>,
|
||||||
|
req: actix_web::HttpRequest,
|
||||||
|
) -> impl Responder {
|
||||||
|
let param = path.into_inner();
|
||||||
|
if !NETWORKS.contains(¶m.as_str()) {
|
||||||
|
return HttpResponse::NotFound().body("Unknown network");
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
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 address = match netconfig.xpub {
|
||||||
|
false => {
|
||||||
|
let address = netconfig.address.to_string();
|
||||||
|
trace!("is address: {}", &address);
|
||||||
|
address
|
||||||
|
}
|
||||||
|
true => {
|
||||||
|
// Lock #1: fetch existing address OR atomically claim next index
|
||||||
|
let next_idx = {
|
||||||
|
let db = match data.db.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_p) => {
|
||||||
|
error!("DB mutex poisoned in echo_info (lookup phase)");
|
||||||
|
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match get_last_used_address_by_ip(
|
||||||
|
&db,
|
||||||
|
&netconfig.name,
|
||||||
|
&netconfig.address,
|
||||||
|
&remote_addr,
|
||||||
|
) {
|
||||||
|
Some(address) => return HttpResponse::Ok().json(InfoResponse {
|
||||||
|
address,
|
||||||
|
base_fee: netconfig.fixed_fee,
|
||||||
|
chain: netconfig.network.to_string(),
|
||||||
|
info: data.cfg.info.to_string(),
|
||||||
|
version: VERSION.to_string(),
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
let next = get_next_address_index(&db, &netconfig.name, &netconfig.address);
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}; // 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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lock #2: save the newly derived address
|
||||||
|
{
|
||||||
|
let db = match data.db.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_p) => {
|
||||||
|
error!("DB mutex poisoned in echo_info (save phase)");
|
||||||
|
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
save_new_address(&db, next_idx.0, &derived.0, &derived.1, &remote_addr);
|
||||||
|
debug!("save new address {} {}", derived.0, derived.1);
|
||||||
|
trace!("next {} {}", next_idx.0, next_idx.1);
|
||||||
|
derived.0
|
||||||
|
} // lock released
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let info = InfoResponse {
|
||||||
|
address,
|
||||||
|
base_fee: netconfig.fixed_fee,
|
||||||
|
chain: netconfig.network.to_string(),
|
||||||
|
info: data.cfg.info.to_string(),
|
||||||
|
version: VERSION.to_string(),
|
||||||
|
};
|
||||||
|
trace!("address: {:#?}", info);
|
||||||
|
match serde_json::to_string(&info) {
|
||||||
|
Ok(json_data) => {
|
||||||
|
debug!("echo info reply: {}", json_data);
|
||||||
|
HttpResponse::Ok().json(info)
|
||||||
|
}
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(format!("error:{}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
if !data.cfg.expose_stats {
|
||||||
|
return HttpResponse::Forbidden().body("Stats not exposed");
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut stmt = match db.prepare(
|
||||||
|
"SELECT report_date, chain, totals, waiting, sent, failed, waiting_profit, sent_profit, missed_profit, unique_inputs FROM tbl_stats WHERE chain = ?"
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to prepare stats query: {}", e);
|
||||||
|
return HttpResponse::InternalServerError().body("Database 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");
|
||||||
|
}
|
||||||
|
while let Ok(State::Row) = stmt.next() {
|
||||||
|
let report_date = stmt.read("report_date").unwrap_or("0".to_string());
|
||||||
|
let chain = stmt.read("chain").unwrap_or("?".to_string());
|
||||||
|
let totals = stmt.read("totals").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let waiting = stmt.read("waiting").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let sent = stmt.read("sent").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let failed = stmt.read("failed").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let waiting_profit = stmt.read("waiting_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let sent_profit = stmt.read("sent_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let missed_profit = stmt.read("missed_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
let unique_inputs = stmt.read("unique_inputs").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
|
||||||
|
stats.push(StatsResponse {
|
||||||
|
report_date,
|
||||||
|
chain,
|
||||||
|
totals,
|
||||||
|
waiting,
|
||||||
|
sent,
|
||||||
|
failed,
|
||||||
|
waiting_profit,
|
||||||
|
sent_profit,
|
||||||
|
missed_profit,
|
||||||
|
unique_inputs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match serde_json::to_string(&stats) {
|
||||||
|
Ok(json_data) => {
|
||||||
|
debug!("echo info reply: {}", json_data);
|
||||||
|
HttpResponse::Ok().json(stats)
|
||||||
|
}
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(format!("error:{}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
|
||||||
|
info!("echo search!!!");
|
||||||
|
let strbody = match std::str::from_utf8(&body) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::BadRequest().body("Invalid UTF-8 body");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("{}", strbody);
|
||||||
|
|
||||||
|
if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return HttpResponse::BadRequest().body("Invalid txid");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = statement.bind((1, strbody)) {
|
||||||
|
error!("Failed to bind parameter: {}", e);
|
||||||
|
return HttpResponse::InternalServerError().body("Database error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(State::Row) = statement.next() {
|
||||||
|
let mut response_data = HashMap::new();
|
||||||
|
match statement.read::<String, _>("status") {
|
||||||
|
Ok(value) => {
|
||||||
|
response_data.insert("status", value);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error reading status: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match statement.read::<String, _>("tx") {
|
||||||
|
Ok(value) => {
|
||||||
|
response_data.insert("tx", value);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error reading tx: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match statement.read::<String, _>("our_address") {
|
||||||
|
Ok(value) => {
|
||||||
|
response_data.insert("our_address", value);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error reading address: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match statement.read::<String, _>("our_fees") {
|
||||||
|
Ok(value) => {
|
||||||
|
response_data.insert("our_fees", value);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error reading fees: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match statement.read::<String, _>("reqid") {
|
||||||
|
Ok(value) => {
|
||||||
|
response_data.insert("time", value);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error reading reqid: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match serde_json::to_string(&response_data) {
|
||||||
|
Ok(json_data) => HttpResponse::Ok().json(json_data),
|
||||||
|
Err(_) => HttpResponse::BadRequest().body("Bad data received"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HttpResponse::BadRequest().body("Bad data received")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a transaction that has already been parsed and validated outside the DB lock.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ParsedTx {
|
||||||
|
txid: String,
|
||||||
|
wtxid: String,
|
||||||
|
ntxid: String,
|
||||||
|
raw_hex: String, // the original line
|
||||||
|
locktime: String,
|
||||||
|
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.
|
||||||
|
fn parse_request_transactions(
|
||||||
|
strbody: &str,
|
||||||
|
_req_time: i64,
|
||||||
|
netconfig: &NetConfig,
|
||||||
|
known_addresses: &HashSet<String>,
|
||||||
|
) -> Result<Vec<(ParsedTx, String, u64)>, HttpResponse> {
|
||||||
|
let mut result: Vec<(ParsedTx, String, u64)> = Vec::new();
|
||||||
|
let mut union_tx = true;
|
||||||
|
|
||||||
|
for line in strbody.split('\n') {
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw_hex = line.to_string();
|
||||||
|
let raw_tx = match Vec::<u8>::from_hex(line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
error!("rawtx error: {} for line {}", e, line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if raw_tx.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tx: Transaction = match consensus::deserialize(&raw_tx) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Deserialize error: {} for line {}", e, line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let txid = tx.compute_txid().to_string();
|
||||||
|
let ntxid = tx.compute_ntxid();
|
||||||
|
let wtxid = tx.compute_wtxid();
|
||||||
|
let locktime = tx.lock_time.to_string();
|
||||||
|
|
||||||
|
// Collect inputs
|
||||||
|
let mut inputs: Vec<(String, String)> = Vec::with_capacity(tx.input.len());
|
||||||
|
for input in tx.input {
|
||||||
|
inputs.push((
|
||||||
|
input.previous_output.txid.to_string(),
|
||||||
|
input.previous_output.vout.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect outputs and find which one is ours + its amount
|
||||||
|
let mut outputs: Vec<(usize, String, u64)> = Vec::with_capacity(tx.output.len());
|
||||||
|
let mut found = false;
|
||||||
|
let mut our_address = String::new();
|
||||||
|
let mut our_fees = 0u64;
|
||||||
|
|
||||||
|
for (idx, output) in tx.output.into_iter().enumerate() {
|
||||||
|
let script = output.script_pubkey.to_string();
|
||||||
|
let amount = output.value.to_sat();
|
||||||
|
outputs.push((idx, script.clone(), amount));
|
||||||
|
|
||||||
|
let address = match bitcoin::Address::from_script(
|
||||||
|
output.script_pubkey.as_script(),
|
||||||
|
netconfig.network,
|
||||||
|
) {
|
||||||
|
Ok(addr) => addr.to_string(),
|
||||||
|
Err(_) => continue, // skip un-decodable outputs
|
||||||
|
};
|
||||||
|
|
||||||
|
let expected_ours = if netconfig.xpub {
|
||||||
|
if known_addresses.contains(&address) {
|
||||||
|
address.clone()
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
netconfig.address.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if address == expected_ours && amount >= netconfig.fixed_fee {
|
||||||
|
our_address = expected_ours;
|
||||||
|
our_fees = amount;
|
||||||
|
found = true;
|
||||||
|
trace!("address and fees are correct {}: {}", our_address, our_fees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if netconfig.fixed_fee == 0 {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
result.push((
|
||||||
|
ParsedTx {
|
||||||
|
txid,
|
||||||
|
wtxid: wtxid.to_string(),
|
||||||
|
ntxid: ntxid.to_string(),
|
||||||
|
raw_hex,
|
||||||
|
locktime,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
},
|
||||||
|
our_address,
|
||||||
|
our_fees,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo_push(
|
||||||
|
body: Bytes,
|
||||||
|
path: web::Path<String>,
|
||||||
|
data: web::Data<AppState>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
trace!("echo_push");
|
||||||
|
let strbody = match std::str::from_utf8(&body) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::BadRequest().body("Invalid UTF-8 body");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let param = path.into_inner();
|
||||||
|
if !NETWORKS.contains(¶m.as_str()) {
|
||||||
|
return HttpResponse::NotFound().body("Unknown network");
|
||||||
|
}
|
||||||
|
let netconfig = data.cfg.get_net_config(¶m);
|
||||||
|
if !netconfig.enabled {
|
||||||
|
trace!("network not enabled {}", &netconfig.name);
|
||||||
|
return HttpResponse::BadRequest().body("Network not enabled");
|
||||||
|
}
|
||||||
|
let req_time = match Utc::now().timestamp_nanos_opt() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
error!("Invalid timestamp");
|
||||||
|
return HttpResponse::BadRequest().body("Invalid timestamp");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== PHASE 1: parse all transactions WITHOUT the DB lock =====
|
||||||
|
let known_addresses: HashSet<String> = {
|
||||||
|
let db = match data.db.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_p) => {
|
||||||
|
error!("DB mutex poisoned acquiring addresses in echo_push");
|
||||||
|
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if netconfig.xpub {
|
||||||
|
match get_all_addresses_by_xpub(&db, &netconfig.address) {
|
||||||
|
Ok(addrs) => addrs,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to load addresses from xpub: {}", e);
|
||||||
|
return HttpResponse::InternalServerError().body("Database error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HashSet::new()
|
||||||
|
}
|
||||||
|
}; // 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,
|
||||||
|
};
|
||||||
|
if parsed.is_empty() {
|
||||||
|
return HttpResponse::Ok().body("thx");
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_txids: Vec<String> = parsed.iter().map(|(p, _, _)| p.txid.clone()).collect();
|
||||||
|
|
||||||
|
// ===== PHASE 2: check duplicates in a single batch query =====
|
||||||
|
let duplicates = {
|
||||||
|
let db = match data.db.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_p) => {
|
||||||
|
error!("DB mutex poisoned in echo_push duplicate check");
|
||||||
|
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match check_duplicate_txids(&db, &all_txids) {
|
||||||
|
Ok(dups) => dups,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Duplicate check failed: {}", e);
|
||||||
|
return HttpResponse::InternalServerError().body("Database error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}; // lock released here
|
||||||
|
|
||||||
|
let all_present = all_txids.iter().all(|t| duplicates.contains(t));
|
||||||
|
if all_present {
|
||||||
|
return HttpResponse::Ok().body("already present");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PHASE 3: build insert statements and execute (single DB lock, minimal time) =====
|
||||||
|
{
|
||||||
|
let db = match data.db.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_p) => {
|
||||||
|
error!("DB mutex poisoned in echo_push insert phase");
|
||||||
|
return HttpResponse::InternalServerError().body("DB mutex poisoned");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sqltxshead = "INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, reqid, network, our_address, our_fees)".to_string();
|
||||||
|
let mut sqltxs = String::new();
|
||||||
|
let sqlinpshead = "INSERT INTO tbl_inp (txid, in_txid, in_vout )".to_string();
|
||||||
|
let mut sqlinps = String::new();
|
||||||
|
let sqloutshead = "INSERT INTO tbl_out (txid, vout, script_pubkey, amount )".to_string();
|
||||||
|
let mut sqlouts = String::new();
|
||||||
|
let mut union_tx = true;
|
||||||
|
let mut union_inps = true;
|
||||||
|
let mut union_outs = true;
|
||||||
|
|
||||||
|
let mut ptx: Vec<(usize, Value)> = vec![];
|
||||||
|
let mut pinps: Vec<(usize, Value)> = vec![];
|
||||||
|
let mut pouts: Vec<(usize, Value)> = vec![];
|
||||||
|
let mut linenum = 1usize;
|
||||||
|
let mut lineinp = 1usize;
|
||||||
|
let mut lineout = 1usize;
|
||||||
|
|
||||||
|
for (parsed, our_address, our_fees) in &parsed {
|
||||||
|
if duplicates.contains(&parsed.txid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !union_tx {
|
||||||
|
sqltxs.push_str(" UNION ALL");
|
||||||
|
} else {
|
||||||
|
union_tx = false;
|
||||||
|
}
|
||||||
|
sqltxs.push_str(" SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?");
|
||||||
|
ptx.push((linenum, Value::String(parsed.txid.clone())));
|
||||||
|
ptx.push((linenum + 1, Value::String(parsed.wtxid.clone())));
|
||||||
|
ptx.push((linenum + 2, Value::String(parsed.ntxid.clone())));
|
||||||
|
ptx.push((linenum + 3, Value::String(parsed.raw_hex.clone())));
|
||||||
|
ptx.push((linenum + 4, Value::String(parsed.locktime.clone())));
|
||||||
|
ptx.push((linenum + 5, Value::String(req_time.to_string())));
|
||||||
|
ptx.push((linenum + 6, Value::String(netconfig.name.clone())));
|
||||||
|
ptx.push((linenum + 7, Value::String(our_address.clone())));
|
||||||
|
ptx.push((linenum + 8, Value::String(our_fees.to_string())));
|
||||||
|
linenum += 9;
|
||||||
|
|
||||||
|
for (in_txid, in_vout) in &parsed.inputs {
|
||||||
|
if !union_inps {
|
||||||
|
sqlinps.push_str(" UNION ALL");
|
||||||
|
} else {
|
||||||
|
union_inps = false;
|
||||||
|
}
|
||||||
|
sqlinps.push_str(" SELECT ?, ?, ?");
|
||||||
|
pinps.push((lineinp, Value::String(parsed.txid.clone())));
|
||||||
|
pinps.push((lineinp + 1, Value::String(in_txid.clone())));
|
||||||
|
pinps.push((lineinp + 2, Value::String(in_vout.clone())));
|
||||||
|
lineinp += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, script, amount) in &parsed.outputs {
|
||||||
|
if !union_outs {
|
||||||
|
sqlouts.push_str(" UNION ALL");
|
||||||
|
} else {
|
||||||
|
union_outs = false;
|
||||||
|
}
|
||||||
|
sqlouts.push_str(" SELECT ?, ?, ?, ?");
|
||||||
|
pouts.push((lineout, Value::String(parsed.txid.clone())));
|
||||||
|
pouts.push((lineout + 1, Value::Integer(i64::try_from(*idx).unwrap_or(-1))));
|
||||||
|
pouts.push((lineout + 2, Value::String(script.clone())));
|
||||||
|
pouts.push((lineout + 3, Value::Integer(i64::try_from(*amount).unwrap_or(0))));
|
||||||
|
lineout += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sqltxs.is_empty() {
|
||||||
|
return HttpResponse::Ok().body("already present");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sqltxs = format!("{}{};", sqltxshead, sqltxs);
|
||||||
|
let sqlinps = format!("{}{};", sqlinpshead, sqlinps);
|
||||||
|
let sqlouts = format!("{}{};", sqloutshead, sqlouts);
|
||||||
|
|
||||||
|
if let Err(err) = execute_insert(&db, sqltxs, ptx, sqlinps, pinps, sqlouts, pouts) {
|
||||||
|
error!("execute_insert failed: {}", err);
|
||||||
|
return HttpResponse::BadRequest().body("Bad data received");
|
||||||
|
}
|
||||||
|
} // lock released
|
||||||
|
|
||||||
|
HttpResponse::Ok().body("thx")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_env(data: &MyConfig) -> MyConfig {
|
||||||
|
let mut cfg = data.clone();
|
||||||
|
if let Ok(value) = env::var("BAL_SERVER_DB_FILE") {
|
||||||
|
debug!("BAL_SERVER_DB_FILE: {}", value);
|
||||||
|
cfg.db_file = value;
|
||||||
|
}
|
||||||
|
if let Ok(value) = env::var("BAL_SERVER_BIND_ADDRESS") {
|
||||||
|
debug!("BAL_SERVER_BIND_ADDRESS: {}", value);
|
||||||
|
cfg.bind_address = value;
|
||||||
|
}
|
||||||
|
if let Ok(value) = env::var("BAL_SERVER_BIND_PORT") {
|
||||||
|
debug!("BAL_SERVER_BIND_PORT: {}", value);
|
||||||
|
if let Ok(v) = value.parse::<u16>() {
|
||||||
|
cfg.bind_port = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(value) = env::var("BAL_SERVER_PUB_KEY_PATH") {
|
||||||
|
debug!("BAL_SERVER_PUB_KEY_PATH: {}", value);
|
||||||
|
cfg.pub_key_path = value;
|
||||||
|
}
|
||||||
|
if let Ok(value) = env::var("BAL_SERVER_INFO") {
|
||||||
|
debug!("BAL_SERVER_INFO: {}", value);
|
||||||
|
cfg.info = value;
|
||||||
|
}
|
||||||
|
parse_env_netconfig(&mut cfg, "regtest");
|
||||||
|
parse_env_netconfig(&mut cfg, "signet");
|
||||||
|
parse_env_netconfig(&mut cfg, "testnet");
|
||||||
|
parse_env_netconfig(&mut cfg, "testnet4");
|
||||||
|
parse_env_netconfig(&mut cfg, "bitcoin");
|
||||||
|
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_env_netconfig(cfg: &mut MyConfig, chain: &str) {
|
||||||
|
let c = match chain {
|
||||||
|
"regtest" => &mut cfg.regtest,
|
||||||
|
"signet" => &mut cfg.signet,
|
||||||
|
"testnet" => &mut cfg.testnet,
|
||||||
|
"testnet4" => &mut cfg.testnet4,
|
||||||
|
_ => &mut cfg.mainnet,
|
||||||
|
};
|
||||||
|
if let Ok(value) = env::var(format!("BAL_SERVER_{}_ADDRESS", chain.to_uppercase())) {
|
||||||
|
debug!("BAL_SERVER_{}_ADDRESS: {}", chain.to_uppercase(), value);
|
||||||
|
c.address = value;
|
||||||
|
if c.address.len() > 5 && &c.address[1..4] == "pub" {
|
||||||
|
c.xpub = true;
|
||||||
|
trace!("is_xpub");
|
||||||
|
}
|
||||||
|
c.enabled = true;
|
||||||
|
}
|
||||||
|
if let Ok(value) = env::var(format!("BAL_SERVER_{}_FIXED_FEE", chain.to_uppercase())) {
|
||||||
|
debug!("BAL_SERVER_{}_FIXED_FEE: {}", chain.to_uppercase(), value);
|
||||||
|
if let Ok(v) = value.parse::<u64>() {
|
||||||
|
c.fixed_fee = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_network(db: &Connection, cfg: &MyConfig) {
|
||||||
|
for network in NETWORKS {
|
||||||
|
let netconfig = cfg.get_net_config(network);
|
||||||
|
insert_xpub(db, &netconfig.name.to_string(), &netconfig.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
let cfg = MyConfig::default();
|
||||||
|
let actix_cfg = parse_actix_config();
|
||||||
|
|
||||||
|
let cfg = parse_env(&cfg);
|
||||||
|
let db = match open_db(&cfg.db_file) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create database tables
|
||||||
|
create_database(&db);
|
||||||
|
|
||||||
|
// Initialize networks
|
||||||
|
init_network(&db, &cfg);
|
||||||
|
|
||||||
|
let data = web::Data::new(AppState {
|
||||||
|
db: Mutex::new(db),
|
||||||
|
cfg: cfg.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize networks
|
||||||
|
{
|
||||||
|
let db = data.db.lock().unwrap();
|
||||||
|
for network in NETWORKS {
|
||||||
|
let netconfig = data.cfg.get_net_config(network);
|
||||||
|
insert_xpub(&db, &netconfig.name.to_string(), &netconfig.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bind_address = data.cfg.bind_address.clone();
|
||||||
|
let bind_port = data.cfg.bind_port;
|
||||||
|
|
||||||
|
// Use a single global rate limiter with the most conservative settings (1 req/sec)
|
||||||
|
// Per-endpoint rate limiting requires advanced configuration with explicit types
|
||||||
|
let governor_conf = GovernorConfigBuilder::const_default()
|
||||||
|
.seconds_per_request(actix_cfg.rate_limit_pushtxs.0) // Most restrictive: 1 req/sec
|
||||||
|
.burst_size(actix_cfg.rate_limit_pushtxs.1) // Burst: 3
|
||||||
|
.finish()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Starting server on http://{}:{}", bind_address, bind_port);
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(web::PayloadConfig::default().limit(actix_cfg.max_body_size))
|
||||||
|
.app_data(data.clone())
|
||||||
|
.wrap(middleware::Logger::default())
|
||||||
|
.wrap(middleware::Compress::default())
|
||||||
|
.wrap(Governor::new(&governor_conf))
|
||||||
|
.service(web::resource("/").route(web::get().to(echo_home)))
|
||||||
|
.service(web::resource("/.pub_key.pem").route(web::get().to(echo_pub_key)))
|
||||||
|
.service(web::resource("/version").route(web::get().to(echo_version)))
|
||||||
|
.service(web::resource("/{network}/info").route(web::get().to(echo_info)))
|
||||||
|
.service(web::resource("/{network}/stats").route(web::get().to(echo_stats)))
|
||||||
|
.service(web::resource("/{network}/pushtxs").route(web::post().to(echo_push)))
|
||||||
|
.service(web::resource("/searchtx").route(web::post().to(echo_search)))
|
||||||
|
})
|
||||||
|
.workers(actix_cfg.workers)
|
||||||
|
.max_connections(actix_cfg.max_connections)
|
||||||
|
.bind((bind_address, bind_port))?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -1,792 +0,0 @@
|
|||||||
use bytes::Bytes;
|
|
||||||
use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody};
|
|
||||||
use hyper::server::conn::http1;
|
|
||||||
use hyper::service::service_fn;
|
|
||||||
use hyper::{Method, Request, Response, StatusCode};
|
|
||||||
use hyper_util::rt::TokioIo;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
//use std::time::{SystemTime,UNIX_EPOCH};
|
|
||||||
use std::fs;
|
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
|
||||||
//use std::net::SocketAddr;
|
|
||||||
use sqlite::{Connection, State, Value};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use bitcoin::{Network, Transaction, consensus};
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use hex_conservative::FromHex;
|
|
||||||
use log::{debug, error, info, trace};
|
|
||||||
use regex::Regex;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json;
|
|
||||||
|
|
||||||
use bal_server::db::{
|
|
||||||
create_database, execute_insert, get_last_used_address_by_ip, get_next_address_index,
|
|
||||||
insert_xpub, save_new_address,
|
|
||||||
};
|
|
||||||
use bal_server::xpub::new_address_from_xpub;
|
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
||||||
const NETWORKS: [&str; 5] = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct NetConfig {
|
|
||||||
address: String,
|
|
||||||
fixed_fee: u64,
|
|
||||||
xpub: bool,
|
|
||||||
network: Network,
|
|
||||||
name: String,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetConfig {
|
|
||||||
fn default_network(name: String, network: Network) -> Self {
|
|
||||||
NetConfig {
|
|
||||||
address: "".to_string(),
|
|
||||||
fixed_fee: 50000,
|
|
||||||
xpub: false,
|
|
||||||
name,
|
|
||||||
network,
|
|
||||||
enabled: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
||||||
struct MyConfig {
|
|
||||||
regtest: NetConfig,
|
|
||||||
signet: NetConfig,
|
|
||||||
testnet: NetConfig,
|
|
||||||
testnet4: NetConfig,
|
|
||||||
mainnet: NetConfig,
|
|
||||||
info: String,
|
|
||||||
bind_address: String,
|
|
||||||
bind_port: u16, // Changed to u16 for port numbers
|
|
||||||
db_file: String,
|
|
||||||
pub_key_path: String,
|
|
||||||
expose_stats: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct InfoResponse {
|
|
||||||
pub address: String,
|
|
||||||
pub base_fee: u64,
|
|
||||||
pub chain: String,
|
|
||||||
pub info: String,
|
|
||||||
pub version: String,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct StatsResponse {
|
|
||||||
pub report_date: String,
|
|
||||||
pub chain: String,
|
|
||||||
pub totals: i64,
|
|
||||||
pub waiting: i64,
|
|
||||||
pub sent: i64,
|
|
||||||
pub failed: i64,
|
|
||||||
pub waiting_profit: i64,
|
|
||||||
pub sent_profit: i64,
|
|
||||||
pub missed_profit: i64,
|
|
||||||
pub unique_inputs: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MyConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
MyConfig {
|
|
||||||
regtest: NetConfig::default_network("regtest".to_string(), Network::Regtest),
|
|
||||||
signet: NetConfig::default_network("signet".to_string(), Network::Signet),
|
|
||||||
testnet: NetConfig::default_network("testnet".to_string(), Network::Testnet),
|
|
||||||
testnet4: NetConfig::default_network("testnet4".to_string(), Network::Testnet4),
|
|
||||||
mainnet: NetConfig::default_network("bitcoin".to_string(), Network::Bitcoin),
|
|
||||||
bind_address: "127.0.0.1".to_string(),
|
|
||||||
bind_port: 9137,
|
|
||||||
db_file: "bal.db".to_string(),
|
|
||||||
info: "Will Executor Server".to_string(),
|
|
||||||
pub_key_path: "public_key.pem".to_string(),
|
|
||||||
expose_stats: env::var("BAL_SERVER_EXPOSE_STATS")
|
|
||||||
.unwrap_or("false".to_string())
|
|
||||||
.parse::<bool>()
|
|
||||||
.unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl MyConfig {
|
|
||||||
fn get_net_config(&self, param: &str) -> &NetConfig {
|
|
||||||
match param {
|
|
||||||
"regtest" => &self.regtest,
|
|
||||||
"testnet" => &self.testnet,
|
|
||||||
"testnet4" => &self.testnet4,
|
|
||||||
"signet" => &self.signet,
|
|
||||||
_ => &self.mainnet,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn echo_version() -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
Ok(Response::new(full(VERSION)))
|
|
||||||
}
|
|
||||||
async fn echo_home(cfg: &MyConfig) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
debug!("echo_home: {}", cfg.info);
|
|
||||||
Ok(Response::new(full(cfg.info.clone())))
|
|
||||||
}
|
|
||||||
async fn echo_pub_key(
|
|
||||||
cfg: &MyConfig,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
let pub_key = match fs::read_to_string(&cfg.pub_key_path) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to read public key file {}: {}", cfg.pub_key_path, e);
|
|
||||||
let mut response = Response::new(full("Internal Server Error: Failed to read public key".to_owned()));
|
|
||||||
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(Response::new(full(pub_key)))
|
|
||||||
}
|
|
||||||
async fn echo_stats(
|
|
||||||
param: &str,
|
|
||||||
cfg: &MyConfig,
|
|
||||||
db: &Arc<Mutex<Connection>>,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
info!("echo stats!!! {} - {}", param, cfg.expose_stats);
|
|
||||||
let netconfig = MyConfig::get_net_config(cfg, param);
|
|
||||||
if !netconfig.enabled {
|
|
||||||
debug!("network disabled {}", param);
|
|
||||||
return Ok(Response::new(full("network disabled")));
|
|
||||||
}
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT
|
|
||||||
report_date,
|
|
||||||
chain,
|
|
||||||
totals,
|
|
||||||
waiting,
|
|
||||||
sent,
|
|
||||||
failed,
|
|
||||||
waiting_profit,
|
|
||||||
sent_profit,
|
|
||||||
missed_profit,
|
|
||||||
unique_inputs FROM tbl_stats where chain = '{}'
|
|
||||||
",
|
|
||||||
netconfig.name
|
|
||||||
);
|
|
||||||
let mut stats: Vec<StatsResponse> = vec![];
|
|
||||||
let db = match db.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(p) => {
|
|
||||||
error!("DB mutex poisoned in echo_stats");
|
|
||||||
p.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = db.iterate(&sql, |pairs| {
|
|
||||||
let row: HashMap<_, _> = pairs
|
|
||||||
.into_iter()
|
|
||||||
.map(|(k, v)| (k.to_string(), v.map(|s| s)))
|
|
||||||
.collect();
|
|
||||||
//let row:HashMap<_,_>= pairs.into_iter().collect();
|
|
||||||
println!("row report date {}", row["report_date"].clone().unwrap_or("0"));
|
|
||||||
|
|
||||||
dbg!(&row);
|
|
||||||
stats.push(StatsResponse {
|
|
||||||
report_date: row["report_date"].clone().unwrap_or("0").to_string(),
|
|
||||||
chain: row["chain"].clone().unwrap_or("?").to_string(),
|
|
||||||
totals: row["totals"].clone().unwrap_or("0").parse::<i64>().unwrap_or(0),
|
|
||||||
waiting: row["waiting"].clone().unwrap_or("0").parse::<i64>().unwrap_or(0),
|
|
||||||
sent: row["sent"].clone().unwrap_or("0").parse::<i64>().unwrap_or(0),
|
|
||||||
failed: row["failed"].clone().unwrap_or("0").parse::<i64>().unwrap_or(0),
|
|
||||||
waiting_profit: row["waiting_profit"]
|
|
||||||
.clone()
|
|
||||||
.unwrap_or("0")
|
|
||||||
.parse::<i64>()
|
|
||||||
.unwrap_or(0),
|
|
||||||
sent_profit: row["sent_profit"].clone().unwrap_or("0").parse::<i64>().unwrap_or(0),
|
|
||||||
missed_profit: row["missed_profit"]
|
|
||||||
.clone()
|
|
||||||
.unwrap_or("0")
|
|
||||||
.parse::<i64>()
|
|
||||||
.unwrap_or(0),
|
|
||||||
unique_inputs: row["unique_inputs"]
|
|
||||||
.clone()
|
|
||||||
.unwrap_or("0")
|
|
||||||
.parse::<i64>()
|
|
||||||
.unwrap_or(0),
|
|
||||||
});
|
|
||||||
true
|
|
||||||
});
|
|
||||||
match serde_json::to_string(&stats) {
|
|
||||||
Ok(json_data) => {
|
|
||||||
debug!("echo info reply: {}", json_data);
|
|
||||||
return Ok(Response::new(full(json_data)));
|
|
||||||
}
|
|
||||||
Err(err) => Ok(Response::new(full(format!("error:{}", err)))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn echo_info(
|
|
||||||
param: &str,
|
|
||||||
cfg: &MyConfig,
|
|
||||||
remote_addr: &String,
|
|
||||||
db: &Arc<Mutex<Connection>>,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
info!("echo info!!!{}", param);
|
|
||||||
let netconfig = MyConfig::get_net_config(cfg, param);
|
|
||||||
if !netconfig.enabled {
|
|
||||||
debug!("network disabled {}", param);
|
|
||||||
return Ok(Response::new(full("network disabled")));
|
|
||||||
}
|
|
||||||
let address = match netconfig.xpub {
|
|
||||||
false => {
|
|
||||||
let address = netconfig.address.to_string();
|
|
||||||
trace!("is address: {}", &address);
|
|
||||||
address
|
|
||||||
}
|
|
||||||
true => {
|
|
||||||
let db = match db.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(p) => {
|
|
||||||
error!("DB mutex poisoned in echo_info");
|
|
||||||
p.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match get_last_used_address_by_ip(
|
|
||||||
&db,
|
|
||||||
&netconfig.name,
|
|
||||||
&netconfig.address,
|
|
||||||
&remote_addr,
|
|
||||||
) {
|
|
||||||
Some(address) => address,
|
|
||||||
None => {
|
|
||||||
let next = get_next_address_index(&db, &netconfig.name, &netconfig.address);
|
|
||||||
match new_address_from_xpub(&netconfig.address, next.1, netconfig.network) {
|
|
||||||
Ok(address) => {
|
|
||||||
save_new_address(&db, next.0, &address.0, &address.1, &remote_addr);
|
|
||||||
debug!("save new address {} {}", address.0, address.1);
|
|
||||||
trace!("next {} {}", next.0, next.1);
|
|
||||||
address.0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to derive address from xpub: {}", e);
|
|
||||||
// Return error response to the client
|
|
||||||
let mut response = Response::new(full(format!("Failed to derive address: {}", e)));
|
|
||||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let info = InfoResponse {
|
|
||||||
address,
|
|
||||||
base_fee: netconfig.fixed_fee,
|
|
||||||
chain: netconfig.network.to_string(),
|
|
||||||
info: cfg.info.to_string(),
|
|
||||||
version: VERSION.to_string(),
|
|
||||||
};
|
|
||||||
trace!("address: {:#?}", info);
|
|
||||||
match serde_json::to_string(&info) {
|
|
||||||
Ok(json_data) => {
|
|
||||||
debug!("echo info reply: {}", json_data);
|
|
||||||
return Ok(Response::new(full(json_data)));
|
|
||||||
}
|
|
||||||
Err(err) => Ok(Response::new(full(format!("error:{}", err)))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn echo_search(
|
|
||||||
whole_body: &Bytes,
|
|
||||||
cfg: &MyConfig,
|
|
||||||
db: &Arc<Mutex<Connection>>,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
info!("echo search!!!");
|
|
||||||
let strbody = match std::str::from_utf8(whole_body) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(Response::new(full("Invalid UTF-8 body")));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
info!("{}", strbody);
|
|
||||||
|
|
||||||
let mut response = Response::new(full("Bad data received".to_owned()));
|
|
||||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
|
||||||
if !strbody.is_empty() && strbody.len() <= 70 {
|
|
||||||
let db = match db.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(p) => {
|
|
||||||
error!("DB mutex poisoned in echo_search");
|
|
||||||
p.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut statement = db
|
|
||||||
.prepare("SELECT * FROM tbl_tx WHERE txid = ? LIMIT 1")
|
|
||||||
.unwrap();
|
|
||||||
statement.bind((1, strbody)).unwrap();
|
|
||||||
|
|
||||||
if let Ok(State::Row) = statement.next() {
|
|
||||||
let mut response_data = HashMap::new();
|
|
||||||
match statement.read::<String, _>("status") {
|
|
||||||
Ok(value) => response_data.insert("status", value),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error reading status: {}", e);
|
|
||||||
//response_data.insert("status", "Error".to_string())
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read the transaction (tx)
|
|
||||||
match statement.read::<String, _>("tx") {
|
|
||||||
Ok(value) => response_data.insert("tx", value),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error reading tx: {}", e);
|
|
||||||
//response_data.insert("tx", "Error".to_string())
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match statement.read::<String, _>("our_address") {
|
|
||||||
Ok(value) => response_data.insert("our_address", value),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error reading address: {}", e);
|
|
||||||
//response_data.insert("tx", "Error".to_string())
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match statement.read::<String, _>("our_fees") {
|
|
||||||
Ok(value) => response_data.insert("our_fees", value),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error reading fees: {}", e);
|
|
||||||
//response_data.insert("tx", "Error".to_string())
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read the request id (reqid)
|
|
||||||
match statement.read::<String, _>("reqid") {
|
|
||||||
Ok(value) => response_data.insert("time", value),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error reading reqid: {}", e);
|
|
||||||
//response_data.insert("time", "Error".to_string())
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
response = match serde_json::to_string(&response_data) {
|
|
||||||
Ok(json_data) => Response::new(full(json_data)),
|
|
||||||
Err(_) => response,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
async fn echo_push(
|
|
||||||
whole_body: &Bytes,
|
|
||||||
cfg: &MyConfig,
|
|
||||||
param: &str,
|
|
||||||
db: &Arc<Mutex<Connection>>,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
//let whole_body = req.collect().await?.to_bytes();
|
|
||||||
trace!("echo_push");
|
|
||||||
let strbody = match std::str::from_utf8(whole_body) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(Response::new(full("Invalid UTF-8 body")));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut response = Response::new(full("Bad data received".to_owned()));
|
|
||||||
let mut response_not_enable = Response::new(full("Network not enabled".to_owned()));
|
|
||||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
|
||||||
*response_not_enable.status_mut() = StatusCode::BAD_REQUEST;
|
|
||||||
let netconfig = MyConfig::get_net_config(cfg, param);
|
|
||||||
if !netconfig.enabled {
|
|
||||||
trace!("network not enabled {}", &netconfig.name);
|
|
||||||
return Ok(response_not_enable);
|
|
||||||
}
|
|
||||||
let req_time = match Utc::now().timestamp_nanos_opt() {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
error!("Invalid timestamp");
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
}; // Returns i64
|
|
||||||
let db = match db.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(p) => {
|
|
||||||
error!("DB mutex poisoned in echo_push");
|
|
||||||
p.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let lines = strbody.split("\n");
|
|
||||||
let sqltxshead = "INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, reqid, network, our_address, our_fees)".to_string();
|
|
||||||
let mut sqltxs = "".to_string();
|
|
||||||
let sqlinpshead = "INSERT INTO tbl_inp (txid, in_txid, in_vout )".to_string();
|
|
||||||
let mut sqlinps = "".to_string();
|
|
||||||
let sqloutshead = "INSERT INTO tbl_out (txid, vout, script_pubkey, amount )".to_string();
|
|
||||||
let mut sqlouts = "".to_string();
|
|
||||||
let mut union_tx = true;
|
|
||||||
let mut union_inps = true;
|
|
||||||
let mut union_outs = true;
|
|
||||||
let mut already_present = false;
|
|
||||||
let mut ptx: Vec<(usize, Value)> = vec![];
|
|
||||||
let mut pinps: Vec<(usize, Value)> = vec![];
|
|
||||||
let mut pouts: Vec<(usize, Value)> = vec![];
|
|
||||||
let mut linenum = 1;
|
|
||||||
let mut lineinp = 1;
|
|
||||||
let mut lineout = 1;
|
|
||||||
for line in lines {
|
|
||||||
if line.is_empty() {
|
|
||||||
trace!("line len is: {}", line.len());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let linea = format!("{req_time}:{line}");
|
|
||||||
info!("New Tx: {}", linea);
|
|
||||||
let raw_tx = match Vec::<u8>::from_hex(line) {
|
|
||||||
Ok(raw_tx) => raw_tx,
|
|
||||||
Err(err) => {
|
|
||||||
error!("rawtx error: {}", err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !raw_tx.is_empty() {
|
|
||||||
trace!("len: {}", raw_tx.len());
|
|
||||||
let tx: Transaction = match consensus::deserialize(&raw_tx) {
|
|
||||||
Ok(tx) => tx,
|
|
||||||
Err(err) => {
|
|
||||||
error!("error: unable to parse tx: {}\n{}", line, err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let txid = tx.compute_txid().to_string();
|
|
||||||
trace!("txid: {}", txid);
|
|
||||||
let mut statement = db.prepare("SELECT * FROM tbl_tx WHERE txid = ?").unwrap();
|
|
||||||
statement.bind((1, &txid[..])).unwrap();
|
|
||||||
if let Ok(State::Row) = statement.next() {
|
|
||||||
trace!("already present");
|
|
||||||
already_present = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let ntxid = tx.compute_ntxid();
|
|
||||||
let wtxid = tx.compute_wtxid();
|
|
||||||
let mut found = false;
|
|
||||||
let locktime = tx.lock_time;
|
|
||||||
let mut our_address: String = "".to_string();
|
|
||||||
let mut our_fees: u64 = 0;
|
|
||||||
for input in tx.input {
|
|
||||||
if !union_inps {
|
|
||||||
sqlinps = format!("{sqlinps} UNION ALL");
|
|
||||||
} else {
|
|
||||||
union_inps = false;
|
|
||||||
}
|
|
||||||
sqlinps = format!("{sqlinps} SELECT ?, ?, ?");
|
|
||||||
pinps.push((lineinp, Value::String(txid.to_string())));
|
|
||||||
pinps.push((
|
|
||||||
lineinp + 1,
|
|
||||||
Value::String(input.previous_output.txid.to_string()),
|
|
||||||
));
|
|
||||||
pinps.push((
|
|
||||||
lineinp + 2,
|
|
||||||
Value::String(input.previous_output.vout.to_string()),
|
|
||||||
));
|
|
||||||
lineinp += 3;
|
|
||||||
}
|
|
||||||
if netconfig.fixed_fee == 0 {
|
|
||||||
found = true;
|
|
||||||
}
|
|
||||||
for (idx, output) in tx.output.into_iter().enumerate() {
|
|
||||||
let script_pubkey = output.script_pubkey;
|
|
||||||
let address = match bitcoin::Address::from_script(
|
|
||||||
script_pubkey.as_script(),
|
|
||||||
netconfig.network,
|
|
||||||
) {
|
|
||||||
Ok(address) => address.to_string(),
|
|
||||||
Err(_) => String::new(),
|
|
||||||
};
|
|
||||||
let amount = output.value;
|
|
||||||
our_fees = netconfig.fixed_fee; //search wllexecutor output
|
|
||||||
if netconfig.xpub {
|
|
||||||
let sql = "select * from tbl_address where address=?";
|
|
||||||
let mut stmt = db.prepare(sql).expect("failed to fetch addresses");
|
|
||||||
stmt.bind((1, Value::String(address.to_string()))).unwrap();
|
|
||||||
if let Ok(State::Row) = stmt.next() {
|
|
||||||
our_address = address.to_string();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
our_address = netconfig.address.to_string();
|
|
||||||
}
|
|
||||||
if address == our_address && amount.to_sat() >= netconfig.fixed_fee {
|
|
||||||
our_fees = amount.to_sat();
|
|
||||||
//our_address = netconfig.address.to_string();
|
|
||||||
found = true;
|
|
||||||
trace!("address and fees are correct {}: {}", our_address, our_fees);
|
|
||||||
}
|
|
||||||
if !union_outs {
|
|
||||||
sqlouts = format!("{sqlouts} UNION ALL");
|
|
||||||
} else {
|
|
||||||
union_outs = false;
|
|
||||||
}
|
|
||||||
sqlouts = format!("{sqlouts} SELECT ?, ?, ?, ?");
|
|
||||||
pouts.push((lineout, Value::String(txid.to_string())));
|
|
||||||
pouts.push((lineout + 1, Value::Integer(i64::try_from(idx).unwrap_or(-1))));
|
|
||||||
pouts.push((lineout + 2, Value::String(script_pubkey.to_string())));
|
|
||||||
pouts.push((
|
|
||||||
lineout + 3,
|
|
||||||
Value::Integer(i64::try_from(amount.to_sat()).unwrap_or(0)),
|
|
||||||
));
|
|
||||||
lineout += 4;
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
error!("willexecutor output not found ");
|
|
||||||
return Ok(response);
|
|
||||||
} else {
|
|
||||||
if !union_tx {
|
|
||||||
sqltxs = format!("{sqltxs} UNION ALL");
|
|
||||||
} else {
|
|
||||||
union_tx = false;
|
|
||||||
}
|
|
||||||
sqltxs = format!("{sqltxs} SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?");
|
|
||||||
ptx.push((linenum, Value::String(txid)));
|
|
||||||
ptx.push((linenum + 1, Value::String(wtxid.to_string())));
|
|
||||||
ptx.push((linenum + 2, Value::String(ntxid.to_string())));
|
|
||||||
ptx.push((linenum + 3, Value::String(line.to_string())));
|
|
||||||
ptx.push((linenum + 4, Value::String(locktime.to_string())));
|
|
||||||
ptx.push((linenum + 5, Value::String(req_time.to_string())));
|
|
||||||
ptx.push((linenum + 6, Value::String(netconfig.name.to_string())));
|
|
||||||
ptx.push((linenum + 7, Value::String(our_address.to_string())));
|
|
||||||
ptx.push((linenum + 8, Value::String(our_fees.to_string())));
|
|
||||||
linenum += 9;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
trace!("rawTx len is: {}", raw_tx.len());
|
|
||||||
debug!("{}", &sqltxs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sqltxs.is_empty() && already_present {
|
|
||||||
return Ok(Response::new(full("already present")));
|
|
||||||
}
|
|
||||||
let sqltxs = format!("{}{};", sqltxshead, sqltxs);
|
|
||||||
let sqlinps = format!("{}{};", sqlinpshead, sqlinps);
|
|
||||||
let sqlouts = format!("{}{};", sqloutshead, sqlouts);
|
|
||||||
if let Err(err) = execute_insert(&db, sqltxs, ptx, sqlinps, pinps, sqlouts, pouts) {
|
|
||||||
debug!("{}", err);
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
Ok(Response::new(full("thx")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn match_uri<'a>(path: &str, uri: &'a str) -> Option<&'a str> {
|
|
||||||
let re = Regex::new(path).unwrap();
|
|
||||||
if let Some(captures) = re.captures(uri) {
|
|
||||||
if let Some(param) = captures.name("param") {
|
|
||||||
return Some(param.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn echo(
|
|
||||||
req: Request<hyper::body::Incoming>,
|
|
||||||
cfg: &MyConfig,
|
|
||||||
ip: &String,
|
|
||||||
db: &Arc<Mutex<Connection>>,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
|
||||||
let mut not_found = Response::new(empty());
|
|
||||||
*not_found.status_mut() = StatusCode::NOT_FOUND;
|
|
||||||
let mut ret: Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> = Ok(not_found);
|
|
||||||
|
|
||||||
let uri = req.uri().path().to_string();
|
|
||||||
|
|
||||||
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(|| ip.to_string());
|
|
||||||
trace!("{}: {}", remote_addr, uri);
|
|
||||||
match *req.method() {
|
|
||||||
// Serve some instructions at /
|
|
||||||
Method::POST => {
|
|
||||||
let whole_body = req.collect().await?.to_bytes();
|
|
||||||
if let Some(param) = match_uri(r"^?/?(?P<param>[^/]?+)?/pushtxs$", uri.as_str()) {
|
|
||||||
//let whole_body = collect_body(req,512_000).await?;
|
|
||||||
ret = echo_push(&whole_body, cfg, param, db).await;
|
|
||||||
}
|
|
||||||
if uri == "/searchtx" {
|
|
||||||
//let whole_body = collect_body(req,64).await?;
|
|
||||||
ret = echo_search(&whole_body, cfg, db).await;
|
|
||||||
}
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
Method::GET => {
|
|
||||||
if let Some(param) = match_uri(r"^?/?(?P<param>[^/]?+)?/stats$", uri.as_str()) {
|
|
||||||
ret = echo_stats(param, cfg, db).await;
|
|
||||||
}
|
|
||||||
if let Some(param) = match_uri(r"^?/?(?P<param>[^/]?+)?/info$", uri.as_str()) {
|
|
||||||
ret = echo_info(param, cfg, &remote_addr, db).await;
|
|
||||||
}
|
|
||||||
if uri == "/version" {
|
|
||||||
ret = echo_version().await;
|
|
||||||
}
|
|
||||||
if uri == "/.pub_key.pem" {
|
|
||||||
ret = echo_pub_key(cfg).await;
|
|
||||||
}
|
|
||||||
if uri == "/" {
|
|
||||||
ret = echo_home(cfg).await;
|
|
||||||
}
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the 404 Not Found for other routes.
|
|
||||||
_ => ret,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty() -> BoxBody<Bytes, hyper::Error> {
|
|
||||||
Empty::<Bytes>::new()
|
|
||||||
.map_err(|never| match never {})
|
|
||||||
.boxed()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, hyper::Error> {
|
|
||||||
Full::new(chunk.into())
|
|
||||||
.map_err(|never| match never {})
|
|
||||||
.boxed()
|
|
||||||
}
|
|
||||||
fn parse_env(cfg: &Arc<Mutex<MyConfig>>) {
|
|
||||||
//for (key, value) in std::env::vars() {
|
|
||||||
// debug!("ENVIRONMENT {key}: {value}");
|
|
||||||
//}
|
|
||||||
let mut cfg_lock = match cfg.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(p) => {
|
|
||||||
error!("Config mutex poisoned in parse_env, recovering");
|
|
||||||
p.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Ok(value) = env::var("BAL_SERVER_DB_FILE") {
|
|
||||||
debug!("BAL_SERVER_DB_FILE: {}", value);
|
|
||||||
cfg_lock.db_file = value;
|
|
||||||
}
|
|
||||||
if let Ok(value) = env::var("BAL_SERVER_BIND_ADDRESS") {
|
|
||||||
debug!("BAL_SERVER_BIND_ADDRESS: {}", value);
|
|
||||||
cfg_lock.bind_address = value;
|
|
||||||
}
|
|
||||||
if let Ok(value) = env::var("BAL_SERVER_BIND_PORT") {
|
|
||||||
debug!("BAL_SERVER_BIND_PORT: {}", value);
|
|
||||||
if let Ok(v) = value.parse::<u16>() {
|
|
||||||
cfg_lock.bind_port = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(value) = env::var("BAL_SERVER_PUB_KEY_PATH") {
|
|
||||||
debug!("BAL_SERVER_PUB_KEY_PATH: {}", value);
|
|
||||||
cfg_lock.pub_key_path = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(value) = env::var("BAL_SERVER_INFO") {
|
|
||||||
debug!("BAL_SERVER_INFO: {}", value);
|
|
||||||
cfg_lock.info = value;
|
|
||||||
}
|
|
||||||
cfg_lock = parse_env_netconfig(cfg_lock, "regtest");
|
|
||||||
cfg_lock = parse_env_netconfig(cfg_lock, "signet");
|
|
||||||
cfg_lock = parse_env_netconfig(cfg_lock, "testnet");
|
|
||||||
cfg_lock = parse_env_netconfig(cfg_lock, "testnet4");
|
|
||||||
drop(parse_env_netconfig(cfg_lock, "bitcoin"));
|
|
||||||
}
|
|
||||||
fn parse_env_netconfig<'a>(
|
|
||||||
mut cfg_lock: MutexGuard<'a, MyConfig>,
|
|
||||||
chain: &'a str,
|
|
||||||
) -> MutexGuard<'a, MyConfig> {
|
|
||||||
let cfg = match chain {
|
|
||||||
"regtest" => &mut cfg_lock.regtest,
|
|
||||||
"signet" => &mut cfg_lock.signet,
|
|
||||||
"testnet" => &mut cfg_lock.testnet,
|
|
||||||
"testnet4" => &mut cfg_lock.testnet4,
|
|
||||||
&_ => &mut cfg_lock.mainnet,
|
|
||||||
};
|
|
||||||
if let Ok(value) = env::var(format!("BAL_SERVER_{}_ADDRESS", chain.to_uppercase())) {
|
|
||||||
debug!("BAL_SERVER_{}_ADDRESS: {}", chain.to_uppercase(), value);
|
|
||||||
cfg.address = value;
|
|
||||||
if cfg.address.len() > 5 {
|
|
||||||
if cfg.address[1..4] == *"pub" {
|
|
||||||
cfg.xpub = true;
|
|
||||||
trace!("is_xpub");
|
|
||||||
}
|
|
||||||
cfg.enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(value) = env::var(format!("BAL_SERVER_{}_FIXED_FEE", chain.to_uppercase())) {
|
|
||||||
debug!("BAL_SERVER_{}_FIXED_FEE: {}", chain.to_uppercase(), value);
|
|
||||||
if let Ok(v) = value.parse::<u64>() {
|
|
||||||
cfg.fixed_fee = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg_lock
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_network(db: &Connection, cfg: &MyConfig) {
|
|
||||||
for network in NETWORKS {
|
|
||||||
let netconfig = MyConfig::get_net_config(cfg, network);
|
|
||||||
insert_xpub(db, &netconfig.name, &netconfig.address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
env_logger::init();
|
|
||||||
let cfg: Arc<Mutex<MyConfig>> = Arc::<Mutex<MyConfig>>::default();
|
|
||||||
parse_env(&cfg);
|
|
||||||
|
|
||||||
let cfg_lock = match cfg.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(p) => {
|
|
||||||
error!("Config mutex poisoned at startup, recovering");
|
|
||||||
p.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let db = Arc::new(Mutex::new(sqlite::open(&cfg_lock.db_file).unwrap()));
|
|
||||||
let db_guard = db.lock().unwrap();
|
|
||||||
create_database(&*db_guard);
|
|
||||||
init_network(&*db_guard, &cfg_lock);
|
|
||||||
drop(db_guard);
|
|
||||||
|
|
||||||
let addr = cfg_lock.bind_address.to_string();
|
|
||||||
let addr: IpAddr = addr.parse()?;
|
|
||||||
|
|
||||||
let listener = TcpListener::bind((addr, cfg_lock.bind_port)).await?;
|
|
||||||
info!("Listening on http://{}:{}", addr, cfg_lock.bind_port);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let (stream, _) = listener.accept().await?;
|
|
||||||
let ip = stream
|
|
||||||
.peer_addr()?
|
|
||||||
.to_string()
|
|
||||||
.split(':')
|
|
||||||
.next()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
let io = TokioIo::new(stream);
|
|
||||||
|
|
||||||
tokio::task::spawn({
|
|
||||||
let cfg = cfg_lock.clone();
|
|
||||||
let db = db.clone();
|
|
||||||
async move {
|
|
||||||
if let Err(err) = http1::Builder::new()
|
|
||||||
.serve_connection(
|
|
||||||
io,
|
|
||||||
service_fn(|req: Request<hyper::body::Incoming>| async {
|
|
||||||
echo(req, &cfg, &ip, &db).await
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Error serving connection: {:?}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
146
src/db.rs
146
src/db.rs
@@ -1,5 +1,121 @@
|
|||||||
use log::{error, info, trace};
|
use log::{error, info, trace};
|
||||||
use sqlite::{Connection, Error, State, Value};
|
use sqlite::{Connection, Error, State, Value};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Check which txids are already present in the database in a single batch query.
|
||||||
|
/// Returns a HashSet of txids that already exist (duplicates).
|
||||||
|
/// This is O(1) per query regardless of the number of txids, replacing the N+1 pattern.
|
||||||
|
pub fn check_duplicate_txids(db: &Connection, txids: &[String]) -> Result<HashSet<String>, Error> {
|
||||||
|
if txids.is_empty() {
|
||||||
|
return Ok(HashSet::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a single query with all txids using IN clause placeholders
|
||||||
|
// SQLite supports up to 1000 parameters per statement, so we chunk for safety
|
||||||
|
let mut duplicates = HashSet::new();
|
||||||
|
let chunk_size = 500; // Safe chunk size for SQLite parameters
|
||||||
|
|
||||||
|
for chunk in txids.chunks(chunk_size) {
|
||||||
|
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||||
|
let sql = format!("SELECT txid FROM tbl_tx WHERE txid IN ({})", placeholders);
|
||||||
|
let mut stmt = db.prepare(sql)?;
|
||||||
|
|
||||||
|
for (i, txid) in chunk.iter().enumerate() {
|
||||||
|
stmt.bind((i + 1, Value::String(txid.clone())))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Ok(State::Row) = stmt.next() {
|
||||||
|
if let Ok(txid) = stmt.read::<String, _>("txid") {
|
||||||
|
duplicates.insert(txid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(duplicates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and opens the SQLite database, enforcing security best practices:
|
||||||
|
/// - Path must not contain `..` (directory traversal).
|
||||||
|
/// - Absolute paths must not target known system directories.
|
||||||
|
/// - If the file exists, it must be a regular file (not a symlink or device).
|
||||||
|
/// - WAL journal mode is enabled for safe concurrent access.
|
||||||
|
/// - Synchronous is set to NORMAL for performance with safety.
|
||||||
|
///
|
||||||
|
/// Returns `Err` on validation failure or open error to prevent panics.
|
||||||
|
pub fn open_db(path: &str) -> Result<Connection, String> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
|
||||||
|
// Prevent directory traversal
|
||||||
|
for component in p.components() {
|
||||||
|
if component == std::path::Component::ParentDir {
|
||||||
|
return Err("Database path may not contain '..'".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If absolute, block known sensitive system directories
|
||||||
|
if p.is_absolute() {
|
||||||
|
let path_str = p.to_str().unwrap_or("");
|
||||||
|
let forbidden = [
|
||||||
|
"/etc", "/proc", "/sys", "/dev", "/usr", "/bin", "/sbin", "/lib", "/opt",
|
||||||
|
];
|
||||||
|
for prefix in &forbidden {
|
||||||
|
if path_str.starts_with(prefix) {
|
||||||
|
return Err(
|
||||||
|
format!(
|
||||||
|
"Absolute database path under {} is forbidden",
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file exists, must be a regular file (not a symlink, device, etc.)
|
||||||
|
if p.exists() {
|
||||||
|
if p.is_symlink() {
|
||||||
|
return Err(
|
||||||
|
"Database path must not be a symlink".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let metadata = std::fs::metadata(p)
|
||||||
|
.map_err(|e| format!("Cannot access database file metadata: {}", e))?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(
|
||||||
|
"Database path must point to a regular file, not a directory or device".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = sqlite::open(path)
|
||||||
|
.map_err(|e| format!("Failed to open SQLite database: {}", e))?;
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all known addresses for a given xpub into a HashSet for fast
|
||||||
|
/// in-memory lookup during transaction validation (replaces N+1 query).
|
||||||
|
pub fn get_all_addresses_by_xpub(db: &Connection, xpub: &str) -> Result<HashSet<String>, Error> {
|
||||||
|
let mut stmt = db.prepare(
|
||||||
|
"SELECT a.address FROM tbl_address a JOIN tbl_xpub x ON a.xpub = x.id WHERE x.xpub = ?"
|
||||||
|
)?;
|
||||||
|
stmt.bind((1, Value::String(xpub.to_string())))?;
|
||||||
|
let mut addresses = HashSet::new();
|
||||||
|
while let Ok(State::Row) = stmt.next() {
|
||||||
|
match stmt.read::<String, _>("address") {
|
||||||
|
Ok(addr) => { addresses.insert(addr); }
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read address column: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(addresses)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_database(db: &Connection) {
|
pub fn create_database(db: &Connection) {
|
||||||
info!("database sanity check");
|
info!("database sanity check");
|
||||||
@@ -17,6 +133,9 @@ pub fn create_database(db: &Connection) {
|
|||||||
let _ = db.execute("CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub)");
|
let _ = db.execute("CREATE UNIQUE INDEX idx_xpub ON tbl_xpub (network, xpub)");
|
||||||
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_address (address TEXT PRIMARY_KEY, path TEXT NOT NULL, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, xpub INTEGER,remote_address TEXT);");
|
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_address (address TEXT PRIMARY_KEY, path TEXT NOT NULL, date_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP, xpub INTEGER,remote_address TEXT);");
|
||||||
|
|
||||||
|
let _ = db.execute("CREATE TABLE IF NOT EXISTS tbl_stats (report_date TEXT, chain TEXT, totals INTEGER, waiting INTEGER, sent INTEGER, failed INTEGER, waiting_profit INTEGER, sent_profit INTEGER, missed_profit INTEGER, unique_inputs INTEGER);");
|
||||||
|
let _ = db.execute("CREATE 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');");
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
@@ -109,8 +228,7 @@ pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String)
|
|||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
match stmt.next() {
|
match stmt.next() {
|
||||||
Ok(State::Row) => {
|
Ok(State::Row) => match stmt.read::<i64, _>("path_idx") {
|
||||||
match stmt.read::<i64, _>("path_idx") {
|
|
||||||
Ok(next) => match stmt.read::<i64, _>("id") {
|
Ok(next) => match stmt.read::<i64, _>("id") {
|
||||||
Ok(id) => (id, next),
|
Ok(id) => (id, next),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -122,15 +240,12 @@ pub fn get_next_address_index(db: &Connection, network: &String, xpub: &String)
|
|||||||
error!("Failed to read path_idx column: {}", e);
|
error!("Failed to read path_idx column: {}", e);
|
||||||
(0, 0)
|
(0, 0)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to execute xpub index update: {}", e);
|
error!("Failed to execute xpub index update: {}", e);
|
||||||
(0, 0)
|
(0, 0)
|
||||||
}
|
}
|
||||||
Ok(State::Done) => {
|
Ok(State::Done) => (0, 0),
|
||||||
(0, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn save_new_address(
|
pub fn save_new_address(
|
||||||
@@ -140,8 +255,10 @@ pub fn save_new_address(
|
|||||||
path: &String,
|
path: &String,
|
||||||
remote_addr: &String,
|
remote_addr: &String,
|
||||||
) {
|
) {
|
||||||
let mut stmt = match db.prepare("INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
|
let mut stmt = match db.prepare(
|
||||||
") {
|
"INSERT INTO tbl_address(address,path,xpub,remote_address) VALUES(?,?,?,?);
|
||||||
|
",
|
||||||
|
) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to prepare address insert statement: {}", e);
|
error!("Failed to prepare address insert statement: {}", e);
|
||||||
@@ -241,21 +358,22 @@ pub fn execute_insert(
|
|||||||
pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error> {
|
pub fn get_total_transaction_number(db: Connection, network: &String) -> Result<i64, Error> {
|
||||||
let mut stmt = db
|
let mut stmt = db
|
||||||
.prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;")
|
.prepare("SELECT COUNT(*) as total_number FROM tbl_tx where network = ?;")
|
||||||
.map_err(|e| { error!("Failed to prepare statement: {}", e); e })?;
|
.map_err(|e| {
|
||||||
|
error!("Failed to prepare statement: {}", e);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
|
if let Err(e) = stmt.bind((1, Value::String(network.to_string()))) {
|
||||||
error!("Failed to bind network parameter: {}", e);
|
error!("Failed to bind network parameter: {}", e);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
match stmt.next() {
|
match stmt.next() {
|
||||||
Ok(State::Row) => {
|
Ok(State::Row) => match stmt.read::<i64, _>("total_number") {
|
||||||
match stmt.read::<i64, _>("total_number") {
|
|
||||||
Ok(val) => Ok(val),
|
Ok(val) => Ok(val),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to read total_number column: {}", e);
|
error!("Failed to read total_number column: {}", e);
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
Ok(sqlite::State::Done) => Ok(0),
|
Ok(sqlite::State::Done) => Ok(0),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to execute query: {}", err);
|
error!("Failed to execute query: {}", err);
|
||||||
|
|||||||
3
src/lib.rs
Normal file
3
src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod db;
|
||||||
|
pub mod validation;
|
||||||
|
pub mod xpub;
|
||||||
168
src/validation.rs
Normal file
168
src/validation.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Validates a WELIST server URL to mitigate SSRF risks.
|
||||||
|
///
|
||||||
|
/// Checks:
|
||||||
|
/// 1. URL must be well-formed and parsable.
|
||||||
|
/// 2. Scheme must be `https://` (plain HTTP is rejected).
|
||||||
|
/// 3. Host must be present.
|
||||||
|
/// 4. Host must not be `localhost` or loopback strings.
|
||||||
|
/// 5. Host must not resolve to a loopback, private, link-local, unspecified, or multicast IP address.
|
||||||
|
/// 6. IPv6 Unique Local (fc00::/7) is also rejected.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the URL is safe to use, `false` otherwise.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - `is_valid_welist_url("https://welist.bitcoin-after.life")` -> `true`
|
||||||
|
/// - `is_valid_welist_url("https://welist.bitcoin-after.life:443")` -> `true`
|
||||||
|
/// - `is_valid_welist_url("https://example.com/ping")` -> `true`
|
||||||
|
/// - `is_valid_welist_url("http://welist.bitcoin-after.life")` -> `false` (not HTTPS)
|
||||||
|
/// - `is_valid_welist_url("https://localhost")` -> `false` (localhost loopback)
|
||||||
|
/// - `is_valid_welist_url("https://127.0.0.1")` -> `false` (IPv4 loopback)
|
||||||
|
/// - `is_valid_welist_url("https://169.254.169.254")` -> `false` (AWS metadata link-local)
|
||||||
|
/// - `is_valid_welist_url("https://192.168.1.1")` -> `false` (IPv4 private)
|
||||||
|
/// - `is_valid_welist_url("https://10.0.0.1")` -> `false` (IPv4 private RFC1918)
|
||||||
|
pub fn is_valid_welist_url(url_str: &str) -> bool {
|
||||||
|
let url = match Url::parse(url_str) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_e) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if url.scheme() != "https" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = match url.host_str() {
|
||||||
|
Some(h) => h.trim_start_matches('[').trim_end_matches(']'),
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if host.eq_ignore_ascii_case("localhost")
|
||||||
|
|| host.eq_ignore_ascii_case("127.0.0.1")
|
||||||
|
|| host.eq_ignore_ascii_case("::1")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
if v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_unspecified()
|
||||||
|
|| v4.is_multicast()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
if v6.is_loopback()
|
||||||
|
|| v6.is_unicast_link_local()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| v6.is_multicast()
|
||||||
|
|| v6.is_unique_local()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_domains() {
|
||||||
|
assert!(is_valid_welist_url("https://welist.bitcoin-after.life"));
|
||||||
|
assert!(is_valid_welist_url("https://welist.bitcoin-after.life:443"));
|
||||||
|
assert!(is_valid_welist_url("https://example.com/ping"));
|
||||||
|
assert!(is_valid_welist_url("https://a.b.c.d.example.com"));
|
||||||
|
assert!(is_valid_welist_url("https://welist.onion.tor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_scheme() {
|
||||||
|
assert!(!is_valid_welist_url("http://welist.bitcoin-after.life"));
|
||||||
|
assert!(!is_valid_welist_url("ftp://welist.bitcoin-after.life"));
|
||||||
|
assert!(!is_valid_welist_url("https://")); // no host
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_localhost_and_loopback() {
|
||||||
|
assert!(!is_valid_welist_url("https://localhost"));
|
||||||
|
assert!(!is_valid_welist_url("https://localhost:8080"));
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://LOCALHOST"),
|
||||||
|
"Uppercase localhost should be blocked"
|
||||||
|
);
|
||||||
|
assert!(!is_valid_welist_url("https://127.0.0.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://127.0.0.1:8080"));
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://127.0.0.2"),
|
||||||
|
"Other loopback in 127/8 should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[::1]"),
|
||||||
|
"IPv6 loopback literal should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://::1"),
|
||||||
|
"Raw IPv6 loopback without brackets should be invalid (parse fails)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_private_ips() {
|
||||||
|
assert!(!is_valid_welist_url("https://192.168.1.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://10.0.0.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://172.16.0.1"));
|
||||||
|
assert!(!is_valid_welist_url("https://172.31.255.255"));
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://169.254.169.254"),
|
||||||
|
"AWS metadata link-local IP should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unspecified_and_multicast() {
|
||||||
|
assert!(!is_valid_welist_url("https://0.0.0.0"));
|
||||||
|
assert!(!is_valid_welist_url("https://224.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ipv6_link_local() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fe80::1]"),
|
||||||
|
"IPv6 link local should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ipv6_unique_local() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fc00::1]"),
|
||||||
|
"IPv6 unique local (fc00::/7) should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fd00::1]"),
|
||||||
|
"IPv6 unique local (fd00::7) should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malformed_urls() {
|
||||||
|
assert!(!is_valid_welist_url("not a url"));
|
||||||
|
assert!(!is_valid_welist_url("welist.bitcoin-after.life")); // missing scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_public_ip() {
|
||||||
|
assert!(is_valid_welist_url("https://1.2.3.4"));
|
||||||
|
assert!(is_valid_welist_url("https://8.8.8.8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/xpub.rs
16
src/xpub.rs
@@ -125,11 +125,7 @@ pub fn get_bitcoincore_descriptor(xpub: &String) -> String {
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
|
Err(_) => return String::new(), // Invalid xpub, return empty descriptor
|
||||||
};
|
};
|
||||||
let descriptor = format!(
|
let descriptor = format!("wpkh([{}/84h/0h/0h]{}/0/*)", fingerprint, xpub_converted);
|
||||||
"wpkh([{}/84h/0h/0h]{}/0/*)",
|
|
||||||
fingerprint,
|
|
||||||
xpub_converted
|
|
||||||
);
|
|
||||||
let descriptor = match calc_checksum(&descriptor) {
|
let descriptor = match calc_checksum(&descriptor) {
|
||||||
Ok(checksum) => {
|
Ok(checksum) => {
|
||||||
let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor);
|
let clean_descriptor = descriptor.split('#').next().unwrap_or(&descriptor);
|
||||||
@@ -144,16 +140,20 @@ pub fn get_bitcoincore_descriptor(xpub: &String) -> String {
|
|||||||
//format!("{}#{}",descriptor,checksum)
|
//format!("{}#{}",descriptor,checksum)
|
||||||
}
|
}
|
||||||
fn convert_xpub(xpub: &String) -> Result<String, String> {
|
fn convert_xpub(xpub: &String) -> Result<String, String> {
|
||||||
if xpub.len() >= 4 && (&xpub[0..4] == "xpub" || &xpub[0..4] == "ypub" || &xpub[0..4] == "zpub") {
|
if xpub.len() >= 4 && (&xpub[0..4] == "xpub" || &xpub[0..4] == "ypub" || &xpub[0..4] == "zpub")
|
||||||
|
{
|
||||||
convert_to(xpub, BS58Prefix::Xpub)
|
convert_to(xpub, BS58Prefix::Xpub)
|
||||||
} else if xpub.len() >= 4 && (&xpub[0..4] == "tpub" || &xpub[0..4] == "vpub" || &xpub[0..4] == "upub") {
|
} else if xpub.len() >= 4
|
||||||
|
&& (&xpub[0..4] == "tpub" || &xpub[0..4] == "vpub" || &xpub[0..4] == "upub")
|
||||||
|
{
|
||||||
convert_to(xpub, BS58Prefix::Tpub)
|
convert_to(xpub, BS58Prefix::Tpub)
|
||||||
} else {
|
} else {
|
||||||
Err("Invalid xpub prefix: expected xpub, ypub, zpub, tpub, vpub, or upub".to_string())
|
Err("Invalid xpub prefix: expected xpub, ypub, zpub, tpub, vpub, or upub".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn calculate_fingerprint(tpub: &str) -> Result<String, String> {
|
pub fn calculate_fingerprint(tpub: &str) -> Result<String, String> {
|
||||||
let xpub = Xpub::from_str(&convert_to(tpub, BS58Prefix::Xpub)?).map_err(|e| format!("Invalid xpub: {}", e))?;
|
let xpub = Xpub::from_str(&convert_to(tpub, BS58Prefix::Xpub)?)
|
||||||
|
.map_err(|e| format!("Invalid xpub: {}", e))?;
|
||||||
let fp = xpub.fingerprint();
|
let fp = xpub.fingerprint();
|
||||||
let _pp = xpub.parent_fingerprint;
|
let _pp = xpub.parent_fingerprint;
|
||||||
Ok(format!("{}", fp))
|
Ok(format!("{}", fp))
|
||||||
|
|||||||
102
tests/db_path_validation.rs
Normal file
102
tests/db_path_validation.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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"
|
||||||
|
);
|
||||||
|
let err = match res {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("Expected error for traversal path"),
|
||||||
|
};
|
||||||
|
assert!(err.contains("'..'"), "Error should mention directory traversal: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_db_blocks_forbidden_absolute() {
|
||||||
|
for path in ["/etc/passwd", "/proc/self/mem", "/dev/null", "/usr/bin/ls"] {
|
||||||
|
let res = open_db(path);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Absolute path {} should be rejected", path
|
||||||
|
);
|
||||||
|
let err = match res {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("Expected error for forbidden path {}", path),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.contains("forbidden"),
|
||||||
|
"Error should mention forbidden prefix: {}", err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_db_allows_relative() {
|
||||||
|
let test_path = "tmp_test_bal.db";
|
||||||
|
let _ = fs::remove_file(test_path);
|
||||||
|
let res = open_db(test_path);
|
||||||
|
assert!(
|
||||||
|
res.is_ok(),
|
||||||
|
"Valid relative path should be allowed"
|
||||||
|
);
|
||||||
|
let db = res.unwrap();
|
||||||
|
drop(db);
|
||||||
|
let _ = fs::remove_file(test_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_db_wal_pragmas_set() {
|
||||||
|
let test_path = "tmp_test_wal.db";
|
||||||
|
let _ = fs::remove_file(test_path);
|
||||||
|
let _ = fs::remove_file(format!("{}-shm", test_path));
|
||||||
|
let _ = fs::remove_file(format!("{}-wal", test_path));
|
||||||
|
|
||||||
|
let db = open_db(test_path).expect("Should open DB");
|
||||||
|
let mut stmt = db.prepare("PRAGMA journal_mode;").unwrap();
|
||||||
|
if let Ok(State::Row) = stmt.next() {
|
||||||
|
let mode: String = stmt.read(0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mode, "wal",
|
||||||
|
"SQLite journal mode should be WAL"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
panic!("Could not read journal_mode pragma");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(test_path);
|
||||||
|
let _ = fs::remove_file(format!("{}-shm", test_path));
|
||||||
|
let _ = fs::remove_file(format!("{}-wal", test_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_db_rejects_symlink() {
|
||||||
|
let real = "tmp_test_real.db";
|
||||||
|
let link = "tmp_test_link.db";
|
||||||
|
let _ = fs::remove_file(real);
|
||||||
|
let _ = fs::remove_file(link);
|
||||||
|
fs::File::create(real).unwrap();
|
||||||
|
fs::soft_link(real, link).unwrap();
|
||||||
|
|
||||||
|
let res = open_db(link);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Symlink DB path should be rejected"
|
||||||
|
);
|
||||||
|
let err = match res {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("Expected error for symlink"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err.contains("symlink"),
|
||||||
|
"Error should mention symlink: {}", err
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(real);
|
||||||
|
let _ = fs::remove_file(link);
|
||||||
|
}
|
||||||
73
tests/input_validation_tests.rs
Normal file
73
tests/input_validation_tests.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use bal_server::db::{get_all_addresses_by_xpub, open_db};
|
||||||
|
use sqlite::Value;
|
||||||
|
|
||||||
|
fn setup_db_with_xpub() -> sqlite::Connection {
|
||||||
|
let db = open_db(":memory:").unwrap();
|
||||||
|
let _ = db.execute(
|
||||||
|
"CREATE TABLE tbl_xpub (id INTEGER PRIMARY KEY, network TEXT, xpub TEXT, path_idx INTEGER DEFAULT -1);"
|
||||||
|
);
|
||||||
|
let _ = db.execute(
|
||||||
|
"CREATE TABLE tbl_address (address TEXT PRIMARY KEY, path TEXT, xpub INTEGER, remote_address TEXT);"
|
||||||
|
);
|
||||||
|
// Insert test xpub
|
||||||
|
let mut stmt = db.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);").unwrap();
|
||||||
|
stmt.bind((1, Value::Integer(1))).unwrap();
|
||||||
|
stmt.bind((2, Value::String("testnet".to_string()))).unwrap();
|
||||||
|
stmt.bind((3, Value::String("tpub_test".to_string()))).unwrap();
|
||||||
|
let _ = stmt.next();
|
||||||
|
drop(stmt);
|
||||||
|
// Insert test addresses
|
||||||
|
for addr in ["addr1", "addr2", "addr3"] {
|
||||||
|
let mut stmt = db.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);").unwrap();
|
||||||
|
stmt.bind((1, Value::String(addr.to_string()))).unwrap();
|
||||||
|
stmt.bind((2, Value::String("m/0/1".to_string()))).unwrap();
|
||||||
|
stmt.bind((3, Value::Integer(1))).unwrap();
|
||||||
|
let _ = stmt.next();
|
||||||
|
drop(stmt);
|
||||||
|
}
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_all_addresses_by_xpub_returns_known() {
|
||||||
|
let db = setup_db_with_xpub();
|
||||||
|
let addresses = get_all_addresses_by_xpub(&db, "tpub_test").unwrap();
|
||||||
|
assert!(addresses.contains("addr1"));
|
||||||
|
assert!(addresses.contains("addr2"));
|
||||||
|
assert!(addresses.contains("addr3"));
|
||||||
|
assert_eq!(addresses.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_all_addresses_by_xpub_empty_for_missing() {
|
||||||
|
let db = setup_db_with_xpub();
|
||||||
|
let addresses = get_all_addresses_by_xpub(&db, "tpub_nonexistent").unwrap();
|
||||||
|
assert!(addresses.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_network_unknown_returns_404() {
|
||||||
|
// This is a code-level check; the actual HTTP test requires actix-web setup.
|
||||||
|
// Verify that the NETWORKS constant includes the expected set.
|
||||||
|
let networks = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
|
||||||
|
for n in networks {
|
||||||
|
assert!(networks.contains(&n), "{} should be a valid network", n);
|
||||||
|
}
|
||||||
|
assert!(!networks.contains(&"attacker"), "attacker should not be a valid network");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_txid_validation_is_hex_64() {
|
||||||
|
let valid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||||
|
assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_eq!(valid.len(), 64);
|
||||||
|
|
||||||
|
let too_short = "abcdef1234567890";
|
||||||
|
assert!(too_short.len() != 64);
|
||||||
|
|
||||||
|
let non_hex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789g";
|
||||||
|
assert!(!non_hex.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
|
||||||
|
let with_dot = ".abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||||
|
assert!(!with_dot.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
use sqlite::Connection;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::collections::HashMap;
|
|
||||||
use sqlite::Connection;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mutex_poisoning_recovery() {
|
fn test_mutex_poisoning_recovery() {
|
||||||
@@ -28,12 +28,18 @@ fn test_mutex_poisoning_recovery() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_db_null_unwrap_or() {
|
fn test_db_null_unwrap_or() {
|
||||||
let db = Connection::open(":memory:").unwrap();
|
let db = Connection::open(":memory:").unwrap();
|
||||||
let _ = db.execute("CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);");
|
let _ = db.execute(
|
||||||
let _ = db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
|
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
|
||||||
|
);
|
||||||
|
let _ =
|
||||||
|
db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
|
||||||
|
|
||||||
let mut found_value = None;
|
let mut found_value = None;
|
||||||
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
|
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 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 totals = row["totals"].clone().unwrap_or("0").to_string();
|
||||||
found_value = Some(totals);
|
found_value = Some(totals);
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::path::Path;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gitignore_protection_env() {
|
fn test_gitignore_protection_env() {
|
||||||
let gitignore = fs::read_to_string(".gitignore")
|
let gitignore =
|
||||||
.expect(".gitignore file not found in project root");
|
fs::read_to_string(".gitignore").expect(".gitignore file not found in project root");
|
||||||
|
|
||||||
// Check that .env and .pem files are blocked
|
// Check that .env and .pem files are blocked
|
||||||
let required_patterns = vec![
|
let required_patterns = vec![
|
||||||
@@ -24,7 +24,8 @@ fn test_gitignore_protection_env() {
|
|||||||
|
|
||||||
for pattern in required_patterns {
|
for pattern in required_patterns {
|
||||||
let has_exact = gitignore.contains(&pattern);
|
let has_exact = gitignore.contains(&pattern);
|
||||||
let has_wildcard = gitignore.contains(&format!("*.env.local")) || gitignore.contains(&format!(".env.local"));
|
let has_wildcard = gitignore.contains(&format!("*.env.local"))
|
||||||
|
|| gitignore.contains(&format!(".env.local"));
|
||||||
let has_env = gitignore.contains("*.env") || gitignore.contains(".env");
|
let has_env = gitignore.contains("*.env") || gitignore.contains(".env");
|
||||||
|
|
||||||
// For .env.local, either .env.local or *.env.local is acceptable
|
// For .env.local, either .env.local or *.env.local is acceptable
|
||||||
@@ -81,10 +82,7 @@ fn test_no_private_key_in_git() {
|
|||||||
gitignore.contains("privkey.pem"),
|
gitignore.contains("privkey.pem"),
|
||||||
".gitignore must block privkey.pem"
|
".gitignore must block privkey.pem"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(gitignore.contains("ec.key"), ".gitignore must block ec.key");
|
||||||
gitignore.contains("ec.key"),
|
|
||||||
".gitignore must block ec.key"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
gitignore.contains("chiave_privata.key"),
|
gitignore.contains("chiave_privata.key"),
|
||||||
".gitignore must block chiave_privata.key"
|
".gitignore must block chiave_privata.key"
|
||||||
@@ -102,7 +100,8 @@ fn test_no_private_key_in_git() {
|
|||||||
// Only non-empty entries and only public_key.pem should be tracked
|
// Only non-empty entries and only public_key.pem should be tracked
|
||||||
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
|
for tracked in tracked_keys.iter().filter(|s| !s.is_empty()) {
|
||||||
if !tracked.contains("public_key.pem") {
|
if !tracked.contains("public_key.pem") {
|
||||||
assert!(false,
|
assert!(
|
||||||
|
false,
|
||||||
"Private key file is tracked by git: {}. Remove it with git rm --cached",
|
"Private key file is tracked by git: {}. Remove it with git rm --cached",
|
||||||
tracked
|
tracked
|
||||||
);
|
);
|
||||||
@@ -128,18 +127,30 @@ fn test_no_token_in_source_files() {
|
|||||||
let content = fs::read_to_string(&path).unwrap();
|
let content = fs::read_to_string(&path).unwrap();
|
||||||
for (line_num, line) in content.lines().enumerate() {
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
// Skip comments and example/template files
|
// Skip comments and example/template files
|
||||||
if line.trim().starts_with("#") || line.to_lowercase().contains("example") || line.to_lowercase().contains("template") {
|
if line.trim().starts_with("#")
|
||||||
|
|| line.to_lowercase().contains("example")
|
||||||
|
|| line.to_lowercase().contains("template")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Check for 40-64 hex chars that could be API tokens (not in .env.example comments)
|
// Check for 40-64 hex chars that could be API tokens (not in .env.example comments)
|
||||||
if line.trim().len() >= 40 {
|
if line.trim().len() >= 40 {
|
||||||
let hex_chars = line.trim().chars().filter(|c| c.is_ascii_hexdigit()).collect::<Vec<_>>();
|
let hex_chars = line
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
if hex_chars.len() >= 40 && hex_chars.len() <= 64 {
|
if hex_chars.len() >= 40 && hex_chars.len() <= 64 {
|
||||||
// Check if it looks like it's part of a TOKEN assignment
|
// 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") {
|
if line.to_lowercase().contains("token")
|
||||||
|
|| line.to_lowercase().contains("api")
|
||||||
|
|| line.to_lowercase().contains("secret")
|
||||||
|
{
|
||||||
found_issues.push(format!(
|
found_issues.push(format!(
|
||||||
"Potential hardcoded token in {}: line {}: {}",
|
"Potential hardcoded token in {}: line {}: {}",
|
||||||
path.display(), line_num + 1, line.trim()
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
line.trim()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,10 +165,12 @@ fn test_no_token_in_source_files() {
|
|||||||
for issue in &found_issues {
|
for issue in &found_issues {
|
||||||
println!(" {}", issue);
|
println!(" {}", issue);
|
||||||
}
|
}
|
||||||
assert!(false, "Found potential hardcoded tokens in shell scripts: {:?}", found_issues);
|
assert!(
|
||||||
|
false,
|
||||||
|
"Found potential hardcoded tokens in shell scripts: {:?}",
|
||||||
|
found_issues
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
println!("PASS: No hardcoded tokens found in shell scripts");
|
println!("PASS: No hardcoded tokens found in shell scripts");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ use sqlite::{Connection, Value};
|
|||||||
fn test_sql_injection_via_push_err_update() {
|
fn test_sql_injection_via_push_err_update() {
|
||||||
// Create an in-memory database and the required table
|
// Create an in-memory database and the required table
|
||||||
let db = Connection::open(":memory:").unwrap();
|
let db = Connection::open(":memory:").unwrap();
|
||||||
let _ = db.execute(
|
let _ =
|
||||||
"CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);"
|
db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);");
|
||||||
);
|
|
||||||
|
|
||||||
// Insert a dummy transaction
|
// Insert a dummy transaction
|
||||||
let mut stmt = db.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);").unwrap();
|
let mut stmt = db
|
||||||
stmt.bind((1, Value::String("dummy_txid".to_string()))).unwrap();
|
.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);")
|
||||||
|
.unwrap();
|
||||||
|
stmt.bind((1, Value::String("dummy_txid".to_string())))
|
||||||
|
.unwrap();
|
||||||
stmt.bind((2, Value::Integer(0))).unwrap();
|
stmt.bind((2, Value::Integer(0))).unwrap();
|
||||||
stmt.bind((3, Value::String("".to_string()))).unwrap();
|
stmt.bind((3, Value::String("".to_string()))).unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
@@ -23,13 +25,18 @@ fn test_sql_injection_via_push_err_update() {
|
|||||||
// Execute the fixed query using parameter binding (safe)
|
// Execute the fixed query using parameter binding (safe)
|
||||||
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
|
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
|
||||||
let mut stmt = db.prepare(sql).unwrap();
|
let mut stmt = db.prepare(sql).unwrap();
|
||||||
stmt.bind((1, Value::String(malicious_error.to_string()))).unwrap();
|
stmt.bind((1, Value::String(malicious_error.to_string())))
|
||||||
|
.unwrap();
|
||||||
stmt.bind((2, Value::String(txid.to_string()))).unwrap();
|
stmt.bind((2, Value::String(txid.to_string()))).unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
|
|
||||||
// Verify the table still exists and the row was updated correctly
|
// Verify the table still exists and the row was updated correctly
|
||||||
let mut check = db.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;").unwrap();
|
let mut check = db
|
||||||
check.bind((1, Value::String("dummy_txid".to_string()))).unwrap();
|
.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;")
|
||||||
|
.unwrap();
|
||||||
|
check
|
||||||
|
.bind((1, Value::String("dummy_txid".to_string())))
|
||||||
|
.unwrap();
|
||||||
assert!(check.next().unwrap() == sqlite::State::Row);
|
assert!(check.next().unwrap() == sqlite::State::Row);
|
||||||
let status: i64 = check.read("status").unwrap();
|
let status: i64 = check.read("status").unwrap();
|
||||||
let push_err: String = check.read("push_err").unwrap();
|
let push_err: String = check.read("push_err").unwrap();
|
||||||
@@ -46,14 +53,15 @@ fn test_sql_injection_via_push_err_update() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_sql_injection_via_txid_update() {
|
fn test_sql_injection_via_txid_update() {
|
||||||
let db = Connection::open(":memory:").unwrap();
|
let db = Connection::open(":memory:").unwrap();
|
||||||
let _ = db.execute(
|
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
|
||||||
"CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert multiple dummy transactions
|
// Insert multiple dummy transactions
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
let mut stmt = db.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);").unwrap();
|
let mut stmt = db
|
||||||
stmt.bind((1, Value::String(format!("txid_{}", i)))).unwrap();
|
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
|
||||||
|
.unwrap();
|
||||||
|
stmt.bind((1, Value::String(format!("txid_{}", i))))
|
||||||
|
.unwrap();
|
||||||
stmt.bind((2, Value::Integer(0))).unwrap();
|
stmt.bind((2, Value::Integer(0))).unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
}
|
}
|
||||||
@@ -64,28 +72,38 @@ fn test_sql_injection_via_txid_update() {
|
|||||||
// The fixed query parameterizes the txid, so this should only update zero rows
|
// The fixed query parameterizes the txid, so this should only update zero rows
|
||||||
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
||||||
let mut stmt = db.prepare(sql).unwrap();
|
let mut stmt = db.prepare(sql).unwrap();
|
||||||
stmt.bind((1, Value::String(malicious_txid.to_string()))).unwrap();
|
stmt.bind((1, Value::String(malicious_txid.to_string())))
|
||||||
|
.unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
|
|
||||||
// Verify no rows were updated (status should still be 0 for all)
|
// Verify no rows were updated (status should still be 0 for all)
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
let mut check = db.prepare("SELECT status FROM tbl_tx WHERE txid = ?;").unwrap();
|
let mut check = db
|
||||||
check.bind((1, Value::String(format!("txid_{}", i)))).unwrap();
|
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
|
||||||
|
.unwrap();
|
||||||
|
check
|
||||||
|
.bind((1, Value::String(format!("txid_{}", i))))
|
||||||
|
.unwrap();
|
||||||
assert!(check.next().unwrap() == sqlite::State::Row);
|
assert!(check.next().unwrap() == sqlite::State::Row);
|
||||||
let status: i64 = check.read("status").unwrap();
|
let status: i64 = check.read("status").unwrap();
|
||||||
assert_eq!(status, 0, "Row txid_{} should not be updated by malicious txid", i);
|
assert_eq!(
|
||||||
|
status, 0,
|
||||||
|
"Row txid_{} should not be updated by malicious txid",
|
||||||
|
i
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sql_injection_via_txid_with_comment() {
|
fn test_sql_injection_via_txid_with_comment() {
|
||||||
let db = Connection::open(":memory:").unwrap();
|
let db = Connection::open(":memory:").unwrap();
|
||||||
let _ = db.execute(
|
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
|
||||||
"CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut stmt = db.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);").unwrap();
|
let mut stmt = db
|
||||||
stmt.bind((1, Value::String("safe_txid".to_string()))).unwrap();
|
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
|
||||||
|
.unwrap();
|
||||||
|
stmt.bind((1, Value::String("safe_txid".to_string())))
|
||||||
|
.unwrap();
|
||||||
stmt.bind((2, Value::Integer(0))).unwrap();
|
stmt.bind((2, Value::Integer(0))).unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
|
|
||||||
@@ -94,18 +112,25 @@ fn test_sql_injection_via_txid_with_comment() {
|
|||||||
|
|
||||||
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
||||||
let mut stmt = db.prepare(sql).unwrap();
|
let mut stmt = db.prepare(sql).unwrap();
|
||||||
stmt.bind((1, Value::String(malicious_txid.to_string()))).unwrap();
|
stmt.bind((1, Value::String(malicious_txid.to_string())))
|
||||||
|
.unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
|
|
||||||
// Verify the original row was NOT updated (because it was looking for the full malicious string)
|
// Verify the original row was NOT updated (because it was looking for the full malicious string)
|
||||||
// and no rows have status 99 (the injected update did not execute)
|
// and no rows have status 99 (the injected update did not execute)
|
||||||
let mut check = db.prepare("SELECT status FROM tbl_tx WHERE txid = ?;").unwrap();
|
let mut check = db
|
||||||
check.bind((1, Value::String("safe_txid".to_string()))).unwrap();
|
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
|
||||||
|
.unwrap();
|
||||||
|
check
|
||||||
|
.bind((1, Value::String("safe_txid".to_string())))
|
||||||
|
.unwrap();
|
||||||
assert!(check.next().unwrap() == sqlite::State::Row);
|
assert!(check.next().unwrap() == sqlite::State::Row);
|
||||||
let status: i64 = check.read("status").unwrap();
|
let status: i64 = check.read("status").unwrap();
|
||||||
assert_eq!(status, 0, "Original row should not be updated");
|
assert_eq!(status, 0, "Original row should not be updated");
|
||||||
|
|
||||||
let mut count_stmt = db.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;").unwrap();
|
let mut count_stmt = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;")
|
||||||
|
.unwrap();
|
||||||
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
|
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
|
||||||
let count: i64 = count_stmt.read(0).unwrap();
|
let count: i64 = count_stmt.read(0).unwrap();
|
||||||
assert_eq!(count, 0, "No rows should have status 99");
|
assert_eq!(count, 0, "No rows should have status 99");
|
||||||
|
|||||||
110
tests/ssrf_tests.rs
Normal file
110
tests/ssrf_tests.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use bal_server::validation::is_valid_welist_url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_blocks_internal_urls() {
|
||||||
|
// Blocked: internal loopback
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://127.0.0.1"),
|
||||||
|
"IPv4 loopback should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://localhost"),
|
||||||
|
"localhost hostname should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[::1]"),
|
||||||
|
"IPv6 loopback should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: private RFC1918 ranges
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://192.168.1.1"),
|
||||||
|
"RFC1918 private IP should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://10.0.0.1"),
|
||||||
|
"RFC1918 private IP should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://172.16.0.1"),
|
||||||
|
"RFC1918 private IP should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: AWS metadata link-local
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://169.254.169.254"),
|
||||||
|
"AWS metadata link-local IP should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: non-HTTPS schemes
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("http://welist.bitcoin-after.life"),
|
||||||
|
"HTTP plaintext should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("ftp://welist.bitcoin-after.life"),
|
||||||
|
"FTP scheme should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocked: malformed URLs
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("not a url"),
|
||||||
|
"Malformed URL should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("welist.bitcoin-after.life"),
|
||||||
|
"URL missing scheme should be blocked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allowed: valid public domain on HTTPS
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://welist.bitcoin-after.life"),
|
||||||
|
"Known production domain should be allowed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://example.com/ping"),
|
||||||
|
"Public domain on HTTPS should be allowed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allowed: valid public IP on HTTPS
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://8.8.8.8"),
|
||||||
|
"Public IP on HTTPS should be allowed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://1.2.3.4"),
|
||||||
|
"Public IP on HTTPS should be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_case_insensitive_localhost() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://LOCALHOST"),
|
||||||
|
"Uppercase localhost should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://LocalHost"),
|
||||||
|
"Mixed case localhost should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_ipv6_unique_local() {
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fc00::1]"),
|
||||||
|
"IPv6 unique local fc00 should be blocked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_valid_welist_url("https://[fd00::1]"),
|
||||||
|
"IPv6 unique local fd00 should be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssrf_port_presence_ok() {
|
||||||
|
assert!(
|
||||||
|
is_valid_welist_url("https://welist.bitcoin-after.life:443"),
|
||||||
|
"HTTPS with explicit port should be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user