6 Commits

Author SHA1 Message Date
7fe5fd3139 fix(pusher): log send_stats_report errors instead of discarding
main_result called send_stats_report/calculate_stats with 'let _ = ...',
silently dropping any failure. A broken welist route (e.g. unreachable
IPv4 path) is then invisible in the logs and can go unnoticed for a long
time. Log failures with warn! so connectivity problems are diagnosable.
2026-07-19 14:09:41 +02:00
b46f85f436 feat(pusher): optional IPv6 preference for welist reports (BAL_PUSHER_PREFER_IPV6)
The welist host publishes both A and AAAA records. On networks where the
IPv4 route is broken (connection stalls after the TCP handshake) while
IPv6 works, the default connector may pick the broken family and the
report request hangs.

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

Includes unit tests for the URL host/port parsing and documentation in
docs/07_deployment_and_ops.md.
2026-07-19 14:09:01 +02:00
190cac929e refactor: rename bal-server-actix.rs to bal-server.rs
- Rename src/bin/bal-server-actix.rs -> src/bin/bal-server.rs
- Update Cargo.toml to point to new filename
- Update Dockerfile reference
- Update docs/08_security_audit.md references
2026-07-17 10:31:56 -04:00
5e1d0d7c54 chore: remove make_release.sh from tracking, keep local 2026-07-17 09:59:05 -04:00
9081e08785 feat(release): add ASCII-armored .asc signature alongside .sig 2026-07-17 09:57:39 -04:00
06db6f1d48 fix(release): move asset paths after ASSET_PATH definition 2026-07-17 09:55:11 -04:00
7 changed files with 131 additions and 198 deletions

View File

@@ -34,7 +34,7 @@ zmq = { version = "0.10.0" }
[[bin]]
name = "bal-server"
path = "src/bin/bal-server-actix.rs"
path = "src/bin/bal-server.rs"
[[bin]]
name = "bal-pusher"

View File

@@ -21,7 +21,7 @@ WORKDIR /build
# Cache dependencies: copy Cargo.toml first, create dummy src to build deps
COPY Cargo.toml Cargo.lock* ./
RUN mkdir -p src/bin && \
echo 'fn main() {}' > src/bin/bal-server-actix.rs && \
echo 'fn main() {}' > src/bin/bal-server.rs && \
echo 'fn main() {}' > src/bin/bal-pusher.rs && \
echo '' > src/lib.rs && \
echo '' > src/db.rs && \

View File

@@ -45,6 +45,7 @@ WELIST_URL=https://welist.example.com/api/stats
- `BAL_SSL_KEY_PATH`: The path to the Ed25519 private key (`private_key.pem`) used to sign the statistics payload before sending it to the `welist` server. This is a critical secret.
- `SEND_STATS`: A boolean flag to enable the reporting of statistics to the remote `welist` server.
- `WELIST_URL`: The URL to which the statistics are sent. If `SEND_STATS` is `true`, this URL must be reachable. If the server is unreachable, the pusher will log an error but might not crash (see `08_security_audit.md` for DoS analysis).
- `BAL_PUSHER_PREFER_IPV6`: Optional boolean flag (default `false`). When set to `true`, the pusher resolves the `welist` host itself and pins the HTTP connection to its first IPv6 (AAAA) address, still using the hostname for the `Host` header and TLS SNI. This works around networks where the IPv4 route to the `welist` host is broken while IPv6 works — the default connector may otherwise pick the unreachable family and the request would stall. Leave unset unless you hit this specific connectivity problem.
---
@@ -133,12 +134,13 @@ This script builds a release binary, creates a Git tag, and uploads the release
```bash
# WARNING: This script contains a hardcoded secret token. Do not use it as-is for production.
```
- **Release Assets:** It generates a `.tar.gz` archive with the binaries, a `.sha256` checksum file, and a `.sig` GPG detached signature.
- **Release Assets:** It generates a `.tar.gz` archive with the binaries, a `.sha256` checksum file, and both a `.sig` GPG detached binary signature and a `.asc` ASCII-armored version.
- **Signature:** The release tarball is signed with the GPG key `Svātantrya <svatantrya@bitcoin-after.life>`. The script verifies that `gpg`, `sha256sum`, and `jq` are installed before proceeding.
- **Verification:** The release body includes instructions for verifying the checksum and signature:
- **Verification:** The release body includes instructions for verifying the checksum and signature (binary or ASCII-armored):
```bash
sha256sum -c <release>.tar.gz.sha256
gpg --verify <release>.tar.gz.sig <release>.tar.gz
gpg --verify <release>.tar.gz.asc <release>.tar.gz
```
- **Security:** It also builds and uploads the binaries. The binaries should be built and signed on a separate, clean build machine, not on the production server.

View File

@@ -87,7 +87,7 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
- Rate limiting: `actix-governor` middleware with token-bucket — configurable via `BAL_SERVER_ACTIX_PUSHTXS_PER_SEC`/`BURST` (default 1 req/s per IP with burst 5)
- Connection limits: `workers(4)` and `max_connections(100)` — configurable via `BAL_SERVER_ACTIX_WORKERS`/`MAX_CONNECTIONS`
- Body timeout: configurable via `BAL_SERVER_ACTIX_TIMEOUT_SECS` (default 30s)
**Migration:** Server replaced `hyper` custom server with `actix-web` (see `src/bin/bal-server-actix.rs`). All handlers migrated with `Arc<Mutex<Connection>>` shared DB. Old `bal-server.rs` (Hyper) removed. `bal-pusher` enhanced with ZMQ timeout (`ZMQ_RCVTIMEO` 5000ms) and RPC retry logic. **Priority:** High. (Mitigated)
**Migration:** Server replaced `hyper` custom server with `actix-web` (see `src/bin/bal-server.rs`). All handlers migrated with `Arc<Mutex<Connection>>` shared DB. Old `bal-server.rs` (Hyper) removed. `bal-pusher` enhanced with ZMQ timeout (`ZMQ_RCVTIMEO` 5000ms) and RPC retry logic. **Priority:** High. (Mitigated)
### 5. SSRF / Network Abuse via `reqwest` (MEDIUM)
**Location:** `src/bin/bal-pusher.rs`.
@@ -121,7 +121,7 @@ Regression tests: `tests/panic_regression_tests.rs` (2 tests).
- Rejects symlinks and non-regular files (directories, devices, etc.).
- If validation fails, the function returns `Err(String)` instead of panicking, preventing crashes or accidental access to system files.
-**WAL mode:** `db::open_db` automatically executes `PRAGMA journal_mode = WAL;` and `PRAGMA synchronous = NORMAL;` on every connection. This is a best practice for safe concurrent access when `bal-server` and `bal-pusher` share the same database file.
-**Replaced `unwrap`:** In `src/bin/bal-server-actix.rs` and `src/bin/bal-pusher.rs`, `sqlite::open(...).unwrap()` was replaced with `db::open_db(...)` with safe error handling (return `Err` in the server, `std::process::exit(1)` in the pusher with a log error).
-**Replaced `unwrap`:** In `src/bin/bal-server.rs` and `src/bin/bal-pusher.rs`, `sqlite::open(...).unwrap()` was replaced with `db::open_db(...)` with safe error handling (return `Err` in the server, `std::process::exit(1)` in the pusher with a log error).
- **Remaining (ops):** Ensure the database file is owned by the `bal` user and not writable by any other user (`chmod 600`). The database file should not reside on a shared or network drive.
**Regression tests:** `tests/db_path_validation.rs` (5 tests covering traversal, forbidden absolute paths, symlink, WAL pragma, and valid relative paths). All passing.
**Status:** Fixed. **Priority:** Medium.

View File

@@ -1,189 +0,0 @@
#!/bin/bash
#author: <your-name>
source lib.sh
usage() {
echo_w "./make_release <version> <message>"
}
if [ -n "$1" ]; then release=$1; else usage; exit; fi
if [ -n "$2" ]; then message=$2; else
# Create temporary file using mktemp
TEMPFILE=$(mktemp)
vi $TEMPFILE
message=$(cat $TEMPFILE)
rm $TEMPFILE
fi
echo_i $message
# Load secrets from .env file (not committed to git)
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
TOKEN="${GITEA_API_TOKEN:-${TOKEN_GITEA}}"
if [ -z "$TOKEN" ]; then
echo_e "Error: GITEA_API_TOKEN (or TOKEN_GITEA) is not set in .env file."
echo_e "Please create a .env file with: GITEA_API_TOKEN=your_token_here"
exit 1
fi
for cmd in gpg sha256sum jq; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo_e "Error: '$cmd' is required but not installed."
exit 1
fi
done
SIGNER_KEY="svatantrya@bitcoin-after.life"
ASSET_SHA256="$ASSET_PATH.sha256"
ASSET_SIG="$ASSET_PATH.sig"
OWNER="bitcoinafterlife"
basename=$(basename $(pwd))
REPO=$basename
TAG="v$release"
binpath="target/release/$basename"
release_name="$basename-$release"
dest="releases/$release"
arch=$(uname -m)
platform="linux-gnu"
destbin="$dest/$arch"
destsrc="$dest/src"
assetname="$release_name""_$arch""_$platform"
asset_tar_gz="$assetname.tar.gz"
ASSET_PATH="$destbin/$assetname.tar.gz"
giteahost="https://bitcoin-after.life/gitea"
url_releases="$giteahost/api/v1/repos/$OWNER/$REPO/releases"
echo_i() {
echo -e "\033[1m==> $1\033[0m"
}
echo_e() {
echo -e "\033[31;1m$1\033[0m"
}
echo_s() {
echo -e "\033[32;1m$1\033[0m"
}
echo_w() {
echo -e "\033[33;1m$1\033[0m"
}
prepare_release(){
mkdir -p "$destbin/$assetname"
if ! cargo build --release; then
echo_w "error building release"
exit 1
fi
ls -l $binpath
cp target/release/bal-server \
target/release/bal-pusher \
README.md \
"$destbin/$assetname"
(
cd "$destbin"
echo_w $ASSET_PATH
echo "ls $(pwd)"
ls
echo "ls $(pwd)/$assetname"
ls "$(pwd)/$assetname"
ls $assetname
tar -czf "$asset_tar_gz" "$assetname"
sha256sum "$asset_tar_gz" > "$asset_tar_gz.sha256"
if ! gpg --batch --yes --detach-sign --local-user "$SIGNER_KEY" "$asset_tar_gz"; then
echo_e "error signing release tarball"
exit 1
fi
)
}
push_tag() {
git commit -am"release: $release_name"
git push
#git tag -a "$TAG" -m"release: $release_name"
#git push origin --tags
}
# Configurazioni
post_release() {
if [ -z "$1" ]; then
echo_e "no data to release"
exit 1
else
echo "data: $1"
fi
echo "token:$TOKEN"
echo url_releases: $url_releases
RELEASE="$(curl -s -X POST \
-H "accept: application/json" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$1" \
$url_releases
)"
echo $RELEASE
}
add_asset_release() {
if [ -z "$1" ]; then
echo_e "error add_asset_release"
exit 1
fi
local assets=("$ASSET_PATH" "$ASSET_SHA256" "$ASSET_SIG")
for asset in "${assets[@]}"; do
if [ ! -f "$asset" ]; then
echo_e "missing asset: $asset"
exit 1
fi
echo "Uploading: $asset"
ls -l "$asset"
curl -X POST \
-H "accept: application/json" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@$asset" \
"$url_releases/$1/assets"
done
}
release_body=$(printf '%s' "Release: $release_name enjoy
$message
---
Verification Instructions:
SHA256 Checksum:
sha256sum -c $assetname.tar.gz.sha256
GPG Signature:
gpg --verify $assetname.tar.gz.sig $assetname.tar.gz
Signed by Svātantrya (svatantrya@bitcoin-after.life)")
release_data=$(jq -n -c \
--arg tag_name "$TAG" \
--arg name "$release_name" \
--arg body "$release_body" \
'{tag_name: $tag_name, name: $name, body: $body}')
prepare_release
echo_s "prepare release done"
push_tag
echo_s "push tag done"
echo "$release_data"
post_release "$release_data"
echo_s "prepare release done"
echo $RELEASE
id_release=
add_asset_release $(echo $RELEASE | jq .id)
echo_s "done"

View File

@@ -29,7 +29,9 @@ use openssl::sign::Signer;
use openssl::sign::Verifier;
use reqwest::Client as rClient;
use std::fs;
use std::net::SocketAddr;
use std::time::Instant;
use url::Url;
const LOCKTIME_THRESHOLD: i64 = 5000000;
const VERSION: &str = "0.0.2";
@@ -331,8 +333,14 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
stmt.bind((2, Value::String(txid.clone()))).unwrap();
let _ = stmt.next();
}
let _ = send_stats_report(cfg, bcinfo).await;
let _ = calculate_stats(&db, network_params.db_field.clone()).await;
if let Err(e) = send_stats_report(cfg, bcinfo).await {
// Never discard silently: a failing report is otherwise
// invisible in the logs and can go unnoticed for a long time.
warn!("send_stats_report failed: {e}");
}
if let Err(e) = calculate_stats(&db, network_params.db_field.clone()).await {
warn!("calculate_stats failed: {e}");
}
}
Err(erx) => {
error!("impossible to get client: {}, retrying on next block", erx);
@@ -422,6 +430,76 @@ ON CONFLICT(chain) DO UPDATE SET
}
Ok(())
}
/// Parse the `(host, port)` pair from a base URL like `https://host[:port]`.
///
/// Falls back to the scheme's well-known default port (443 for `https`,
/// 80 for plain `http`), or to 443 when the scheme is unknown.
fn parse_host_port(base_url: &str) -> Option<(String, u16)> {
let url = Url::parse(base_url).ok()?;
let host = url
.host_str()?
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
let port = url.port_or_known_default().unwrap_or(443);
Some((host, port))
}
/// Resolve `host:port` and return the first IPv6 (AAAA) address, if any.
///
/// Returns `None` when the host has no IPv6 address.
async fn resolve_first_ipv6(host: &str, port: u16) -> Option<SocketAddr> {
use std::net::ToSocketAddrs;
let host = host.to_string();
tokio::task::spawn_blocking(move || {
format!("{}:{}", host, port)
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.find(|a| a.is_ipv6()))
})
.await
.ok()
.flatten()
}
/// Build the HTTP client used for welist reports.
///
/// When `BAL_PUSHER_PREFER_IPV6` is truthy, the welist host is resolved and
/// the client is pinned to its first IPv6 address (the original hostname is
/// still used for the `Host` header and TLS SNI). This works around networks
/// where the IPv4 route to the welist host is broken while IPv6 works: the
/// default connector may otherwise pick the broken family and the request
/// stalls. When the variable is unset (the default), behavior is unchanged.
async fn welist_http_client(welist_url: &str) -> rClient {
let prefer_ipv6 = env::var("BAL_PUSHER_PREFER_IPV6")
.unwrap_or("false".to_string())
.parse::<bool>()
.unwrap_or(false);
if !prefer_ipv6 {
return rClient::new();
}
let (host, port) = match parse_host_port(welist_url) {
Some(hp) => hp,
None => {
warn!("BAL_PUSHER_PREFER_IPV6: cannot parse '{welist_url}', using default resolver");
return rClient::new();
}
};
match resolve_first_ipv6(&host, port).await {
Some(addr) => {
debug!("BAL_PUSHER_PREFER_IPV6: pinning {host} to {addr}");
rClient::builder()
.resolve(&host, addr)
.build()
.unwrap_or_else(|_| rClient::new())
}
None => {
debug!("BAL_PUSHER_PREFER_IPV6: no IPv6 address for {host}, using default resolver");
rClient::new()
}
}
}
async fn send_stats_report(
cfg: &MyConfig,
bcinfo: GetBlockchainInfoResult,
@@ -437,7 +515,7 @@ async fn send_stats_report(
);
return Ok(());
}
let client = rClient::new();
let client = welist_http_client(&welist_url).await;
let url = format!("{}/ping", welist_url);
debug!("welist url: {}", url);
let chain = bcinfo.chain.to_string().to_lowercase();
@@ -724,3 +802,45 @@ fn seq_to_str(seq: &Vec<u8>) -> String {
}
"Unknown".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_host_port_https_default_port() {
assert_eq!(
parse_host_port("https://welist.bitcoin-after.life"),
Some(("welist.bitcoin-after.life".to_string(), 443))
);
}
#[test]
fn parse_host_port_explicit_port_and_path() {
assert_eq!(
parse_host_port("https://example.com:8443/ping"),
Some(("example.com".to_string(), 8443))
);
}
#[test]
fn parse_host_port_http_default_port() {
assert_eq!(
parse_host_port("http://example.com"),
Some(("example.com".to_string(), 80))
);
}
#[test]
fn parse_host_port_ipv6_literal_brackets_stripped() {
assert_eq!(
parse_host_port("https://[2a13:2c0::1]:443"),
Some(("2a13:2c0::1".to_string(), 443))
);
}
#[test]
fn parse_host_port_invalid_url() {
assert_eq!(parse_host_port("not a url"), None);
}
}