Skip to content
Open
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
33 changes: 33 additions & 0 deletions crates/marmot-app/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::net::SocketAddr;
use std::time::Duration;

use serde::{Deserialize, Serialize};
Expand All @@ -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,
Expand Down Expand Up @@ -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<u64>,
/// 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
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 15 additions & 3 deletions crates/marmot-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1006,6 +1008,7 @@ impl MarmotApp {
pub async fn client(&self, label: &str) -> Result<AppClient, AppError> {
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
Expand Down Expand Up @@ -2966,7 +2969,16 @@ impl MarmotApp {
endpoints: &[TransportEndpoint],
) -> Arc<dyn NostrRelayClient> {
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))
}

Expand Down
63 changes: 52 additions & 11 deletions crates/marmot-app/src/relay_plane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -157,8 +194,12 @@ impl MarmotRelayPlane {
)
}

fn from_sdk(subscription_rebuild_lookback: Option<Duration>, allow_loopback: bool) -> Self {
let client = NostrSdkClient::builder().build();
fn from_sdk(
subscription_rebuild_lookback: Option<Duration>,
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(
Expand Down