1
0
forked from GRIN/grim

Build 65: link the Nym SDK in-process — no sidecar subprocess

Goblin now links nym-sdk directly and runs its SOCKS5 client on an
internal tokio runtime exposing 127.0.0.1:1080 — the same loopback seam
the transport already dials. There is no sidecar subprocess and no
bundled/embedded/sideloaded helper binary; the goblin process itself owns
:1080. This mirrors how GRIM links arti/Tor in-process. Verified live: the
mixnet comes up in ~1.4-2s (gateway persisted in ~/.goblin/nym, reused
across launches) and a relay connects in ~2s over it, with no separate
process.

- Cargo.toml: add nym-sdk (path dep on the local nym checkout, which carries
  the Android webpki-roots patch) + rustls with the ring feature.
- src/lib.rs: install rustls' ring CryptoProvider at startup. Linking nym-sdk
  pulls aws-lc-rs alongside our ring; with two providers present rustls 0.23
  won't auto-pick a default and tokio-tungstenite/reqwest panic on the first
  TLS handshake. nym uses its own explicit provider, so this only steers our
  relay/HTTP TLS.
- src/nym/sidecar.rs: replace the subprocess machinery with an in-process
  Socks5MixnetClient (persistent storage; ephemeral fallback) kept alive for
  the process lifetime on a dedicated runtime. Drops the binary lookup, embed
  extraction, init/launch, and child management.
- build.rs: drop the GOBLIN_NYM_*_BIN embed block (nothing to embed).
- src/nym/{mod,transport}.rs, src/nostr/{mod,avatar}.rs: docs now describe the
  in-process client; clear stray "old Tor/arti" wording (no Tor transport code
  remains — only grin-core's slatepack OnionV3Address, which is unrelated).

Also: named users now get the pubkey-seeded gradient background with their
initial composited on top (instead of the Grin mark) — gui identicon.rs gains
gradient_bg_svg and widgets.rs gains gradient_letter_avatar; avatar_any routes
named keys to it. Verified live with @nymgoblin.
This commit is contained in:
2ro
2026-06-14 03:46:02 -04:00
parent 8c48d2f5ce
commit 63d5ca2b5f
11 changed files with 5380 additions and 369 deletions
Generated
+5190 -90
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -113,6 +113,18 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
## SOCKS5 TCP dialer for the nostr relay WebSocket transport over the mixnet.
tokio-socks = "0.5"
## rustls is pulled by both our TLS (tungstenite/reqwest, ring) and nym-sdk
## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default,
## so we install ring explicitly at startup (see lib.rs). Direct dep just to make
## `rustls::crypto::ring::default_provider()` reachable.
rustls = { version = "0.23", features = ["ring"] }
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary). We
## run the SDK's SOCKS5 client on an internal tokio task exposing 127.0.0.1:1080,
## the same loopback seam the transport already dials. Path dep: the local nym
## checkout carries our Android webpki-roots patch.
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
## NIP-98 payload hashing
sha2 = "0.10.8"
+3 -30
View File
@@ -56,34 +56,7 @@ fn main() {
.expect("failed to execute git config for hooks");
}
// Goblin routes all traffic over the Nym mixnet via a bundled
// `nym-socks5-client` sidecar (see src/nym/); there is no embedded Tor and
// thus no webtunnel pluggable-transport binary to build here anymore.
// Single-file desktop builds: embed the matching nym-socks5-client into the
// goblin binary so the release ships as ONE self-contained file with no loose
// sidecar beside it. At startup the app extracts it to a per-user data dir and
// runs it (src/nym/sidecar.rs). Windows reads GOBLIN_NYM_WIN_BIN (a .exe);
// Linux/macOS read GOBLIN_NYM_UNIX_BIN. Android does NOT embed — its sidecar
// rides in the APK's jniLibs (the only exec-allowed location).
println!("cargo:rerun-if-env-changed=GOBLIN_NYM_WIN_BIN");
println!("cargo:rerun-if-env-changed=GOBLIN_NYM_UNIX_BIN");
println!("cargo:rustc-check-cfg=cfg(goblin_embed_nym)");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let embed = match target_os.as_str() {
"windows" => Some(("GOBLIN_NYM_WIN_BIN", "nym-socks5-client.exe")),
"android" => None,
_ => Some(("GOBLIN_NYM_UNIX_BIN", "nym-socks5-client")),
};
if let Some((var, out_name)) = embed {
if let Ok(src) = env::var(var) {
if !src.is_empty() {
let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join(out_name);
std::fs::copy(&src, &out)
.unwrap_or_else(|e| panic!("copy {var} into OUT_DIR for embedding: {e}"));
println!("cargo:rustc-cfg=goblin_embed_nym");
println!("cargo:rerun-if-changed={}", src);
}
}
}
// Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar
// subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There
// is nothing transport-related to build or embed here.
}
+25 -5
View File
@@ -64,11 +64,11 @@ pub fn to_hex_seed(id: &str) -> String {
}
}
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
/// are inlined into ONE html document; for a standalone document (how egui
/// rasterizes each one) `""` is fine.
pub fn gradient_avatar_svg(hex: &str, size: u32, id_suffix: &str) -> String {
/// Gradient stop colors (`#rrggbb`) + rotation angle derived from the seed `hex`.
/// Shared by the Grin-mark avatar and the bare-background variant so both draw
/// the byte-identical gradient for one key. Keep this math in lockstep with the
/// shared reference port.
fn gradient_params(hex: &str) -> (String, String, f64) {
let hash = Sha256::digest(hex.as_bytes());
let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0;
let offset = 40.0 + (hash[2] as f64 / 255.0) * 120.0;
@@ -76,6 +76,26 @@ pub fn gradient_avatar_svg(hex: &str, size: u32, id_suffix: &str) -> String {
let angle = (hash[3] as f64 / 255.0) * 360.0;
let c1 = hsl_to_rgb(base, 0.62, 0.55);
let c2 = hsl_to_rgb(h2, 0.62, 0.42);
(c1, c2, angle)
}
/// The seeded two-tone gradient WITHOUT the Grin mark — a bare background tile.
/// Used for **named** users, where the app paints the person's initial on top
/// (see `widgets::gradient_letter_avatar`) instead of the Grin mark. Same seed →
/// same background as the anonymous gradient avatar, so one key reads consistently.
pub fn gradient_bg_svg(hex: &str, size: u32) -> String {
let (c1, c2, angle) = gradient_params(hex);
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g)"/></svg>"##
)
}
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
/// are inlined into ONE html document; for a standalone document (how egui
/// rasterizes each one) `""` is fine.
pub fn gradient_avatar_svg(hex: &str, size: u32, id_suffix: &str) -> String {
let (c1, c2, angle) = gradient_params(hex);
let target = size as f64 * LOGO_FRAC;
let scale = target / GRIN_NATIVE;
+47 -3
View File
@@ -80,9 +80,52 @@ pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response {
resp
}
/// Picture avatar when a texture exists; otherwise the deterministic gradient
/// avatar for an anonymous key (display name is an `npub…`), or a lettered tile
/// for a named contact/@handle. `id` is the npub/hex used to seed the gradient.
/// A named user's avatar: the same pubkey-seeded gradient background as
/// [`gradient_avatar`], but with the person's initial painted on top (white with
/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id`
/// seeds the gradient; `name` supplies the letter.
pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let hex = super::identicon::to_hex_seed(id);
let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32);
let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32);
egui::Image::new(egui::ImageSource::Bytes {
uri: uri.into(),
bytes: svg.into_bytes().into(),
})
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.fit_to_exact_size(Vec2::splat(size))
.paint_at(ui, rect);
// Initial — first alphanumeric of the name, never the @ prefix.
let initial = name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
let font = FontId::new(size * 0.46, fonts::bold());
let c = rect.center();
ui.painter().text(
c + Vec2::splat(size * 0.03),
egui::Align2::CENTER_CENTER,
&initial,
font.clone(),
Color32::from_black_alpha(80),
);
ui.painter().text(
c,
egui::Align2::CENTER_CENTER,
&initial,
font,
Color32::from_rgb(0xFA, 0xFA, 0xF7),
);
resp
}
/// Picture avatar when a texture exists; otherwise the deterministic
/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name
/// is an `npub…`), or with the person's initial for a named contact/@handle. A
/// flat lettered tile is the last resort when no pubkey is known. `id` is the
/// npub/hex used to seed the gradient.
pub fn avatar_any(
ui: &mut Ui,
name: &str,
@@ -94,6 +137,7 @@ pub fn avatar_any(
match tex {
Some(t) => avatar_tex(ui, t, size),
None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size),
None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size),
None => avatar(ui, name, size, hue),
}
}
+9 -2
View File
@@ -111,14 +111,21 @@ where
/// Entry point to start ui with [`eframe`].
pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe::Result<()> {
// Pin rustls to the ring provider process-wide. Linking nym-sdk brings
// aws-lc-rs into the graph alongside our ring; with two providers present
// rustls 0.23 won't auto-select a default, and tokio-tungstenite/reqwest
// would panic on the first TLS handshake. nym uses its own explicit provider,
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
let _ = rustls::crypto::ring::default_provider().install_default();
// Setup translations.
setup_i18n();
// Start integrated node if needed.
if AppConfig::autostart_node() {
Node::start();
}
// Pre-warm the Nym mixnet sidecar so price/NIP-05/nostr are ready at first
// use. All of Goblin's outbound traffic egresses through it; nothing clearnet.
// Pre-warm the in-process Nym mixnet client so price/NIP-05/nostr are ready at
// first use. All of Goblin's outbound traffic egresses through it; nothing
// clearnet.
nym::warm_up();
// Launch graphical interface.
eframe::run_native("Goblin", options, app_creator)
+1 -1
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//! Client-side avatar handling: local preprocessing of a picked picture
//! (mirrors the server pipeline so uploads over Tor stay small and previews
//! (mirrors the server pipeline so uploads over the mixnet stay small and previews
//! are instant — the server still re-validates everything), plus a small
//! disk cache of fetched avatars keyed by username.
+1 -1
View File
@@ -14,7 +14,7 @@
//! Nostr payment-messaging subsystem: contacts are nostr users, slatepacks
//! travel as NIP-17 private DMs (NIP-44 encrypted, NIP-59 gift-wrapped) over
//! relays reached through the embedded Tor client.
//! relays reached through the in-process Nym mixnet client.
mod types;
pub use types::*;
+8 -9
View File
@@ -13,12 +13,12 @@
// limitations under the License.
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
//! every HTTP request (NIP-05, price, avatars) — is routed through a local
//! Nym SOCKS5 client (`nym-socks5-client`) that tunnels over the 5-hop mixnet
//! to a network requester. This replaces the embedded Tor (arti) client: the
//! mixnet breaks the sender↔receiver timing correlation that Mimblewimble's
//! interactive slate exchange otherwise leaks at the network layer, and it
//! bootstraps in ~2s rather than Tor's tens of seconds. Nothing goes clearnet.
//! every HTTP request (NIP-05, price, avatars) — is routed through Goblin's
//! in-process Nym SOCKS5 client (the Nym SDK linked directly, no subprocess)
//! that tunnels over the 5-hop mixnet to a network requester. The mixnet breaks
//! the sender↔receiver timing correlation that Mimblewimble's interactive slate
//! exchange otherwise leaks at the network layer, and it bootstraps in ~2s.
//! Nothing goes clearnet.
pub mod sidecar;
pub mod transport;
@@ -44,8 +44,7 @@ pub fn socks5_addr() -> String {
format!("{SOCKS5_HOST}:{SOCKS5_PORT}")
}
/// An HTTP request routed over the Nym mixnet via the local SOCKS5 sidecar.
/// Mirrors the old `Tor::http_request_bytes` signature so call sites swap 1:1.
/// An HTTP request routed over the Nym mixnet via the in-process SOCKS5 client.
/// Returns `(status, body)`.
pub async fn http_request_bytes(
method: &str,
@@ -75,7 +74,7 @@ pub async fn http_request_bytes(
Some((code, bytes))
}
/// String-bodied convenience wrapper (mirrors the old `Tor::http_request`).
/// String-bodied convenience wrapper around [`http_request_bytes`].
pub async fn http_request(
method: &str,
url: String,
+81 -224
View File
@@ -12,87 +12,45 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Lifecycle for the bundled `nym-socks5-client` sidecar. Goblin doesn't link
//! the Nym SDK (its native-lib graph conflicts with ours — see the project
//! notes); instead it ships the standalone SOCKS5 client binary and runs it as
//! a child process, exposing the mixnet at `127.0.0.1:1080`. Every relay and
//! HTTP request in the app is pointed at that port, so all traffic egresses
//! through the 5-hop mixnet to our network requester. Nothing goes clearnet.
//! In-process Nym mixnet client. Goblin links the Nym SDK directly — there is no
//! sidecar subprocess and no bundled/sideloaded binary. It runs the SDK's SOCKS5
//! client on a private internal tokio runtime, exposing the mixnet at
//! `127.0.0.1:1080`; every relay + HTTP request in the app is pointed at that
//! loopback port, so all traffic egresses through the 5-hop mixnet to our network
//! requester. Nothing goes clearnet. This mirrors how GRIM links arti/Tor
//! in-process — the loopback is a private interface inside one process, not a
//! separate program.
use std::net::{SocketAddr, TcpStream};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
use lazy_static::lazy_static;
use log::{error, info, warn};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use nym_sdk::mixnet::{MixnetClientBuilder, Socks5, Socks5MixnetClient, StoragePaths};
use super::{SOCKS5_HOST, SOCKS5_PORT};
/// CreateProcess flag (`CREATE_NO_WINDOW`): run the console-mode sidecar
/// silently so launching it doesn't flash a terminal window on Windows.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// The sidecar embedded into the goblin binary for single-file distribution.
/// Present only when build.rs was given `GOBLIN_NYM_WIN_BIN` (Windows) or
/// `GOBLIN_NYM_UNIX_BIN` (Linux/macOS) — i.e. release builds. Android never
/// embeds (its sidecar rides in the APK's jniLibs).
#[cfg(all(target_os = "windows", goblin_embed_nym))]
const EMBEDDED_SIDECAR: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/nym-socks5-client.exe"));
#[cfg(all(
not(target_os = "windows"),
not(target_os = "android"),
goblin_embed_nym
))]
const EMBEDDED_SIDECAR: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/nym-socks5-client"));
/// Bundled SOCKS5 client binary name. Windows release archives ship the `.exe`;
/// `Command`/`current_exe().parent().join(..)` need the suffix to find it. On
/// Android the sidecar is shipped inside the APK's `jniLibs` as a `lib*.so` (the
/// only files extracted to the exec-allowed native-library dir) — same trick
/// upstream Grim used for Tor's webtunnel binary.
#[cfg(target_os = "windows")]
const BIN_NAME: &str = "nym-socks5-client.exe";
#[cfg(target_os = "android")]
const BIN_NAME: &str = "libnym_socks5_client.so";
#[cfg(not(any(target_os = "windows", target_os = "android")))]
const BIN_NAME: &str = "nym-socks5-client";
/// Per-app client id; namespaces the config/keys under the Nym data root.
const CLIENT_ID: &str = "goblin";
/// Network requester (the mixnet exit) Goblin routes through — the SOCKS5
/// client's `--provider`. This is the always-on requester we run on us-ea.st
/// (standard Nym exit policy, which permits the wss/443 + HTTPS hosts Goblin
/// needs). Overridable at runtime with `GOBLIN_NYM_PROVIDER`. If left empty,
/// the sidecar isn't auto-launched but an already-running SOCKS5 endpoint (a
/// dev sidecar / system service on :1080) is still reused.
/// needs). Overridable at runtime with `GOBLIN_NYM_PROVIDER`. If left empty, the
/// in-process client isn't started, but an already-running SOCKS5 endpoint (a dev
/// sidecar / system service on :1080) is still reused.
pub const NETWORK_REQUESTER: &str = "5ibBQ9SS1er3tks5tfmrzCQ29qU1uBSvZN2dUwLKPRwu.HdbktiMVniUyaKBnorFVXLRHdwRb8iG9dV481r5xyopV@2RmEBKhQHsqvw5sjnnt2Bhpy96MPDUkbfWkT6r2RWNCR";
lazy_static! {
/// Handle to the spawned child so it is killed when Goblin exits.
static ref CHILD: Mutex<Option<Child>> = Mutex::new(None);
}
/// Pre-warm the mixnet transport in the background so relays / NIP-05 / price
/// are ready by first use. Mirrors the old `Tor::warm_up()` seam. If a SOCKS5
/// endpoint is already listening (a dev sidecar, or a system-managed service),
/// it is reused as-is; otherwise the bundled client is launched.
/// Pre-warm the mixnet transport in the background so relays / NIP-05 / price are
/// ready by first use. If a SOCKS5 endpoint
/// is already listening (a dev sidecar, or a system-managed service), it is reused
/// as-is; otherwise the in-process client is started.
pub fn warm_up() {
thread::spawn(|| {
if port_open(Duration::from_millis(300)) {
info!("nym: reusing SOCKS5 sidecar already listening on {SOCKS5_HOST}:{SOCKS5_PORT}");
info!("nym: reusing SOCKS5 endpoint already listening on {SOCKS5_HOST}:{SOCKS5_PORT}");
return;
}
if let Err(e) = launch() {
error!("nym: could not start the SOCKS5 sidecar: {e}");
}
run_client();
});
}
@@ -105,44 +63,6 @@ fn port_open(timeout: Duration) -> bool {
TcpStream::connect_timeout(&addr, timeout).is_ok()
}
/// Locate the `nym-socks5-client` binary: an explicit `GOBLIN_NYM_BIN`
/// override, then alongside the running executable (how release archives ship
/// it), then a bare name resolved against `PATH`.
fn binary_path() -> PathBuf {
if let Ok(p) = std::env::var("GOBLIN_NYM_BIN") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
// Android: `current_exe()` is the zygote/app_process, not us — the sidecar
// rides in the APK's jniLibs and is extracted to the native-library dir
// (the one exec-allowed location). MainActivity exports it as
// `NATIVE_LIBS_DIR` (see android/.../MainActivity.java).
#[cfg(target_os = "android")]
if let Ok(dir) = std::env::var("NATIVE_LIBS_DIR") {
let p = PathBuf::from(dir).join(BIN_NAME);
if p.exists() {
return p;
}
}
// Single-file desktop build: the sidecar is baked into the goblin binary —
// extract it once to the per-user data dir and run that. Falls through to a
// sibling binary when not embedded (a plain `cargo build`).
#[cfg(all(not(target_os = "android"), goblin_embed_nym))]
if let Some(p) = extract_embedded_sidecar() {
return p;
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let sibling = dir.join(BIN_NAME);
if sibling.exists() {
return sibling;
}
}
}
PathBuf::from(BIN_NAME)
}
/// The network requester address to register against (`--provider`).
fn provider() -> String {
std::env::var("GOBLIN_NYM_PROVIDER")
@@ -151,139 +71,76 @@ fn provider() -> String {
.unwrap_or_else(|| NETWORK_REQUESTER.to_string())
}
/// Write the embedded sidecar to the per-user data dir (`%LOCALAPPDATA%\Goblin`
/// on Windows, `~/.local/share/Goblin` on Linux) once, or when the bundled copy
/// changed, and return its path. Keeps the release a single self-contained
/// binary with no loose helper to misplace.
#[cfg(all(not(target_os = "android"), goblin_embed_nym))]
fn extract_embedded_sidecar() -> Option<PathBuf> {
let dir = dirs::data_local_dir()?.join("Goblin");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(BIN_NAME);
let stale = match std::fs::metadata(&path) {
Ok(m) => m.len() != EMBEDDED_SIDECAR.len() as u64,
Err(_) => true,
};
if stale {
if let Err(e) = std::fs::write(&path, EMBEDDED_SIDECAR) {
warn!("nym: could not extract embedded sidecar: {e}");
return None;
}
// Unix: a freshly written file isn't executable; the sidecar must be.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
{
warn!("nym: could not mark embedded sidecar executable: {e}");
return None;
}
}
}
Some(path)
/// Persistent storage dir for the in-process client's identity + gateway choice,
/// so the gateway is selected once and reused across launches (cuts cold-start
/// time). `<home>/.goblin/nym`. `None` ⇒ fall back to ephemeral in-memory keys.
fn storage_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".goblin").join("nym"))
}
/// A fresh handle to `<home>/.goblin/nym-sidecar.log` for the sidecar's output,
/// so a failed bootstrap leaves a trace instead of vanishing into a null sink.
/// Falls back to discarding output if the log can't be opened.
fn log_sink() -> Stdio {
if let Some(home) = dirs::home_dir() {
let dir = home.join(".goblin");
let _ = std::fs::create_dir_all(&dir);
if let Ok(f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("nym-sidecar.log"))
{
return Stdio::from(f);
}
}
Stdio::null()
}
/// Apply the cross-platform sidecar spawn settings: log its output to a file and,
/// on Windows, suppress the console window.
fn quiet_logged(cmd: &mut Command) {
cmd.stdin(Stdio::null())
.stdout(log_sink())
.stderr(log_sink());
#[cfg(windows)]
cmd.creation_flags(CREATE_NO_WINDOW);
}
/// `~/.nym/socks5-clients/<id>/config/config.toml` — present once initialized.
fn config_marker() -> Option<PathBuf> {
dirs::home_dir().map(|h| {
h.join(".nym")
.join("socks5-clients")
.join(CLIENT_ID)
.join("config")
.join("config.toml")
})
}
/// Extract (if bundled), initialize (once), then spawn the SOCKS5 client and
/// block until its port is accepting connections.
fn launch() -> std::io::Result<()> {
if provider().is_empty() {
/// Build the in-process SOCKS5 mixnet client on a dedicated multi-thread tokio
/// runtime, then keep the client (its SOCKS5 listener + mixnet tasks) AND the
/// runtime alive for the lifetime of the process. Blocks the calling thread.
fn run_client() {
let prov = provider();
if prov.is_empty() {
warn!(
"nym: no network requester configured (set GOBLIN_NYM_PROVIDER or bake \
NETWORK_REQUESTER); not launching a sidecar"
"nym: no network requester configured (set GOBLIN_NYM_PROVIDER or bake NETWORK_REQUESTER); mixnet disabled"
);
return Ok(());
}
let bin = binary_path();
ensure_initialized(&bin);
info!("nym: launching SOCKS5 sidecar ({})", bin.display());
let mut cmd = Command::new(&bin);
cmd.arg("run").arg("--id").arg(CLIENT_ID);
quiet_logged(&mut cmd);
let child = cmd.spawn()?;
*CHILD.lock().unwrap() = Some(child);
// The mixnet bootstraps in ~2s; give it generous headroom on cold start.
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
if port_open(Duration::from_millis(500)) {
info!("nym: SOCKS5 sidecar ready on {SOCKS5_HOST}:{SOCKS5_PORT}");
return Ok(());
}
thread::sleep(Duration::from_millis(500));
}
warn!("nym: SOCKS5 sidecar did not open its port within 60s");
Ok(())
}
/// Run `init` once, when the client has no config yet. `init` selects a gateway
/// and writes keys; it needs the network but no funds (zero-value mode).
fn ensure_initialized(bin: &PathBuf) {
let needs_init = config_marker().map(|p| !p.exists()).unwrap_or(true);
if !needs_init {
return;
}
info!("nym: initializing SOCKS5 client '{CLIENT_ID}'");
let mut cmd = Command::new(bin);
cmd.arg("init")
.arg("--id")
.arg(CLIENT_ID)
.arg("--provider")
.arg(provider());
quiet_logged(&mut cmd);
let res = cmd.status();
match res {
Ok(s) if s.success() => info!("nym: SOCKS5 client initialized"),
Ok(s) => warn!("nym: SOCKS5 client init exited with {s}"),
Err(e) => error!("nym: SOCKS5 client init failed to run: {e}"),
}
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
error!("nym: could not build mixnet runtime: {e}");
return;
}
};
rt.block_on(async move {
let started = Instant::now();
info!("nym: starting in-process SOCKS5 mixnet client on {SOCKS5_HOST}:{SOCKS5_PORT}");
let client = match build_client(prov).await {
Ok(c) => c,
Err(e) => {
error!("nym: mixnet client failed to start: {e}");
return;
}
};
info!(
"nym: mixnet ready on {SOCKS5_HOST}:{SOCKS5_PORT} in ~{}ms (nym addr {})",
started.elapsed().as_millis(),
client.nym_address()
);
// Hold the client (and thus the SOCKS5 listener + mixnet tasks) open for
// the whole process lifetime; the runtime keeps polling them.
std::future::pending::<()>().await;
drop(client);
});
}
/// Stop the sidecar if Goblin spawned one (best-effort; no-op when reusing an
/// externally-managed client).
#[allow(dead_code)]
pub fn shutdown() {
if let Some(mut child) = CHILD.lock().unwrap().take() {
let _ = child.kill();
let _ = child.wait();
/// Persistent identity if we have a home dir, else ephemeral in-memory keys.
async fn build_client(provider: String) -> Result<Socks5MixnetClient, nym_sdk::Error> {
match storage_dir() {
Some(dir) => {
let _ = std::fs::create_dir_all(&dir);
let paths = StoragePaths::new_from_dir(&dir)?;
MixnetClientBuilder::new_with_default_storage(paths)
.await?
.socks5_config(Socks5::new(provider))
.build()?
.connect_to_mixnet_via_socks5()
.await
}
None => {
MixnetClientBuilder::new_ephemeral()
.socks5_config(Socks5::new(provider))
.build()?
.connect_to_mixnet_via_socks5()
.await
}
}
}
+3 -4
View File
@@ -12,10 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! WebSocket transport for the Nostr relay pool routed through the local
//! `nym-socks5-client` sidecar, so every relay connection traverses the 5-hop
//! Nym mixnet. This replaces the arti/Tor transport: instead of dialing a Tor
//! data stream we open a SOCKS5 connection to `127.0.0.1:1080`, ask the proxy
//! WebSocket transport for the Nostr relay pool routed through Goblin's
//! in-process Nym SOCKS5 client, so every relay connection traverses the 5-hop
//! Nym mixnet. We open a SOCKS5 connection to `127.0.0.1:1080`, ask the proxy
//! to reach the relay host (`socks5h`-style: the proxy does the DNS, so the
//! destination is never resolved on the clear), then run the TLS + websocket
//! handshake over that tunnel. Nothing goes clearnet.