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:
+2
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 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 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
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user