From b8098aea60937cdf47f3b5bda03f7a9746ef5d10 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Tue, 18 Aug 2026 08:43:47 -0600 Subject: [PATCH 1/4] fix(cli,core,types): verify datasource TLS by default The client used to fetch fork/datasource data disabled certificate verification on every connection, including the default public mainnet endpoint, and `impl Clone` rebuilt it through that same path on each request. Both runloop call sites now go through a single constructor that verifies by default. The self-signed case is preserved as an explicit opt-in, `--allow-insecure-remote-tls`, carried on `SimnetConfig` with `#[serde(default)]` so existing configs mean verified. The client records its TLS posture so a clone reproduces it rather than re-deriving the insecure one. Refs #757 --- crates/cli/src/cli/mod.rs | 9 +++ crates/core/src/runloops/mod.rs | 9 ++- crates/core/src/surfnet/remote.rs | 106 ++++++++++++++++++++++++++++-- crates/types/src/types.rs | 26 ++++++++ 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 14cb19337..de8856204 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -284,6 +284,14 @@ pub struct StartNetworkOptions { long_help = "Run without a remote RPC datasource. Use this to simulate an offline environment.\n\nExample: surfpool start --offline" )] pub offline: bool, + /// Accept the datasource's TLS certificate without verifying it. + #[clap( + long = "allow-insecure-remote-tls", + action=ArgAction::SetTrue, + default_value = "false", + long_help = "Accept the datasource's TLS certificate without verifying it. Use this only for a datasource behind a self-signed cert.\n\nAnyone able to intercept the connection can then impersonate the datasource, and the accounts it serves are written into the local bank, executable program bytecode included.\n\nExample: surfpool start --rpc-url https://my-datasource.internal --allow-insecure-remote-tls" + )] + pub allow_insecure_remote_tls: bool, } #[derive(Args, PartialEq, Clone, Debug)] @@ -672,6 +680,7 @@ impl StartSimnet { SimnetConfig { remote_rpc_url, + allow_insecure_remote_tls: self.network.allow_insecure_remote_tls, slot_time: self.svm.slot_time, block_production_mode: self.svm.block_production_mode.clone(), airdrop_addresses, diff --git a/crates/core/src/runloops/mod.rs b/crates/core/src/runloops/mod.rs index f4366adcc..7f062df0f 100644 --- a/crates/core/src/runloops/mod.rs +++ b/crates/core/src/runloops/mod.rs @@ -186,11 +186,12 @@ pub async fn start_local_surfnet_runloop( let remote_rpc_client = match simnet.offline_mode { true => None, - false => SurfnetRemoteClient::new_unsafe( + false => SurfnetRemoteClient::for_datasource( simnet .remote_rpc_url .as_ref() .unwrap_or(&DEFAULT_MAINNET_RPC_URL.to_string()), + simnet.allow_insecure_remote_tls, ), }; @@ -372,6 +373,7 @@ pub async fn start_block_production_runloop( expiry_duration_ms.map(|expiry_val| Utc::now().timestamp_millis() as u64 + expiry_val); let global_skip_sig_verify = simnet_config.skip_signature_verification; let ix_profiling_initially_enabled = simnet_config.instruction_profiling_enabled; + let allow_insecure_remote_tls = simnet_config.allow_insecure_remote_tls; loop { let mut do_produce_block = false; @@ -567,7 +569,10 @@ pub async fn start_block_production_runloop( SimnetCommand::FetchRemoteAccounts(pubkeys, remote_url) => { // The submitter already marked RemoteAccounts as started; // StartStartupTask precedes this command on the same channel. - let fetch_result = match SurfnetRemoteClient::new_unsafe(&remote_url) { + let fetch_result = match SurfnetRemoteClient::for_datasource( + &remote_url, + allow_insecure_remote_tls, + ) { Some(remote_client) => match svm_locker .get_multiple_accounts_with_remote_fallback( &remote_client, diff --git a/crates/core/src/surfnet/remote.rs b/crates/core/src/surfnet/remote.rs index 3b50bd9c3..e8882ebf5 100644 --- a/crates/core/src/surfnet/remote.rs +++ b/crates/core/src/surfnet/remote.rs @@ -121,7 +121,11 @@ impl SurfpoolRpcClient { /// A variant that accepts invalid TLS certificates, for datasources /// behind self-signed certs. - fn new_unsafe(remote_rpc_url: U) -> Option { + /// + /// Reached only where the operator asked for it. Certificate verification + /// is what establishes that the datasource on the other end is the one that + /// was asked for, so a client built here trusts whoever answers. + fn new_insecure(remote_rpc_url: U) -> Option { use reqwest; // Construction can fail after a fork (the daemonize path), so a @@ -148,14 +152,29 @@ impl SurfpoolRpcClient { } } +/// Whether the datasource's TLS certificate gets verified. +/// +/// Recorded on the client because [`Clone`] has nothing but `&self` to rebuild +/// from: a posture that is not stored is a posture a clone cannot keep. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DatasourceTls { + Verified, + /// The operator opted in, per [`SurfpoolRpcClient::new_insecure`]. + Insecure, +} + pub struct SurfnetRemoteClient { pub client: RpcClient, + tls: DatasourceTls, } impl Clone for SurfnetRemoteClient { fn clone(&self) -> Self { let remote_rpc_url = self.client.url(); - SurfnetRemoteClient::new_unsafe(remote_rpc_url) - .expect("unable to clone SurfnetRemoteClient") + match self.tls { + DatasourceTls::Verified => SurfnetRemoteClient::new(remote_rpc_url), + DatasourceTls::Insecure => SurfnetRemoteClient::new_insecure(remote_rpc_url) + .expect("unable to clone SurfnetRemoteClient"), + } } } @@ -174,15 +193,33 @@ impl SurfnetRemoteClient { pub fn new(remote_rpc_url: U) -> Self { SurfnetRemoteClient { client: SurfpoolRpcClient::new(remote_rpc_url).client, + tls: DatasourceTls::Verified, } } - pub fn new_unsafe(remote_rpc_url: U) -> Option { - SurfpoolRpcClient::new_unsafe(remote_rpc_url).map(|rpc_client| SurfnetRemoteClient { + /// Accepts the datasource's certificate without verifying it. Call only + /// where the operator opted in; [`Self::for_datasource`] is that decision. + pub fn new_insecure(remote_rpc_url: U) -> Option { + SurfpoolRpcClient::new_insecure(remote_rpc_url).map(|rpc_client| SurfnetRemoteClient { client: rpc_client.client, + tls: DatasourceTls::Insecure, }) } + /// The client a running surfnet reaches its datasource through. + /// + /// One place decides the posture, so the two runloop paths that build a + /// datasource client cannot drift apart on it. + pub fn for_datasource( + remote_rpc_url: U, + allow_insecure_remote_tls: bool, + ) -> Option { + match allow_insecure_remote_tls { + false => Some(Self::new(remote_rpc_url)), + true => Self::new_insecure(remote_rpc_url), + } + } + pub async fn get_epoch_info(&self) -> SurfpoolResult { self.client.get_epoch_info().await.map_err(Into::into) } @@ -656,4 +693,63 @@ mod tests { "the failure should identify the request: {message}" ); } + + const DATASOURCE: &str = "https://api.mainnet-beta.solana.com"; + + /// The default datasource, the public mainnet endpoint included, is reached + /// over a connection whose certificate is checked. Accepting an unverified + /// certificate is a decision the operator makes, not the default. + #[test] + fn the_default_datasource_client_verifies_certificates() { + assert_eq!( + SurfnetRemoteClient::new(DATASOURCE).tls, + DatasourceTls::Verified + ); + assert_eq!( + SurfnetRemoteClient::for_datasource(DATASOURCE, false) + .expect("the verified client is infallible") + .tls, + DatasourceTls::Verified + ); + } + + /// A datasource behind a self-signed cert stays reachable, by asking. + #[test] + fn accepting_an_invalid_certificate_remains_available_as_an_opt_in() { + assert_eq!( + SurfnetRemoteClient::for_datasource(DATASOURCE, true) + .expect("the opt-in client should build") + .tls, + DatasourceTls::Insecure + ); + } + + /// Every datasource read clones the client, so a clone that rebuilt itself + /// through the unverified constructor downgraded every request made after + /// the first regardless of what the operator asked for. + #[test] + fn a_clone_does_not_downgrade_a_verified_client() { + let client = SurfnetRemoteClient::new(DATASOURCE); + + assert_eq!(client.clone().tls, DatasourceTls::Verified); + assert_eq!(client.clone().clone().tls, DatasourceTls::Verified); + } + + /// The other direction of the same requirement: a clone preserves the + /// posture it was given, so opting in survives being cloned. + #[test] + fn a_clone_preserves_an_opted_in_insecure_client() { + let client = SurfnetRemoteClient::for_datasource(DATASOURCE, true) + .expect("the opt-in client should build"); + + assert_eq!(client.clone().tls, DatasourceTls::Insecure); + } + + /// The URL survives the posture-preserving clone. + #[test] + fn a_clone_still_points_at_the_same_datasource() { + let client = SurfnetRemoteClient::new(DATASOURCE); + + assert_eq!(client.clone().client.url(), client.client.url()); + } } diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index ae86afd2d..403621da0 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -845,6 +845,14 @@ pub enum StartupPlanner { pub struct SimnetConfig { pub offline_mode: bool, pub remote_rpc_url: Option, + /// Accept the datasource's TLS certificate without verifying it, for a + /// datasource behind a self-signed cert. + /// + /// Off unless the operator asks for it. An unverified datasource can be + /// impersonated by anyone on the path to it, and the accounts it serves are + /// written into the local bank, executable bytecode included. + #[serde(default)] + pub allow_insecure_remote_tls: bool, pub slot_time: u64, pub block_production_mode: BlockProductionMode, pub airdrop_addresses: Vec, @@ -869,6 +877,7 @@ impl Default for SimnetConfig { Self { offline_mode: false, remote_rpc_url: Some(DEFAULT_MAINNET_RPC_URL.to_string()), + allow_insecure_remote_tls: false, slot_time: DEFAULT_SLOT_TIME_MS, // Default to 400ms to match CLI default block_production_mode: BlockProductionMode::Clock, airdrop_addresses: vec![], @@ -2297,6 +2306,23 @@ mod tests { assert!(!config.skip_blockhash_check); } + // The datasource's certificate is verified unless the operator asks + // otherwise, and a config written before the field existed asks for + // nothing. + #[test] + fn test_simnet_config_allow_insecure_remote_tls_defaults_off() { + assert!(!SimnetConfig::default().allow_insecure_remote_tls); + + let mut config_json = serde_json::to_value(SimnetConfig::default()).unwrap(); + config_json + .as_object_mut() + .unwrap() + .remove("allow_insecure_remote_tls"); + + let config: SimnetConfig = serde_json::from_value(config_json).unwrap(); + assert!(!config.allow_insecure_remote_tls); + } + // Configs written before the startup planner existed must keep working; // the runloop-seals default is also the correct reading of them, since // none of their writers seal a plan. From 5e9ad86f09602cb113e377c92b6a557dc84572a7 Mon Sep 17 00:00:00 2001 From: scratch Date: Tue, 18 Aug 2026 16:32:52 -0600 Subject: [PATCH 2/4] expose allow_insecure_remote_tls on the SDK builder and the MCP start_surfnet tool --- crates/mcp/src/surfpool/mod.rs | 26 +++++++- crates/mcp/src/surfpool/start_surfnet.rs | 68 +++++++++++++++---- crates/sdk/src/surfnet.rs | 84 +++++++++++++++++------- 3 files changed, 140 insertions(+), 38 deletions(-) diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index b7568cc95..2a4a8f226 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -30,6 +30,14 @@ pub struct StartSurfnetParams { description = "If `false` (default), returns a command for the AI to execute. If `true`, starts surfnet directly as a background process." )] pub run_as_subprocess: bool, + /// Omitted by every client that predates this field, which is why it + /// defaults rather than being required: a caller who says nothing keeps + /// certificate verification. + #[serde(default)] + #[schemars( + description = "If `true`, accept the datasource's TLS certificate without verifying it, for a datasource behind a self-signed cert. Defaults to `false`. Anyone able to intercept the connection can then impersonate the datasource, and the accounts it serves are written into the local bank, executable program bytecode included." + )] + pub allow_insecure_remote_tls: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -368,8 +376,18 @@ impl Surfpool { }; let res = match params.run_as_subprocess { - true => start_surfnet::run_headless(surfnet_id, port, port.saturating_sub(9)), - false => start_surfnet::run_command(surfnet_id, port, port.saturating_sub(9)), + true => start_surfnet::run_headless( + surfnet_id, + port, + port.saturating_sub(9), + params.allow_insecure_remote_tls, + ), + false => start_surfnet::run_command( + surfnet_id, + port, + port.saturating_sub(9), + params.allow_insecure_remote_tls, + ), }; // Keep track of the surfnet instance in the registry @@ -447,7 +465,9 @@ impl Surfpool { } }; - let start_response = start_surfnet::run_headless(surfnet_id, port, port.saturating_sub(9)); + // This tool exposes no TLS parameter, so it keeps certificate verification. + let start_response = + start_surfnet::run_headless(surfnet_id, port, port.saturating_sub(9), false); let surfnet_url = match start_response.success { Some(ref success_data) => { diff --git a/crates/mcp/src/surfpool/start_surfnet.rs b/crates/mcp/src/surfpool/start_surfnet.rs index e0b8fb8f8..2e0759552 100644 --- a/crates/mcp/src/surfpool/start_surfnet.rs +++ b/crates/mcp/src/surfpool/start_surfnet.rs @@ -56,17 +56,25 @@ impl StartSurfnetResponse { } } -pub fn generate_command(rpc_port: u16, ws_port: u16) -> Command { +pub fn generate_command(rpc_port: u16, ws_port: u16, allow_insecure_remote_tls: bool) -> Command { let mut cmd = Command::new("surfpool"); cmd.arg("start"); cmd.arg("--port").arg(format!("{}", rpc_port)); cmd.arg("--ws-port").arg(format!("{}", ws_port)); cmd.arg("--no-deploy"); + if allow_insecure_remote_tls { + cmd.arg("--allow-insecure-remote-tls"); + } cmd } -pub fn run_command(surfnet_id: u16, rpc_port: u16, ws_port: u16) -> StartSurfnetResponse { - let command = generate_command(rpc_port, ws_port); +pub fn run_command( + surfnet_id: u16, + rpc_port: u16, + ws_port: u16, + allow_insecure_remote_tls: bool, +) -> StartSurfnetResponse { + let command = generate_command(rpc_port, ws_port, allow_insecure_remote_tls); let surfnet_url = format!("http://127.0.0.1:{}", rpc_port); StartSurfnetResponse::success(StartSurfnetSuccess { @@ -76,7 +84,23 @@ pub fn run_command(surfnet_id: u16, rpc_port: u16, ws_port: u16) -> StartSurfnet }) } -pub fn run_headless(surfnet_id: u16, rpc_port: u16, ws_port: u16) -> StartSurfnetResponse { +/// The [`SimnetConfig`] a headless surfnet starts with. Split out of +/// `run_headless` so the tool's parameters can be checked against the config the +/// runloop actually receives, without binding ports and starting a surfnet. +fn simnet_config(allow_insecure_remote_tls: bool) -> SimnetConfig { + SimnetConfig { + expiry: Some(15 * 60 * 1000), + allow_insecure_remote_tls, + ..Default::default() + } +} + +pub fn run_headless( + surfnet_id: u16, + rpc_port: u16, + ws_port: u16, + allow_insecure_remote_tls: bool, +) -> StartSurfnetResponse { let (surfnet_svm, simnet_events_rx, geyser_events_rx) = SurfnetSvm::default(); let (simnet_commands_tx, simnet_commands_rx) = crossbeam_channel::unbounded(); @@ -90,12 +114,7 @@ pub fn run_headless(surfnet_id: u16, rpc_port: u16, ws_port: u16) -> StartSurfne config.rpc.bind_port = rpc_port; config.rpc.ws_port = ws_port; - let simnet_config = SimnetConfig { - expiry: Some(15 * 60 * 1000), - ..Default::default() - }; - - config.simnets = vec![simnet_config]; + config.simnets = vec![simnet_config(allow_insecure_remote_tls)]; let handle = hiro_system_kit::thread_named("surfnet").spawn(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -255,7 +274,7 @@ mod tests { let rpc_port = free_port(); let ws_port = free_port(); - let response = run_headless(1, rpc_port, ws_port); + let response = run_headless(1, rpc_port, ws_port, false); assert!( response.error.is_none(), "surfnet failed to start: {:?}", @@ -279,4 +298,31 @@ mod tests { "no compat entry should remain once startup is ready: {info}" ); } + + // The runloop reads allow_insecure_remote_tls off the SimnetConfig it is + // handed, so the tool parameter alone proves nothing: these assert the + // value in the config that reaches the runloop. + #[test] + fn headless_simnet_config_verifies_tls_by_default() { + assert!(!simnet_config(false).allow_insecure_remote_tls); + } + + #[test] + fn headless_simnet_config_carries_the_tls_opt_in() { + assert!(simnet_config(true).allow_insecure_remote_tls); + } + + #[test] + fn generated_command_omits_the_tls_flag_unless_asked() { + let cmd = generate_command(8899, 8900, false); + let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect(); + assert!(!args.iter().any(|a| a == "--allow-insecure-remote-tls")); + } + + #[test] + fn generated_command_carries_the_tls_flag_when_asked() { + let cmd = generate_command(8899, 8900, true); + let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect(); + assert!(args.iter().any(|a| a == "--allow-insecure-remote-tls")); + } } diff --git a/crates/sdk/src/surfnet.rs b/crates/sdk/src/surfnet.rs index b380ea798..6ab6b2e40 100644 --- a/crates/sdk/src/surfnet.rs +++ b/crates/sdk/src/surfnet.rs @@ -43,6 +43,7 @@ use crate::{ pub struct SurfnetBuilder { offline_mode: bool, remote_rpc_url: Option, + allow_insecure_remote_tls: bool, block_production_mode: BlockProductionMode, slot_time_ms: u64, airdrop_addresses: Vec, @@ -57,6 +58,7 @@ impl Default for SurfnetBuilder { Self { offline_mode: true, remote_rpc_url: None, + allow_insecure_remote_tls: false, block_production_mode: BlockProductionMode::Transaction, slot_time_ms: 1, airdrop_addresses: vec![], @@ -82,6 +84,18 @@ impl SurfnetBuilder { self } + /// Accept the datasource's TLS certificate without verifying it, for a + /// datasource behind a self-signed cert. Mirrors the CLI's + /// `--allow-insecure-remote-tls` flag. Default: `false`. + /// + /// Anyone able to intercept the connection can then impersonate the + /// datasource, and the accounts it serves are written into the local bank, + /// executable program bytecode included. + pub fn allow_insecure_remote_tls(mut self, allow: bool) -> Self { + self.allow_insecure_remote_tls = allow; + self + } + /// How blocks are produced. Default: `Transaction` (advance on each tx). pub fn block_production_mode(mut self, mode: BlockProductionMode) -> Self { self.block_production_mode = mode; @@ -137,44 +151,49 @@ impl SurfnetBuilder { self } - /// Start the surfnet with the configured options. - pub async fn start(self) -> SurfnetResult { - let SurfnetBuilder { - offline_mode, - remote_rpc_url, - block_production_mode, - slot_time_ms, + /// The [`SimnetConfig`] this builder hands to the runloop. Split out of + /// `start` so a setter can be checked against the config the runloop + /// actually receives, without binding ports and starting a surfnet. + fn simnet_config(&self, airdrop_addresses: Vec) -> SimnetConfig { + SimnetConfig { + offline_mode: self.offline_mode, + remote_rpc_url: self.remote_rpc_url.clone(), + allow_insecure_remote_tls: self.allow_insecure_remote_tls, + slot_time: self.slot_time_ms, + block_production_mode: self.block_production_mode.clone(), airdrop_addresses, - airdrop_lamports, - skip_blockhash_check, - payer, - feature_config, - } = self; - let payer = payer.unwrap_or_else(Keypair::new); + airdrop_token_amount: self.airdrop_lamports, + skip_blockhash_check: self.skip_blockhash_check, + ..Default::default() + } + } + + /// Start the surfnet with the configured options. + pub async fn start(mut self) -> SurfnetResult { + let payer = self.payer.take().unwrap_or_else(Keypair::new); let bind_port = get_free_port()?; let ws_port = get_free_port()?; let bind_host = "127.0.0.1".to_string(); let mut startup_airdrop_addresses = vec![payer.pubkey()]; - startup_airdrop_addresses.extend(airdrop_addresses); + startup_airdrop_addresses.append(&mut self.airdrop_addresses); let startup_airdrop_addresses_for_rpc = startup_airdrop_addresses.clone(); + let simnet_config = self.simnet_config(startup_airdrop_addresses); + let SurfnetBuilder { + airdrop_lamports, + skip_blockhash_check, + feature_config, + .. + } = self; + // The default StartupPlanner::Runloop makes the runloop seal an empty // startup plan before announcing Ready, so wait_for_ready below // implies a publicly ready surfnet (getSurfnetInfo reports phase // ready with no pending compat entry). let surfpool_config = SurfpoolConfig { - simnets: vec![SimnetConfig { - offline_mode, - remote_rpc_url, - slot_time: slot_time_ms, - block_production_mode, - airdrop_addresses: startup_airdrop_addresses, - airdrop_token_amount: airdrop_lamports, - skip_blockhash_check, - ..Default::default() - }], + simnets: vec![simnet_config], rpc: RpcConfig { bind_host: bind_host.clone(), bind_port, @@ -468,4 +487,21 @@ mod tests { let builder = SurfnetBuilder::default().skip_blockhash_check(true); assert!(builder.skip_blockhash_check); } + + // The runloop reads allow_insecure_remote_tls off the SimnetConfig it is + // handed, so the builder field alone proves nothing: these assert the value + // in the config that reaches the runloop. + #[test] + fn surfnet_builder_allow_insecure_remote_tls_defaults_to_false_in_simnet_config() { + let config = SurfnetBuilder::default().simnet_config(vec![]); + assert!(!config.allow_insecure_remote_tls); + } + + #[test] + fn surfnet_builder_allow_insecure_remote_tls_reaches_simnet_config() { + let config = SurfnetBuilder::default() + .allow_insecure_remote_tls(true) + .simnet_config(vec![]); + assert!(config.allow_insecure_remote_tls); + } } From ab14d4c5d82510deaabb88c516845b435eafa2a6 Mon Sep 17 00:00:00 2001 From: scratch Date: Tue, 18 Aug 2026 16:36:22 -0600 Subject: [PATCH 3/4] test: the start_surfnet tool advertises the TLS opt-in in its schema --- crates/mcp/src/surfpool/mod.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 2a4a8f226..ffcef5152 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -1156,6 +1156,30 @@ impl ServerHandler for Surfpool { mod tests { use super::*; + // The tool's advertised schema is the only thing an MCP client can see, so + // this is what "the entrypoint exposes the opt-in" actually means. + #[test] + fn start_surfnet_tool_exposes_the_tls_opt_in() { + let schema = serde_json::to_value(schemars::schema_for!(StartSurfnetParams)) + .expect("schema serializes"); + let properties = schema["properties"] + .as_object() + .expect("schema has properties"); + assert!( + properties.contains_key("allow_insecure_remote_tls"), + "{properties:?}" + ); + // Not in `required`: a client that predates the field still validates. + assert!( + !schema["required"] + .as_array() + .expect("schema has required") + .iter() + .any(|f| f == "allow_insecure_remote_tls"), + "{schema}" + ); + } + #[test] fn search_takes_its_template_id_the_way_the_response_names_it() { let schema = serde_json::to_value(schemars::schema_for!(SearchConstantOptionsParams)) From 12a4502c97f145732ac523754de0630a041ce45d Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Tue, 18 Aug 2026 18:02:17 -0600 Subject: [PATCH 4/4] expose allow_insecure_remote_tls on the Node SDK's SurfnetConfig A JavaScript caller selecting a self-signed HTTPS datasource could not reach the opt-in: SurfnetConfig had no field for it, so the runloop received the default false and rejected a datasource that worked before TLS verification was turned on. Left unset the builder default (false) still applies, so verification stays on by default. --- crates/sdk-node/src/lib.rs | 7 +++++++ .../sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/crates/sdk-node/src/lib.rs b/crates/sdk-node/src/lib.rs index b4390e504..046d4f69f 100644 --- a/crates/sdk-node/src/lib.rs +++ b/crates/sdk-node/src/lib.rs @@ -74,6 +74,9 @@ impl Surfnet { if let Some(url) = config.remote_rpc_url { builder = builder.remote_rpc_url(url); } + if let Some(allow) = config.allow_insecure_remote_tls { + builder = builder.allow_insecure_remote_tls(allow); + } if let Some(mode) = config.block_production_mode.as_deref() { let mode = mode.parse::().map_err(|e| { Error::new( @@ -504,6 +507,10 @@ impl Surfnet { pub struct SurfnetConfig { pub offline: Option, pub remote_rpc_url: Option, + /// Accept the datasource's TLS certificate without verifying it, for a + /// datasource behind a self-signed cert. Default: `false`. Anyone able to + /// intercept the connection can then impersonate the datasource. + pub allow_insecure_remote_tls: Option, pub block_production_mode: Option, pub slot_time_ms: Option, pub airdrop_sol: Option, diff --git a/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts b/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts index a8d1ba95b..830e197ef 100644 --- a/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts +++ b/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts @@ -25,6 +25,8 @@ void surfpool({ surfnet: { offline: true } }); void surfpool({ offline: true }); // @ts-expect-error unknown RPC option. void surfpool({ maxConcurrencyy: 2 }); +// The self-signed-datasource TLS opt-in is reachable from a JS caller. +void surfpool({ surfnet: { allowInsecureRemoteTls: true } }); // Attach mode: requires an existing payer; no `surfnet` handle or config. void (async () => {