bsv-wallet-cli 0.7.3

Self-hosted BSV wallet CLI and BRC-100 server, wire-compatible with MetaNet Client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
pub mod audit;
pub mod broadcast_follow_up;
pub mod handlers;
pub mod post_word;
pub mod types;

use anyhow::Result;
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;

use handlers::WalletState;

/// Lock that serializes spending operations (createAction).
/// Non-spending endpoints (encrypt, getPublicKey, listOutputs, etc.) are unaffected.
///
/// Why: With limited UTXOs, concurrent createAction calls race on SQLite's write lock
/// and the loser gets SQLITE_BUSY_SNAPSHOT instead of waiting for the winner's change
/// output. This lock queues them so each spending request sees the previous one's change.
pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;

/// TLS configuration (cert + key paths).
#[derive(Clone)]
#[allow(dead_code)]
pub struct TlsConfig {
    pub cert_path: String,
    pub key_path: String,
}

/// Server configuration (auth, TLS, etc.)
#[derive(Clone)]
pub struct ServerConfig {
    /// Optional bearer token. When set, all requests must include
    /// `Authorization: Bearer <token>`. When None, auth is disabled.
    pub auth_token: Option<String>,
    /// Optional TLS config. Requires `--features tls` at build time.
    pub tls: Option<TlsConfig>,
    /// Network the served wallet is on. Drives post-broadcast verification
    /// (which ARC / WoC endpoints to probe). Defaults to `Main`.
    pub chain: Chain,
    /// Address to bind (default `127.0.0.1`). Set `BIND_ADDR=0.0.0.0` for the
    /// tunnel/public-webhook case (`POST /arc-callback` behind cloudflared /
    /// tailscale funnel / direct TLS).
    pub bind_addr: std::net::IpAddr,
    /// Per-wallet ARC/Arcade callback token. When set, `POST /arc-callback`
    /// is enabled, authenticated by THIS token (`Authorization: Bearer` or
    /// `X-CallbackToken`) and EXEMPT from the wallet bearer auth above.
    pub callback_token: Option<String>,
}

impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            auth_token: None,
            tls: None,
            chain: Chain::Main,
            bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
            callback_token: None,
        }
    }
}

/// 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
    // the per-wallet callback token inside its own handler (ARC/Arcade call it
    // with `Authorization: Bearer <callback-token>`, not the wallet token).
    if request.uri().path() == "/arc-callback" {
        return next.run(request).await;
    }

    // Extract config from request extensions
    let token = request
        .extensions()
        .get::<ServerConfig>()
        .and_then(|c| c.auth_token.clone());

    if let Some(expected) = token {
        let provided = headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "));

        match provided {
            Some(t) if t == expected => {}
            _ => {
                return (
                    StatusCode::UNAUTHORIZED,
                    Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
                )
                    .into_response();
            }
        }
    }

    next.run(request).await
}

/// Build the axum Router with all 28 WalletInterface endpoints.
pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
    let cors = CorsLayer::very_permissive();
    let cfg = config.clone();
    let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
    // Post-broadcast verifier — lets `/createAction` fail loudly when ARC
    // silently dropped an immediate broadcast (e.g. 465 fee-too-low on a deep
    // unconfirmed BEEF) instead of returning a phantom txid.
    let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);

    Router::new()
        // Existing 5 endpoints
        // Status endpoints accept BOTH GET and POST: @bsv/sdk's HTTPWalletJSON substrate
        // POSTs every method (incl. these), while curl/CLI clients GET them. Supporting
        // both makes the server wire-compatible with browser WalletClient connections.
        .route(
            "/isAuthenticated",
            get(handlers::is_authenticated).post(handlers::is_authenticated),
        )
        .route("/getPublicKey", post(handlers::get_public_key))
        // PERMISSION AUDIT (not BRC-100): what a wallet with a human behind it
        // would have prompted for. See `audit.rs` — this daemon grants everything
        // silently, which is what makes it headless AND blind.
        .route("/permission-audit", get(permission_audit))
        .route("/permission-audit/reset", post(permission_audit_reset))
        .route("/createSignature", post(handlers::create_signature))
        // The two doors that carry a caller's BEEF (`inputBEEF`, `tx`): no
        // body cap, a valid BEEF is never refused for its size (see
        // `BEEF_DOORS`).
        .route(
            "/createAction",
            post(handlers::create_action).layer(DefaultBodyLimit::disable()),
        )
        .route(
            "/internalizeAction",
            post(handlers::internalize_action).layer(DefaultBodyLimit::disable()),
        )
        // Batch 1: Status (GET + POST for HTTPWalletJSON compat)
        .route(
            "/getHeight",
            get(handlers::get_height).post(handlers::get_height),
        )
        .route(
            "/getNetwork",
            get(handlers::get_network).post(handlers::get_network),
        )
        .route(
            "/getVersion",
            get(handlers::get_version).post(handlers::get_version),
        )
        .route(
            "/waitForAuthentication",
            get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
        )
        // Batch 2: Header
        .route("/getHeaderForHeight", post(handlers::get_header_for_height))
        // Batch 3: Crypto
        .route("/verifySignature", post(handlers::verify_signature))
        .route("/encrypt", post(handlers::encrypt))
        .route("/decrypt", post(handlers::decrypt))
        .route("/createHmac", post(handlers::create_hmac))
        .route("/verifyHmac", post(handlers::verify_hmac))
        // Batch 4: Transaction workflow
        .route("/signAction", post(handlers::sign_action))
        .route("/abortAction", post(handlers::abort_action))
        .route("/listActions", post(handlers::list_actions))
        .route("/listOutputs", post(handlers::list_outputs))
        .route("/relinquishOutput", post(handlers::relinquish_output))
        // Batch 5: Certificates
        .route("/acquireCertificate", post(handlers::acquire_certificate))
        .route("/listCertificates", post(handlers::list_certificates))
        .route("/proveCertificate", post(handlers::prove_certificate))
        .route(
            "/relinquishCertificate",
            post(handlers::relinquish_certificate),
        )
        // Batch 6: Discovery + Key linkage
        .route(
            "/discoverByIdentityKey",
            post(handlers::discover_by_identity_key),
        )
        .route(
            "/discoverByAttributes",
            post(handlers::discover_by_attributes),
        )
        .route(
            "/revealCounterpartyKeyLinkage",
            post(handlers::reveal_counterparty_key_linkage),
        )
        .route(
            "/revealSpecificKeyLinkage",
            post(handlers::reveal_specific_key_linkage),
        )
        // ARC/Arcade proof-delivery webhook (callback-token auth, exempt from
        // wallet bearer auth — see auth_middleware).
        .route("/arc-callback", post(arc_callback))
        // The last layer added runs first: body limit, trace, the
        // extensions, then auth BEFORE the body is read (an uncapped BEEF
        // door is open only to the wallet's own caller), then the body.
        .layer(cors)
        .layer(middleware::from_fn(lenient_json_body))
        .layer(middleware::from_fn(auth_middleware))
        .layer(axum::Extension(cfg))
        .layer(axum::Extension(spending_lock))
        .layer(axum::Extension(verifier))
        .layer(TraceLayer::new_for_http())
        .layer(DefaultBodyLimit::max(BODY_CAP))
        .with_state(wallet)
}

/// `POST /arc-callback` — ARC/Arcade status webhook receiving push status
/// updates and (on MINED) the merkle path, straight into wallet storage.
///
/// Authenticated by the per-wallet callback token: ARC-convention
/// `Authorization: Bearer <callback-token>` or an `X-CallbackToken` header
/// (both accepted — broadcaster implementations vary). Returns 404 when no
/// callback token is configured (route effectively disabled).
async fn arc_callback(
    axum::extract::State(wallet): axum::extract::State<WalletState>,
    request: Request,
) -> Response {
    let expected = request
        .extensions()
        .get::<ServerConfig>()
        .and_then(|c| c.callback_token.clone());
    let Some(expected) = expected else {
        return (
            StatusCode::NOT_FOUND,
            Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
        )
            .into_response();
    };

    let headers = request.headers().clone();
    let provided = headers
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .map(str::to_string)
        .or_else(|| {
            headers
                .get("x-callbacktoken")
                .and_then(|v| v.to_str().ok())
                .map(str::to_string)
        });

    if provided.as_deref() != Some(expected.as_str()) {
        // Arcade authenticates its webhook POSTs as `Authorization: Bearer
        // <X-CallbackToken>` — a 401 here means a token mismatch and Arcade
        // will burn its (~10) retries, then permanently drop the submission.
        tracing::warn!(
            has_authorization = headers.contains_key("authorization"),
            has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
            "arc-callback: rejected POST with invalid/missing callback token"
        );
        return (
            StatusCode::UNAUTHORIZED,
            Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
        )
            .into_response();
    }

    let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
        Ok(b) => b,
        Err(_) => {
            return (
                StatusCode::PAYLOAD_TOO_LARGE,
                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
            )
                .into_response();
        }
    };
    let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
        Ok(v) => v,
        Err(e) => {
            return (
                StatusCode::BAD_REQUEST,
                Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
            )
                .into_response();
        }
    };

    match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
        Ok(action) => (
            StatusCode::OK,
            Json(json!({"ok": true, "action": format!("{:?}", action)})),
        )
            .into_response(),
        Err(e) => (
            StatusCode::BAD_REQUEST,
            Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
        )
            .into_response(),
    }
}

/// Browser wallets accept JSON whose strings carry lone UTF-16 surrogate escapes
/// (`"\ud83d"` without its pair); serde does not, so a page whose keyID or data
/// happened to contain one got `400 Failed to parse the request body as JSON:
/// keyID: lone leading surrogate` from this wallet while MetaNet Desktop served
/// the same call (beta soak, 2026-09-02, two logins lost). A browser's encoder
/// turns such a code unit into U+FFFD when it hashes the string, so the same
/// substitution here yields the same bytes the JS wallet would derive from.
pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
    if !input.windows(2).any(|w| w == b"\\u") {
        return std::borrow::Cow::Borrowed(input);
    }
    let hex4 = |b: &[u8]| -> Option<u32> {
        if b.len() < 4 {
            return None;
        }
        std::str::from_utf8(&b[..4])
            .ok()
            .and_then(|h| u32::from_str_radix(h, 16).ok())
    };
    let mut out = Vec::with_capacity(input.len());
    let mut i = 0;
    let mut changed = false;
    while i < input.len() {
        if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
            if let Some(cu) = hex4(&input[i + 2..]) {
                let is_lead = (0xD800..=0xDBFF).contains(&cu);
                let is_trail = (0xDC00..=0xDFFF).contains(&cu);
                if is_lead {
                    let next = &input[i + 6..];
                    let paired = next.len() >= 6
                        && next[0] == b'\\'
                        && next[1] == b'u'
                        && hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
                    if paired {
                        out.extend_from_slice(&input[i..i + 12]);
                        i += 12;
                        continue;
                    }
                    out.extend_from_slice(b"\\ufffd");
                    changed = true;
                    i += 6;
                    continue;
                }
                if is_trail {
                    out.extend_from_slice(b"\\ufffd");
                    changed = true;
                    i += 6;
                    continue;
                }
                out.extend_from_slice(&input[i..i + 6]);
                i += 6;
                continue;
            }
        }
        out.push(input[i]);
        i += 1;
    }
    if changed {
        std::borrow::Cow::Owned(out)
    } else {
        std::borrow::Cow::Borrowed(input)
    }
}

/// The routes whose JSON body carries a caller's BEEF. Their body is read
/// whole with no cap: the BRC-100 JSON wire carries the BEEF as one array, so
/// the body is held in memory (linear in the BEEF, about four bytes of body
/// per byte of BEEF) and the BEEF is then judged by the toolbox's streaming
/// reader for invalid bytes only. Every other route keeps
/// [`BODY_CAP`].
const BEEF_DOORS: [&str; 2] = ["/createAction", "/internalizeAction"];

/// The body cap of the routes that carry no BEEF.
const BODY_CAP: usize = 50 * 1024 * 1024;

/// Rewrite lone surrogate escapes in JSON request bodies before the `Json`
/// extractors see them (see [`sanitize_lone_surrogates`]).
async fn lenient_json_body(request: Request, next: Next) -> Response {
    let is_json = request
        .headers()
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
    if !is_json {
        return next.run(request).await;
    }
    let (parts, body) = request.into_parts();
    let cap = if BEEF_DOORS.contains(&parts.uri.path()) {
        usize::MAX
    } else {
        BODY_CAP
    };
    let bytes = match axum::body::to_bytes(body, cap).await {
        Ok(b) => b,
        Err(_) => {
            return (
                StatusCode::PAYLOAD_TOO_LARGE,
                Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
            )
                .into_response();
        }
    };
    let body = match sanitize_lone_surrogates(&bytes) {
        std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
        std::borrow::Cow::Owned(fixed) => {
            tracing::warn!(
                path = %parts.uri.path(),
                "json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
            );
            axum::body::Body::from(fixed)
        }
    };
    next.run(Request::from_parts(parts, body)).await
}

pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
    let tls = config.tls.clone();
    let bind_addr = config.bind_addr;
    let app = make_router(wallet, config);
    let addr = SocketAddr::from((bind_addr, port));

    #[cfg(feature = "tls")]
    if let Some(tls_cfg) = tls {
        use axum_server::tls_rustls::RustlsConfig;
        let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
        tracing::info!("HTTPS server listening on {addr}");
        eprintln!("HTTPS server listening on {addr}");
        axum_server::bind_rustls(addr, rustls)
            .serve(app.into_make_service())
            .await?;
        return Ok(());
    }

    #[cfg(not(feature = "tls"))]
    if tls.is_some() {
        anyhow::bail!("TLS requested but binary was built without `--features tls`");
    }

    tracing::info!("HTTP server listening on {addr}");
    eprintln!("HTTP server listening on {addr}");

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}

pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
    Arc::new(wallet)
}

/// Build a `WalletState` from an existing `Arc<Wallet>`.
///
/// Use this when the caller is already holding an `Arc<Wallet>` and wants the
/// HTTP server to share the *same* wallet instance — same identity key, same
/// balance, same UTXO set. Avoids opening a second `Wallet` handle against
/// the same SQLite DB (which would work via busy_timeout but adds write
/// contention and a second instance of any in-memory caches).
#[allow(dead_code)] // public API for external callers; not used inside this binary
pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
    wallet
}

async fn shutdown_signal() {
    tokio::signal::ctrl_c()
        .await
        .expect("failed to listen for ctrl-c");
    eprintln!("\nShutting down...");
}

/// GET /permission-audit — every permissioned request since start or reset.
async fn permission_audit() -> Json<serde_json::Value> {
    let entries = audit::snapshot();
    Json(json!({ "count": entries.len(), "entries": entries }))
}

/// POST /permission-audit/reset — call immediately before the flow you measure.
async fn permission_audit_reset() -> Json<serde_json::Value> {
    audit::reset();
    Json(json!({ "ok": true }))
}

#[cfg(test)]
mod lenient_json_tests {
    use super::sanitize_lone_surrogates;

    #[test]
    fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
        let lead = br#"{"keyID":"ab \ud83d cd"}"#;
        assert_eq!(
            &*sanitize_lone_surrogates(lead),
            br#"{"keyID":"ab \ufffd cd"}"#
        );
        let trail = br#"{"keyID":"\udc00"}"#;
        assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
        let pair = br#"{"keyID":"\ud83d\ude00"}"#;
        assert!(matches!(
            sanitize_lone_surrogates(pair),
            std::borrow::Cow::Borrowed(_)
        ));
        let plain = br#"{"keyID":"plain \\u0041"}"#;
        assert!(matches!(
            sanitize_lone_surrogates(plain),
            std::borrow::Cow::Borrowed(_)
        ));
        let fixed = sanitize_lone_surrogates(lead);
        let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
        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}");
        }
    }
}