add SOCKS5/Tor/I2P proxy support and improve network UI clarity

- 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
This commit is contained in:
2026-05-18 21:46:09 -07:00
parent b88e329074
commit bac30ecfdf
12 changed files with 207 additions and 31 deletions
+2 -2
View File
@@ -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,
}
}
+42
View File
@@ -9,6 +9,7 @@ pub struct Config {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub logging: LoggingConfig,
pub network: NetworkConfig,
pub relays: Vec<String>,
pub negentropy_bootstrap: bool,
pub backfill_days: i64,
@@ -89,6 +90,41 @@ pub struct QBittorrentConfig {
pub categories: Vec<String>,
}
#[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<String> {
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(),
+2 -2
View File
@@ -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 {
+46 -7
View File
@@ -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<Arc<BlossomClient>> = 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<reqwest::Client> {
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::<SocketAddr>() {
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<AppState>) -> Json<Value> {
let db_ok = db::get_stats(&state.pool).await.is_ok();
let uptime = state.started_at.elapsed().as_secs();
+4 -3
View File
@@ -10,6 +10,7 @@ use crate::{config::Config, db};
pub struct ProfileFetcher {
cfg: Arc<Config>,
pool: SqlitePool,
nostr_opts: nostr_sdk::ClientOptions,
}
#[derive(Deserialize, Default)]
@@ -22,8 +23,8 @@ struct Nip0Content {
}
impl ProfileFetcher {
pub fn new(cfg: Arc<Config>, pool: SqlitePool) -> Self {
ProfileFetcher { cfg, pool }
pub fn new(cfg: Arc<Config>, 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<nostr_sdk::Event> {
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}");
+7 -4
View File
@@ -15,6 +15,7 @@ pub struct Reader {
connected: Arc<AtomicI32>,
enricher: Arc<Enricher>,
blossom: Option<Arc<BlossomClient>>,
nostr_opts: nostr_sdk::ClientOptions,
}
impl Reader {
@@ -24,8 +25,9 @@ impl Reader {
connected: Arc<AtomicI32>,
enricher: Arc<Enricher>,
blossom: Option<Arc<BlossomClient>>,
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<Config>, pool: SqlitePool, connected: Arc<AtomicI32>, enricher: Arc<Enricher>, blossom: Option<Arc<BlossomClient>>) {
async fn run_reader(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32>, enricher: Arc<Enricher>, blossom: Option<Arc<BlossomClient>>, 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; }
+72 -3
View File
@@ -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<Html<String>> {
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() {
"<div class='empty'>No relays configured</div>".into()
} else {
@@ -44,7 +49,9 @@ async fn render(state: &AppState) -> anyhow::Result<Html<String>> {
.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#"<div class="relay-row">
@@ -59,7 +66,7 @@ async fn render(state: &AppState) -> anyhow::Result<Html<String>> {
let relays_html = format!(
r#"<div class="section">
<div class="section-title">Relays ({} configured)</div>
<div class="section-title">Relays — {relay_count} connected / {} configured</div>
<div class="relay-list">{relay_rows}</div>
</div>"#,
relays.len(),
@@ -104,6 +111,68 @@ async fn render(state: &AppState) -> anyhow::Result<Html<String>> {
</div>"#
);
let body = format!("<h1>Dashboard</h1>{stats_html}{relays_html}{recent_html}");
let body = format!("<h1>Dashboard</h1>{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#"<span class="badge badge-accent">IPv6</span>"#
} 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#"<span class="badge badge-dim">IPv4</span>"#
} else {
""
};
format!(
r#"<div class="section">
<div class="section-title">Network</div>
<div class="card" style="margin-bottom:.75rem">
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap">
<div style="min-width:160px">
<div style="font-size:.65rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.3rem">Proxy / anonymity</div>
<span class="badge {proxy_badge_class}" style="font-size:12px;padding:.2rem .6rem">{proxy_label}</span>
<div style="font-size:11px;color:var(--muted);margin-top:.4rem;max-width:440px">{proxy_detail}</div>
</div>
<div style="min-width:160px">
<div style="font-size:.65rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.3rem">Listening on</div>
<span class="mono" style="font-size:12px">{listen}</span>
<span style="margin-left:.5rem">{ip_ver}</span>
</div>
</div>
</div>
</div>"#,
listen = esc(listen),
)
}
+8 -5
View File
@@ -47,7 +47,7 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
// Vouch form
let vouch_form = r#"<div class="card">
<div class="card-title">Vouch for a publisher</div>
<div class="card-title">Override publisher trust</div>
<form method="POST" action="/ui/publishers/vouch">
<div class="form-row">
<input class="wide mono-in" type="text" name="pubkey"
@@ -61,9 +61,12 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
</select>
<button type="submit" class="btn-accent">Apply</button>
</div>
<div style="margin-top:.5rem;font-size:11px;color:var(--muted)">
Vouching sets the trust score for this publisher independently of the WoT graph.
Blocked publishers' events are silently dropped at ingest.
<div style="margin-top:.6rem;font-size:11px;color:var(--muted);line-height:1.6">
<strong style="color:var(--text)">Local override only.</strong>
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&nbsp;3 follow graph (<a href="https://github.com/nostr-protocol/nostr/blob/master/nips/02.md" target="_blank">NIP-02</a>).
Blocking silently drops all future events from this pubkey at ingest.
</div>
</form>
</div>"#;
@@ -165,7 +168,7 @@ async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<St
<div class="table-wrap">
<table>
<thead><tr>
<th>Pubkey</th><th>Trust</th><th>WoT level</th>
<th>Identity</th><th>Trust</th><th title="Web of Trust — from your operator pubkey's NIP-02 kind&nbsp;3 follow graph">WoT (NIP-02)</th>
<th>Events</th><th>Reports</th><th>Last seen</th><th>Actions</th>
</tr></thead>
<tbody>{table_rows}</tbody>
+4 -3
View File
@@ -9,11 +9,12 @@ use crate::{config::Config, db};
pub struct WotBuilder {
cfg: Arc<Config>,
pool: SqlitePool,
nostr_opts: nostr_sdk::ClientOptions,
}
impl WotBuilder {
pub fn new(cfg: Arc<Config>, pool: SqlitePool) -> Self {
Self { cfg, pool }
pub fn new(cfg: Arc<Config>, 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<nostr_sdk::Event> {
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}");