diff --git a/crates/marmot-app/src/config.rs b/crates/marmot-app/src/config.rs index 4ffa63fe..8a3b7cb8 100644 --- a/crates/marmot-app/src/config.rs +++ b/crates/marmot-app/src/config.rs @@ -1,3 +1,4 @@ +use std::net::SocketAddr; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -10,6 +11,26 @@ const COMPILED_AUDIT_LOG_TRACKER_ENDPOINT: Option<&str> = const COMPILED_ENCRYPTED_MEDIA_BLOB_ENDPOINTS: Option<&str> = option_env!("MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS"); +/// How the relay plane's underlying Nostr client dials relays. +/// +/// Defaults to [`RelayConnectionMode::Direct`]. Selecting +/// [`RelayConnectionMode::Socks5`] routes all relay WebSocket traffic (and the +/// user-directory fetch client, which shares the same underlying Nostr client) +/// through a SOCKS5 proxy — e.g. a local Tor daemon's SOCKS port, or any SOCKS5 +/// forward — which is what lets a client reach relays on a network that tears +/// down direct WebSocket connections. The proxy socket is dialed by the Nostr +/// client itself and is deliberately outside the relay-host safety chokepoint +/// (`relay_plane/safety.rs`), which still validates the *relay* URL; the proxy +/// is an explicit, user-configured egress akin to a system VPN. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum RelayConnectionMode { + /// Connect to relays directly (the default). + #[default] + Direct, + /// Route all relay connections through a SOCKS5 proxy at this address. + Socks5(SocketAddr), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct MarmotAppConfig { pub directory_max_future_skew: Duration, @@ -38,6 +59,9 @@ pub struct MarmotAppConfig { /// value (spec/implementation-model.md, "Convergence Policy Overrides"). /// Test harnesses set `Some(0)` for deterministic, instant settlement. pub dev_settlement_quiescence_ms: Option, + /// How the relay plane dials relays: directly (default) or through a SOCKS5 + /// proxy. See [`RelayConnectionMode`]. + pub relay_connection: RelayConnectionMode, } /// Compiled or app-level default service URLs for production telemetry export @@ -63,6 +87,7 @@ impl Default for MarmotAppConfig { allow_loopback_blob_endpoints: false, allow_loopback_relay_endpoints: false, dev_settlement_quiescence_ms: None, + relay_connection: RelayConnectionMode::Direct, } } } @@ -101,6 +126,14 @@ impl MarmotAppConfig { self.dev_settlement_quiescence_ms = Some(ms); self } + + /// Route relay connections through a SOCKS5 proxy (e.g. a local Tor SOCKS + /// port) instead of dialing relays directly. Defaults to + /// [`RelayConnectionMode::Direct`]. + pub fn with_relay_connection(mut self, mode: RelayConnectionMode) -> Self { + self.relay_connection = mode; + self + } } impl MarmotServiceEndpoints { diff --git a/crates/marmot-app/src/lib.rs b/crates/marmot-app/src/lib.rs index c51b4e8f..b11b34a9 100644 --- a/crates/marmot-app/src/lib.rs +++ b/crates/marmot-app/src/lib.rs @@ -118,8 +118,8 @@ pub use audit_log::{ pub use client::AppClient; pub use config::{ AuditLogTrackerConfig, AuditLogUploadSource, MarmotAppConfig, MarmotServiceEndpoints, - RelayTelemetryExportConfig, RelayTelemetryResource, RelayTelemetryRuntimeConfig, - RelayTelemetrySettings, + RelayConnectionMode, RelayTelemetryExportConfig, RelayTelemetryResource, + RelayTelemetryRuntimeConfig, RelayTelemetrySettings, }; pub use directory::{ DirectoryKeyPackage, UserDirectoryLocalAccount, UserDirectoryRecord, UserDirectoryRefresh, @@ -927,6 +927,7 @@ impl MarmotApp { let relay_plane = MarmotRelayPlane::runtime_default_with_loopback( APP_RUNTIME_RELAY_REBUILD_LOOKBACK, config.allow_loopback_relay_endpoints, + &config.relay_connection, ); Self { account_home: AccountHome::open(&root), @@ -970,6 +971,7 @@ impl MarmotApp { let relay_plane = MarmotRelayPlane::runtime_default_with_loopback( APP_RUNTIME_RELAY_REBUILD_LOOKBACK, config.allow_loopback_relay_endpoints, + &config.relay_connection, ); Self { root: root.as_ref().to_path_buf(), @@ -1006,6 +1008,7 @@ impl MarmotApp { pub async fn client(&self, label: &str) -> Result { let relay_plane = MarmotRelayPlane::full_history_with_loopback( self.config.allow_loopback_relay_endpoints, + &self.config.relay_connection, ); self.client_with_relay_plane(label, &relay_plane, None) .await @@ -2966,7 +2969,16 @@ impl MarmotApp { endpoints: &[TransportEndpoint], ) -> Arc { let _ = endpoints; - let client = NostrSdkClient::builder().signer(keys.clone()).build(); + // Apply the configured relay connection (direct or SOCKS5 proxy) to the + // per-account publish client too, so account setup / relay-list / + // KeyPackage publish goes through the proxy on a censored network -- not + // just the shared relay plane. See `relay_plane::relay_client_options`. + let client = NostrSdkClient::builder() + .opts(crate::relay_plane::relay_client_options( + &self.config.relay_connection, + )) + .signer(keys.clone()) + .build(); Arc::new(NostrSdkRelayClient::new(client)) } diff --git a/crates/marmot-app/src/relay_plane/mod.rs b/crates/marmot-app/src/relay_plane/mod.rs index 664fe2ba..6db4ec79 100644 --- a/crates/marmot-app/src/relay_plane/mod.rs +++ b/crates/marmot-app/src/relay_plane/mod.rs @@ -10,8 +10,8 @@ use cgka_traits::{ TransportPublishRequest, }; use nostr_sdk::prelude::{ - Client as NostrSdkClient, Filter, Kind, PublicKey, RelayMessage, RelayPoolNotification, - RelayUrl, SubscriptionId, Timestamp as NostrTimestamp, + Client as NostrSdkClient, ClientOptions, Connection, Filter, Kind, PublicKey, RelayMessage, + RelayPoolNotification, RelayUrl, SubscriptionId, Timestamp as NostrTimestamp, }; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, broadcast, mpsc}; @@ -22,7 +22,7 @@ use transport_nostr_adapter::{ NostrTransportAdapter, RelayExportConsent, RelayLabelResolution, }; -use crate::config::RelayTelemetryExportConfig; +use crate::config::{RelayConnectionMode, RelayTelemetryExportConfig}; use transport_nostr_peeler::NostrTransportEvent; use crate::directory::DirectorySyncPlan; @@ -112,9 +112,38 @@ pub struct RelayPlaneHealth { pub directory_subscriptions_removed: usize, } +/// The `nostr-sdk` client options for a given [`RelayConnectionMode`] — default +/// options for `Direct`, or a SOCKS5 proxy connection for `Socks5`. Shared by +/// every place MDK builds a relay-facing client so the configured proxy is +/// applied uniformly: the relay plane here, and the per-account publish client +/// in `lib.rs` (`relay_client_for_endpoints`). Missing either one leaves a +/// connection dialing directly. +pub(crate) fn relay_client_options(connection: &RelayConnectionMode) -> ClientOptions { + match connection { + RelayConnectionMode::Direct => ClientOptions::new(), + RelayConnectionMode::Socks5(addr) => { + ClientOptions::new().connection(Connection::new().proxy(*addr)) + } + } +} + +/// Build the relay plane's underlying Nostr client, applying the configured +/// [`RelayConnectionMode`] (direct, or a SOCKS5 proxy) to its options. The same +/// client backs both the relay transport and the user-directory fetcher, so a +/// proxy set here routes every relay connection this plane makes. +fn build_sdk_client(connection: &RelayConnectionMode) -> NostrSdkClient { + NostrSdkClient::builder() + .opts(relay_client_options(connection)) + .build() +} + impl MarmotRelayPlane { pub fn runtime_default(subscription_rebuild_lookback: Duration) -> Self { - Self::from_sdk(Some(subscription_rebuild_lookback), false) + Self::from_sdk( + Some(subscription_rebuild_lookback), + false, + &RelayConnectionMode::Direct, + ) } /// Production runtime plane whose relay-safety chokepoint admits loopback @@ -123,23 +152,31 @@ impl MarmotRelayPlane { pub fn runtime_default_with_loopback( subscription_rebuild_lookback: Duration, allow_loopback: bool, + connection: &RelayConnectionMode, ) -> Self { - Self::from_sdk(Some(subscription_rebuild_lookback), allow_loopback) + Self::from_sdk( + Some(subscription_rebuild_lookback), + allow_loopback, + connection, + ) } pub fn full_history() -> Self { - Self::from_sdk(None, false) + Self::from_sdk(None, false, &RelayConnectionMode::Direct) } /// Full-history plane whose relay-safety chokepoint admits loopback /// endpoints only when `allow_loopback` is set /// (`MarmotAppConfig::allow_loopback_relay_endpoints`, off by default). - pub fn full_history_with_loopback(allow_loopback: bool) -> Self { - Self::from_sdk(None, allow_loopback) + pub fn full_history_with_loopback( + allow_loopback: bool, + connection: &RelayConnectionMode, + ) -> Self { + Self::from_sdk(None, allow_loopback, connection) } pub fn with_subscription_rebuild_lookback(lookback: Duration) -> Self { - Self::from_sdk(Some(lookback), false) + Self::from_sdk(Some(lookback), false, &RelayConnectionMode::Direct) } pub fn new( @@ -157,8 +194,12 @@ impl MarmotRelayPlane { ) } - fn from_sdk(subscription_rebuild_lookback: Option, allow_loopback: bool) -> Self { - let client = NostrSdkClient::builder().build(); + fn from_sdk( + subscription_rebuild_lookback: Option, + allow_loopback: bool, + connection: &RelayConnectionMode, + ) -> Self { + let client = build_sdk_client(connection); let relay_client = NostrSdkRelayClient::new(client.clone()); let adapter = NostrTransportAdapter::new(Arc::new(relay_client.clone())); Self::from_adapter(