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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions crates/core/src/runloops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
};

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
106 changes: 101 additions & 5 deletions crates/core/src/surfnet/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ impl SurfpoolRpcClient {

/// A variant that accepts invalid TLS certificates, for datasources
/// behind self-signed certs.
fn new_unsafe<U: ToString>(remote_rpc_url: U) -> Option<Self> {
///
/// 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<U: ToString>(remote_rpc_url: U) -> Option<Self> {
use reqwest;

// Construction can fail after a fork (the daemonize path), so a
Expand All @@ -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"),
}
}
}

Expand All @@ -174,15 +193,33 @@ impl SurfnetRemoteClient {
pub fn new<U: ToString>(remote_rpc_url: U) -> Self {
SurfnetRemoteClient {
client: SurfpoolRpcClient::new(remote_rpc_url).client,
tls: DatasourceTls::Verified,
}
}

pub fn new_unsafe<U: ToString>(remote_rpc_url: U) -> Option<Self> {
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<U: ToString>(remote_rpc_url: U) -> Option<Self> {
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<U: ToString>(
remote_rpc_url: U,
allow_insecure_remote_tls: bool,
) -> Option<Self> {
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<EpochInfo> {
self.client.get_epoch_info().await.map_err(Into::into)
}
Expand Down Expand Up @@ -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());
}
}
50 changes: 47 additions & 3 deletions crates/mcp/src/surfpool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1136,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))
Expand Down
68 changes: 57 additions & 11 deletions crates/mcp/src/surfpool/start_surfnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
Expand All @@ -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(|| {
Expand Down Expand Up @@ -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: {:?}",
Expand All @@ -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"));
}
}
Loading
Loading