Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
Releases before 0.7.0 carry their notes in the release commit's message
(`RELEASING.md`).

## 0.7.1

- **No open bind without a token.** `serve`, `daemon` and `serve-fleet`
refuse at startup, before the wallet is opened or a socket bound, to bind
an address beyond loopback (`BIND_ADDR`, IPv4 or IPv6) when no bearer token
(`AUTH_TOKEN`, an empty value counting as none) is set. The error names the
one flag, `--allow-no-token`, that permits the open bind deliberately. A
loopback bind without a token is unchanged.
- **The risk it closes.** Since 0.7.0 the served `/internalizeAction` and
`/createAction` doors take a body of any size, auth before the body; a
daemon bound to `0.0.0.0` with no token was a wallet any host on the
network could drive, and made to hold any body it sent.

### Upgrade

A daemon started with `BIND_ADDR` beyond loopback and no `AUTH_TOKEN` now
exits non-zero at startup: set `AUTH_TOKEN` (and send it as
`Authorization: Bearer`), or add `--allow-no-token` to keep the open bind.
The library gains `server::refuse_open_bind`,
`server::refuse_open_bind_from_env` and `server::bind_addr_from_env`;
`server::run` itself does not check, so a host calling it directly checks
first.

## 0.7.0

The 0.4 line: bsv-rs 0.4.1 under the CLI, the toolbox and the tracker, one
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ resolver = "2"

[package]
name = "bsv-wallet-cli"
version = "0.7.0"
version = "0.7.1"
edition = "2021"
license = "MIT"
authors = ["John Calhoun"]
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ warns and you should pick an earlier date for real value.

All 28 BRC WalletInterface endpoints on `http://127.0.0.1:3322`, wire-compatible with MetaNet Client.

`serve`, `daemon` and `serve-fleet` refuse at startup to bind an address beyond loopback (`BIND_ADDR`) when no `AUTH_TOKEN` is set, since a wallet served without a token answers anyone who can reach it and its BEEF doors take a body of any size. Set `AUTH_TOKEN`, or pass `--allow-no-token` to bind open deliberately; a loopback bind without a token is unchanged.

### Endpoints

**Status** (GET)
Expand Down Expand Up @@ -168,15 +170,15 @@ All configuration is via environment variables:
| `ROOT_KEY` | Yes | Wallet root private key (hex). Set by `bsv-wallet init`. |
| `CHAINTRACKS_URL` | Yes | Header service that validates merkle proofs: your own `chaintracks-cloudflare` or rust-chaintracks deployment. There is no default; a command that builds the wallet's services refuses to run without it (`CHAINTRACKS_URL is not set`). `off` runs with no header service: every merkle proof is refused and nothing is marked proven, and `tick` and `reproof --execute` exit non-zero |
| `BREAK_GLASS_EXPLORER_HEADERS` | No | Break-glass, off by default. `1`, `true` or `yes` lets the proof path ask WhatsOnChain (then Bitails) for block headers when the header service gives no answer; every such call is logged at warn level. An explorer never overrules the header service's own answer |
| `AUTH_TOKEN` | No | Bearer token for HTTP auth (localhost-only, optional) |
| `AUTH_TOKEN` | No | Bearer token for HTTP auth. Optional on loopback; a bind beyond loopback without it is refused unless `--allow-no-token` is passed |
| `TLS_CERT_PATH` | No | TLS certificate path (requires `--features tls`) |
| `TLS_KEY_PATH` | No | TLS private key path (requires `--features tls`) |
| `MIN_UTXOS` | No | Low UTXO warning threshold in daemon mode (default: 3) |
| `ARC_URL` | No | Override the broadcaster URL (classic ARC, or the Arcade endpoint in Arcade mode) |
| `ARC_MODE=arcade` / `ARCADE=1` | No | Arcade V2 mode: EF-only submit, SSE push statuses, push proofs |
| `CALLBACK_TOKEN` | No | Override the per-wallet callback token (auto-generated + persisted as `<db>.callback-token` otherwise) |
| `PUBLIC_CALLBACK_URL` | No | Public HTTPS URL for Arcade status webhooks (`X-CallbackUrl`), pointing at this daemon's `/arc-callback` |
| `BIND_ADDR` | No | HTTP server bind address (default `127.0.0.1`; set `0.0.0.0` for the tunnel/webhook case) |
| `BIND_ADDR` | No | HTTP server bind address (default `127.0.0.1`; set `0.0.0.0` for the tunnel/webhook case, with `AUTH_TOKEN`) |
| `RELAY_URL` / `RELAY_POLL_SECS` | No | Drain a `bsv-wallet-relay` queue (see proof-delivery ladder) |
| `TAAL_API_KEY` / `MAIN_TAAL_API_KEY` | No | TAAL ARC auth (Bearer / raw `Authorization` respectively) |
| `TRACKER_AGE_SECS` | No | Seconds without a new word from a broadcaster before the tracker asks for a transaction's merkle proof (default 600); the pause between two asks for the same transaction doubles from there |
Expand Down
18 changes: 16 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,19 @@ pub enum Commands {
/// 60 s. Refused (non-zero exit) when CHAINTRACKS_URL=off
TrackerTick,
/// Run monitor + HTTP server (foreground)
Daemon,
Daemon {
/// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without
/// this flag such a bind is refused at startup
#[arg(long)]
allow_no_token: bool,
},
/// Run HTTP server only (no monitor)
Serve,
Serve {
/// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without
/// this flag such a bind is refused at startup
#[arg(long)]
allow_no_token: bool,
},
/// ONE process serving MANY wallets (fleet mode): each --wallet
/// <seat-dir>:<port> serves <seat-dir>/wallet.db on its own port with the
/// ROOT_KEY read from <seat-dir>/.env (never process env). Per-seat port
Expand All @@ -174,6 +184,10 @@ pub enum Commands {
/// never proves 0-conf ancestry; the createAction-502 class returns).
#[arg(long)]
daemon: bool,
/// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without
/// this flag such a bind is refused at startup
#[arg(long)]
allow_no_token: bool,
},
/// Split UTXOs into multiple outputs for concurrency
Split {
Expand Down
5 changes: 1 addition & 4 deletions src/commands/daemon_tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,7 @@ pub async fn start(db: &str, root_key_hex: &str, chain: Chain, port: u16) -> Res
.ok()
.filter(|s| !s.is_empty())
});
let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]));
let bind_addr = server::bind_addr_from_env();
let config = ServerConfig {
auth_token: std::env::var("AUTH_TOKEN").ok(),
tls,
Expand Down
5 changes: 1 addition & 4 deletions src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,7 @@ pub async fn run(ctx: WalletContext, port: u16) -> Result<()> {
.filter(|s| !s.is_empty())
});

let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]));
let bind_addr = server::bind_addr_from_env();

let config = ServerConfig {
auth_token: std::env::var("AUTH_TOKEN").ok(),
Expand Down
5 changes: 1 addition & 4 deletions src/commands/serve_fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,7 @@ fn tenant_config(ctx: &WalletContext) -> Result<ServerConfig> {
.ok()
.filter(|s| !s.is_empty())
});
let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]));
let bind_addr = server::bind_addr_from_env();
Ok(ServerConfig {
auth_token: std::env::var("AUTH_TOKEN").ok(),
tls,
Expand Down
13 changes: 10 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,21 @@ async fn main() -> Result<()> {
let ctx = context::WalletContext::load(&cli).await?;
commands::tracker_tick::run(&ctx).await?;
}
Commands::Daemon => {
Commands::Daemon { allow_no_token } => {
server::refuse_open_bind_from_env(*allow_no_token)?;
commands::daemon::run(&cli).await?;
}
Commands::Serve => {
Commands::Serve { allow_no_token } => {
server::refuse_open_bind_from_env(*allow_no_token)?;
let ctx = context::WalletContext::load(&cli).await?;
commands::serve::run(ctx, cli.port).await?;
}
Commands::ServeFleet { wallet, daemon } => {
Commands::ServeFleet {
wallet,
daemon,
allow_no_token,
} => {
server::refuse_open_bind_from_env(*allow_no_token)?;
commands::serve_fleet::run(wallet, cli.testnet, *daemon).await?;
}
Commands::Split { count } => {
Expand Down
74 changes: 74 additions & 0 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,43 @@ impl Default for ServerConfig {
}
}

/// The address to bind: `BIND_ADDR` when it parses, else `127.0.0.1`. Read
/// by every served command and by the bind guard, so the two agree.
pub fn bind_addr_from_env() -> std::net::IpAddr {
std::env::var("BIND_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]))
}

/// The bind guard (0.7.1). With no bearer token a served wallet answers any
/// caller that can reach it, and its two BEEF doors take a body of any size
/// (0.7.0), so an address beyond loopback needs a token or the operator's
/// explicit `allow_no_token`. An empty token counts as none. Loopback is
/// unchanged. Run at startup, before the wallet is opened or a socket bound.
pub fn refuse_open_bind(
bind_addr: std::net::IpAddr,
auth_token: Option<&str>,
allow_no_token: bool,
) -> Result<()> {
let has_token = auth_token.is_some_and(|t| !t.is_empty());
if has_token || allow_no_token || bind_addr.to_canonical().is_loopback() {
return Ok(());
}
anyhow::bail!(
"refusing to bind {bind_addr}: no bearer token is configured, and a wallet served \
beyond loopback without one answers anyone who can reach it. Set AUTH_TOKEN, or pass \
--allow-no-token to bind it open deliberately."
)
}

/// [`refuse_open_bind`] on the environment the served commands read
/// (`BIND_ADDR`, `AUTH_TOKEN`).
pub fn refuse_open_bind_from_env(allow_no_token: bool) -> Result<()> {
let token = std::env::var("AUTH_TOKEN").ok();
refuse_open_bind(bind_addr_from_env(), token.as_deref(), allow_no_token)
}

/// Auth middleware — checks Bearer token if configured.
async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
// /arc-callback is EXEMPT from wallet bearer auth: it is authenticated by
Expand Down Expand Up @@ -513,3 +550,40 @@ mod lenient_json_tests {
assert_eq!(v["keyID"], "ab \u{fffd} cd");
}
}

#[cfg(test)]
mod bind_guard_tests {
use super::refuse_open_bind;
use std::net::IpAddr;

fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}

#[test]
fn beyond_loopback_needs_a_token_or_the_flag() {
for open in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1"] {
let err = refuse_open_bind(ip(open), None, false).unwrap_err();
assert!(
err.to_string().contains("--allow-no-token"),
"{open}: {err}"
);
assert!(
refuse_open_bind(ip(open), Some(""), false).is_err(),
"{open}"
);
assert!(
refuse_open_bind(ip(open), Some("t"), false).is_ok(),
"{open}"
);
assert!(refuse_open_bind(ip(open), None, true).is_ok(), "{open}");
}
}

#[test]
fn loopback_is_unchanged() {
for lo in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] {
assert!(refuse_open_bind(ip(lo), None, false).is_ok(), "{lo}");
}
}
}
Loading
Loading