From bac30ecfdf58bb3e887628ceb7dee39a90717621 Mon Sep 17 00:00:00 2001 From: enki Date: Mon, 18 May 2026 21:46:09 -0700 Subject: [PATCH] add SOCKS5/Tor/I2P proxy support and improve network UI clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NetworkConfig/ProxyConfig in config.rs: mode=direct|tor|i2p|socks5 with socks5_addr() helper that maps tor→9050, i2p→4446, socks5→url - build_http_client() routes reqwest through SOCKS5 when configured - build_nostr_opts() routes nostr-sdk WebSocket connections through SOCKS5 using Connection::new().proxy(addr) - Shared http_client passed to Enricher and BlossomClient so proxy applies to TMDB and Blossom blob fetches as well - nostr_opts passed to Reader, WotBuilder, ProfileFetcher - Dashboard: network status card showing proxy mode, listen address, IPv4/IPv6; relay section now shows X connected / Y configured - Publishers: rename vouch form to "Override publisher trust", add clear note that this is local-only (not broadcast to Nostr), explain WoT comes from NIP-02 kind 3; rename column headers for clarity - Example and dev configs updated with network.proxy section --- Cargo.toml | 2 +- deploy/kindexr.example.yaml | 12 ++++++ kindexr.dev.yaml | 8 +++- src/blossom/mod.rs | 4 +- src/config.rs | 42 +++++++++++++++++++++ src/enrich/tmdb.rs | 4 +- src/main.rs | 53 ++++++++++++++++++++++---- src/nostr/profiles.rs | 7 ++-- src/nostr/reader.rs | 11 ++++-- src/ui/dashboard.rs | 75 +++++++++++++++++++++++++++++++++++-- src/ui/publishers.rs | 13 ++++--- src/wot/follows.rs | 7 ++-- 12 files changed, 207 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7293284..27c4aea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ serde_json = "1" lava_torrent = "0.11" # HTTP client (TMDB enrichment, Phase 2) -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "socks"], default-features = false } # Error handling anyhow = "1" diff --git a/deploy/kindexr.example.yaml b/deploy/kindexr.example.yaml index dbcf704..c237a64 100644 --- a/deploy/kindexr.example.yaml +++ b/deploy/kindexr.example.yaml @@ -56,6 +56,18 @@ blossom: enabled: false server: "https://blossom.example.com" +# Networking — proxy configuration for all outbound connections +# (Nostr relay WebSockets, TMDB API, Blossom blob fetches) +# mode: direct | tor | i2p | socks5 +# direct — no proxy (default) +# tor — SOCKS5 via 127.0.0.1:9050 (Tor daemon must be running) +# i2p — SOCKS5 via 127.0.0.1:4446 (I2P SAM/SOCKS proxy must be running) +# socks5 — explicit SOCKS5 proxy; set url to socks5://host:port +network: + proxy: + mode: "direct" + url: "" # only used when mode=socks5, e.g. socks5://127.0.0.1:1080 + # Publisher — publish your own torrents as NIP-35 events publisher: enabled: false diff --git a/kindexr.dev.yaml b/kindexr.dev.yaml index 97ec121..56f2079 100644 --- a/kindexr.dev.yaml +++ b/kindexr.dev.yaml @@ -12,7 +12,8 @@ logging: relays: - "wss://relay.damus.io" - "wss://nos.lol" - - "wss://sovbit.host" + - "wss://freelay.sovbit.dev" + - "wss://freelay.sovbit.host" backfill_days: 7 negentropy_bootstrap: false @@ -37,6 +38,11 @@ health: method: "dht" refresh_interval: "30m" +network: + proxy: + mode: "direct" + url: "" + publisher: enabled: false outbox: [] diff --git a/src/blossom/mod.rs b/src/blossom/mod.rs index cc2e50c..a9b817f 100644 --- a/src/blossom/mod.rs +++ b/src/blossom/mod.rs @@ -20,10 +20,10 @@ struct BlobDescriptor { } impl BlossomClient { - pub fn new(server: &str) -> Self { + pub fn new(server: &str, http: reqwest::Client) -> Self { BlossomClient { server: server.trim_end_matches('/').to_owned(), - http: reqwest::Client::new(), + http, } } diff --git a/src/config.rs b/src/config.rs index d0ba2fa..4ce68df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, pub logging: LoggingConfig, + pub network: NetworkConfig, pub relays: Vec, pub negentropy_bootstrap: bool, pub backfill_days: i64, @@ -89,6 +90,41 @@ pub struct QBittorrentConfig { pub categories: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + pub proxy: ProxyConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyConfig { + /// Proxy mode: "direct" | "socks5" | "tor" | "i2p" + /// tor → SOCKS5 to 127.0.0.1:9050 (system Tor daemon) + /// i2p → SOCKS5 to 127.0.0.1:4446 (I2P SOCKS tunnel) + /// socks5 → SOCKS5 to the URL you specify in `url` + pub mode: String, + /// SOCKS5 URL for the "socks5" mode, e.g. "socks5://127.0.0.1:1080" + /// Ignored for tor/i2p (they use well-known default addresses). + pub url: String, +} + +impl ProxyConfig { + /// Returns the effective SOCKS5 address string, or None if direct. + pub fn socks5_addr(&self) -> Option { + match self.mode.as_str() { + "tor" => Some("127.0.0.1:9050".into()), + "i2p" => Some("127.0.0.1:4446".into()), + "socks5" if !self.url.is_empty() => { + // Strip scheme prefix if present — nostr-sdk wants a bare SocketAddr. + let bare = self.url + .trim_start_matches("socks5://") + .trim_start_matches("socks5h://"); + Some(bare.to_owned()) + } + _ => None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlossomConfig { pub enabled: bool, @@ -142,6 +178,12 @@ impl Default for Config { method: "dht".into(), refresh_interval: "30m".into(), }, + network: NetworkConfig { + proxy: ProxyConfig { + mode: "direct".into(), + url: String::new(), + }, + }, blossom: BlossomConfig { enabled: false, server: String::new(), diff --git a/src/enrich/tmdb.rs b/src/enrich/tmdb.rs index 4381dbb..ad2f5ab 100644 --- a/src/enrich/tmdb.rs +++ b/src/enrich/tmdb.rs @@ -22,8 +22,8 @@ struct TmdbHit { } impl Enricher { - pub fn new(api_key: String) -> Self { - Enricher { api_key, client: Client::new() } + pub fn new(api_key: String, client: Client) -> Self { + Enricher { api_key, client } } pub fn enabled(&self) -> bool { diff --git a/src/main.rs b/src/main.rs index cc1f9cd..2ecb22b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,12 @@ use clap::Parser; use kindexr::{blossom::BlossomClient, config, db, enrich::tmdb::Enricher, nostr, publisher, torznab, ui, wot, AppState}; use serde_json::{json, Value}; use std::{ + net::SocketAddr, sync::{atomic::AtomicI32, Arc}, time::Instant, }; use tokio::signal; -use tracing::info; +use tracing::{info, warn}; /// kindexr — Nostr-native Torznab indexer #[derive(Parser)] @@ -44,6 +45,12 @@ async fn main() -> anyhow::Result<()> { info!("starting kindexr {VERSION}"); + // ── Shared HTTP client with optional proxy ────────────────────────────── + let http_client = build_http_client(&cfg.network.proxy)?; + + // ── nostr-sdk connection options with optional SOCKS5 proxy ───────────── + let nostr_opts = build_nostr_opts(&cfg.network.proxy); + let pool = db::open(&cfg.database.path).await?; info!("database ready"); @@ -60,20 +67,20 @@ async fn main() -> anyhow::Result<()> { relay_count: relay_count.clone(), }; - let enricher = Arc::new(Enricher::new(cfg.tmdb.api_key.clone())); + let enricher = Arc::new(Enricher::new(cfg.tmdb.api_key.clone(), http_client.clone())); let blossom: Option> = if cfg.blossom.enabled && !cfg.blossom.server.is_empty() { info!(server = %cfg.blossom.server, "blossom client enabled"); - Some(Arc::new(BlossomClient::new(&cfg.blossom.server))) + Some(Arc::new(BlossomClient::new(&cfg.blossom.server, http_client.clone()))) } else { None }; - let reader = nostr::reader::Reader::new(cfg.clone(), pool.clone(), relay_count, enricher.clone(), blossom.clone()); + let reader = nostr::reader::Reader::new(cfg.clone(), pool.clone(), relay_count, enricher.clone(), blossom.clone(), nostr_opts.clone()); reader.start(); - wot::follows::WotBuilder::new(cfg.clone(), pool.clone()).start(); - nostr::profiles::ProfileFetcher::new(cfg.clone(), pool.clone()).start(); + wot::follows::WotBuilder::new(cfg.clone(), pool.clone(), nostr_opts.clone()).start(); + nostr::profiles::ProfileFetcher::new(cfg.clone(), pool.clone(), nostr_opts.clone()).start(); if cfg.publisher.enabled { match nostr::signer::Signer::from_nsec(&cfg.publisher.identity.nsec) { @@ -89,7 +96,7 @@ async fn main() -> anyhow::Result<()> { info!("publisher watcher started"); } Err(e) => { - tracing::warn!("publisher.enabled=true but nsec is invalid — watcher not started: {e}"); + warn!("publisher.enabled=true but nsec is invalid — watcher not started: {e}"); } } } @@ -114,6 +121,38 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn build_http_client(proxy: &config::ProxyConfig) -> anyhow::Result { + let mut b = reqwest::Client::builder(); + if let Some(addr) = proxy.socks5_addr() { + let proxy_url = if addr.starts_with("socks5://") { + addr + } else { + format!("socks5://{addr}") + }; + info!(proxy = %proxy_url, "HTTP client using SOCKS5 proxy ({})", proxy.mode); + b = b.proxy(reqwest::Proxy::all(&proxy_url).context("invalid proxy URL")?); + } + Ok(b.build()?) +} + +fn build_nostr_opts(proxy: &config::ProxyConfig) -> nostr_sdk::ClientOptions { + use nostr_sdk::{client::Connection, ClientOptions}; + if let Some(addr_str) = proxy.socks5_addr() { + match addr_str.parse::() { + Ok(addr) => { + info!(proxy = %addr_str, "nostr-sdk using SOCKS5 proxy ({})", proxy.mode); + ClientOptions::new().connection(Connection::new().proxy(addr)) + } + Err(e) => { + warn!("invalid proxy address '{addr_str}': {e} — using direct connection"); + ClientOptions::new() + } + } + } else { + ClientOptions::new() + } +} + async fn health_handler(State(state): State) -> Json { let db_ok = db::get_stats(&state.pool).await.is_ok(); let uptime = state.started_at.elapsed().as_secs(); diff --git a/src/nostr/profiles.rs b/src/nostr/profiles.rs index 9aa4c76..c7e410e 100644 --- a/src/nostr/profiles.rs +++ b/src/nostr/profiles.rs @@ -10,6 +10,7 @@ use crate::{config::Config, db}; pub struct ProfileFetcher { cfg: Arc, pool: SqlitePool, + nostr_opts: nostr_sdk::ClientOptions, } #[derive(Deserialize, Default)] @@ -22,8 +23,8 @@ struct Nip0Content { } impl ProfileFetcher { - pub fn new(cfg: Arc, pool: SqlitePool) -> Self { - ProfileFetcher { cfg, pool } + pub fn new(cfg: Arc, pool: SqlitePool, nostr_opts: nostr_sdk::ClientOptions) -> Self { + ProfileFetcher { cfg, pool, nostr_opts } } pub fn start(self) { @@ -98,7 +99,7 @@ impl ProfileFetcher { } async fn fetch(&self, filter: Filter, timeout_secs: u64) -> Vec { - let client = Client::default(); + let client = Client::builder().opts(self.nostr_opts.clone()).build(); for relay in &self.cfg.relays { if let Err(e) = client.add_relay(relay.as_str()).await { warn!(url = relay, "profiles: relay add failed: {e}"); diff --git a/src/nostr/reader.rs b/src/nostr/reader.rs index 158fd3d..30fb1a0 100644 --- a/src/nostr/reader.rs +++ b/src/nostr/reader.rs @@ -15,6 +15,7 @@ pub struct Reader { connected: Arc, enricher: Arc, blossom: Option>, + nostr_opts: nostr_sdk::ClientOptions, } impl Reader { @@ -24,8 +25,9 @@ impl Reader { connected: Arc, enricher: Arc, blossom: Option>, + nostr_opts: nostr_sdk::ClientOptions, ) -> Self { - Reader { cfg, pool, connected, enricher, blossom } + Reader { cfg, pool, connected, enricher, blossom, nostr_opts } } pub fn start(self) { @@ -34,19 +36,20 @@ impl Reader { let connected = self.connected.clone(); let enricher = self.enricher.clone(); let blossom = self.blossom.clone(); + let nostr_opts = self.nostr_opts.clone(); tokio::spawn(async move { - run_reader(cfg, pool, connected, enricher, blossom).await; + run_reader(cfg, pool, connected, enricher, blossom, nostr_opts).await; }); } } -async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc, enricher: Arc, blossom: Option>) { +async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc, enricher: Arc, blossom: Option>, nostr_opts: nostr_sdk::ClientOptions) { if cfg.relays.is_empty() { warn!("no relays configured; reader idle"); return; } - let client = Client::default(); + let client = Client::builder().opts(nostr_opts).build(); for relay_url in &cfg.relays { match client.add_relay(relay_url.as_str()).await { Ok(_) => { let _ = db::upsert_relay(&pool, relay_url).await; } diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index af5d8ec..c14e9c8 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -1,5 +1,6 @@ use axum::extract::State; use axum::response::Html; +use std::sync::atomic::Ordering; use crate::{db, AppState}; use super::{page, err_page, fmt_ts_ago, short_pubkey, esc}; @@ -35,7 +36,11 @@ async fn render(state: &AppState) -> anyhow::Result> { stats.published_count, ); + // Network status + let network_html = build_network_html(state); + // Relay status + let relay_count = state.relay_count.load(Ordering::Relaxed); let relay_rows: String = if relays.is_empty() { "
No relays configured
".into() } else { @@ -44,7 +49,9 @@ async fn render(state: &AppState) -> anyhow::Result> { .map(|r| { let (dot, ts_str) = match r.last_event { Some(ts) => ("dot-ok", format!("last event {}", fmt_ts_ago(ts))), - None => ("dot-dim", "no events yet".into()), + // "no events yet" is normal on a fresh start — the relay is connected + // but kindexr hasn't received any NIP-35 events from it this session. + None => ("dot-dim", "connected — no events received yet".into()), }; format!( r#"
@@ -59,7 +66,7 @@ async fn render(state: &AppState) -> anyhow::Result> { let relays_html = format!( r#"
-
Relays ({} configured)
+
Relays — {relay_count} connected / {} configured
{relay_rows}
"#, relays.len(), @@ -104,6 +111,68 @@ async fn render(state: &AppState) -> anyhow::Result> {
"# ); - let body = format!("

Dashboard

{stats_html}{relays_html}{recent_html}"); + let body = format!("

Dashboard

{stats_html}{network_html}{relays_html}{recent_html}"); Ok(page("dashboard", "Dashboard", &body)) } + +fn build_network_html(state: &AppState) -> String { + let proxy = &state.cfg.network.proxy; + let listen = &state.cfg.server.listen; + + // Proxy mode badge + let (proxy_badge_class, proxy_label, proxy_detail) = match proxy.mode.as_str() { + "tor" => ( + "badge-warn", + "Tor", + "SOCKS5 → 127.0.0.1:9050 — all outbound traffic routed through Tor".to_string(), + ), + "i2p" => ( + "badge-accent", + "I2P", + "SOCKS5 → 127.0.0.1:4446 — all outbound traffic routed through I2P".to_string(), + ), + "socks5" => { + let addr = proxy.url.trim_start_matches("socks5://").trim_start_matches("socks5h://"); + ( + "badge-accent", + "SOCKS5", + format!("SOCKS5 → {addr} — all outbound traffic proxied"), + ) + } + _ => ( + "badge-dim", + "Direct", + format!("No proxy — outbound connections use your host's network stack directly"), + ), + }; + + // IP version from listen address + let ip_ver = if listen.starts_with('[') || listen.contains("::") { + r#"IPv6"# + } else if listen.starts_with("0.0.0.0") || listen.starts_with("127.") || listen.chars().next().map_or(false, |c| c.is_ascii_digit()) { + r#"IPv4"# + } else { + "" + }; + + format!( + r#"
+
Network
+
+
+
+
Proxy / anonymity
+ {proxy_label} +
{proxy_detail}
+
+
+
Listening on
+ {listen} + {ip_ver} +
+
+
+
"#, + listen = esc(listen), + ) +} diff --git a/src/ui/publishers.rs b/src/ui/publishers.rs index fa5124f..0c80ed1 100644 --- a/src/ui/publishers.rs +++ b/src/ui/publishers.rs @@ -47,7 +47,7 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result -
Vouch for a publisher
+
Override publisher trust
anyhow::Result
-
- Vouching sets the trust score for this publisher independently of the WoT graph. - Blocked publishers' events are silently dropped at ingest. +
+ Local override only. + This sets the trust score in your local database — nothing is broadcast to Nostr. + The WoT score (column below) comes from real Nostr data: your operator pubkey's + kind 3 follow graph (NIP-02). + Blocking silently drops all future events from this pubkey at ingest.
"#; @@ -165,7 +168,7 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result - + {table_rows} diff --git a/src/wot/follows.rs b/src/wot/follows.rs index 02faa88..1b68676 100644 --- a/src/wot/follows.rs +++ b/src/wot/follows.rs @@ -9,11 +9,12 @@ use crate::{config::Config, db}; pub struct WotBuilder { cfg: Arc, pool: SqlitePool, + nostr_opts: nostr_sdk::ClientOptions, } impl WotBuilder { - pub fn new(cfg: Arc, pool: SqlitePool) -> Self { - Self { cfg, pool } + pub fn new(cfg: Arc, pool: SqlitePool, nostr_opts: nostr_sdk::ClientOptions) -> Self { + Self { cfg, pool, nostr_opts } } pub fn start(self) { @@ -102,7 +103,7 @@ impl WotBuilder { /// Subscribes to a filter, collects events for `timeout_secs`, returns them. async fn fetch(&self, filter: Filter, timeout_secs: u64) -> Vec { - let client = Client::default(); + let client = Client::builder().opts(self.nostr_opts.clone()).build(); for relay in &self.cfg.relays { if let Err(e) = client.add_relay(relay.as_str()).await { warn!(url = relay, "wot: relay add failed: {e}");
PubkeyTrustWoT levelIdentityTrustWoT (NIP-02) EventsReportsLast seenActions