Compare commits
41 Commits
main
...
5e18a2e06c
| Author | SHA1 | Date | |
|---|---|---|---|
|
5e18a2e06c
|
|||
|
7f62ffaf25
|
|||
|
8f764f06b2
|
|||
|
8dc344cbd1
|
|||
|
eacb2e1450
|
|||
|
734b2ee71d
|
|||
|
7999902cc0
|
|||
|
d6b888e403
|
|||
|
36219c49a0
|
|||
|
ca530bf987
|
|||
|
1c76755ea6
|
|||
|
c371a4f478
|
|||
|
59250289a7
|
|||
|
0f0f0a08c3
|
|||
|
6e6c634e98
|
|||
|
efcd91e6b4
|
|||
| 22b60e55c7 | |||
| 7fe5fd3139 | |||
| b46f85f436 | |||
|
cd24eda111
|
|||
|
8ce3f6a445
|
|||
|
190cac929e
|
|||
|
5e1d0d7c54
|
|||
|
9081e08785
|
|||
|
06db6f1d48
|
|||
|
ba8f828e89
|
|||
|
9abfad29b9
|
|||
|
bc9ec1a48c
|
|||
|
c461232095
|
|||
|
fbe61a5862
|
|||
|
56696050ec
|
|||
| 638f00bf0c | |||
| da2124031e | |||
| 538e72f806 | |||
| 4fc0790fe7 | |||
| 237e62d4be | |||
| fa2f458468 | |||
| 167869b881 | |||
| 0fdefcfd0f | |||
| df8effcc60 | |||
| 69d877a360 |
11
.env.example
11
.env.example
@@ -3,7 +3,8 @@
|
||||
# DO NOT commit the real token to git!
|
||||
# This file is in .gitignore and should not be committed to git
|
||||
GITEA_API_TOKEN=your_gitea_api_token_here
|
||||
# If a token was ever committed, revoke it and set a new one via the environment.
|
||||
# Example: GITEA_API_TOKEN=5cfa8c33e337ebaadb355c0ffa2d053d521ee43b
|
||||
# (replace with your actual token after revoking the old one)
|
||||
|
||||
# === bal-server ===
|
||||
# !!! WARNING: Never bind to 0.0.0.0 in production. Use 127.0.0.1 and place
|
||||
@@ -32,14 +33,8 @@ BAL_SERVER_ACTIX_DEFAULT_BURST=100
|
||||
BAL_SERVER_ACTIX_WORKERS=4
|
||||
BAL_SERVER_ACTIX_MAX_CONNECTIONS=100
|
||||
|
||||
# === PostgreSQL (optional; used by pg-server.sh) ===
|
||||
# pg-server.sh requires BAL_PG_PASSWORD to be set; never commit a real password.
|
||||
BAL_PG_PASSWORD=change_me
|
||||
BAL_PG_USER=bal
|
||||
BAL_PG_DB=baldb
|
||||
|
||||
# === bal-pusher ===
|
||||
BAL_SERVER_DB_FILE=/var/bal/bal.db
|
||||
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
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,18 +1,10 @@
|
||||
# Secrets and environment files - NEVER commit
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
*.env.local
|
||||
.env.production
|
||||
.env.secret
|
||||
!.env.example
|
||||
*.pem
|
||||
private_key.pem
|
||||
privkey.pem
|
||||
!public_key.pem
|
||||
*.key
|
||||
ec.key
|
||||
chiave_privata.key
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
|
||||
@@ -22,7 +22,7 @@ serde = { version = "1.0.152", features = ["derive"] }
|
||||
serde_json = { version = "1.0.116" }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||
regex = { version = "1.10.4" }
|
||||
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread","time"] }
|
||||
tokio = { version = "1", features = ["rt", "net","macros","rt-multi-thread"] }
|
||||
url = { version = "2" }
|
||||
|
||||
# server-only
|
||||
|
||||
@@ -82,6 +82,7 @@ ENV RUST_LOG=info \
|
||||
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
|
||||
BAL_SERVER_BIND_PORT=9137 \
|
||||
BAL_SERVER_DB_FILE=/var/bal/bal.db \
|
||||
BAL_PUSHER_DB_FILE=/var/bal/bal.db \
|
||||
BAL_SERVER_URL=http://127.0.0.1:9137 \
|
||||
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
|
||||
SSL_KEY_PATH=/var/bal/private_key.pem
|
||||
|
||||
@@ -90,6 +90,7 @@ ENV RUST_LOG=info \
|
||||
BAL_SERVER_BIND_ADDRESS=127.0.0.1 \
|
||||
BAL_SERVER_BIND_PORT=9137 \
|
||||
BAL_SERVER_DB_FILE=/var/bal/bal.db \
|
||||
BAL_PUSHER_DB_FILE=/var/bal/bal.db \
|
||||
BAL_SERVER_URL=http://127.0.0.1:9137 \
|
||||
BAL_SERVER_PUB_KEY_PATH=/var/bal/public_key.pem \
|
||||
SSL_KEY_PATH=/var/bal/private_key.pem
|
||||
|
||||
@@ -112,7 +112,6 @@ The `bal-server` application can be configured using environment variables.
|
||||
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | Rate limit: default burst size. | `100` |
|
||||
| `BAL_SERVER_ACTIX_WORKERS` | Number of Actix worker threads. | `4` |
|
||||
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | Maximum concurrent connections. | `100` |
|
||||
| `BAL_SERVER_TRUSTED_PROXY` | Trusted reverse proxy IP for rate-limiting client identification. | `127.0.0.1` |
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +142,7 @@ If no network is specified, defaults to `bitcoin`.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `BAL_SERVER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
|
||||
| `BAL_PUSHER_DB_FILE` | Path to the SQLite3 database file. | `bal.db` |
|
||||
| `BAL_PUSHER_SEND_STATS` | Send stats to welist server. | `false` |
|
||||
| `BAL_SERVER_URL` | URL of bal-server (for stats reporting). | - |
|
||||
| `SSL_KEY_PATH` | Ed25519 private key for signing stats reports. | `private_key.pem` |
|
||||
|
||||
18
bal-pusher.sh
Executable file → Normal file
18
bal-pusher.sh
Executable file → Normal file
@@ -1,15 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
WORKING_DIR=$(pwd)
|
||||
|
||||
export RUST_LOG=trace
|
||||
|
||||
export BAL_SERVER_DB_FILE="$WORKING_DIR/bal.db"
|
||||
export BAL_SERVER_DB_BACKEND="postgresql"
|
||||
export BAL_PUSHER_DB_FILE="$(pwd)/bal.db"
|
||||
#export BAL_PUSHER_BITCOIN_COOKIE_FILE=/~/.bitcoin/.cookie
|
||||
#export BAL_PUSHER_REGTEST_COOKIE_FILE=/~/.bitcoin/regtest/.cookie
|
||||
#export BAL_PUSHER_TESTNET_COOKIE_FILE=/~/.bitcoin/testnet3/.cookie
|
||||
@@ -20,10 +11,5 @@ export BAL_PUSHER_SEND_STATS=true
|
||||
export WELIST_SERVER_URL=http://localhost:8086
|
||||
export WELIST_SKIP_URL_VALIDATION=true
|
||||
export BAL_SERVER_URL="http://127.0.0.1:9133"
|
||||
export SSL_KEY_PATH="$WORKING_DIR/private_key.pem"
|
||||
|
||||
if [ "$BAL_SERVER_DB_BACKEND" = "postgresql" ]; then
|
||||
. "$SCRIPT_DIR/pg-server.sh"
|
||||
fi
|
||||
|
||||
export SSL_KEY_PATH="$(pwd)/private_key.pem"
|
||||
cargo run --bin=bal-pusher regtest --features=pusher
|
||||
|
||||
@@ -31,7 +31,31 @@ export BAL_SERVER_REGTEST_FIXED_FEE=1000
|
||||
export BAL_SERVER_DB_BACKEND="postgresql"
|
||||
|
||||
if [ "$BAL_SERVER_DB_BACKEND" = "postgresql" ]; then
|
||||
. "$SCRIPT_DIR/pg-server.sh"
|
||||
PG_CONTAINER="bal-pg"
|
||||
PG_DSN="${BAL_SERVER_PG_DSN:-postgres://bal:balpass@localhost:5432/baldb}"
|
||||
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
echo_i "Starting PostgreSQL container..."
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=bal \
|
||||
-e POSTGRES_PASSWORD=balpass \
|
||||
-e POSTGRES_DB=baldb \
|
||||
-p 5432:5432 postgres:16-alpine
|
||||
|
||||
echo_i "Waiting for PostgreSQL..."
|
||||
until docker exec "$PG_CONTAINER" pg_isready -U bal >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
echo_i "PostgreSQL ready at $PG_DSN"
|
||||
export BAL_SERVER_PG_DSN="$PG_DSN"
|
||||
|
||||
|
||||
cleanup() {
|
||||
echo_i "Stopping PostgreSQL container..."
|
||||
docker stop "$PG_CONTAINER" >/dev/null 2>&1
|
||||
docker rm "$PG_CONTAINER" >/dev/null 2>&1
|
||||
}
|
||||
trap cleanup EXIT
|
||||
fi
|
||||
|
||||
cargo run --bin=bal-server
|
||||
|
||||
@@ -47,7 +47,7 @@ BAL_SERVER_INFO="$info"
|
||||
EOF
|
||||
)
|
||||
bal_pusher_conf=$(cat << EOF
|
||||
BAL_SERVER_DB_FILE=/home/bal/bal.db
|
||||
BAL_PUSHER_DB_FILE=/home/bal/bal.db
|
||||
BAL_PUSHER_BITCOIN_COOKIE_FILE=/home/bitcoin/.bitcoin/.cookie
|
||||
|
||||
EOF
|
||||
|
||||
@@ -117,7 +117,7 @@ The main application binary that provides an async HTTP server.
|
||||
|
||||
### Architecture
|
||||
- **Runtime:** `actix-web 4.9.0` with `actix-rt` (`#[actix_web::main]`).
|
||||
- **Rate Limiting:** `actix-governor` middleware with token-bucket algorithm. Uses `RealIpKeyExtractor` to identify clients by real IP behind reverse proxy (via `X-Real-IP` / `X-Forwarded-For` headers).
|
||||
- **Rate Limiting:** `actix-governor` middleware with token-bucket algorithm per endpoint.
|
||||
- **Response Compression:** `actix_web::middleware::Compress`.
|
||||
- **Request Logging:** `actix_web::middleware::Logger::default()`.
|
||||
- **Shared State:** `Arc<Mutex<Connection>>` for database access, `MyConfig` for configuration.
|
||||
@@ -132,7 +132,7 @@ The main application binary that provides an async HTTP server.
|
||||
- `address` (xpub or address), `fixed_fee` (sats), `xpub` (bool), `network` (bitcoin::Network), `name`, `enabled`
|
||||
|
||||
**`ActixConfig`** (server tuning):
|
||||
- `max_body_size`, `timeout_secs`, rate limits (`pushtxs` per sec/burst), `workers`, `max_connections`, `trusted_proxy`
|
||||
- `max_body_size`, `timeout_secs`, per-endpoint rate limits (`pushtxs`, `searchtx`, `info`, `default`), `workers`, `max_connections`
|
||||
|
||||
### Key Routes
|
||||
| Method | Path | Handler | Description |
|
||||
@@ -201,7 +201,7 @@ This is the async daemon that monitors the blockchain and pushes pending transac
|
||||
|
||||
### Configuration
|
||||
All configuration is via environment variables (no config files):
|
||||
- `BAL_SERVER_DB_FILE`: Path to SQLite database.
|
||||
- `BAL_PUSHER_DB_FILE`: Path to SQLite database.
|
||||
- `BAL_PUSHER_BITCOIN_DIR`: Bitcoin data directory (for cookie file path).
|
||||
- `BAL_PUSHER_SEND_STATS`: Enable/disable remote stats reporting.
|
||||
- `BAL_SERVER_URL`: URL of the bal-server for internal communication.
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
All endpoints are rate-limited via `actix-governor` with a token-bucket algorithm. The rate limit key is the **real client IP address**, extracted from `X-Real-IP` / `X-Forwarded-For` headers when the request comes from a trusted proxy (default `127.0.0.1`, configurable via `BAL_SERVER_TRUSTED_PROXY`). Direct connections (non-proxy) use the TCP peer IP.
|
||||
All endpoints are rate-limited via `actix-governor` with a token-bucket algorithm. Defaults:
|
||||
|
||||
Default: 1 req/s with burst of 3 (configurable via `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` / `BAL_SERVER_ACTIX_PUSHTXS_BURST`).
|
||||
| Endpoint | Rate (req/s) | Burst |
|
||||
|----------|-------------|-------|
|
||||
| `POST /{network}/pushtxs` | 1 | 3 |
|
||||
| `POST /searchtx` | 5 | 10 |
|
||||
| `GET /{network}/info` | 20 | 30 |
|
||||
| All others | 50 | 100 |
|
||||
|
||||
When behind Nginx, ensure `proxy_set_header X-Real-IP $remote_addr` is set so the server can identify individual clients.
|
||||
Rate limits are configurable via `BAL_SERVER_ACTIX_*` environment variables.
|
||||
|
||||
### `GET /`
|
||||
- **Description:** Returns a static identification string (default: "Will Executor Server").
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Database Technology
|
||||
- **Engine:** `sqlite` (Rust `sqlite` crate, version 0.34.0)
|
||||
- **File:** `bal.db` (default, configurable via `BAL_SERVER_DB_FILE`)
|
||||
- **File:** `bal.db` (default, configurable via `BAL_SERVER_DB_FILE` / `BAL_PUSHER_DB_FILE`)
|
||||
- **Connection Management:** Shared `Arc<Mutex<Connection>>` in `bal-server`, single connection in `bal-pusher`.
|
||||
- **WAL Mode:** Enabled via `PRAGMA journal_mode=WAL` with retry logic (up to 5 attempts) for concurrent access safety.
|
||||
- **Busy Timeout:** Set to 5000ms via `PRAGMA busy_timeout=5000`.
|
||||
|
||||
@@ -40,31 +40,25 @@ Example: `BAL_SERVER_REGTEST_ADDRESS=tpub...`, `BAL_SERVER_BITCOIN_FIXED_FEE=500
|
||||
| `BAL_SERVER_ACTIX_TIMEOUT_SECS` | `5` | Request timeout in seconds |
|
||||
| `BAL_SERVER_ACTIX_WORKERS` | `4` | Number of actix-web worker threads |
|
||||
| `BAL_SERVER_ACTIX_MAX_CONNECTIONS` | `100` | Maximum concurrent connections |
|
||||
| `BAL_SERVER_TRUSTED_PROXY` | `127.0.0.1` | Trusted reverse proxy IP for rate-limiting client identification |
|
||||
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: push txs requests per second |
|
||||
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: push txs burst size |
|
||||
| `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC` | `1` | Rate limit: pushtxs requests per second |
|
||||
| `BAL_SERVER_ACTIX_PUSHTXS_BURST` | `3` | Rate limit: pushtxs burst size |
|
||||
| `BAL_SERVER_ACTIX_SEARCHTX_PER_SEC` | `5` | Rate limit: searchtx requests per second |
|
||||
| `BAL_SERVER_ACTIX_SEARCHTX_BURST` | `10` | Rate limit: searchtx burst size |
|
||||
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info/stats requests per second |
|
||||
| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info/stats burst size |
|
||||
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second (`/`, `/.pub_key.pem`, `/version`) |
|
||||
| `BAL_SERVER_ACTIX_INFO_PER_SEC` | `20` | Rate limit: info requests per second |
|
||||
| `BAL_SERVER_ACTIX_INFO_BURST` | `30` | Rate limit: info burst size |
|
||||
| `BAL_SERVER_ACTIX_DEFAULT_PER_SEC` | `50` | Rate limit: default requests per second |
|
||||
| `BAL_SERVER_ACTIX_DEFAULT_BURST` | `100` | Rate limit: default burst size |
|
||||
|
||||
Each endpoint class has its own independent token bucket, keyed by the real
|
||||
client IP (see `BAL_SERVER_TRUSTED_PROXY`). `BAL_SERVER_ACTIX_TIMEOUT_SECS` is
|
||||
enforced by a request-timeout middleware that returns `408 Request Timeout` if
|
||||
the handler (including request body reading) exceeds the limit.
|
||||
|
||||
### `bal-pusher` (prefixed `BAL_PUSHER_`)
|
||||
|
||||
#### Core Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BAL_SERVER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
|
||||
| `BAL_PUSHER_DB_FILE` | `"bal.db"` | Path to the SQLite database file |
|
||||
| `BAL_PUSHER_BITCOIN_DIR` | `""` | Bitcoin data directory (for cookie file path resolution) |
|
||||
| `BAL_PUSHER_SEND_STATS` | `false` | Enable/disable remote stats reporting |
|
||||
| `BAL_SERVER_URL` | `"http://127.0.0.1:9133"` | URL of the bal-server for internal communication |
|
||||
| `BAL_SERVER_URL` | `"http://localhost/"` | URL of the bal-server for internal communication |
|
||||
| `SSL_KEY_PATH` | `"privkey.pem"` | Path to Ed25519 private key for signing stats |
|
||||
| `BAL_PUSHER_PREFER_IPV6` | `false` | Pin HTTP connection to first IPv6 address (for broken IPv4 routes) |
|
||||
| `WELIST_SERVER_URL` | `"https://welist.bitcoin-after.life"` | URL to POST signed stats to (validated against SSRF) |
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
**Description:** All `unwrap()`/`expect()` calls on critical paths have been replaced with safe error handling.
|
||||
**Mitigation Applied:**
|
||||
- `bal-server`: `from_utf8` returns 400, `sqlite::open` uses `open_db()` with validation, all handlers return proper HTTP status codes.
|
||||
- `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, malformed ZMQ frames are handled with `get()`/`first()` and `from_utf8_lossy` (no indexing or UTF-8 `expect`), stats signing returns an error that is logged and skipped rather than `expect`-panicking.
|
||||
- `bal-pusher`: RPC failures log errors + sleep + retry (no panic), ZMQ `recv` uses `set_rcvtimeo(5000)`, `fs::read_to_string` uses `match` + 500, `cfg.lock()` uses `poisoned.into_inner()` recovery.
|
||||
**Regression Tests:** `tests/panic_regression_tests.rs` (2 tests).
|
||||
|
||||
### 3. Secret Leakage (FIXED)
|
||||
@@ -52,20 +52,18 @@
|
||||
**Description:** All DoS vectors mitigated via actix-web migration.
|
||||
**Mitigation Applied:**
|
||||
- Body size limit: `PayloadConfig::default().limit(max_body_size)` via `BAL_SERVER_ACTIX_MAX_BODY_SIZE` (default 1 MiB).
|
||||
- Rate limiting: `actix-governor` with a token bucket per client IP, with an **independent bucket for each endpoint class** (push txs, searchtx, info/stats, default) so that polling a cheap endpoint cannot exhaust the push-txs budget. Uses `RealIpKeyExtractor` to extract the real client IP from proxy headers.
|
||||
- Rate limiting: `actix-governor` with token-bucket per endpoint (`BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST`).
|
||||
- Connection limits: `workers(4)` and `max_connections(100)` via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`.
|
||||
- Request timeout: `middleware::from_fn` wraps every handler in `tokio::time::timeout`, returning `408 Request Timeout` when `BAL_SERVER_ACTIX_TIMEOUT_SECS` (default 5) elapses, covering slow request bodies.
|
||||
- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS`.
|
||||
- ZMQ timeout: `set_rcvtimeo(5000)` prevents infinite blocking.
|
||||
- RPC retry: sleep + retry on connection failure instead of panic.
|
||||
- ZMQ message handling is panic-free: multipart frames are accessed with `get()`/`first()` and topics are decoded with `from_utf8_lossy`, so malformed frames cannot abort the pusher.
|
||||
- Stats-report signing failures are logged and skipped instead of panicking the pusher when the key is unreadable.
|
||||
|
||||
### 5. SSRF / Network Abuse via `reqwest` (FIXED)
|
||||
**Severity:** MEDIUM | **Status:** Fixed
|
||||
**Location:** `src/bin/bal-pusher.rs`, `src/validation.rs`
|
||||
**Description:** URL validation prevents redirecting requests to internal/private IPs.
|
||||
**Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, IPv6 unique-local addresses, and **IPv4-mapped IPv6 addresses** (`::ffff:a.b.c.d`) that route to loopback/private/link-local/unspecified IPv4 targets. HTTPS-only scheme enforced. Note: hostnames are not DNS-resolved before validation, so a domain that resolves to a private address is not caught by this check — restrict `WELIST_SERVER_URL` to trusted values.
|
||||
**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 11 unit tests in `src/validation.rs`.
|
||||
**Mitigation Applied:** `is_valid_welist_url()` in `src/validation.rs` blocks localhost, loopback, RFC1918, link-local, multicast, unspecified, and IPv6 unique-local addresses. HTTPS-only scheme enforced.
|
||||
**Regression Tests:** `tests/ssrf_tests.rs` (integration) + 8 unit tests in `src/validation.rs`.
|
||||
|
||||
### 6. Insecure Database Access (FIXED)
|
||||
**Severity:** MEDIUM | **Status:** Fixed
|
||||
@@ -132,17 +130,16 @@
|
||||
6. Use read-only filesystem for the server binary.
|
||||
|
||||
### Application-Level
|
||||
1. **Rate Limiting:** Implemented via `actix-governor` with token-bucket per client IP and a **separate bucket per endpoint class**. `RealIpKeyExtractor` identifies clients behind reverse proxy using `X-Real-IP` / `X-Forwarded-For` headers. Trusted proxy IP configurable via `BAL_SERVER_TRUSTED_PROXY`.
|
||||
1. **Rate Limiting:** Implemented via `actix-governor` with per-endpoint token-bucket configuration.
|
||||
2. **Input Validation:** Network enum check, txid hex validation, body size limits.
|
||||
3. **Request Timeout:** `tokio::time::timeout` middleware returns `408` when a handler (including body read) exceeds `BAL_SERVER_ACTIX_TIMEOUT_SECS`.
|
||||
4. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt.
|
||||
5. **WAL Mode:** Enabled with retry logic for concurrent access.
|
||||
6. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking.
|
||||
7. **Transaction Size Limits:** Configurable via rate limiting and body size.
|
||||
8. **ZMQ Retry:** Reconnect logic with timeout-based detection.
|
||||
9. **Fee Limits:** Per-network `fixed_fee` configuration.
|
||||
10. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest).
|
||||
11. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction.
|
||||
3. **HTTPS:** Via Nginx reverse proxy with Let's Encrypt.
|
||||
4. **WAL Mode:** Enabled with retry logic for concurrent access.
|
||||
5. **ZMQ Timeout:** 5-second receive timeout prevents infinite blocking.
|
||||
6. **Transaction Size Limits:** Configurable via rate limiting and body size.
|
||||
7. **ZMQ Retry:** Reconnect logic with timeout-based detection.
|
||||
8. **Fee Limits:** Per-network `fixed_fee` configuration.
|
||||
9. **Network Limits:** Only known networks accepted (bitcoin, testnet, testnet4, signet, regtest).
|
||||
10. **Locktime Reasonableness:** Locktime compared against blockchain height and median time with threshold-based distinction.
|
||||
|
||||
---
|
||||
|
||||
@@ -156,4 +153,4 @@
|
||||
| `tests/secret_leakage_tests.rs` | 3 | .gitignore, no tracked secrets, no hardcoded tokens |
|
||||
| `tests/input_validation_tests.rs` | 4 | Address caching, network validation, txid hex validation |
|
||||
| `tests/db_path_validation.rs` | 5 | Path traversal, forbidden dirs, symlinks, WAL pragma, valid paths |
|
||||
| `src/validation.rs` (inline) | 11 | SSRF URL validation unit tests |
|
||||
| `src/validation.rs` (inline) | 8 | SSRF URL validation unit tests |
|
||||
|
||||
34
pg-server.sh
34
pg-server.sh
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
PG_CONTAINER="bal-pg"
|
||||
PG_USER="${BAL_PG_USER:-bal}"
|
||||
PG_DB="${BAL_PG_DB:-baldb}"
|
||||
# The password MUST come from the environment; never hardcode it.
|
||||
PG_PASSWORD="${BAL_PG_PASSWORD:?BAL_PG_PASSWORD must be set (see .env.example)}"
|
||||
PG_DSN="${BAL_SERVER_PG_DSN:-postgres://${PG_USER}:${PG_PASSWORD}@localhost:5432/${PG_DB}}"
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
|
||||
echo_i "PostgreSQL container '$PG_CONTAINER' is already running"
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
|
||||
echo_i "Starting existing PostgreSQL container '$PG_CONTAINER'..."
|
||||
docker start "$PG_CONTAINER" >/dev/null 2>&1
|
||||
else
|
||||
echo_i "Creating PostgreSQL container '$PG_CONTAINER'..."
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER="$PG_USER" \
|
||||
-e POSTGRES_PASSWORD="$PG_PASSWORD" \
|
||||
-e POSTGRES_DB="$PG_DB" \
|
||||
-p 5432:5432 postgres:16-alpine
|
||||
fi
|
||||
|
||||
echo_i "Waiting for PostgreSQL..."
|
||||
until docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
# Do not echo the DSN: it embeds the password.
|
||||
echo_s "PostgreSQL ready (user=$PG_USER, db=$PG_DB, host=localhost:5432)"
|
||||
export BAL_SERVER_PG_DSN="$PG_DSN"
|
||||
@@ -24,8 +24,7 @@ use reqwest::Client as rClient;
|
||||
use std::net::SocketAddr;
|
||||
use url::Url;
|
||||
|
||||
// BIP-65: locktime values below this are block heights, at or above are UNIX timestamps.
|
||||
const LOCKTIME_THRESHOLD: i64 = 500_000_000;
|
||||
const LOCKTIME_THRESHOLD: i64 = 5000000;
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MyConfig {
|
||||
@@ -46,9 +45,9 @@ struct MyConfig {
|
||||
impl Default for MyConfig {
|
||||
fn default() -> Self {
|
||||
MyConfig {
|
||||
db_backend: env::var("BAL_SERVER_DB_BACKEND").unwrap_or("sqlite".to_string()),
|
||||
db_file: env::var("BAL_SERVER_DB_FILE").unwrap_or("bal.db".to_string()),
|
||||
pg_dsn: env::var("BAL_SERVER_PG_DSN").unwrap_or_default(),
|
||||
db_backend: env::var("BAL_PUSHER_DB_BACKEND").unwrap_or("sqlite".to_string()),
|
||||
db_file: env::var("BAL_PUSHER_DB_FILE").unwrap_or("bal.db".to_string()),
|
||||
pg_dsn: env::var("BAL_PUSHER_PG_DSN").unwrap_or_default(),
|
||||
bitcoin_dir: env::var("BAL_PUSHER_BITCOIN_DIR").unwrap_or("".to_string()),
|
||||
regtest: get_network_params_default(Network::Regtest),
|
||||
testnet: get_network_params_default(Network::Testnet),
|
||||
@@ -59,7 +58,7 @@ impl Default for MyConfig {
|
||||
.unwrap_or("false".to_string())
|
||||
.parse::<bool>()
|
||||
.unwrap_or(false),
|
||||
url: env::var("BAL_SERVER_URL").unwrap_or("http://127.0.0.1:9133".to_string()),
|
||||
url: env::var("BAL_SERVER_URL").unwrap_or("http://localhost/".to_string()),
|
||||
ssl_key_path: env::var("SSL_KEY_PATH").unwrap_or("privkey.pem".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -388,13 +387,7 @@ async fn send_stats_report(
|
||||
cfg.url, chain, bcinfo.blocks, bcinfo.median_time, bcinfo.best_block_hash
|
||||
);
|
||||
trace!("message to be sent: {}", message);
|
||||
let sign = match sign_message(cfg.ssl_key_path.as_str(), message.as_str()) {
|
||||
Ok(signature) => signature,
|
||||
Err(e) => {
|
||||
error!("Cannot sign stats report, skipping: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let sign = sign_message(cfg.ssl_key_path.as_str(), message.as_str());
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("User-Agent", format!("bal-pusher/{}", VERSION))
|
||||
@@ -420,22 +413,22 @@ async fn send_stats_report(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn sign_message(private_key_path: &str, message: &str) -> Result<String, Box<dyn StdError>> {
|
||||
let signing_key = SigningKey::read_pkcs8_pem_file(private_key_path)
|
||||
.map_err(|e| format!("failed to parse private key PEM at {private_key_path}: {e}"))?;
|
||||
fn sign_message(private_key_path: &str, message: &str) -> String {
|
||||
let signing_key =
|
||||
SigningKey::read_pkcs8_pem_file(private_key_path).expect("failed to parse private key PEM");
|
||||
let signature = signing_key.sign(message.as_bytes());
|
||||
|
||||
Ok(general_purpose::STANDARD.encode(signature.to_bytes()))
|
||||
general_purpose::STANDARD.encode(signature.to_bytes())
|
||||
}
|
||||
|
||||
fn parse_env(cfg: &mut MyConfig) {
|
||||
if let Ok(value) = env::var("BAL_SERVER_DB_BACKEND") {
|
||||
if let Ok(value) = env::var("BAL_PUSHER_DB_BACKEND") {
|
||||
cfg.db_backend = value;
|
||||
}
|
||||
if let Ok(value) = env::var("BAL_SERVER_DB_FILE") {
|
||||
if let Ok(value) = env::var("BAL_PUSHER_DB_FILE") {
|
||||
cfg.db_file = value;
|
||||
}
|
||||
if let Ok(value) = env::var("BAL_SERVER_PG_DSN") {
|
||||
if let Ok(value) = env::var("BAL_PUSHER_PG_DSN") {
|
||||
cfg.pg_dsn = value;
|
||||
}
|
||||
cfg.regtest = parse_env_netconfig(cfg, "regtest");
|
||||
@@ -567,12 +560,12 @@ async fn main() -> std::io::Result<()> {
|
||||
);
|
||||
}
|
||||
consecutive_timeouts = 0;
|
||||
let Some(topic) = message.first().cloned() else {
|
||||
warn!("ZMQ: received empty multipart message, skipping");
|
||||
continue;
|
||||
};
|
||||
let body = message.get(1).cloned().unwrap_or_default();
|
||||
debug!("ZMQ:GET TOPIC: {}", String::from_utf8_lossy(&topic));
|
||||
let topic = message[0].clone();
|
||||
let body = message[1].clone();
|
||||
debug!(
|
||||
"ZMQ:GET TOPIC: {}",
|
||||
String::from_utf8(topic.clone()).expect("invalid topic")
|
||||
);
|
||||
trace!("ZMQ:GET BODY: {}", hex::encode(&body));
|
||||
if topic == b"hashblock" {
|
||||
info!("NEW BLOCK: {}", hex::encode(&body));
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use actix_governor::{
|
||||
Governor, GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
|
||||
};
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::middleware::{self, Next};
|
||||
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||
use actix_web::middleware;
|
||||
use actix_web::web::Bytes;
|
||||
use actix_web::{App, Error, HttpResponse, HttpServer, Responder, web};
|
||||
use actix_web::{App, HttpResponse, HttpServer, Responder, web};
|
||||
use bitcoin::{Network, Transaction, consensus};
|
||||
use chrono::Utc;
|
||||
use hex_conservative::FromHex;
|
||||
@@ -14,9 +10,6 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bal_server::db::{
|
||||
DatabasePool, InsertInpData, InsertOutData, InsertTxData, check_duplicate_txids,
|
||||
@@ -127,6 +120,7 @@ pub struct StatsResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[expect(dead_code)]
|
||||
struct ActixConfig {
|
||||
max_body_size: usize,
|
||||
timeout_secs: u64,
|
||||
@@ -136,7 +130,6 @@ struct ActixConfig {
|
||||
rate_limit_default: (u64, u32),
|
||||
workers: usize,
|
||||
max_connections: usize,
|
||||
trusted_proxy: IpAddr,
|
||||
}
|
||||
|
||||
fn parse_actix_config() -> ActixConfig {
|
||||
@@ -197,10 +190,6 @@ fn parse_actix_config() -> ActixConfig {
|
||||
.unwrap_or("100".to_string())
|
||||
.parse::<usize>()
|
||||
.unwrap_or(100),
|
||||
trusted_proxy: env::var("BAL_SERVER_TRUSTED_PROXY")
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||
.parse::<IpAddr>()
|
||||
.unwrap_or(IpAddr::from_str("127.0.0.1").unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,108 +219,41 @@ async fn echo_version() -> impl Responder {
|
||||
HttpResponse::Ok().body(VERSION)
|
||||
}
|
||||
|
||||
fn extract_real_ip(req: &actix_web::HttpRequest, trusted_proxy: IpAddr) -> String {
|
||||
let peer_ip = req.peer_addr().map(|socket| socket.ip());
|
||||
let connection_info = req.connection_info();
|
||||
|
||||
let ip = match peer_ip {
|
||||
Some(peer) if peer == trusted_proxy => {
|
||||
connection_info.realip_remote_addr().unwrap_or("unknown")
|
||||
}
|
||||
_ => connection_info.peer_addr().unwrap_or("unknown"),
|
||||
};
|
||||
|
||||
debug!("client IP: {}", ip);
|
||||
ip.to_string()
|
||||
fn is_valid_ip(ip: &str) -> bool {
|
||||
ip.parse::<std::net::IpAddr>().is_ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct RealIpKeyExtractor;
|
||||
|
||||
impl KeyExtractor for RealIpKeyExtractor {
|
||||
type Key = IpAddr;
|
||||
type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
|
||||
|
||||
fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
|
||||
let proxy_ip = req
|
||||
.app_data::<web::Data<IpAddr>>()
|
||||
.map(|ip| *ip.get_ref())
|
||||
.unwrap_or_else(|| IpAddr::from_str("0.0.0.0").unwrap());
|
||||
|
||||
let peer_ip = req.peer_addr().map(|socket| socket.ip());
|
||||
let connection_info = req.connection_info();
|
||||
|
||||
match peer_ip {
|
||||
Some(peer) if peer == proxy_ip => connection_info
|
||||
.realip_remote_addr()
|
||||
.ok_or_else(|| {
|
||||
SimpleKeyExtractionError::new("Could not extract real IP address from request")
|
||||
})
|
||||
.and_then(|str| {
|
||||
str.parse::<IpAddr>().map_err(|_| {
|
||||
SimpleKeyExtractionError::new(
|
||||
"Could not extract real IP address from request",
|
||||
)
|
||||
})
|
||||
}),
|
||||
_ => connection_info
|
||||
.peer_addr()
|
||||
.ok_or_else(|| {
|
||||
SimpleKeyExtractionError::new("Could not extract peer IP address from request")
|
||||
})
|
||||
.and_then(|str| {
|
||||
str.parse::<IpAddr>().map_err(|_| {
|
||||
SimpleKeyExtractionError::new(
|
||||
"Could not extract peer IP address from request",
|
||||
)
|
||||
})
|
||||
}),
|
||||
fn extract_client_ip(req: &actix_web::HttpRequest) -> String {
|
||||
if let Some(val) = req.headers().get("X-Real-IP")
|
||||
&& let Ok(s) = val.to_str()
|
||||
{
|
||||
let ip = s.split(',').next().unwrap_or(s).trim();
|
||||
if is_valid_ip(ip) {
|
||||
debug!("client IP from X-Real-IP: {}", ip);
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete governor configuration type used by all rate-limited routes.
|
||||
///
|
||||
/// `NoOpMiddleware` is the default `actix-governor` middleware; it is only used
|
||||
/// here as a type parameter so a single helper can build every route's config.
|
||||
type GovernorConfigType =
|
||||
GovernorConfig<RealIpKeyExtractor, actix_governor::governor::middleware::NoOpMiddleware>;
|
||||
|
||||
/// Build a rate-limiter configuration keyed by the real client IP.
|
||||
///
|
||||
/// A period of zero is rejected by `actix-governor`, so the values are clamped
|
||||
/// to a minimum of one to avoid a `None` result.
|
||||
fn build_governor_config(per_sec: u64, burst: u32) -> Result<GovernorConfigType, String> {
|
||||
GovernorConfigBuilder::default()
|
||||
.seconds_per_request(per_sec.max(1))
|
||||
.burst_size(burst.max(1))
|
||||
.key_extractor(RealIpKeyExtractor)
|
||||
.finish()
|
||||
.ok_or_else(|| "invalid rate limiter configuration".to_string())
|
||||
}
|
||||
|
||||
/// Middleware that aborts a request with `408 Request Timeout` if the inner
|
||||
/// handler (including request body reading) exceeds the configured duration.
|
||||
///
|
||||
/// The duration is read from the `web::Data<u64>` app data registered in `main`.
|
||||
async fn request_timeout<B>(
|
||||
timeout_secs: web::Data<u64>,
|
||||
req: ServiceRequest,
|
||||
next: Next<B>,
|
||||
) -> Result<ServiceResponse<B>, Error>
|
||||
where
|
||||
B: MessageBody,
|
||||
{
|
||||
match tokio::time::timeout(Duration::from_secs(**timeout_secs), next.call(req)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(actix_web::error::ErrorRequestTimeout("request timeout")),
|
||||
if let Some(val) = req.headers().get("X-Forwarded-For")
|
||||
&& let Ok(s) = val.to_str()
|
||||
{
|
||||
let ip = s.split(',').next().unwrap_or(s).trim();
|
||||
if is_valid_ip(ip) {
|
||||
debug!("client IP from X-Forwarded-For: {}", ip);
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
let fallback = req
|
||||
.connection_info()
|
||||
.peer_addr()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
debug!("client IP from peer_addr fallback: {}", fallback);
|
||||
fallback
|
||||
}
|
||||
|
||||
async fn echo_info(
|
||||
path: web::Path<String>,
|
||||
data: web::Data<AppState>,
|
||||
proxy: web::Data<IpAddr>,
|
||||
req: actix_web::HttpRequest,
|
||||
) -> impl Responder {
|
||||
let param = path.into_inner();
|
||||
@@ -344,7 +266,7 @@ async fn echo_info(
|
||||
debug!("network disabled {}", param);
|
||||
return HttpResponse::BadRequest().body("error");
|
||||
}
|
||||
let remote_addr = extract_real_ip(&req, **proxy);
|
||||
let remote_addr = extract_client_ip(&req);
|
||||
let address = match netconfig.xpub {
|
||||
false => {
|
||||
let address = netconfig.address.to_string();
|
||||
@@ -505,7 +427,6 @@ fn parse_request_transactions(
|
||||
known_addresses: &HashSet<String>,
|
||||
) -> Vec<(ParsedTx, String, u64)> {
|
||||
let mut result: Vec<(ParsedTx, String, u64)> = Vec::new();
|
||||
let mut seen_txids: HashSet<String> = HashSet::new();
|
||||
|
||||
for line in strbody.split('\n') {
|
||||
if line.is_empty() {
|
||||
@@ -531,10 +452,6 @@ fn parse_request_transactions(
|
||||
};
|
||||
|
||||
let txid = tx.compute_txid().to_string();
|
||||
if !seen_txids.insert(txid.clone()) {
|
||||
trace!("duplicate txid {} in request body, skipping", txid);
|
||||
continue;
|
||||
}
|
||||
let ntxid = tx.compute_ntxid();
|
||||
let wtxid = tx.compute_wtxid();
|
||||
let locktime = tx.lock_time.to_string();
|
||||
@@ -796,10 +713,7 @@ fn parse_env_netconfig(cfg: &mut MyConfig, chain: &str) {
|
||||
if let Ok(value) = env::var(format!("BAL_SERVER_{}_ADDRESS", chain.to_uppercase())) {
|
||||
debug!("BAL_SERVER_{}_ADDRESS: {}", chain.to_uppercase(), value);
|
||||
c.address = value;
|
||||
let is_xpub = ["xpub", "ypub", "zpub", "tpub", "vpub", "upub"]
|
||||
.iter()
|
||||
.any(|prefix| c.address.starts_with(prefix));
|
||||
if is_xpub {
|
||||
if c.address.len() > 5 && &c.address[1..4] == "pub" {
|
||||
c.xpub = true;
|
||||
trace!("is_xpub");
|
||||
}
|
||||
@@ -862,26 +776,11 @@ async fn main() -> std::io::Result<()> {
|
||||
let bind_address = data.cfg.bind_address.clone();
|
||||
let bind_port = data.cfg.bind_port;
|
||||
|
||||
// One shared limiter per endpoint class: all workers clone the same
|
||||
// underlying state, so the rate limit is global per real client IP.
|
||||
let governor_pushtxs = build_governor_config(
|
||||
actix_cfg.rate_limit_pushtxs.0,
|
||||
actix_cfg.rate_limit_pushtxs.1,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let governor_searchtx = build_governor_config(
|
||||
actix_cfg.rate_limit_searchtx.0,
|
||||
actix_cfg.rate_limit_searchtx.1,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let governor_info =
|
||||
build_governor_config(actix_cfg.rate_limit_info.0, actix_cfg.rate_limit_info.1)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let governor_default = build_governor_config(
|
||||
actix_cfg.rate_limit_default.0,
|
||||
actix_cfg.rate_limit_default.1,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let governor_conf = GovernorConfigBuilder::const_default()
|
||||
.seconds_per_request(actix_cfg.rate_limit_pushtxs.0)
|
||||
.burst_size(actix_cfg.rate_limit_pushtxs.1)
|
||||
.finish()
|
||||
.unwrap();
|
||||
|
||||
println!("Starting server on http://{}:{}", bind_address, bind_port);
|
||||
|
||||
@@ -889,46 +788,16 @@ async fn main() -> std::io::Result<()> {
|
||||
App::new()
|
||||
.app_data(web::PayloadConfig::default().limit(actix_cfg.max_body_size))
|
||||
.app_data(data.clone())
|
||||
.app_data(web::Data::new(actix_cfg.trusted_proxy))
|
||||
.app_data(web::Data::new(actix_cfg.timeout_secs))
|
||||
.wrap(middleware::Logger::default())
|
||||
.wrap(middleware::Compress::default())
|
||||
.wrap(middleware::from_fn(request_timeout))
|
||||
.service(
|
||||
web::resource("/")
|
||||
.wrap(Governor::new(&governor_default))
|
||||
.route(web::get().to(echo_home)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/.pub_key.pem")
|
||||
.wrap(Governor::new(&governor_default))
|
||||
.route(web::get().to(echo_pub_key)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/version")
|
||||
.wrap(Governor::new(&governor_default))
|
||||
.route(web::get().to(echo_version)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{network}/info")
|
||||
.wrap(Governor::new(&governor_info))
|
||||
.route(web::get().to(echo_info)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{network}/stats")
|
||||
.wrap(Governor::new(&governor_info))
|
||||
.route(web::get().to(echo_stats)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{network}/pushtxs")
|
||||
.wrap(Governor::new(&governor_pushtxs))
|
||||
.route(web::post().to(echo_push)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/searchtx")
|
||||
.wrap(Governor::new(&governor_searchtx))
|
||||
.route(web::post().to(echo_search)),
|
||||
)
|
||||
.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)
|
||||
|
||||
@@ -38,23 +38,13 @@ fn validate_sqlite_path(dsn: &str) -> Result<(), String> {
|
||||
// If absolute, block known sensitive system directories
|
||||
if p.is_absolute() {
|
||||
let forbidden = [
|
||||
Path::new("/etc"),
|
||||
Path::new("/proc"),
|
||||
Path::new("/sys"),
|
||||
Path::new("/dev"),
|
||||
Path::new("/usr"),
|
||||
Path::new("/bin"),
|
||||
Path::new("/sbin"),
|
||||
Path::new("/lib"),
|
||||
Path::new("/opt"),
|
||||
"/etc", "/proc", "/sys", "/dev", "/usr", "/bin", "/sbin", "/lib", "/opt",
|
||||
];
|
||||
for prefix in &forbidden {
|
||||
// `Path::starts_with` matches whole path components, so "/etcfoo"
|
||||
// is not treated as being under "/etc".
|
||||
if p.starts_with(prefix) {
|
||||
if path_str.starts_with(prefix) {
|
||||
return Err(format!(
|
||||
"Absolute database path under {} is forbidden",
|
||||
prefix.display()
|
||||
prefix
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -634,9 +624,9 @@ pub async fn get_pending_txs(
|
||||
"SELECT txid, tx, locktime, network, status FROM tbl_tx WHERE network = $1 AND status = 0 AND (locktime < $2 OR (locktime > $3 AND locktime < $4))",
|
||||
)
|
||||
.bind(network)
|
||||
.bind(bestblock_height)
|
||||
.bind(locktime_threshold)
|
||||
.bind(bestblock_time)
|
||||
.bind(bestblock_height as i32)
|
||||
.bind(locktime_threshold as i32)
|
||||
.bind(bestblock_time as i32)
|
||||
.fetch_all(p)
|
||||
.await?;
|
||||
|
||||
@@ -644,7 +634,7 @@ pub async fn get_pending_txs(
|
||||
results.push(TxRow {
|
||||
txid: row.try_get("txid")?,
|
||||
tx: row.try_get("tx")?,
|
||||
locktime: row.try_get::<i64, _>("locktime").unwrap_or(0),
|
||||
locktime: row.try_get::<i32, _>("locktime").unwrap_or(0) as i64,
|
||||
network: row.try_get("network")?,
|
||||
status: row.try_get::<i32, _>("status").unwrap_or(0) as i64,
|
||||
});
|
||||
@@ -849,9 +839,9 @@ pub async fn calculate_and_upsert_stats(
|
||||
(SELECT COUNT(*) FROM tbl_tx WHERE status = 0 AND network = $1),
|
||||
(SELECT COUNT(*) FROM tbl_tx WHERE status = 1 AND network = $1),
|
||||
(SELECT COUNT(*) FROM tbl_tx WHERE status = 2 AND network = $1),
|
||||
(SELECT COALESCE(SUM(CAST(our_fees AS BIGINT)),0) FROM tbl_tx WHERE status = 0 AND network = $1),
|
||||
(SELECT COALESCE(SUM(CAST(our_fees AS BIGINT)),0) FROM tbl_tx WHERE status = 1 AND network = $1),
|
||||
(SELECT COALESCE(SUM(CAST(our_fees AS BIGINT)),0) FROM tbl_tx WHERE status = 2 AND network = $1),
|
||||
(SELECT COALESCE(SUM(our_fees),0) FROM tbl_tx WHERE status = 0 AND network = $1),
|
||||
(SELECT COALESCE(SUM(our_fees),0) FROM tbl_tx WHERE status = 1 AND network = $1),
|
||||
(SELECT COALESCE(SUM(our_fees),0) FROM tbl_tx WHERE status = 2 AND network = $1),
|
||||
(SELECT COUNT(DISTINCT tbl_inp.in_txid)
|
||||
FROM tbl_inp
|
||||
JOIN tbl_tx ON tbl_inp.txid = tbl_tx.txid
|
||||
|
||||
@@ -128,7 +128,7 @@ pub async fn create_pg_schema(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
wtxid TEXT,
|
||||
ntxid TEXT,
|
||||
tx TEXT,
|
||||
locktime BIGINT,
|
||||
locktime INTEGER,
|
||||
network TEXT,
|
||||
network_fees TEXT,
|
||||
reqid TEXT,
|
||||
@@ -141,13 +141,6 @@ pub async fn create_pg_schema(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Migrate pre-existing deployments where locktime was created as INTEGER.
|
||||
// nLockTime is a u32 (up to 4_294_967_295); INTEGER (i32) cannot hold
|
||||
// timestamp-based locktimes after 2038-01-19.
|
||||
let _ = sqlx::query("ALTER TABLE tbl_tx ALTER COLUMN locktime TYPE BIGINT")
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS tbl_inp (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -65,19 +65,6 @@ pub fn is_valid_welist_url(url_str: &str) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 addresses (e.g. `::ffff:127.0.0.1`) are
|
||||
// routed to the embedded IPv4 address on dual-stack hosts, so
|
||||
// they must be validated against the IPv4 rules as well.
|
||||
if let Some(v4) = v6.to_ipv4_mapped()
|
||||
&& (v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,38 +154,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv4_mapped_ipv6() {
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::ffff:127.0.0.1]"),
|
||||
"IPv4-mapped loopback should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::ffff:10.0.0.1]"),
|
||||
"IPv4-mapped private address should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::ffff:172.16.0.1]"),
|
||||
"IPv4-mapped private address should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::ffff:192.168.1.1]"),
|
||||
"IPv4-mapped private address should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::ffff:169.254.169.254]"),
|
||||
"IPv4-mapped link-local address should be blocked"
|
||||
);
|
||||
assert!(
|
||||
!is_valid_welist_url("https://[::ffff:0.0.0.0]"),
|
||||
"IPv4-mapped unspecified address should be blocked"
|
||||
);
|
||||
assert!(
|
||||
is_valid_welist_url("https://[::ffff:8.8.8.8]"),
|
||||
"IPv4-mapped public address should be allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malformed_urls() {
|
||||
assert!(!is_valid_welist_url("not a url"));
|
||||
|
||||
@@ -148,12 +148,12 @@ pub fn calculate_fingerprint(tpub: &str) -> Result<String, String> {
|
||||
fn base58check_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||
let data = bs58::decode(s).into_vec().map_err(|e| e.to_string())?;
|
||||
if data.len() < 4 {
|
||||
return Err("Data too short".to_string());
|
||||
return Err("Data troppo corta".to_string());
|
||||
}
|
||||
let (payload, checksum) = data.split_at(data.len() - 4);
|
||||
let hash = Sha256::digest(Sha256::digest(payload));
|
||||
if hash[0..4] != checksum[..] {
|
||||
return Err("Invalid checksum".to_string());
|
||||
return Err("Checksum invalido".to_string());
|
||||
}
|
||||
Ok(payload.to_vec())
|
||||
}
|
||||
@@ -168,7 +168,7 @@ fn convert_to(zpub: &str, prefix: BS58Prefix) -> Result<String, String> {
|
||||
let mut data = base58check_decode(zpub)?;
|
||||
|
||||
if data.len() < 4 {
|
||||
return Err("Not a valid zpub".to_string());
|
||||
return Err("Non è una zpub valida.".to_string());
|
||||
}
|
||||
data.splice(
|
||||
0..4,
|
||||
@@ -207,7 +207,7 @@ pub fn new_address_from_xpub(
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>>{
|
||||
match convert_to(zpub,BS58Prefix::Tpub) {
|
||||
Ok(tpub) => println!("XPUB: {}", tpub),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
Err(e) => eprintln!("Errore: {}", e),
|
||||
}
|
||||
let fingerprint = base58check_encode(&calculate_fingerprint(zpub));
|
||||
println!("ZPUB: {}, FINGERPRINT: {}",zpub,fingerprint);
|
||||
|
||||
Reference in New Issue
Block a user