From b08a2ee87eedb57f957a71dc9026a85565ccd7ca Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Fri, 21 Aug 2026 19:57:24 +0000 Subject: [PATCH] cli: obtain an IP ownership proof during connect and attach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #4201. Part of RFC-27; tracker #4194. Stacked on #4224. The SDK can carry an RFC-27 proof as of #4200, but nothing obtained one. This makes `doublezero connect` ask the verification service for a proof and attach it to user creation. - config: NetworkConfig gains ip_verifier_url, overridable by DZ_IP_VERIFIER_URL and by a new --ip-verifier-url on connect. Only localnet has a built-in default, the verifier's own listen address; deployed URLs land with #4199, and until then those environments simply have no verifier. - New ip_proof module in doublezero-daemon-cli: an automock'd IpProofClient trait plus a blocking reqwest implementation. The request is bound to the address the tunnel will use, so a multi-homed host proves the address it actually originates from; on a NATed host that bind fails and the request falls back to the default egress, where the service observes the NAT address the daemon already discovered. - The service's observed address is authoritative. Where it disagrees with what the daemon discovered, connect stops and names both: attaching the proof would guarantee an onchain rejection, and dropping it would bind an address nobody proved. - Every other failure is reported and non-fatal — unconfigured, unreachable, or declined (a CGNAT source, a rate limit) each print the specific reason and continue without a proof. The program is the enforcement point, so this succeeds while require-ip-ownership-proof is clear and fails with a named error once it is set, which is the behavior wanted during rollout. - One proof per invocation: a single connect only ever creates users of one user_type, and the proof binds user_type. The issue asks to fail when an explicit --client-ip disagrees with the service. That flag is deprecated and ignored on the CLI, so the disagreement that can actually happen is between the daemon's discovered address and the service's observed one; that is what is checked. --- CHANGELOG.md | 2 + Cargo.lock | 5 + config/src/constants.rs | 12 + config/src/env.rs | 13 + crates/doublezero-daemon-cli/Cargo.toml | 6 + crates/doublezero-daemon-cli/src/connect.rs | 524 ++++++++++++++++++- crates/doublezero-daemon-cli/src/ip_proof.rs | 279 ++++++++++ crates/doublezero-daemon-cli/src/lib.rs | 1 + 8 files changed, 830 insertions(+), 12 deletions(-) create mode 100644 crates/doublezero-daemon-cli/src/ip_proof.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c4ce9cc8..95f557f372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All notable changes to this project will be documented in this file. - `CreateUser` and `CreateSubscribeUser` validate an optional RFC-27 `IpOwnershipProof`, verified through the native Ed25519 precompile and signed by `globalstate.ip_verifier_authority_pk`, so a caller can no longer bind a `client_ip` it cannot originate traffic from. Enforcement is gated on the new `require-ip-ownership-proof` feature flag: while it is clear a missing proof is accepted, and a supplied proof is validated in full either way. The sentinel authority may omit the proof, because the shred-oracle provisions users owned by validators and has no proof it could obtain; a proof it does supply is still validated (#4215). (#4197) - IP verifier - New `doublezero-ip-verifier` service signs the source address it observes as an RFC-27 `IpOwnershipProof`, over `POST /v1/proof`. Forwarded headers count only for connections from a `--trusted-proxy` CIDR, and only the `--forwarded-header` the proxy actually writes is read; the chain is walked from the right so a client-prepended hop is ignored. With no trusted proxies configured the connection peer address is the only address it will sign. Non-routable and IPv6 sources are refused, as is a request the cached ledger epoch is too old to answer. The verifier key is checked against `GlobalState.ip_verifier_authority_pk` at startup and periodically after, so a rotation this service was not redeployed for takes it out of rotation instead of silently failing every user creation onchain. Built on axum, the first HTTP server framework in the Rust workspace. (#4198) +- CLI + - `doublezero connect` obtains an RFC-27 IP ownership proof from the verification service and attaches it to user creation. The service signs the source address it observes, so that address — not the daemon's own discovery — is authoritative; where the two disagree, `connect` stops and names both rather than binding an address nobody proved. The request is bound to the address the tunnel will use where the host allows it, so a multi-homed machine proves the right one. A verifier that is unreachable, unconfigured, or that declines (a CGNAT source, for instance) is reported and the connect continues without a proof, which the program accepts until `require-ip-ownership-proof` is set for the environment. `--ip-verifier-url` or `DZ_IP_VERIFIER_URL` points at a verifier; only localnet has a built-in default until the deployment work lands. (#4201) - Rust SDK - `CreateUserCommand` and `CreateSubscribeUserCommand` take an optional RFC-27 `ip_proof`. Supplying one attaches the native `Ed25519SigVerify` instruction the program looks for and sends both as one transaction; the verifier key comes from `GlobalState.ip_verifier_authority_pk`, the same place the program reads it, so a caller cannot pair a proof with the wrong key. A proof naming a different owner, address, or user type is refused before the transaction is paid for. On the `--owner` override path the proof must name that owner, because the program binds it to the user's effective owner. Omitting it produces the pre-RFC-27 transaction unchanged. Nothing sets it yet; the CLI is #4201. (#4200) - `DoubleZeroClient` gains `send_instructions`, for a transaction that needs more than one instruction. `send_transaction` is unchanged. (#4200) diff --git a/Cargo.lock b/Cargo.lock index bc68bf8651..72fb026731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,6 +1775,7 @@ dependencies = [ "ctor", "doublezero-cli-core", "doublezero-config", + "doublezero-ip-proof", "doublezero-program-common", "doublezero-serviceability", "doublezero_sdk", @@ -1786,11 +1787,13 @@ dependencies = [ "hyperlocal", "indicatif", "mockall 0.15.0", + "reqwest 0.13.4", "serde", "serde_json", "solana-sdk", "tabled", "tempfile", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -4846,7 +4849,9 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http 1.4.2", "http-body", diff --git a/config/src/constants.rs b/config/src/constants.rs index 6c27c13f8e..b1cdd1a13e 100644 --- a/config/src/constants.rs +++ b/config/src/constants.rs @@ -83,3 +83,15 @@ pub const ENV_LOCAL_INTERNET_LATENCY_COLLECTOR_PUBKEY: Pubkey = Pubkey::from_str_const("3fXen9LP5JUAkaaDJtyLo1ohPiJ2LdzVqAnmhtGgAmwJ"); pub const ENV_LOCAL_GEOLOCATION_PUBKEY: Pubkey = Pubkey::from_str_const("36WA9nUCsJaAQL5h44WYoLezDpocy8Q71NZbtrUN8DyC"); + +// RFC-27 IP ownership verification service (`doublezero-ip-verifier`). The service signs the +// source address it observes a request originate from, so this URL has to be reachable from the +// connecting host — and reachable over the same path the tunnel will use. +// +// Only localnet has a default, the verifier's own default listen address, which is what +// `dev/dzctl` runs (#4204). Deployed URLs for mainnet-beta, testnet, and devnet land with the +// deployment work (#4199); until then those environments have no verifier, and `doublezero +// connect` creates users without a proof — which the program accepts while +// `require-ip-ownership-proof` is clear. `--ip-verifier-url` or `DZ_IP_VERIFIER_URL` points at +// one in the meantime. +pub const ENV_LOCAL_IP_VERIFIER_URL: &str = "http://localhost:8080"; diff --git a/config/src/env.rs b/config/src/env.rs index 3074403aef..8c7f874b02 100644 --- a/config/src/env.rs +++ b/config/src/env.rs @@ -67,6 +67,7 @@ impl Environment { telemetry_program_id: ENV_MAINNET_BETA_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_MAINNET_BETA_INTERNET_LATENCY_COLLECTOR_PUBKEY, geolocation_program_id: ENV_MAINNET_BETA_GEOLOCATION_PUBKEY, + ip_verifier_url: None, }, Environment::Testnet => NetworkConfig { ledger_public_rpc_url: ENV_TESTNET_DOUBLEZERO_LEDGER_RPC_URL.to_string(), @@ -76,6 +77,7 @@ impl Environment { telemetry_program_id: ENV_TESTNET_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_TESTNET_INTERNET_LATENCY_COLLECTOR_PUBKEY, geolocation_program_id: ENV_TESTNET_GEOLOCATION_PUBKEY, + ip_verifier_url: None, }, Environment::Devnet => NetworkConfig { ledger_public_rpc_url: ENV_DEVNET_DOUBLEZERO_LEDGER_RPC_URL.to_string(), @@ -85,6 +87,7 @@ impl Environment { telemetry_program_id: ENV_DEVNET_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_DEVNET_INTERNET_LATENCY_COLLECTOR_PUBKEY, geolocation_program_id: ENV_DEVNET_GEOLOCATION_PUBKEY, + ip_verifier_url: None, }, Environment::Local => NetworkConfig { ledger_public_rpc_url: ENV_LOCAL_DOUBLEZERO_LEDGER_RPC_URL.to_string(), @@ -94,6 +97,7 @@ impl Environment { telemetry_program_id: ENV_LOCAL_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_LOCAL_INTERNET_LATENCY_COLLECTOR_PUBKEY, geolocation_program_id: ENV_LOCAL_GEOLOCATION_PUBKEY, + ip_verifier_url: Some(ENV_LOCAL_IP_VERIFIER_URL.to_string()), }, }; @@ -106,6 +110,11 @@ impl Environment { if std::env::var("DZ_SOLANA_RPC_URL").is_ok() { config.solana_l1_rpc_url = std::env::var("DZ_SOLANA_RPC_URL").unwrap(); } + // Set this to point at a verifier in an environment that has no deployed one yet, or at + // a local one while developing. `--ip-verifier-url` on `connect` overrides it in turn. + if std::env::var("DZ_IP_VERIFIER_URL").is_ok() { + config.ip_verifier_url = Some(std::env::var("DZ_IP_VERIFIER_URL").unwrap()); + } Ok(config) } @@ -163,6 +172,10 @@ pub struct NetworkConfig { pub telemetry_program_id: Pubkey, pub internet_latency_collector_pk: Pubkey, pub geolocation_program_id: Pubkey, + /// Base URL of the RFC-27 IP ownership verification service, or `None` where none is + /// deployed yet. `None` is not an error: user creation without a proof is accepted until + /// `require-ip-ownership-proof` is set for the environment. + pub ip_verifier_url: Option, } #[cfg(test)] diff --git a/crates/doublezero-daemon-cli/Cargo.toml b/crates/doublezero-daemon-cli/Cargo.toml index d0e31731ce..68a053be5c 100644 --- a/crates/doublezero-daemon-cli/Cargo.toml +++ b/crates/doublezero-daemon-cli/Cargo.toml @@ -24,6 +24,12 @@ hyper-util.workspace = true hyperlocal.workspace = true indicatif.workspace = true mockall.workspace = true +# The RFC-27 proof retrieved from the verification service and forwarded to the SDK. +doublezero-ip-proof.workspace = true +# One short blocking HTTP request per `connect`, to the IP verification service. `local_address` +# is what lets a multi-homed host prove the address its tunnel will actually use. +reqwest = { workspace = true, features = ["json", "blocking"] } +thiserror.workspace = true serde.workspace = true serde_json.workspace = true solana-sdk.workspace = true diff --git a/crates/doublezero-daemon-cli/src/connect.rs b/crates/doublezero-daemon-cli/src/connect.rs index 16f72a2969..9388370387 100644 --- a/crates/doublezero-daemon-cli/src/connect.rs +++ b/crates/doublezero-daemon-cli/src/connect.rs @@ -11,6 +11,7 @@ use std::{collections::HashMap, io::Write, net::Ipv4Addr, str::FromStr, time::Du use backon::{BlockingRetryable, ExponentialBuilder}; use clap::{Args, Subcommand, ValueEnum}; use doublezero_cli_core::CliContext; +use doublezero_ip_proof::IpOwnershipProof; use doublezero_sdk::{ commands::{ multicastgroup::{ @@ -31,6 +32,7 @@ use solana_sdk::pubkey::Pubkey; use crate::{ client::{DaemonClient, LatencyRecord, StatusResponse}, helpers::{init_spinner, resolve_client_ip}, + ip_proof::{HttpIpProofClient, IpProofClient, IpProofError}, latency::{best_latency, retrieve_latencies, select_tunnel_endpoint}, ledger::LedgerClient, requirements::check_daemon, @@ -104,6 +106,11 @@ pub struct Connect { #[arg(long, global = true)] pub device: Option, + /// Base URL of the RFC-27 IP ownership verification service, overriding the environment + /// default. Point this at a local or devnet verifier; `DZ_IP_VERIFIER_URL` does the same. + #[arg(long, global = true)] + pub ip_verifier_url: Option, + /// Verbose output #[arg(short, long, global = true, default_value_t = false)] pub verbose: bool, @@ -158,12 +165,112 @@ fn check_accesspass( Ok(accesspass.last_access_epoch >= epoch) } +/// The RFC-27 `user_type` a parsed mode will create a user as. The proof binds `user_type`, and +/// one `connect` invocation only ever creates one kind of user, so one proof covers it. +fn proof_user_type(mode: &ParsedDzMode) -> UserType { + match mode { + ParsedDzMode::Ibrl(user_type, _) => *user_type, + ParsedDzMode::Multicast { .. } | ParsedDzMode::MulticastFeeds { .. } => UserType::Multicast, + } +} + +/// Obtains a proof, or explains why there is none. +/// +/// A refusal is not fatal. The program is the enforcement point: without a proof, creation +/// succeeds while `require-ip-ownership-proof` is clear and fails with a named error once it is +/// set. Breaking `connect` here would take every host in a not-yet-enforcing environment offline +/// the moment a verifier went down. +/// +/// The one hard failure is a proof for an address other than the one being provisioned. The +/// service signs what it observed; the daemon reports what it discovered. When those disagree the +/// host's verification request and its tunnel traffic leave by different paths, and neither +/// address is safe to use — attaching the proof guarantees an onchain rejection, and dropping it +/// binds an address nobody proved. +fn obtain_ip_proof( + proof_client: &P, + payer: Pubkey, + user_type: UserType, + client_ip: Ipv4Addr, + out: &mut W, +) -> eyre::Result> { + let proof = match proof_client.request_proof(payer, user_type, client_ip) { + Ok(proof) => proof, + Err(IpProofError::NotConfigured) => { + tracing::debug!("no IP ownership verification service configured; continuing"); + return Ok(None); + } + Err(err @ IpProofError::Declined { .. }) => { + writeln!(out, "⚠️ {err}")?; + writeln!( + out, + " Continuing without an IP ownership proof. This will be rejected onchain \ + once IP ownership verification is required for this environment." + )?; + return Ok(None); + } + Err(err) => { + writeln!(out, "⚠️ {err}")?; + writeln!( + out, + " Continuing without an IP ownership proof. This will be rejected onchain \ + once IP ownership verification is required for this environment." + )?; + return Ok(None); + } + }; + + if proof.client_ip != client_ip { + writeln!( + out, + "❌ The verification service observed this host at {}, but the daemon is \ + provisioning {client_ip}.", + proof.client_ip + )?; + return Err(eyre::eyre!( + "IP ownership verification saw {} while the daemon reports {client_ip}. The \ + verification request and the tunnel would leave this host by different paths. Set \ + --client-ip on the daemon to the address the tunnel must use, or point \ + --ip-verifier-url at a verifier reachable over that path.", + proof.client_ip + )); + } + + writeln!(out, " IP ownership verified for {client_ip}")?; + Ok(Some(proof)) +} + impl Connect { pub async fn execute( + self, + ctx: &CliContext, + daemon: &D, + ledger: &L, + out: &mut W, + ) -> eyre::Result<()> { + // The flag wins over the environment default, which in turn is overridable by + // DZ_IP_VERIFIER_URL inside `config()`. + let verifier_url = match &self.ip_verifier_url { + Some(url) => Some(url.clone()), + None => ctx.env.config().ok().and_then(|c| c.ip_verifier_url), + }; + let proof_client = HttpIpProofClient::new(verifier_url); + self.execute_with_proof_client(ctx, daemon, ledger, &proof_client, out) + .await + } + + /// The body of [`Connect::execute`], with the verification service abstracted so tests can + /// drive every branch without an HTTP server. + pub async fn execute_with_proof_client< + D: DaemonClient, + L: LedgerClient, + P: IpProofClient, + W: Write, + >( self, _ctx: &CliContext, daemon: &D, ledger: &L, + proof_client: &P, out: &mut W, ) -> eyre::Result<()> { let spinner = init_spinner(5); @@ -210,10 +317,23 @@ impl Connect { writeln!(out, " DoubleZero ID: {}", ledger.get_payer())?; writeln!(out, "⚡ Provisioning for IP: {client_ip_str}")?; + // RFC-27: one proof per invocation, because one invocation creates users of a single + // `user_type`. Requested after the AccessPass pre-flight so a host with no pass fails on + // that first — the clearer diagnostic — rather than on a verifier it did not need. + let ip_proof = obtain_ip_proof( + proof_client, + ledger.get_payer(), + proof_user_type(&parsed_mode), + client_ip, + out, + )?; + let provisioned = match parsed_mode { ParsedDzMode::Ibrl(user_type, tenant) => { - self.execute_ibrl(ledger, daemon, user_type, client_ip, tenant, &spinner, out) - .await?; + self.execute_ibrl( + ledger, daemon, user_type, client_ip, tenant, ip_proof, &spinner, out, + ) + .await?; true } ParsedDzMode::Multicast { @@ -226,6 +346,7 @@ impl Connect { &pub_groups, &sub_groups, client_ip, + ip_proof, &spinner, out, ) @@ -241,6 +362,7 @@ impl Connect { &sub_feeds, &unsub_feeds, client_ip, + ip_proof, &spinner, out, ) @@ -264,12 +386,15 @@ impl Connect { user_type: UserType, client_ip: Ipv4Addr, tenant: Option, + ip_proof: Option, spinner: &ProgressBar, out: &mut W, ) -> eyre::Result<()> { // Look for user let (_user_pubkey, user) = self - .find_or_create_user(ledger, daemon, &client_ip, spinner, user_type, tenant, out) + .find_or_create_user( + ledger, daemon, &client_ip, spinner, user_type, tenant, ip_proof, out, + ) .await?; // Check user status @@ -290,6 +415,7 @@ impl Connect { pub_groups: &[String], sub_groups: &[String], client_ip: Ipv4Addr, + ip_proof: Option, spinner: &ProgressBar, out: &mut W, ) -> eyre::Result { @@ -315,7 +441,15 @@ impl Connect { // makes a zero-argument connect work for a feed customer. if !accesspass.feed_seats().is_empty() { return self - .auto_join_purchased_feeds(ledger, daemon, &accesspass, client_ip, spinner, out) + .auto_join_purchased_feeds( + ledger, + daemon, + &accesspass, + client_ip, + ip_proof, + spinner, + out, + ) .await; } @@ -405,6 +539,7 @@ impl Connect { spinner, &pub_group_pks, &sub_group_pks, + ip_proof, out, ) .await?; @@ -434,6 +569,7 @@ impl Connect { sub_feeds: &[String], unsub_feeds: &[String], client_ip: Ipv4Addr, + ip_proof: Option, spinner: &ProgressBar, out: &mut W, ) -> eyre::Result { @@ -498,7 +634,8 @@ impl Connect { let sub_result = match feed_join { None => None, Some(join) => { - let result = self.execute_feed_join(ledger, join, client_ip, spinner, out); + let result = + self.execute_feed_join(ledger, join, client_ip, ip_proof, spinner, out); if result.is_ok() { writeln!(out, " Joined feed(s): {}", sub_feeds.join(", "))?; } @@ -554,12 +691,14 @@ impl Connect { /// deliberate: exiting 0 with nothing subscribed is indistinguishable from success to an /// unattended installer. A re-run that holds a feed already still activates the user: a /// disabled reconciler must not go unnoticed just because nothing new needed joining. + #[allow(clippy::too_many_arguments)] async fn auto_join_purchased_feeds( &self, ledger: &L, daemon: &D, accesspass: &AccessPass, client_ip: Ipv4Addr, + ip_proof: Option, spinner: &ProgressBar, out: &mut W, ) -> eyre::Result { @@ -871,6 +1010,7 @@ impl Connect { feed_pks: selection.join, }, client_ip, + ip_proof, spinner, out, )?; @@ -1065,6 +1205,7 @@ impl Connect { ledger: &L, join: FeedJoin, client_ip: Ipv4Addr, + ip_proof: Option, spinner: &ProgressBar, out: &mut W, ) -> eyre::Result<()> { @@ -1083,7 +1224,7 @@ impl Connect { client_ip, tunnel_endpoint, tenant_pk: None, - ip_proof: None, + ip_proof, })?; spinner.set_message("Multicast user created"); user_pk @@ -1263,6 +1404,7 @@ impl Connect { spinner: &ProgressBar, user_type: UserType, tenant: Option, + ip_proof: Option, out: &mut W, ) -> eyre::Result<(Pubkey, User)> { spinner.set_message("Searching for user account..."); @@ -1364,7 +1506,7 @@ impl Connect { client_ip: *client_ip, tunnel_endpoint, tenant_pk, - ip_proof: None, + ip_proof, }); match res { @@ -1396,6 +1538,7 @@ impl Connect { spinner: &ProgressBar, pub_group_pks: &[Pubkey], sub_group_pks: &[Pubkey], + ip_proof: Option, out: &mut W, ) -> eyre::Result<(Pubkey, User)> { spinner.set_message("Searching for user account..."); @@ -1480,7 +1623,7 @@ impl Connect { tunnel_endpoint, owner: None, feed_pk: None, - ip_proof: None, + ip_proof, }); let user_pk = match res { @@ -1626,7 +1769,7 @@ impl Connect { tunnel_endpoint, owner: None, feed_pk: None, - ip_proof: None, + ip_proof, }); let user_pk = match res { @@ -2055,6 +2198,7 @@ mod tests { DoubleZeroStatus, LatencyRecord, LatencyResponse, MockDaemonClient, StatusResponse, V2StatusResponse, }, + ip_proof::MockIpProofClient, ledger::MockLedgerClient, }; use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; @@ -2740,6 +2884,18 @@ mod tests { pk: Pubkey, user: &User, tenant_pk: Option, + ) { + self.expect_create_user_with_tenant_and_proof(pk, user, tenant_pk, None); + } + + /// The RFC-27 variant: asserts the exact proof the command carries, so a test can pin + /// that the proof the verification service returned is the one that reaches the SDK. + pub fn expect_create_user_with_tenant_and_proof( + &mut self, + pk: Pubkey, + user: &User, + tenant_pk: Option, + ip_proof: Option, ) { let expected_create_user_command = CreateUserCommand { user_type: user.user_type, @@ -2748,7 +2904,7 @@ mod tests { client_ip: user.client_ip, tunnel_endpoint: user.tunnel_endpoint, tenant_pk, - ip_proof: None, + ip_proof, }; let users = self.users.clone(); @@ -2774,6 +2930,27 @@ mod tests { mcast_group_pk: Pubkey, publisher: bool, subscriber: bool, + ) { + self.expect_create_subscribe_user_with_proof( + pk, + user, + mcast_group_pk, + publisher, + subscriber, + None, + ); + } + + /// The RFC-27 variant: asserts the exact proof the command carries. + #[allow(clippy::too_many_arguments)] + pub fn expect_create_subscribe_user_with_proof( + &mut self, + pk: Pubkey, + user: &User, + mcast_group_pk: Pubkey, + publisher: bool, + subscriber: bool, + ip_proof: Option, ) { let expected_create_subscribe_user_command = CreateSubscribeUserCommand { user_type: user.user_type, @@ -2786,7 +2963,7 @@ mod tests { tunnel_endpoint: user.tunnel_endpoint, owner: None, feed_pk: None, - ip_proof: None, + ip_proof, }; let users = self.users.clone(); @@ -2954,15 +3131,71 @@ mod tests { /// Run `connect` against the fixture's mocks with a captured writer, /// returning the result and the writer output. + /// + /// Defaults to an environment with no verification service, which is the pre-RFC-27 + /// behavior every test below other than the proof tests is asserting. async fn run(fixture: &TestFixture, command: Connect) -> (eyre::Result<()>, String) { + run_with_proof_client(fixture, command, no_verifier()).await + } + + /// The same, with a verification service the test controls. + async fn run_with_proof_client( + fixture: &TestFixture, + command: Connect, + proof_client: MockIpProofClient, + ) -> (eyre::Result<()>, String) { let ctx = cli_context_default_for_tests(); let mut out = Vec::new(); let result = command - .execute(&ctx, &fixture.daemon, &fixture.ledger, &mut out) + .execute_with_proof_client( + &ctx, + &fixture.daemon, + &fixture.ledger, + &proof_client, + &mut out, + ) .await; (result, String::from_utf8(out).unwrap()) } + /// An environment with no verifier configured — what every environment looks like until the + /// deployment work lands. + fn no_verifier() -> MockIpProofClient { + let mut client = MockIpProofClient::new(); + client + .expect_request_proof() + .returning(|_, _, _| Err(IpProofError::NotConfigured)); + client + } + + /// A verifier that signs `client_ip` for whatever it is asked, recording the `user_type` it + /// was asked for so a test can assert the proof is bound to the user actually being created. + fn verifier_for(client_ip: Ipv4Addr) -> (MockIpProofClient, Arc>>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let recorded = seen.clone(); + let mut client = MockIpProofClient::new(); + client + .expect_request_proof() + .returning_st(move |payer, user_type, _source| { + recorded.lock().unwrap().push(user_type); + Ok(test_proof(payer, client_ip, user_type)) + }); + (client, seen) + } + + /// A proof with a placeholder signature: nothing client-side verifies it, and the program + /// side is covered by the serviceability tests. + fn test_proof(payer: Pubkey, client_ip: Ipv4Addr, user_type: UserType) -> IpOwnershipProof { + IpOwnershipProof { + version: 1, + payer, + client_ip, + epoch: 10, + user_type: user_type as u8, + signature: [3u8; 64], + } + } + #[test] fn test_connect_command_ibrl_hybrid() { block_on(async { @@ -2985,6 +3218,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3020,6 +3254,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3029,6 +3264,219 @@ mod tests { }); } + // ======================================================================== + // RFC-27 IP ownership proof (issue #4201) + // ======================================================================== + + /// An IBRL connect where the verification service answers: the proof it signed is the one + /// that reaches the SDK, and it is bound to the `user_type` being created. + #[test] + fn test_connect_attaches_the_ip_proof_the_service_issued() { + block_on(async { + let mut fixture = TestFixture::new(); + let (tenant_pk, tenant) = fixture.add_tenant("proof-tenant"); + let (device1_pk, _) = fixture.add_device(DeviceType::Hybrid, 100, true); + fixture.add_device(DeviceType::Hybrid, 110, true); + + let user = fixture.create_user(UserType::IBRL, device1_pk, "1.2.3.4"); + let payer = fixture.ledger.get_payer(); + let proof = test_proof(payer, user.client_ip, UserType::IBRL); + fixture.expect_create_user_with_tenant_and_proof( + Pubkey::new_unique(), + &user, + Some(tenant_pk), + Some(proof), + ); + + let command = Connect { + dz_mode: DzMode::IBRL { + tenant: Some(tenant.code.clone()), + allocate_addr: false, + }, + client_ip: None, + device: None, + ip_verifier_url: None, + verbose: false, + }; + + let (verifier, user_types) = verifier_for(user.client_ip); + let (result, output) = run_with_proof_client(&fixture, command, verifier).await; + + assert!(result.is_ok(), "{result:?}\n{output}"); + assert!( + output.contains("IP ownership verified for 1.2.3.4"), + "{output}" + ); + // The proof binds `user_type`, so a proof requested for the wrong one would be + // rejected onchain even though every other field matched. + assert_eq!(*user_types.lock().unwrap(), vec![UserType::IBRL]); + }); + } + + /// A multicast connect asks for a proof bound to `Multicast`, not to the IBRL type the + /// unicast path uses. + #[test] + fn test_connect_binds_the_proof_to_the_multicast_user_type() { + block_on(async { + let mut fixture = TestFixture::new(); + let (mcast_group_pk, _) = fixture.add_multicast_group("test-group", "239.0.0.1"); + let (device_pk, _) = fixture.add_device(DeviceType::Hybrid, 100, true); + + let mcast_user = fixture.create_user(UserType::Multicast, device_pk, "1.2.3.4"); + let payer = fixture.ledger.get_payer(); + fixture.expect_create_subscribe_user_with_proof( + Pubkey::new_unique(), + &mcast_user, + mcast_group_pk, + true, + false, + Some(test_proof(payer, mcast_user.client_ip, UserType::Multicast)), + ); + + let command = Connect { + dz_mode: DzMode::Multicast { + mode: Some(MulticastMode::Publisher), + multicast_groups: vec!["test-group".to_string()], + pub_groups: vec![], + sub_groups: vec![], + sub_feeds: vec![], + unsub_feeds: vec![], + }, + client_ip: None, + device: None, + ip_verifier_url: None, + verbose: false, + }; + + let (verifier, user_types) = verifier_for(mcast_user.client_ip); + let (result, output) = run_with_proof_client(&fixture, command, verifier).await; + + assert!(result.is_ok(), "{result:?}\n{output}"); + assert_eq!(*user_types.lock().unwrap(), vec![UserType::Multicast]); + }); + } + + /// An unreachable service must not take a host offline. Creation proceeds without a proof, + /// which the program accepts until enforcement is switched on — and the operator is told. + #[test] + fn test_connect_continues_without_a_proof_when_the_service_is_unreachable() { + block_on(async { + let mut fixture = TestFixture::new(); + let (tenant_pk, tenant) = fixture.add_tenant("proof-tenant"); + let (device1_pk, _) = fixture.add_device(DeviceType::Hybrid, 100, true); + fixture.add_device(DeviceType::Hybrid, 110, true); + + let user = fixture.create_user(UserType::IBRL, device1_pk, "1.2.3.4"); + // `expect_create_user_with_tenant` pins `ip_proof: None`, so this test fails if the + // fallback ever starts attaching something. + fixture.expect_create_user_with_tenant(Pubkey::new_unique(), &user, Some(tenant_pk)); + + let command = Connect { + dz_mode: DzMode::IBRL { + tenant: Some(tenant.code.clone()), + allocate_addr: false, + }, + client_ip: None, + device: None, + ip_verifier_url: None, + verbose: false, + }; + + let mut verifier = MockIpProofClient::new(); + verifier.expect_request_proof().returning(|_, _, _| { + Err(IpProofError::Unreachable { + url: "http://ip-verifier.example:8080/v1/proof".to_string(), + detail: "connection refused".to_string(), + }) + }); + + let (result, output) = run_with_proof_client(&fixture, command, verifier).await; + + assert!(result.is_ok(), "{result:?}\n{output}"); + assert!(output.contains("could not reach"), "{output}"); + assert!( + output.contains("Continuing without an IP ownership proof"), + "{output}" + ); + }); + } + + /// A CGNAT or RFC-1918 source address is a refusal, not an outage. The operator needs the + /// service's own reason, because the remedy is theirs, not ours. + #[test] + fn test_connect_reports_a_non_routable_source_address_and_continues() { + block_on(async { + let mut fixture = TestFixture::new(); + let (tenant_pk, tenant) = fixture.add_tenant("proof-tenant"); + let (device1_pk, _) = fixture.add_device(DeviceType::Hybrid, 100, true); + fixture.add_device(DeviceType::Hybrid, 110, true); + + let user = fixture.create_user(UserType::IBRL, device1_pk, "1.2.3.4"); + fixture.expect_create_user_with_tenant(Pubkey::new_unique(), &user, Some(tenant_pk)); + + let command = Connect { + dz_mode: DzMode::IBRL { + tenant: Some(tenant.code.clone()), + allocate_addr: false, + }, + client_ip: None, + device: None, + ip_verifier_url: None, + verbose: false, + }; + + let mut verifier = MockIpProofClient::new(); + verifier.expect_request_proof().returning(|_, _, _| { + Err(IpProofError::Declined { + reason: "not_globally_routable".to_string(), + message: "100.64.3.9 is not a globally routable address".to_string(), + }) + }); + + let (result, output) = run_with_proof_client(&fixture, command, verifier).await; + + assert!(result.is_ok(), "{result:?}\n{output}"); + assert!(output.contains("not_globally_routable"), "{output}"); + assert!(output.contains("100.64.3.9"), "{output}"); + }); + } + + /// The one hard failure: the service saw a different address than the one being provisioned. + /// Attaching the proof would be rejected onchain and dropping it would bind an address nobody + /// proved, so `connect` stops and names both. + #[test] + fn test_connect_fails_when_the_service_saw_a_different_address() { + block_on(async { + let mut fixture = TestFixture::new(); + let (_, tenant) = fixture.add_tenant("proof-tenant"); + fixture.add_device(DeviceType::Hybrid, 100, true); + fixture.add_device(DeviceType::Hybrid, 110, true); + // No create_user expectation: reaching the ledger at all is the failure. + + let command = Connect { + dz_mode: DzMode::IBRL { + tenant: Some(tenant.code.clone()), + allocate_addr: false, + }, + client_ip: None, + device: None, + ip_verifier_url: None, + verbose: false, + }; + + // The daemon reports 1.2.3.4; the service egressed by another path and saw 9.9.9.9. + let (verifier, _) = verifier_for(Ipv4Addr::new(9, 9, 9, 9)); + let (result, output) = run_with_proof_client(&fixture, command, verifier).await; + + let err = result.expect_err("a proof for another address must stop the connect"); + let message = err.to_string(); + assert!(message.contains("9.9.9.9"), "{message}"); + assert!(message.contains("1.2.3.4"), "{message}"); + assert!(output.contains("9.9.9.9"), "{output}"); + assert!(output.contains("1.2.3.4"), "{output}"); + }); + } + #[test] fn test_connect_command_ibrl_edge() { block_on(async { @@ -3051,6 +3499,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3081,6 +3530,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3104,6 +3554,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3132,6 +3583,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3159,6 +3611,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3182,6 +3635,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3208,6 +3662,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3245,6 +3700,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3297,6 +3753,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -3334,6 +3791,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -3383,6 +3841,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -3410,6 +3869,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -3458,6 +3918,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3500,6 +3961,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3551,6 +4013,7 @@ mod tests { }, client_ip: Some(ibrl_user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3604,6 +4067,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3632,6 +4096,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3692,6 +4157,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3720,6 +4186,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3769,6 +4236,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3814,6 +4282,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3862,6 +4331,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3914,6 +4384,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3932,6 +4403,7 @@ mod tests { }, client_ip: Some(ibrl_user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -3977,6 +4449,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4005,6 +4478,7 @@ mod tests { }, client_ip: Some(ibrl_user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4037,6 +4511,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: Some(device.code.clone()), // Explicitly specify the device + ip_verifier_url: None, verbose: false, }; @@ -4080,6 +4555,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: Some(device.code.clone()), // Explicitly specify the device + ip_verifier_url: None, verbose: false, }; @@ -4118,6 +4594,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: None, // auto-select + ip_verifier_url: None, verbose: false, }; @@ -4166,6 +4643,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, // auto-select + ip_verifier_url: None, verbose: false, }; @@ -4213,6 +4691,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4249,6 +4728,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4284,6 +4764,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4313,6 +4794,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4338,6 +4820,7 @@ mod tests { }, client_ip: Some("1.2.3.4".to_string()), device: Some("nonexistent-device".to_string()), // Device that doesn't exist + ip_verifier_url: None, verbose: false, }; @@ -4371,6 +4854,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4420,6 +4904,7 @@ mod tests { }, client_ip: Some(ibrl_user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4466,6 +4951,7 @@ mod tests { }, client_ip: Some(ibrl_user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4513,6 +4999,7 @@ mod tests { }, client_ip: Some(user.client_ip.to_string()), device: None, + ip_verifier_url: None, verbose: false, }; @@ -4553,6 +5040,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -4589,6 +5077,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -4630,6 +5119,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -4663,6 +5153,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -4694,6 +5185,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -4733,6 +5225,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -4756,6 +5249,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, } } @@ -5237,6 +5731,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -5265,6 +5760,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -5303,6 +5799,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -5350,6 +5847,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -5395,6 +5893,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; @@ -5449,6 +5948,7 @@ mod tests { }, client_ip: None, device: None, + ip_verifier_url: None, verbose: false, }; diff --git a/crates/doublezero-daemon-cli/src/ip_proof.rs b/crates/doublezero-daemon-cli/src/ip_proof.rs new file mode 100644 index 0000000000..6fba14acd9 --- /dev/null +++ b/crates/doublezero-daemon-cli/src/ip_proof.rs @@ -0,0 +1,279 @@ +//! RFC-27 IP ownership proof retrieval for `connect`. +//! +//! `client_ip` is a plain argument to user creation: nothing onchain attests that the caller can +//! originate traffic from it. RFC-27 closes that with a proof signed by a DoubleZero-operated +//! verifier, and this is where `connect` obtains one. +//! +//! **The service, not the host, decides the address.** The verifier signs the source address it +//! observes the request originate from and refuses to accept a caller-supplied one — the request +//! body has no `client_ip` field at all. So the proof it returns is the authoritative value, and +//! the daemon's own discovery (`resolve_client_ip`, ultimately `ifconfig.me` inside +//! `doublezerod`) is a convenience for display and pre-flight checks. Where the two disagree, +//! `connect` stops rather than guessing. +//! +//! Retrieval is deliberately best-effort. A host with no reachable verifier, or behind CGNAT, +//! still connects: creation proceeds without a proof and the program decides, which succeeds +//! while `require-ip-ownership-proof` is clear and fails cleanly once it is set. The one hard +//! failure is a proof for an address that is not the one being provisioned, because attaching it +//! would guarantee an onchain rejection and ignoring it would bind an address nobody proved. + +use std::{net::Ipv4Addr, str::FromStr, time::Duration}; + +use doublezero_ip_proof::IpOwnershipProof; +use doublezero_sdk::UserType; +use mockall::automock; +use serde::Deserialize; +use solana_sdk::{pubkey::Pubkey, signature::Signature}; +use tracing::debug; + +/// How long to wait on the verifier before giving up and letting the program decide. Short on +/// purpose: `connect` is interactive, and the fallback is a working connection while the feature +/// flag is clear. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// Why no proof is available. Every variant is a reason to *continue* without one — the program +/// is the enforcement point, and refusing to connect here would break every host in an +/// environment whose flag is still clear. They are separated so the operator learns which one +/// happened, because the remedies are completely different. +#[derive(Debug, thiserror::Error)] +pub enum IpProofError { + /// No verifier is deployed for this environment and none was configured (#4199). + #[error("no IP ownership verification service is configured for this environment")] + NotConfigured, + + /// The service could not be reached: DNS, connect, TLS, or timeout. + #[error("could not reach the IP ownership verification service at {url}: {detail}")] + Unreachable { url: String, detail: String }, + + /// The service answered and declined. `reason` is its stable machine-readable code — notably + /// `not_globally_routable` for a CGNAT or RFC-1918 source, and `rate_limited`. + #[error( + "the IP ownership verification service declined to issue a proof ({reason}): {message}" + )] + Declined { reason: String, message: String }, + + /// The service answered with something this client cannot turn into a proof. A version skew + /// or a captive portal in the path both land here. + #[error("could not read the proof the verification service returned: {0}")] + Malformed(String), +} + +/// Obtains an RFC-27 proof for the calling host. +#[automock] +pub trait IpProofClient: Send + Sync { + /// Request a proof binding `payer`, the address the service observes, and `user_type`. + /// + /// `source_addr` is the address the tunnel will use; the implementation binds the outbound + /// request to it where it can, so a multi-homed host proves the address it will actually + /// originate tunnel traffic from rather than whichever one the routing table prefers. + fn request_proof( + &self, + payer: Pubkey, + user_type: UserType, + source_addr: Ipv4Addr, + ) -> Result; +} + +/// Mirrors the service's `ProofResponse`. Deliberately a separate type from `IpOwnershipProof`: +/// the wire form carries base58 strings, and a field the program does not understand must fail +/// here rather than be silently coerced. +#[derive(Debug, Deserialize)] +struct ProofResponse { + version: u8, + payer: String, + client_ip: Ipv4Addr, + epoch: u64, + user_type: u8, + signature: String, +} + +/// Mirrors the service's `ErrorResponse`. +#[derive(Debug, Deserialize)] +struct ErrorResponse { + error: String, + message: String, +} + +/// The real client. `None` for `base_url` models an environment with no verifier, so the caller +/// does not have to special-case its own configuration. +pub struct HttpIpProofClient { + base_url: Option, +} + +impl HttpIpProofClient { + pub fn new(base_url: Option) -> Self { + Self { base_url } + } + + /// Blocking on purpose: it sits alongside `LedgerClient`'s blocking RPC calls in the same + /// `connect` code path, and one short request per invocation does not justify a second + /// async HTTP stack in this crate. + fn build(source_addr: Ipv4Addr) -> Result<(reqwest::blocking::Client, bool), IpProofError> { + // Bind the request to the address the tunnel will use, so a multi-homed host proves the + // right one. On a NATed host that address is not assigned to any local interface and the + // bind fails, which is expected, not an error: fall back to an unbound request, where + // the service observes the NAT's public address — the same address the daemon + // discovered. Both paths are reported, because a silent fallback on a multi-homed host + // would prove the wrong address. + match reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .local_address(Some(std::net::IpAddr::V4(source_addr))) + .build() + { + Ok(client) => Ok((client, true)), + Err(bind_err) => { + debug!( + %source_addr, + error = %bind_err, + "could not bind the verification request to the tunnel source address; \ + falling back to the host's default egress" + ); + let client = reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| IpProofError::Malformed(e.to_string()))?; + Ok((client, false)) + } + } + } +} + +impl IpProofClient for HttpIpProofClient { + fn request_proof( + &self, + payer: Pubkey, + user_type: UserType, + source_addr: Ipv4Addr, + ) -> Result { + let base_url = self + .base_url + .as_deref() + .ok_or(IpProofError::NotConfigured)?; + let url = format!("{}/v1/proof", base_url.trim_end_matches('/')); + + let (client, bound) = Self::build(source_addr)?; + debug!(%url, %payer, bound, "requesting an IP ownership proof"); + + let response = client + .post(&url) + .json(&serde_json::json!({ + "payer": payer.to_string(), + "user_type": user_type as u8, + })) + .send() + .map_err(|e| IpProofError::Unreachable { + url: url.clone(), + detail: e.to_string(), + })?; + + let status = response.status(); + let body = response.text().map_err(|e| IpProofError::Unreachable { + url: url.clone(), + detail: e.to_string(), + })?; + + if !status.is_success() { + // The service's own reason string, when it sent one. A proxy or captive portal in + // the path will not have, so fall back to the status and whatever body arrived. + return Err(match serde_json::from_str::(&body) { + Ok(err) => IpProofError::Declined { + reason: err.error, + message: err.message, + }, + Err(_) => IpProofError::Declined { + reason: format!("http_{}", status.as_u16()), + message: body.trim().chars().take(200).collect(), + }, + }); + } + + let parsed: ProofResponse = serde_json::from_str(&body) + .map_err(|e| IpProofError::Malformed(format!("{e} (body: {})", body.trim())))?; + proof_from_response(parsed) + } +} + +/// Converts the wire form into the struct the instruction carries, rejecting anything the +/// program would reject anyway. +fn proof_from_response(parsed: ProofResponse) -> Result { + let payer = Pubkey::from_str(&parsed.payer) + .map_err(|e| IpProofError::Malformed(format!("payer '{}': {e}", parsed.payer)))?; + let signature = Signature::from_str(&parsed.signature) + .map_err(|e| IpProofError::Malformed(format!("signature '{}': {e}", parsed.signature)))?; + let signature: [u8; 64] = signature.as_ref().try_into().map_err(|_| { + IpProofError::Malformed(format!("signature '{}' is not 64 bytes", parsed.signature)) + })?; + + Ok(IpOwnershipProof { + version: parsed.version, + payer, + client_ip: parsed.client_ip, + epoch: parsed.epoch, + user_type: parsed.user_type, + signature, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn response() -> ProofResponse { + // A fixed payer, so a test can rebuild the same response and compare against it. + ProofResponse { + version: 1, + payer: Pubkey::from([9u8; 32]).to_string(), + client_ip: Ipv4Addr::new(203, 0, 113, 7), + epoch: 931, + user_type: UserType::IBRL as u8, + signature: Signature::from([7u8; 64]).to_string(), + } + } + + #[test] + fn test_proof_from_response_round_trips_the_wire_form() { + let wire = response(); + let proof = proof_from_response(response()).expect("a well-formed response must parse"); + + assert_eq!(proof.version, 1); + assert_eq!(proof.payer.to_string(), wire.payer); + assert_eq!(proof.client_ip, Ipv4Addr::new(203, 0, 113, 7)); + assert_eq!(proof.epoch, 931); + assert_eq!(proof.user_type, UserType::IBRL as u8); + assert_eq!(proof.signature, [7u8; 64]); + } + + #[test] + fn test_proof_from_response_rejects_a_bad_signature() { + let err = proof_from_response(ProofResponse { + signature: "not-base58!".to_string(), + ..response() + }) + .expect_err("a signature that is not base58 must not become a proof"); + assert!(matches!(err, IpProofError::Malformed(_)), "{err}"); + } + + #[test] + fn test_proof_from_response_rejects_a_bad_payer() { + let err = proof_from_response(ProofResponse { + payer: "nope".to_string(), + ..response() + }) + .expect_err("a payer that is not a pubkey must not become a proof"); + assert!(matches!(err, IpProofError::Malformed(_)), "{err}"); + } + + /// An environment with no verifier must be distinguishable from one whose verifier is down: + /// the first is expected during rollout, the second is worth investigating. + #[test] + fn test_no_configured_url_reports_not_configured() { + let err = HttpIpProofClient::new(None) + .request_proof( + Pubkey::new_unique(), + UserType::IBRL, + Ipv4Addr::new(203, 0, 113, 7), + ) + .expect_err("an unconfigured client cannot produce a proof"); + assert!(matches!(err, IpProofError::NotConfigured), "{err}"); + } +} diff --git a/crates/doublezero-daemon-cli/src/lib.rs b/crates/doublezero-daemon-cli/src/lib.rs index 78a68eff1f..6a9f628573 100644 --- a/crates/doublezero-daemon-cli/src/lib.rs +++ b/crates/doublezero-daemon-cli/src/lib.rs @@ -11,6 +11,7 @@ pub mod disable; pub mod disconnect; pub mod enable; pub mod helpers; +pub mod ip_proof; pub mod latency; pub mod ledger; pub mod multicast;