diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 14cb1933..de885620 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 f4366adc..7f062df0 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 3b50bd9c..e8882ebf 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 ae86afd2..403621da 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.