From b4f519d8ed71383521c86367470e93c0ffa58af7 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Wed, 30 Sep 2026 08:51:26 -0400 Subject: [PATCH] v0.0.13 - Fix Processes tab: read /proc for browser and child processes, add swap column, align tab/probe JSON with processes.js --- Cargo.lock | 3 +- Cargo.toml | 3 +- VERSION | 2 +- plans/final-gap-analysis.md | 27 +- plans/full-implementation-analysis.md | 6 +- src/fips_api.rs | 282 ++++++ src/fips_control.rs | 343 ++++--- src/lib.rs | 1 + src/main.rs | 22 + src/net_services.rs | 1221 ++++++++++++++++++++++--- src/nostr_bridge.rs | 66 +- src/perf_probe.rs | 43 +- src/process_info.rs | 220 ++++- src/search.rs | 114 +++ src/settings.rs | 66 ++ src/tab_manager.rs | 15 + src/tor_control.rs | 365 +++++--- src/tor_scheme.rs | 32 +- src/version.rs | 4 +- www/processes.html | 1 + www/processes.js | 5 +- 21 files changed, 2369 insertions(+), 472 deletions(-) create mode 100644 src/fips_api.rs diff --git a/Cargo.lock b/Cargo.lock index d25f811..7c14b8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3055,7 +3055,7 @@ dependencies = [ [[package]] name = "sovereign_browser" -version = "0.0.11" +version = "0.0.13" dependencies = [ "anyhow", "base64", @@ -3075,6 +3075,7 @@ dependencies = [ "image", "javascriptcore-rs", "lazy_static", + "libc", "log", "nostr-core", "nostr-nips", diff --git a/Cargo.toml b/Cargo.toml index e91220c..867b898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "sovereign_browser" -version = "0.0.12" +version = "0.0.13" edition = "2021" license = "MIT" description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" @@ -69,6 +69,7 @@ env_logger = "0.11" thiserror = "2" anyhow = "1" once_cell = "1" +libc = "0.2" lazy_static = "1" regex = "1" qrcode = "0.14" diff --git a/VERSION b/VERSION index 8cbf02c..43b2961 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.12 +0.0.13 diff --git a/plans/final-gap-analysis.md b/plans/final-gap-analysis.md index 028fc6c..e8d9203 100644 --- a/plans/final-gap-analysis.md +++ b/plans/final-gap-analysis.md @@ -124,7 +124,7 @@ The `www/settings.html` + `settings.js` expects the `settings/config` JSON to in | `sovereign://bookmarks` | Tree view with add/delete/create/move/rename | ✅ Implemented ([`src/nostr_bridge.rs:95`](src/nostr_bridge.rs:95)) | | `sovereign://profile` | Kind 0/3/10002 data from SQLite | ✅ Implemented ([`src/nostr_bridge.rs:386`](src/nostr_bridge.rs:386)) | | `sovereign://processes` | Layer 1 (OS) + Layer 2 (tabs) | ✅ Implemented ([`src/nostr_bridge.rs:99`](src/nostr_bridge.rs:99)) | -| `sovereign://fips` | Status, peers, network, tree, directory | ✅ Served from `www/fips.html` + JS, backend stubs | +| `sovereign://fips` | Status, peers, network, tree, directory | ✅ Implemented — page from `www/fips.html` + JS, API in [`src/fips_api.rs`](src/fips_api.rs) | | `sovereign://agents` | Provider config, models, skills | ✅ Served from `www/agents/config.html` + JS | | `sovereign://agents/chat` | Full chat client with markdown, conversations | ✅ Served from `www/agents/chat.html` + JS | | `sovereign://qr` | QR code generation | ✅ Implemented ([`src/nostr_bridge.rs:419`](src/nostr_bridge.rs:419)) | @@ -137,9 +137,28 @@ The `www/settings.html` + `settings.js` expects the `settings/config` JSON to in | Service | C Status | Rust Status | |---------|----------|-------------| -| **Tor control** | TCP/Unix socket, authenticate, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.rs)) | -| **FIPS control** | Unix socket JSON-line protocol | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) | -| **Net services** | Start/stop Tor & FIPS managed services | Stubs ([`src/net_services.rs`](src/net_services.rs)) | +| **Tor control** | TCP/Unix socket, cookie/password/null auth, multi-line replies, bootstrap poll, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.rs)) | +| **FIPS control** | Unix socket JSON-line protocol, socket discovery | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) | +| **Net services** | Attach to running daemon, else spawn/supervise managed Tor & FIPS; autostart; clean shutdown | ✅ Implemented ([`src/net_services.rs`](src/net_services.rs)) | +| **`fips://` URLs** | Normalize to `http://.fips` (URL bar, links, agent `open`) | ✅ Implemented ([`src/search.rs`](src/search.rs), unit-tested) | +| **`.onion` / `tor://`** | Route via Tor SOCKS, wait for bootstrap, full error text | ✅ Implemented ([`src/tor_scheme.rs`](src/tor_scheme.rs)) | + +### Network services: behaviour and known limits + +- **Modes** (`tor_mode` / `fips_mode`): `auto` (attach, else manage), `attach`, `manage`. + Settable via `sovereign://settings/set?key=...`; other keys: `*_binary_path`, + `tor_attach_socks`, `tor_attach_control`, `tor_data_dir`, `fips_control_socket`, `fips_config_dir`. +- **Managed Tor** uses a loopback TCP `SocksPort` (so `reqwest` can use it) and a Unix + `ControlPort` with cookie auth. `__OwningControllerProcess` makes Tor exit if the browser is + SIGKILLed; SIGTERM/SIGINT/normal quit stop it gracefully (SIGTERM, then SIGKILL after 5s). +- **Attached Tor on a Unix SOCKS socket** (`unix:/run/tor/socks`) is bridged through a + loopback TCP forwarder because `reqwest` cannot dial Unix sockets. +- **Managed FIPS** needs `CAP_NET_ADMIN` (or root) on the `fips` binary; the browser refuses + to start it otherwise. Attached daemons are never stopped. +- **Tor control on the Debian system Tor**: `/run/tor/control.authcookie` is only readable by + the `debian-tor` group, so the browser attaches for SOCKS only and skips bootstrap polling. +- **Not yet done**: NEWNYM / circuit info in the UI, Tor password auth from settings, a + settings-page UI for the new Tor/FIPS keys. --- diff --git a/plans/full-implementation-analysis.md b/plans/full-implementation-analysis.md index 86cc0a1..5959cf0 100644 --- a/plans/full-implementation-analysis.md +++ b/plans/full-implementation-analysis.md @@ -19,7 +19,7 @@ This document provides a detailed breakdown of all components, subsystems, inter | **URL Autocomplete / Search Dropdown** | Complete (GtkEntryCompletion for bookmarks & history) | Incomplete (URL entry exists, completion model not wired) | **20%** | **Medium** | | **Per-Tab Performance Probe (`sovereign://processes`)** | Complete (`perf-probe.js` injection, CPU/FPS/DOM stats, probe-report endpoint) | Incomplete (Skeleton probe struct, probe injection & report collection missing) | **15%** | **Medium** | | **NIP-78 Settings & Bookmark Sync** | Complete (Kind 30078 / 30003 NIP-44 encrypted sync to bootstrap relays) | Partial (Library functions exist, automatic background publish not wired) | **40%** | **Medium** | -| **Tor & FIPS Service Controllers** | Complete (Tor control protocol, FIPS Unix IPC / daemon management) | Stubs (Module placeholders returning mock/default status) | **15%** | **Low** | +| **Tor & FIPS Service Controllers** | Complete (Tor control protocol, FIPS Unix IPC / daemon management) | Implemented (attach/manage/supervise, control clients, `sovereign://fips/*` API, `fips://` URLs) — see `plans/final-gap-analysis.md` §6 | **90%** | **Low** | | **Offline Site Downloader** | Complete (Headless webview rendering & asset bundling) | Stubs (Log message placeholder) | **10%** | **Low** | --- @@ -88,10 +88,10 @@ In the original C version, the `www/` directory contains complete single-page we 1. **Tor Control**: - **C**: Connects to Tor ControlPort (default 9051), authenticates, checks circuit status, and requests new identity (`SIGNAL NEWNYM`). - - **Rust**: Simulated in memory. + - **Rust**: Implemented (TCP + Unix endpoints, cookie/password auth, bootstrap polling). 2. **FIPS Mesh Control**: - **C**: Connects via Unix domain socket to the local FIPS daemon, queries peer tables, resolves `.fips` and npub overlay addresses. - - **Rust**: Stubs returning default status. + - **Rust**: Implemented (socket discovery, status/peers/tree/identity cache/connect/disconnect, directory query). --- diff --git a/src/fips_api.rs b/src/fips_api.rs new file mode 100644 index 0000000..b595e9d --- /dev/null +++ b/src/fips_api.rs @@ -0,0 +1,282 @@ +//! `sovereign://fips/*` JSON API consumed by `www/fips.js`. +//! +//! Port of the FIPS handlers in `nostr_bridge.c` (`handle_fips_*`). Every +//! function here blocks on socket or relay I/O, so callers must run +//! [`handle`] on a worker thread (see `respond_json_async` in +//! `nostr_bridge.rs`). + +use std::collections::HashSet; + +use serde_json::{json, Value}; + +use crate::fips_control as fc; +use crate::net_services::{self, ServiceState}; + +/// Default FIPS advert relays (from fips/src/config/node.rs). +const ADVERT_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://nos.lol", "wss://offchain.pub"]; +/// Kind 37195: parameterized-replaceable FIPS node advert. +const ADVERT_KIND: u64 = 37195; +const ADVERT_D_TAG: &str = "fips-overlay-v1"; +const DIRECTORY_TIMEOUT_MS: u64 = 10_000; + +/// Dispatch `sovereign://fips/?`. +pub fn handle(route: &str, query: &str) -> Value { + match route { + "status" => net_services::net_services_status_json(), + "peers" => peers(), + "tree" => tree(), + "network" => network(), + "directory" => directory(), + "connect" => connect(query), + "disconnect" => disconnect(query), + "restart" => restart(), + _ => json!({ "error": "Not found" }), + } +} + +fn param(query: &str, key: &str) -> String { + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| { + urlencoding::decode(&v.replace('+', " ")) + .map(|d| d.into_owned()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +/// `Some(error JSON)` unless the FIPS service is ready. +fn require_ready() -> Option { + if net_services::net_services_fips_status().state == ServiceState::Ready { + None + } else { + Some(json!({ "error": "FIPS not ready" })) + } +} + +fn peer_npubs(peers: &Value) -> HashSet { + peers + .as_array() + .map(|a| { + a.iter() + .filter_map(|p| p.get("npub").and_then(|n| n.as_str())) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default() +} + +fn peers() -> Value { + if let Some(e) = require_ready() { + return json!({ "peers": [], "error": e["error"] }); + } + match fc::fips_control_show_peers() { + Ok(peers) => json!({ "peers": peers }), + Err(e) => json!({ "peers": [], "error": e.to_string() }), + } +} + +fn tree() -> Value { + if let Some(e) = require_ready() { + return e; + } + match fc::fips_control_show_tree() { + Ok(tree) if tree.is_object() => json!({ "tree": tree }), + Ok(_) => json!({ "error": "invalid tree JSON" }), + Err(e) => json!({ "error": e.to_string() }), + } +} + +/// Combined network view: mesh summary, known nodes (flagging direct +/// peers) and the spanning tree. +fn network() -> Value { + if let Some(e) = require_ready() { + return e; + } + let mut root = json!({}); + let mut summary = json!({}); + + match fc::fips_control_status() { + Ok(st) => { + root["peer_count"] = json!(st.peer_count); + summary["node_npub"] = json!(st.npub); + summary["daemon_state"] = json!(st.state); + summary["tree_state"] = json!(st.tree_state); + summary["tun_name"] = json!(st.tun_name); + summary["tun_active"] = json!(st.tun_active); + } + Err(e) => summary["error"] = json!(e.to_string()), + } + + let direct = fc::fips_control_show_peers() + .map(|p| peer_npubs(&p)) + .unwrap_or_default(); + + let mut nodes: Vec = Vec::new(); + if let Ok(cache) = fc::fips_control_show_identity_cache() { + if let Some(entries) = cache.get("entries").and_then(|e| e.as_array()) { + for entry in entries { + let Some(npub) = entry.get("npub").and_then(|n| n.as_str()) else { + continue; + }; + let mut node = entry.clone(); + node["is_direct_peer"] = json!(direct.contains(npub)); + nodes.push(node); + } + } + if let Some(count) = cache.get("count").and_then(|c| c.as_i64()) { + summary["known_nodes"] = json!(count); + } + if let Some(max) = cache.get("max_entries").and_then(|c| c.as_i64()) { + summary["max_cache_entries"] = json!(max); + } + } + + if let Ok(tree) = fc::fips_control_show_tree() { + if tree.is_object() { + if let Some(v) = tree.get("root_npub").filter(|v| v.is_string()) { + summary["root_npub"] = v.clone(); + } + if let Some(v) = tree.get("is_root").filter(|v| v.is_boolean()) { + summary["is_root"] = v.clone(); + } + if let Some(v) = tree.get("depth").filter(|v| v.is_number()) { + summary["tree_depth"] = v.clone(); + } + if let Some(v) = tree.get("parent_display_name").filter(|v| v.is_string()) { + summary["parent_display_name"] = v.clone(); + } + root["tree"] = tree; + } + } + + root["summary"] = summary; + root["nodes"] = json!(nodes); + root +} + +fn connect(query: &str) -> Value { + let npub = param(query, "npub"); + let address = param(query, "address"); + let transport = param(query, "transport"); + if npub.is_empty() || address.is_empty() { + return json!({ "error": "npub and address are required" }); + } + match fc::fips_control_connect_peer(&npub, &address, &transport) { + Ok(()) => json!({ "status": "ok" }), + Err(e) => json!({ "error": e.to_string() }), + } +} + +fn disconnect(query: &str) -> Value { + let npub = param(query, "npub"); + if npub.is_empty() { + return json!({ "error": "npub is required" }); + } + match fc::fips_control_disconnect_peer(&npub) { + Ok(()) => json!({ "status": "ok" }), + Err(e) => json!({ "error": e.to_string() }), + } +} + +fn restart() -> Value { + match net_services::net_services_restart_fips() { + Ok(()) => json!({ "status": "ok" }), + Err(e) => json!({ "error": format!("FIPS restart failed: {}", e) }), + } +} + +/// Query the FIPS advert relays for Kind 37195 / `fips-overlay-v1` events +/// (the global node directory) and cross-reference the local daemon's +/// direct peers. May take a few seconds. +fn directory() -> Value { + let direct = if net_services::net_services_fips_status().state == ServiceState::Ready { + fc::fips_control_show_peers() + .map(|p| peer_npubs(&p)) + .unwrap_or_default() + } else { + HashSet::new() + }; + + let events = match fetch_adverts() { + Ok(e) => e, + Err(e) => return json!({ "nodes": [], "count": 0, "error": e }), + }; + + // Keep the newest advert per author. + let mut newest: std::collections::HashMap = + std::collections::HashMap::new(); + for ev in events { + let has_d = ev + .tags + .iter() + .any(|t| t.0.first().map(String::as_str) == Some("d") + && t.0.get(1).map(String::as_str) == Some(ADVERT_D_TAG)); + if !has_d { + continue; + } + let key = ev.pubkey.to_hex(); + match newest.get(&key) { + Some(existing) if existing.created_at >= ev.created_at => {} + _ => { + newest.insert(key, ev); + } + } + } + + let mut nodes: Vec = newest + .into_values() + .map(|ev| { + let npub = nostr_core::util::bech32::npub_encode(&ev.pubkey).unwrap_or_default(); + let mut node = json!({ + "npub": npub, + "pubkey_hex": ev.pubkey.to_hex(), + "created_at": ev.created_at, + "is_direct_peer": direct.contains(&npub), + }); + if let Ok(advert) = serde_json::from_str::(&ev.content) { + if let Some(v) = advert.get("endpoints").filter(|v| v.is_array()) { + node["endpoints"] = v.clone(); + } + if let Some(v) = advert.get("signalRelays").filter(|v| v.is_array()) { + node["signal_relays"] = v.clone(); + } + } + node + }) + .collect(); + nodes.sort_by(|a, b| b["created_at"].as_u64().cmp(&a["created_at"].as_u64())); + + json!({ "count": nodes.len(), "nodes": nodes }) +} + +fn fetch_adverts() -> Result, String> { + use nostr_relay::pool::{ReconnectConfig, RelayPool}; + use std::sync::Arc; + + let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + rt.block_on(async { + let pool = Arc::new(RelayPool::new(Some(ReconnectConfig::default()))); + for url in ADVERT_RELAYS { + let _ = pool.add_relay(url).await; + } + pool.connect_all_with_timeout(5_000).await; + let connected = pool.connected_relay_urls().await; + if connected.is_empty() { + return Err("no advert relays reachable".to_string()); + } + + let pool_for_loop = pool.clone(); + let event_loop = tokio::spawn(async move { + pool_for_loop.run(100).await; + }); + + let filter = nostr_core::Filter::new().kinds(vec![ADVERT_KIND]).limit(500); + let result = pool.query_sync(&connected, filter, DIRECTORY_TIMEOUT_MS).await; + event_loop.abort(); + pool.disconnect_all().await; + result.map_err(|e| format!("relay query failed: {:?}", e)) + }) +} diff --git a/src/fips_control.rs b/src/fips_control.rs index 111be32..77f1cab 100644 --- a/src/fips_control.rs +++ b/src/fips_control.rs @@ -2,193 +2,242 @@ //! //! Port of `fips_control.c` / `fips_control.h` from the C project. //! Implements a synchronous FIPS JSON-line control client over a Unix -//! domain socket. Requests are `{"command": "..."}` JSON lines; responses -//! are `{"status": "ok", "data": {...}}` JSON lines. +//! domain socket. Requests are `{"command": "...", "params": {...}}` JSON +//! lines; responses are `{"status": "ok", "data": {...}}` JSON lines. +//! +//! The FIPS daemon closes the control connection after each response, so +//! every request opens a fresh connection. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; +use std::path::Path; use std::sync::Mutex; +use std::time::Duration; + use once_cell::sync::Lazy; -const FIPS_RESPONSE_MAX: usize = 1024 * 1024; +const FIPS_RESPONSE_MAX: u64 = 1024 * 1024; const FIPS_TIMEOUT_MS: u64 = 1500; -/// FIPS control state. -static G_FIPS_CONTROL: Lazy> = Lazy::new(|| Mutex::new(FipsControlState::default())); +/// Well-known control socket locations, in discovery order (after any +/// explicitly configured path). Mirrors `net_service_enable()` in C. +const FIPS_SOCKET_CANDIDATES: &[&str] = &["/run/fips/control.sock", "/tmp/fips-control.sock"]; -struct FipsControlState { - running: bool, - fips_path: String, - socket_path: String, +/// The control socket the browser currently talks to. Empty until a +/// service attach/spawn resolves one. +static G_FIPS_SOCKET: Lazy> = Lazy::new(|| Mutex::new(String::new())); + +type Result = std::result::Result>; + +/// Snapshot of `show_status`. +#[derive(Debug, Clone, Default)] +pub struct FipsStatus { + pub npub: String, + pub tun_name: String, + pub tun_active: bool, + pub state: String, + pub tree_state: String, + pub peer_count: i64, } -impl Default for FipsControlState { - fn default() -> Self { - FipsControlState { - running: false, - fips_path: String::new(), - socket_path: "/tmp/fips_control.sock".to_string(), +/// Initialize FIPS control (forgets any previously resolved socket). +pub fn fips_control_init() { + G_FIPS_SOCKET.lock().unwrap().clear(); +} + +/// Set the control socket path used by all subsequent requests. +pub fn fips_control_set_socket(path: &str) { + *G_FIPS_SOCKET.lock().unwrap() = path.to_string(); +} + +/// The control socket path currently in use (empty if none). +pub fn fips_control_socket() -> String { + G_FIPS_SOCKET.lock().unwrap().clone() +} + +/// Connect to a control socket with read/write timeouts applied. +fn connect(path: &str) -> Result { + if path.is_empty() { + return Err("empty FIPS control socket path".into()); + } + let stream = UnixStream::connect(path) + .map_err(|e| format!("FIPS connect failed ({}): {}", path, e))?; + stream.set_read_timeout(Some(Duration::from_millis(FIPS_TIMEOUT_MS)))?; + stream.set_write_timeout(Some(Duration::from_millis(FIPS_TIMEOUT_MS)))?; + Ok(stream) +} + +/// True if something accepts connections on `path`. +pub fn fips_control_socket_available(path: &str) -> bool { + !path.is_empty() && Path::new(path).exists() && UnixStream::connect(path).is_ok() +} + +/// Find a reachable FIPS control socket: the configured path first, then +/// `/run/fips/control.sock`, `$XDG_RUNTIME_DIR/fips/control.sock` and +/// `/tmp/fips-control.sock`. +pub fn fips_control_discover(configured: &str) -> Option { + let mut candidates: Vec = Vec::new(); + if !configured.is_empty() { + candidates.push(configured.to_string()); + } + candidates.push(FIPS_SOCKET_CANDIDATES[0].to_string()); + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + if !xdg.is_empty() { + candidates.push(format!("{}/fips/control.sock", xdg)); } } + candidates.push(FIPS_SOCKET_CANDIDATES[1].to_string()); + candidates.into_iter().find(|p| fips_control_socket_available(p)) } -/// Initialize FIPS control. -pub fn fips_control_init() { - let mut state = G_FIPS_CONTROL.lock().unwrap(); - state.running = false; -} +/// Send one command on a fresh connection to `socket` and return the +/// response's `data` field. +fn request_on(socket: &str, command: &str, params: Option) -> Result { + let mut stream = connect(socket)?; -/// Start the FIPS mesh node. -pub fn fips_control_start() -> Result<(), Box> { - let mut state = G_FIPS_CONTROL.lock().unwrap(); - if state.running { - return Ok(()); + let mut req = serde_json::json!({ "command": command }); + if let Some(p) = params { + req["params"] = p; } - // Try to connect to the FIPS control socket to verify it's running. - let socket_path = state.socket_path.clone(); - if UnixStream::connect(&socket_path).is_ok() { - state.running = true; - println!("[fips] FIPS mesh node connected via {}", socket_path); - Ok(()) - } else { - // FIPS daemon not running — mark as not running. - println!("[fips] FIPS control socket {} not available", socket_path); - Err(format!("FIPS control socket {} not available", socket_path).into()) - } -} - -/// Stop the FIPS mesh node. -pub fn fips_control_stop() { - let mut state = G_FIPS_CONTROL.lock().unwrap(); - state.running = false; - println!("[fips] FIPS mesh node stopped"); -} - -/// Check if FIPS is running. -pub fn fips_control_is_running() -> bool { - let state = G_FIPS_CONTROL.lock().unwrap(); - state.running -} - -/// Send a JSON-line command to the FIPS control socket and return the -/// parsed response JSON. -fn fips_request(command: &str) -> Result> { - let state = G_FIPS_CONTROL.lock().unwrap(); - let socket_path = state.socket_path.clone(); - drop(state); - - let mut stream = UnixStream::connect(&socket_path)?; - stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; - stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; - - // Send the command as a JSON line. - let request = serde_json::json!({ "command": command }); - let line = format!("{}\n", serde_json::to_string(&request)?); + let line = format!("{}\n", serde_json::to_string(&req)?); stream.write_all(line.as_bytes())?; - // Read the response (until newline). - let mut reader = BufReader::new(stream); + let mut reader = BufReader::new(stream.take(FIPS_RESPONSE_MAX)); let mut response = String::new(); - let n = reader.read_line(&mut response)?; + let n = reader.read_line(&mut response).map_err(|e| { + if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut { + "FIPS response timed out".to_string() + } else { + format!("FIPS read failed: {}", e) + } + })?; if n == 0 { return Err("FIPS closed control connection".into()); } - let root: serde_json::Value = serde_json::from_str(&response)?; + let root: serde_json::Value = + serde_json::from_str(response.trim()).map_err(|_| "invalid FIPS JSON response")?; if root.get("status").and_then(|s| s.as_str()) != Some("ok") { - let msg = root.get("message").and_then(|m| m.as_str()).unwrap_or("request failed"); + let msg = root + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("request failed"); return Err(format!("FIPS control error: {}", msg).into()); } - Ok(root) + Ok(root.get("data").cloned().unwrap_or(serde_json::Value::Null)) } -/// Get the FIPS status. -pub fn fips_control_show_status() -> Result> { - let root = fips_request("show_status")?; - Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +/// Send a command using the current control socket. +fn request(command: &str, params: Option) -> Result { + let socket = fips_control_socket(); + if socket.is_empty() { + return Err("FIPS control socket unavailable".into()); + } + request_on(&socket, command, params) } -/// Get the FIPS peer list. -pub fn fips_control_show_peers() -> Result> { - let root = fips_request("show_peers")?; - Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +/// Parse a `show_status` payload. +fn parse_status(data: &serde_json::Value) -> FipsStatus { + let s = |k: &str| data.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let tun_state = s("tun_state"); + let mut tree_state = s("tree_state"); + if tree_state.is_empty() { + tree_state = s("tree"); + } + FipsStatus { + npub: s("npub"), + tun_name: s("tun_name"), + tun_active: tun_state == "active" || tun_state == "up", + state: s("state"), + tree_state, + peer_count: data.get("peer_count").and_then(|v| v.as_i64()).unwrap_or(0), + } +} + +/// Query `show_status` on a specific socket (used while attaching, before +/// the socket becomes the current one). +pub fn fips_control_status_on(socket: &str) -> Result { + let data = request_on(socket, "show_status", None)?; + if !data.is_object() { + return Err("FIPS status response lacks data".into()); + } + Ok(parse_status(&data)) +} + +/// Get the typed FIPS status from the current socket. +pub fn fips_control_status() -> Result { + let data = request("show_status", None)?; + if !data.is_object() { + return Err("FIPS status response lacks data".into()); + } + Ok(parse_status(&data)) +} + +/// Get the raw FIPS `show_status` data. +pub fn fips_control_show_status() -> Result { + request("show_status", None) +} + +/// Get the FIPS peer list. Normalizes both response shapes (a bare array +/// or `{"peers": [...]}`) to a JSON array. +pub fn fips_control_show_peers() -> Result { + let data = request("show_peers", None)?; + Ok(match data { + serde_json::Value::Array(_) => data, + serde_json::Value::Object(ref o) => o + .get("peers") + .cloned() + .filter(|p| p.is_array()) + .unwrap_or_else(|| serde_json::json!([])), + _ => serde_json::json!([]), + }) } /// Get the FIPS spanning tree. -pub fn fips_control_show_tree() -> Result> { - let root = fips_request("show_tree")?; - Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +pub fn fips_control_show_tree() -> Result { + request("show_tree", None) } -/// Get the FIPS identity cache. -pub fn fips_control_show_identity_cache() -> Result> { - let root = fips_request("show_identity_cache")?; - Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +/// Get the FIPS identity cache (`{"entries": [...], "count": N, ...}`). +pub fn fips_control_show_identity_cache() -> Result { + request("show_identity_cache", None) } /// Connect to a FIPS peer. -pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result> { - let state = G_FIPS_CONTROL.lock().unwrap(); - let socket_path = state.socket_path.clone(); - drop(state); - - let mut stream = UnixStream::connect(&socket_path)?; - stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; - stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; - - let request = serde_json::json!({ - "command": "connect_peer", - "npub": npub, - "address": address, - "transport": transport, - }); - let line = format!("{}\n", serde_json::to_string(&request)?); - stream.write_all(line.as_bytes())?; - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader.read_line(&mut response)?; - let root: serde_json::Value = serde_json::from_str(&response)?; - Ok(root) +pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result<()> { + if npub.is_empty() || address.is_empty() { + return Err("FIPS peer npub and address are required".into()); + } + let transport = if transport.is_empty() { "udp" } else { transport }; + request( + "connect", + Some(serde_json::json!({ + "npub": npub, + "address": address, + "transport": transport, + })), + )?; + Ok(()) } /// Disconnect from a FIPS peer. -pub fn fips_control_disconnect_peer(npub: &str) -> Result> { - let state = G_FIPS_CONTROL.lock().unwrap(); - let socket_path = state.socket_path.clone(); - drop(state); - - let mut stream = UnixStream::connect(&socket_path)?; - stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; - stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; - - let request = serde_json::json!({ - "command": "disconnect_peer", - "npub": npub, - }); - let line = format!("{}\n", serde_json::to_string(&request)?); - stream.write_all(line.as_bytes())?; - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader.read_line(&mut response)?; - let root: serde_json::Value = serde_json::from_str(&response)?; - Ok(root) -} - -/// Resolve a FIPS address (npub) to a reachable endpoint. -pub fn fips_control_resolve(npub: &str) -> Option { - // Query the identity cache for the npub's address. - if let Ok(cache) = fips_control_show_identity_cache() { - if let Some(nodes) = cache.get("nodes").and_then(|n| n.as_array()) { - for node in nodes { - if node.get("npub").and_then(|n| n.as_str()) == Some(npub) { - if let Some(addr) = node.get("address").and_then(|a| a.as_str()) { - return Some(addr.to_string()); - } - } - } - } +pub fn fips_control_disconnect_peer(npub: &str) -> Result<()> { + if npub.is_empty() { + return Err("FIPS peer npub is required".into()); } - None + request("disconnect", Some(serde_json::json!({ "npub": npub })))?; + Ok(()) +} + +/// Resolve a FIPS address (npub) to a reachable endpoint, using the +/// identity cache. +#[allow(dead_code)] +pub fn fips_control_resolve(npub: &str) -> Option { + let cache = fips_control_show_identity_cache().ok()?; + let entries = cache.get("entries").and_then(|n| n.as_array())?; + entries + .iter() + .find(|e| e.get("npub").and_then(|n| n.as_str()) == Some(npub)) + .and_then(|e| e.get("address").and_then(|a| a.as_str())) + .map(|a| a.to_string()) } diff --git a/src/lib.rs b/src/lib.rs index 671bc27..9a3ea25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,7 @@ pub mod net_services; pub mod tor_control; pub mod tor_scheme; pub mod fips_control; +pub mod fips_api; pub mod web_context; pub mod webkit_data; pub mod site_downloader; diff --git a/src/main.rs b/src/main.rs index d632331..b9dd8f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ mod net_services; mod tor_control; mod tor_scheme; mod fips_control; +mod fips_api; mod web_context; mod webkit_data; mod site_downloader; @@ -339,6 +340,9 @@ fn main() { if args.no_login { println!("[login] No-login mode (browsing without Nostr identity)"); menu::app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true); + // Other login paths load saved settings; do the same here so Tor / + // FIPS autostart (and everything else) honours the stored values. + settings::settings_load(); } else if args.login_method.is_some() { // Auto-login from CLI args (no dialog). if !do_cli_login(&args) { @@ -351,6 +355,10 @@ fn main() { do_login(&window); } + // Bring up Tor / FIPS according to the (now loaded) user settings: + // attach to running daemons, or spawn managed ones. Non-blocking. + net_services::net_services_autostart(); + // Now build the UI. Each tab carries its own toolbar (URL entry, // back/forward/refresh, bookmark, hamburger menu), so there is no // shared top-level toolbar. @@ -434,6 +442,20 @@ fn main() { } } + // Quit cleanly on SIGTERM / SIGINT so managed Tor/FIPS processes are + // stopped below instead of being orphaned. + for sig in [15 /* SIGTERM */, 2 /* SIGINT */] { + glib::unix_signal_add_local(sig, || { + println!("[browser] Termination signal received; shutting down"); + session::session_save(); + gtk::main_quit(); + glib::ControlFlow::Break + }); + } + println!("[browser] Starting GTK main loop..."); gtk::main(); + + // Stop managed Tor/FIPS processes (attached daemons are left alone). + net_services::net_services_shutdown(); } diff --git a/src/net_services.rs b/src/net_services.rs index cd270f7..cb7869d 100644 --- a/src/net_services.rs +++ b/src/net_services.rs @@ -1,141 +1,1136 @@ -//! Network services management (Tor, FIPS, etc.) +//! Network services management (Tor, FIPS) //! -//! Port of `net_services.c` / `net_services.h` from the C project. +//! Port of `net_services.c` / `net_services.h` from the C project: +//! unified discovery, ownership and supervision of the Tor and FIPS +//! services. +//! +//! Enabling a service first tries to **attach** to an already-running +//! daemon (system Tor, system FIPS). If none is found and the mode allows +//! it, the browser **manages** its own process: it writes a private +//! config, spawns the binary, forwards its output to the log, watches the +//! child and polls its control interface until it reports ready. +//! +//! Every enabled service has one supervisor thread (poll + child watch). +//! All shared state lives behind a single mutex; blocking I/O is never +//! done while it is held. +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream, ToSocketAddrs}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; + use once_cell::sync::Lazy; -/// Network service status. -#[derive(Debug, Clone, PartialEq)] -#[allow(dead_code)] -pub enum ServiceStatus { - Stopped, +use crate::settings::{self, BrowserSettings}; +use crate::{fips_control, tor_control}; + +type Res = Result>; + +/// Supervisor wake-up granularity. +const TICK: Duration = Duration::from_millis(250); +/// Tor bootstrap poll interval. +const TOR_POLL: Duration = Duration::from_secs(2); +/// FIPS poll interval while starting. +const FIPS_POLL_STARTING: Duration = Duration::from_secs(2); +/// FIPS poll interval once ready. +const FIPS_POLL_READY: Duration = Duration::from_secs(5); +/// Grace period between SIGTERM and SIGKILL for a managed process. +const STOP_GRACE: Duration = Duration::from_secs(5); +/// Connect timeout when probing for an attachable daemon. +const PROBE_TIMEOUT: Duration = Duration::from_millis(500); + +// ── Public state types ────────────────────────────────────────────── + +/// Lifecycle state of a service (mirrors `service_state_t` in C). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceState { + Disabled, + Discovering, + Attaching, Starting, - Running, - Error(String), + Bootstrapping, + Ready, + Stopping, + Exited, + Failed, } -/// Network services state. -static G_NET_SERVICES: Lazy> = Lazy::new(|| Mutex::new(NetServicesState::default())); - -struct NetServicesState { - tor_status: ServiceStatus, - fips_status: ServiceStatus, -} - -impl Default for NetServicesState { - fn default() -> Self { - NetServicesState { - tor_status: ServiceStatus::Stopped, - fips_status: ServiceStatus::Stopped, +impl ServiceState { + pub fn as_str(self) -> &'static str { + match self { + ServiceState::Disabled => "disabled", + ServiceState::Discovering => "discovering", + ServiceState::Attaching => "attaching", + ServiceState::Starting => "starting", + ServiceState::Bootstrapping => "bootstrapping", + ServiceState::Ready => "ready", + ServiceState::Stopping => "stopping", + ServiceState::Exited => "exited", + ServiceState::Failed => "failed", } } } -/// Initialize network services. +/// Who owns the service process (mirrors `service_ownership_t` in C). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ownership { + None, + Attached, + Managed, +} + +impl Ownership { + pub fn as_str(self) -> &'static str { + match self { + Ownership::None => "none", + Ownership::Attached => "attached", + Ownership::Managed => "managed", + } + } +} + +/// Snapshot of the Tor service. +#[derive(Debug, Clone)] +pub struct TorStatus { + pub state: ServiceState, + pub ownership: Ownership, + pub error: String, + pub bootstrap_progress: i32, + /// Proxy URL usable by `reqwest` (`socks5h://host:port`). + pub socks_endpoint: String, + pub circuit_info: String, + /// `host:port` or `unix:/path`; empty if unknown. + pub control_endpoint: String, + pub cookie_path: String, +} + +/// Snapshot of the FIPS service. +#[derive(Debug, Clone)] +pub struct FipsServiceStatus { + pub state: ServiceState, + pub ownership: Ownership, + pub error: String, + pub node_npub: String, + pub peer_count: i64, + pub tun_active: bool, + pub tun_name: String, + pub tree_state: String, + pub daemon_state: String, + pub control_socket: String, +} + +impl TorStatus { + fn new() -> Self { + TorStatus { + state: ServiceState::Disabled, + ownership: Ownership::None, + error: String::new(), + bootstrap_progress: 0, + socks_endpoint: String::new(), + circuit_info: String::new(), + control_endpoint: String::new(), + cookie_path: String::new(), + } + } +} + +impl FipsServiceStatus { + fn new() -> Self { + FipsServiceStatus { + state: ServiceState::Disabled, + ownership: Ownership::None, + error: String::new(), + node_npub: String::new(), + peer_count: 0, + tun_active: false, + tun_name: String::new(), + tree_state: String::new(), + daemon_state: String::new(), + control_socket: String::new(), + } + } +} + +// ── Internal state ────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Which { + Tor, + Fips, +} + +impl Which { + fn name(self) -> &'static str { + match self { + Which::Tor => "tor", + Which::Fips => "fips", + } + } +} + +/// Process/thread bookkeeping for one service. +#[derive(Default)] +struct Slot { + /// Bumped whenever a supervisor is (re)started or detached; a + /// supervisor whose generation no longer matches exits. + generation: u64, + pid: Option, + stop_requested: bool, + stop_at: Option, +} + +struct Inner { + tor: TorStatus, + fips: FipsServiceStatus, + tor_slot: Slot, + fips_slot: Slot, +} + +static G: Lazy> = Lazy::new(|| { + Mutex::new(Inner { + tor: TorStatus::new(), + fips: FipsServiceStatus::new(), + tor_slot: Slot::default(), + fips_slot: Slot::default(), + }) +}); + +static AUTOSTARTED: AtomicBool = AtomicBool::new(false); + +impl Inner { + fn state(&self, w: Which) -> ServiceState { + match w { + Which::Tor => self.tor.state, + Which::Fips => self.fips.state, + } + } + fn set_state(&mut self, w: Which, s: ServiceState) { + match w { + Which::Tor => self.tor.state = s, + Which::Fips => self.fips.state = s, + } + } + fn set_ownership(&mut self, w: Which, o: Ownership) { + match w { + Which::Tor => self.tor.ownership = o, + Which::Fips => self.fips.ownership = o, + } + } + fn ownership(&self, w: Which) -> Ownership { + match w { + Which::Tor => self.tor.ownership, + Which::Fips => self.fips.ownership, + } + } + fn set_error(&mut self, w: Which, e: &str) { + match w { + Which::Tor => self.tor.error = e.to_string(), + Which::Fips => self.fips.error = e.to_string(), + } + } + fn slot(&self, w: Which) -> &Slot { + match w { + Which::Tor => &self.tor_slot, + Which::Fips => &self.fips_slot, + } + } + fn slot_mut(&mut self, w: Which) -> &mut Slot { + match w { + Which::Tor => &mut self.tor_slot, + Which::Fips => &mut self.fips_slot, + } + } +} + +fn lock() -> std::sync::MutexGuard<'static, Inner> { + G.lock().unwrap_or_else(|e| e.into_inner()) +} + +// ── Public getters ────────────────────────────────────────────────── + +/// Reset both services to disabled. Does not start anything; see +/// [`net_services_autostart`]. pub fn net_services_init() { - let mut state = G_NET_SERVICES.lock().unwrap(); - state.tor_status = ServiceStatus::Stopped; - state.fips_status = ServiceStatus::Stopped; + let mut g = lock(); + g.tor = TorStatus::new(); + g.fips = FipsServiceStatus::new(); + println!("[net.service] initialized"); } -/// Get Tor status. -pub fn net_services_get_tor_status() -> ServiceStatus { - let state = G_NET_SERVICES.lock().unwrap(); - state.tor_status.clone() +/// Current Tor status snapshot. +pub fn net_services_tor_status() -> TorStatus { + lock().tor.clone() } -/// Get FIPS status. -pub fn net_services_get_fips_status() -> ServiceStatus { - let state = G_NET_SERVICES.lock().unwrap(); - state.fips_status.clone() +/// Current FIPS status snapshot. +pub fn net_services_fips_status() -> FipsServiceStatus { + lock().fips.clone() } -/// Start Tor. -/// -/// Spawning a managed Tor process is not ported yet, so this attaches to -/// an existing Tor SOCKS listener (e.g. the system daemon) and reports an -/// error if none is reachable, rather than falsely marking Tor as running. -pub fn net_services_start_tor() -> Result<(), Box> { - // TODO: spawn and manage a Tor process like the C net_service_enable(). - let available = tor_socks_port_available(); - let mut state = G_NET_SERVICES.lock().unwrap(); - if available { - state.tor_status = ServiceStatus::Running; - println!("[net] Tor attached (SOCKS 127.0.0.1:9050)"); - Ok(()) - } else { - let msg = "no Tor SOCKS listener on 127.0.0.1:9050".to_string(); - state.tor_status = ServiceStatus::Error(msg.clone()); - Err(msg.into()) - } -} - -/// Stop Tor. -pub fn net_services_stop_tor() { - let mut state = G_NET_SERVICES.lock().unwrap(); - state.tor_status = ServiceStatus::Stopped; - println!("[net] Tor stopped"); -} - -/// Start FIPS (attaches to the FIPS daemon's control socket). -pub fn net_services_start_fips() -> Result<(), Box> { - // TODO: spawn and manage a FIPS process like the C net_service_enable(). - let result = crate::fips_control::fips_control_start(); - let mut state = G_NET_SERVICES.lock().unwrap(); - match &result { - Ok(()) => { - state.fips_status = ServiceStatus::Running; - println!("[net] FIPS attached"); - } - Err(e) => state.fips_status = ServiceStatus::Error(e.to_string()), - } - result -} - -/// Stop FIPS. -pub fn net_services_stop_fips() { - crate::fips_control::fips_control_stop(); - let mut state = G_NET_SERVICES.lock().unwrap(); - state.fips_status = ServiceStatus::Stopped; - println!("[net] FIPS stopped"); -} - -/// Get the Tor SOCKS proxy endpoint. -/// -/// Returns the SOCKS5 endpoint (e.g. "socks5h://127.0.0.1:9050") if Tor -/// is available, or None if Tor is not running. The "socks5h" prefix -/// tells the proxy to perform DNS resolution through Tor (remote DNS), -/// which is required for .onion hostnames. -/// -/// Checks both the internal service status AND whether the SOCKS port -/// is actually reachable (e.g. a system Tor daemon started outside the -/// browser). +/// The Tor SOCKS proxy URL (e.g. `socks5h://127.0.0.1:9050`) if Tor is +/// ready. `socks5h` makes the proxy resolve hostnames, which is required +/// for `.onion` addresses. pub fn net_services_get_tor_socks_endpoint() -> Option { - { - let state = G_NET_SERVICES.lock().unwrap(); - if state.tor_status == ServiceStatus::Running { - return Some("socks5h://127.0.0.1:9050".to_string()); - } - } - - // Check if a system Tor is listening on the default SOCKS port. - if tor_socks_port_available() { - Some("socks5h://127.0.0.1:9050".to_string()) + let g = lock(); + if g.tor.state == ServiceState::Ready && !g.tor.socks_endpoint.is_empty() { + Some(g.tor.socks_endpoint.clone()) } else { None } } -/// Check if Tor's SOCKS port (9050) is accepting connections. -fn tor_socks_port_available() -> bool { - use std::net::TcpStream; - use std::time::Duration; - match TcpStream::connect_timeout( - &"127.0.0.1:9050".parse().unwrap(), - Duration::from_millis(300), - ) { - Ok(_) => true, +/// Get a ready Tor SOCKS proxy URL for a request, waiting up to `timeout` +/// for a starting/bootstrapping Tor and starting the service on demand. +/// Returns a human-readable error if Tor is disabled or unavailable. +/// Blocks; call from a worker thread. +pub fn net_services_tor_socks_for_request(timeout: Duration) -> Result { + if !settings::settings_get().tor_enabled { + return Err( + "Tor is disabled. Enable \"Tor-routed transport\" in the menu to open .onion sites." + .to_string(), + ); + } + let deadline = Instant::now() + timeout; + let mut tried_start = false; + loop { + let st = net_services_tor_status(); + match st.state { + ServiceState::Ready if !st.socks_endpoint.is_empty() => return Ok(st.socks_endpoint), + ServiceState::Disabled | ServiceState::Exited | ServiceState::Failed => { + if tried_start { + return Err(if st.error.is_empty() { + "Tor is not running".to_string() + } else { + format!("Tor is not available: {}", st.error) + }); + } + tried_start = true; + if let Err(e) = net_services_start_tor() { + return Err(format!("Tor is not available: {}", e)); + } + } + _ => {} + } + if Instant::now() >= deadline { + return Err(format!( + "Tor is still starting ({}% bootstrapped)", + st.bootstrap_progress + )); + } + thread::sleep(Duration::from_millis(250)); + } +} + +/// Combined status JSON consumed by `www/fips.js` (`sovereign://fips/status`). +pub fn net_services_status_json() -> serde_json::Value { + let s = settings::settings_get(); + let (tor, fips) = { + let g = lock(); + (g.tor.clone(), g.fips.clone()) + }; + serde_json::json!({ + "fips": { + "state": fips.state.as_str(), + "enabled": s.fips_enabled, + "ownership": fips.ownership.as_str(), + "node_npub": fips.node_npub, + "peer_count": fips.peer_count, + "tun_active": fips.tun_active, + "tun_name": fips.tun_name, + "tree_state": fips.tree_state, + "daemon_state": fips.daemon_state, + "control_socket": fips.control_socket, + "error": fips.error, + }, + "tor": { + "state": tor.state.as_str(), + "enabled": s.tor_enabled, + "ownership": tor.ownership.as_str(), + "bootstrap_progress": tor.bootstrap_progress, + "socks_endpoint": tor.socks_endpoint, + "circuit_info": tor.circuit_info, + "error": tor.error, + }, + }) +} + +// ── Lifecycle ─────────────────────────────────────────────────────── + +/// Enable the services the user's settings ask for. Runs once; call it +/// after the per-user settings have been loaded. +pub fn net_services_autostart() { + if AUTOSTARTED.swap(true, Ordering::SeqCst) { + return; + } + let s = settings::settings_get(); + println!( + "[net.service] autostart (Tor={}, FIPS={})", + if s.tor_enabled { "enabled" } else { "disabled" }, + if s.fips_enabled { "enabled" } else { "disabled" } + ); + if s.tor_enabled { + if let Err(e) = net_services_start_tor() { + eprintln!("[net.service.tor] autostart failed: {}", e); + } + } + if s.fips_enabled { + if let Err(e) = net_services_start_fips() { + eprintln!("[net.service.fips] autostart failed: {}", e); + } + } +} + +/// Stop everything the browser started and wait (bounded) for managed +/// processes to exit. Attached daemons are left running. +pub fn net_services_shutdown() { + println!("[net.service] shutting down"); + net_services_stop_tor(); + net_services_stop_fips(); + let deadline = Instant::now() + STOP_GRACE + Duration::from_secs(2); + while Instant::now() < deadline { + { + let g = lock(); + if g.tor_slot.pid.is_none() && g.fips_slot.pid.is_none() { + return; + } + } + thread::sleep(Duration::from_millis(100)); + } +} + +/// Claim the service for a start attempt. `Ok(false)` means it is already +/// running/starting and nothing needs to be done. +fn begin(which: Which) -> Res { + let mut g = lock(); + match g.state(which) { + ServiceState::Discovering + | ServiceState::Attaching + | ServiceState::Starting + | ServiceState::Bootstrapping + | ServiceState::Ready => return Ok(false), + ServiceState::Stopping => { + return Err(format!("{} is still stopping", which.name()).into()) + } + _ => {} + } + g.set_state(which, ServiceState::Discovering); + g.set_ownership(which, Ownership::None); + g.set_error(which, ""); + match which { + Which::Tor => { + g.tor.bootstrap_progress = 0; + g.tor.circuit_info.clear(); + } + Which::Fips => { + g.fips.peer_count = 0; + g.fips.tun_active = false; + } + } + let slot = g.slot_mut(which); + slot.stop_requested = false; + slot.stop_at = None; + Ok(true) +} + +/// Record a failure and return it as an error. +fn fail(which: Which, msg: &str) -> Box { + eprintln!("[net.service.{}] FAILED: {}", which.name(), msg); + let mut g = lock(); + g.set_state(which, ServiceState::Failed); + g.set_error(which, msg); + msg.to_string().into() +} + +/// Start Tor: attach to an existing instance, else manage our own. +pub fn net_services_start_tor() -> Res<()> { + if !begin(Which::Tor)? { + return Ok(()); + } + let s = settings::settings_get(); + let allow_attach = s.tor_mode != "manage"; + let allow_manage = s.tor_mode != "attach"; + + if allow_attach { + if let Some((socks, control)) = discover_tor(&s) { + return attach_tor(&socks, control); + } + } + if allow_manage { + return start_managed_tor(&s); + } + Err(fail(Which::Tor, "no attachable Tor instance found")) +} + +/// Start FIPS: attach to an existing daemon, else manage our own. +pub fn net_services_start_fips() -> Res<()> { + if !begin(Which::Fips)? { + return Ok(()); + } + let s = settings::settings_get(); + let allow_attach = s.fips_mode != "manage"; + let allow_manage = s.fips_mode != "attach"; + + if allow_attach { + if let Some(sock) = fips_control::fips_control_discover(&s.fips_control_socket) { + match attach_fips(&sock) { + Ok(()) => return Ok(()), + Err(e) => eprintln!("[net.service.fips] attach to {} failed: {}", sock, e), + } + } + } + if allow_manage { + return start_managed_fips(&s); + } + Err(fail(Which::Fips, "no attachable FIPS instance found")) +} + +pub fn net_services_stop_tor() { + stop(Which::Tor); +} + +pub fn net_services_stop_fips() { + stop(Which::Fips); +} + +fn stop(which: Which) { + let mut g = lock(); + let ownership = g.ownership(which); + let pid = g.slot(which).pid; + match (ownership, pid) { + (Ownership::Managed, Some(pid)) => { + if g.state(which) == ServiceState::Stopping { + return; + } + g.set_state(which, ServiceState::Stopping); + let slot = g.slot_mut(which); + slot.stop_requested = true; + slot.stop_at = Some(Instant::now() + STOP_GRACE); + // SAFETY: plain kill(2) on a pid we spawned and still own. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + println!("[net.service.{}] stopping managed process pid={}", which.name(), pid); + } + _ => { + // Attached (or never started): detach and stop polling. The + // daemon itself is left running. + let was_attached = ownership == Ownership::Attached; + g.slot_mut(which).generation += 1; + g.set_state(which, ServiceState::Disabled); + g.set_ownership(which, Ownership::None); + g.set_error(which, ""); + if which == Which::Tor { + g.tor.bootstrap_progress = 0; + g.tor.socks_endpoint.clear(); + g.tor.control_endpoint.clear(); + } else { + g.fips.control_socket.clear(); + } + drop(g); + if which == Which::Fips { + fips_control::fips_control_set_socket(""); + } + if was_attached { + println!( + "[net.service.{}] detached (system service left running)", + which.name() + ); + } + } + } +} + +/// Restart FIPS. An attached (external) daemon is left alone. Blocks; +/// call from a worker thread. +pub fn net_services_restart_fips() -> Res<()> { + let (ownership, state) = { + let g = lock(); + (g.fips.ownership, g.fips.state) + }; + if ownership == Ownership::Attached { + return Ok(()); + } + if state != ServiceState::Disabled { + stop(Which::Fips); + let deadline = Instant::now() + STOP_GRACE + Duration::from_secs(3); + while Instant::now() < deadline { + if lock().fips_slot.pid.is_none() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + // Allow a restart from the terminal states. + let mut g = lock(); + if g.fips.state == ServiceState::Stopping { + return Err("FIPS did not stop in time".into()); + } + if g.fips.state != ServiceState::Disabled { + g.fips.state = ServiceState::Exited; + } + } + net_services_start_fips() +} + +// ── Supervisor ────────────────────────────────────────────────────── + +/// Watch a service: reap/escalate its child (if managed) and poll its +/// control interface. Exits when superseded, when the child exits, or +/// (for attached Tor) when bootstrap completes. +fn supervisor(which: Which, gen: u64, mut child: Option) { + let mut next_poll = Instant::now(); + let mut polling = true; + loop { + thread::sleep(TICK); + + let (superseded, stop_at, state) = { + let g = lock(); + let slot = g.slot(which); + (slot.generation != gen, slot.stop_at, g.state(which)) + }; + if superseded { + if let Some(c) = child.as_mut() { + let _ = c.kill(); + let _ = c.wait(); + } + return; + } + + if let Some(c) = child.as_mut() { + match c.try_wait() { + Ok(Some(status)) => { + on_child_exit(which, gen, status); + return; + } + Ok(None) => { + if let Some(at) = stop_at { + if Instant::now() >= at { + eprintln!( + "[net.service.{}] SIGTERM timeout; sending SIGKILL", + which.name() + ); + let _ = c.kill(); + } + } + } + Err(e) => { + let _ = fail(which, &format!("cannot wait for process: {}", e)); + lock().slot_mut(which).pid = None; + return; + } + } + } else if !polling { + return; + } + + if state == ServiceState::Stopping || !polling || Instant::now() < next_poll { + continue; + } + match which { + Which::Tor => { + if poll_tor(gen) { + polling = false; + } + next_poll = Instant::now() + TOR_POLL; + } + Which::Fips => { + let ready = poll_fips(gen); + next_poll = + Instant::now() + if ready { FIPS_POLL_READY } else { FIPS_POLL_STARTING }; + } + } + } +} + +fn on_child_exit(which: Which, gen: u64, status: ExitStatus) { + let mut g = lock(); + if g.slot(which).generation != gen { + return; + } + let expected = g.slot(which).stop_requested; + let slot = g.slot_mut(which); + slot.pid = None; + slot.stop_at = None; + if expected { + g.set_state(which, ServiceState::Exited); + println!("[net.service.{}] managed process exited", which.name()); + } else { + use std::os::unix::process::ExitStatusExt; + let msg = match (status.code(), status.signal()) { + (Some(c), _) => format!("process exited with status {}", c), + (None, Some(s)) => format!("process killed by signal {}", s), + _ => "process exited unexpectedly".to_string(), + }; + eprintln!("[net.service.{}] FAILED: {}", which.name(), msg); + g.set_state(which, ServiceState::Failed); + g.set_error(which, &msg); + } +} + +/// Poll Tor's bootstrap phase. Returns `true` when polling is finished. +fn poll_tor(gen: u64) -> bool { + let (endpoint, cookie) = { + let g = lock(); + (g.tor.control_endpoint.clone(), g.tor.cookie_path.clone()) + }; + if endpoint.is_empty() { + return false; + } + match tor_control::tor_control_bootstrap_on(&endpoint, &cookie) { + Ok((progress, summary)) => { + let mut g = lock(); + if g.tor_slot.generation != gen { + return true; + } + g.tor.bootstrap_progress = progress; + g.tor.circuit_info = summary; + if progress >= 100 { + g.tor.state = ServiceState::Ready; + println!("[net.service.tor] bootstrap complete"); + return true; + } + if g.tor.state != ServiceState::Stopping { + g.tor.state = ServiceState::Bootstrapping; + } + println!("[net.service.tor] bootstrap {}%", progress); + false + } + // Control socket / cookie not there yet (managed Tor still starting). Err(_) => false, } } + +/// Poll FIPS `show_status`. Returns whether the node is ready. +fn poll_fips(gen: u64) -> bool { + let socket = lock().fips.control_socket.clone(); + match fips_control::fips_control_status_on(&socket) { + Ok(st) => { + let mut g = lock(); + if g.fips_slot.generation != gen { + return false; + } + g.fips.peer_count = st.peer_count; + g.fips.node_npub = st.npub.clone(); + g.fips.tree_state = st.tree_state; + g.fips.tun_name = st.tun_name.clone(); + g.fips.daemon_state = st.state; + g.fips.tun_active = st.tun_active; + if g.fips.state != ServiceState::Ready && g.fips.state != ServiceState::Stopping { + g.fips.state = ServiceState::Ready; + g.fips.error.clear(); + println!( + "[net.service.fips] ready: node={} peers={} tun={}", + st.npub, st.peer_count, st.tun_name + ); + } + g.fips.state == ServiceState::Ready + } + Err(_) => { + let mut g = lock(); + if g.fips_slot.generation == gen && g.fips.state == ServiceState::Ready { + g.fips.daemon_state = "unreachable".to_string(); + } + false + } + } +} + +// ── Tor ───────────────────────────────────────────────────────────── + +/// Strip an optional `socks5://` / `socks5h://` scheme. +fn normalize_socks(s: &str) -> String { + let s = s.trim(); + let s = s + .strip_prefix("socks5h://") + .or_else(|| s.strip_prefix("socks5://")) + .unwrap_or(s); + s.to_string() +} + +/// True if a SOCKS endpoint (`host:port` or `unix:/path`) accepts connections. +fn socks_endpoint_available(endpoint: &str) -> bool { + if let Some(path) = endpoint.strip_prefix("unix:") { + return UnixStream::connect(path).is_ok(); + } + match endpoint.to_socket_addrs() { + Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()), + Err(_) => false, + } +} + +/// Find an attachable Tor: `(socks endpoint, control endpoint)`. +fn discover_tor(s: &BrowserSettings) -> Option<(String, Option)> { + let nonempty = |v: &str| if v.trim().is_empty() { None } else { Some(v.trim().to_string()) }; + + if let Some(configured) = nonempty(&s.tor_attach_socks) { + let socks = normalize_socks(&configured); + if socks_endpoint_available(&socks) { + return Some((socks, nonempty(&s.tor_attach_control))); + } + } + + let tcp_socks = "127.0.0.1:9050"; + let unix_control = "unix:/run/tor/control"; + let tcp_control = "127.0.0.1:9051"; + let tcp_ok = socks_endpoint_available(tcp_socks); + + if tcp_ok { + let control = if tor_control::tor_control_endpoint_available(unix_control) { + Some(unix_control.to_string()) + } else if tor_control::tor_control_endpoint_available(tcp_control) { + Some(tcp_control.to_string()) + } else { + None + }; + return Some((tcp_socks.to_string(), control)); + } + + let unix_socks = "unix:/run/tor/socks"; + if socks_endpoint_available(unix_socks) { + let control = tor_control::tor_control_endpoint_available(unix_control) + .then(|| unix_control.to_string()); + return Some((unix_socks.to_string(), control)); + } + None +} + +/// Unix-socket SOCKS ports cannot be used by `reqwest` directly, so each +/// distinct socket gets a loopback TCP forwarder (lifetime: the process). +static UNIX_SOCKS_BRIDGES: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +fn unix_socks_bridge(path: &str) -> std::io::Result { + let mut map = UNIX_SOCKS_BRIDGES.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(port) = map.get(path) { + return Ok(*port); + } + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let path_owned = path.to_string(); + thread::spawn(move || { + for conn in listener.incoming().flatten() { + let p = path_owned.clone(); + thread::spawn(move || forward_to_unix(conn, &p)); + } + }); + map.insert(path.to_string(), port); + println!("[net.service.tor] bridging unix:{} on 127.0.0.1:{}", path, port); + Ok(port) +} + +fn forward_to_unix(tcp: TcpStream, path: &str) { + let Ok(unix) = UnixStream::connect(path) else { return }; + let (Ok(mut tcp_r), Ok(mut unix_w)) = (tcp.try_clone(), unix.try_clone()) else { return }; + let (mut unix_r, mut tcp_w) = (unix, tcp); + let up = thread::spawn(move || { + let _ = std::io::copy(&mut tcp_r, &mut unix_w); + let _ = unix_w.shutdown(std::net::Shutdown::Write); + }); + let _ = std::io::copy(&mut unix_r, &mut tcp_w); + let _ = tcp_w.shutdown(std::net::Shutdown::Write); + let _ = up.join(); +} + +/// Proxy URL for a SOCKS endpoint. +fn socks_proxy_url(endpoint: &str) -> std::io::Result { + if let Some(path) = endpoint.strip_prefix("unix:") { + let port = unix_socks_bridge(path)?; + Ok(format!("socks5h://127.0.0.1:{}", port)) + } else { + Ok(format!("socks5h://{}", endpoint)) + } +} + +fn attach_tor(socks: &str, control: Option) -> Res<()> { + let proxy = match socks_proxy_url(socks) { + Ok(p) => p, + Err(e) => return Err(fail(Which::Tor, &format!("cannot proxy {}: {}", socks, e))), + }; + + // The Debian convention is `.authcookie`; only use it + // when readable, otherwise best-effort null authentication. + let cookie = control + .as_deref() + .and_then(|c| c.strip_prefix("unix:")) + .map(|p| format!("{}.authcookie", p)) + .filter(|p| std::fs::File::open(p).is_ok()) + .unwrap_or_default(); + let progress = control + .as_deref() + .and_then(|c| tor_control::tor_control_bootstrap_on(c, &cookie).ok()); + + let gen = { + let mut g = lock(); + g.tor_slot.generation += 1; + g.tor.ownership = Ownership::Attached; + g.tor.socks_endpoint = proxy; + g.tor.control_endpoint = control.clone().unwrap_or_default(); + g.tor.cookie_path = cookie.clone(); + match progress { + Some((p, summary)) if p < 100 => { + g.tor.state = ServiceState::Bootstrapping; + g.tor.bootstrap_progress = p; + g.tor.circuit_info = summary; + } + other => { + g.tor.state = ServiceState::Ready; + g.tor.bootstrap_progress = 100; + if let Some((_, summary)) = other { + g.tor.circuit_info = summary; + } + } + } + (g.tor_slot.generation, g.tor.state) + }; + if let Some(c) = &control { + tor_control::tor_control_configure(c, &cookie); + let _ = tor_control::tor_control_connect(); + } + println!("[net.service.tor] attached to SOCKS endpoint {}", socks); + if gen.1 == ServiceState::Bootstrapping { + thread::spawn(move || supervisor(Which::Tor, gen.0, None)); + } + Ok(()) +} + +fn start_managed_tor(s: &BrowserSettings) -> Res<()> { + let root = expand_path(&s.tor_data_dir); + let data = root.join("data"); + let torrc = root.join("torrc"); + let control = root.join("control.sock"); + let cookie = data.join("control_auth_cookie"); + let log = root.join("tor.log"); + + for dir in [&root, &data] { + if let Err(e) = ensure_private_dir(dir) { + return Err(fail(Which::Tor, &format!("cannot prepare {}: {}", dir.display(), e))); + } + } + let _ = std::fs::remove_file(&control); + + // Loopback TCP SocksPort: usable by reqwest without a Unix bridge. + let port = match free_loopback_port() { + Ok(p) => p, + Err(e) => return Err(fail(Which::Tor, &format!("no free port for Tor SOCKS: {}", e))), + }; + // `__OwningControllerProcess` makes Tor exit by itself if the browser + // dies without running its shutdown path (crash, SIGKILL). + let config = format!( + "DataDirectory {}\nSocksPort 127.0.0.1:{}\nControlPort unix:{}\n\ + CookieAuthentication 1\nCookieAuthFileGroupReadable 0\n\ + Log notice file {}\nAvoidDiskWrites 1\n__OwningControllerProcess {}\n", + data.display(), + port, + control.display(), + log.display(), + std::process::id() + ); + if let Err(e) = write_private_file(&torrc, &config) { + return Err(fail(Which::Tor, &format!("cannot write torrc: {}", e))); + } + + let child = match spawn_managed(Which::Tor, &s.tor_binary_path, &["-f", &torrc.to_string_lossy()]) { + Ok(c) => c, + Err(e) => { + return Err(fail( + Which::Tor, + &format!("cannot start {}: {}", s.tor_binary_path, e), + )) + } + }; + + let control_endpoint = format!("unix:{}", control.display()); + let cookie_path = cookie.to_string_lossy().to_string(); + let gen = { + let mut g = lock(); + g.tor_slot.generation += 1; + g.tor_slot.pid = Some(child.id()); + g.tor.ownership = Ownership::Managed; + g.tor.state = ServiceState::Bootstrapping; + g.tor.socks_endpoint = format!("socks5h://127.0.0.1:{}", port); + g.tor.control_endpoint = control_endpoint.clone(); + g.tor.cookie_path = cookie_path.clone(); + g.tor_slot.generation + }; + tor_control::tor_control_configure(&control_endpoint, &cookie_path); + thread::spawn(move || supervisor(Which::Tor, gen, Some(child))); + Ok(()) +} + +// ── FIPS ──────────────────────────────────────────────────────────── + +fn attach_fips(socket: &str) -> Res<()> { + let st = fips_control::fips_control_status_on(socket)?; + fips_control::fips_control_set_socket(socket); + let gen = { + let mut g = lock(); + g.fips_slot.generation += 1; + g.fips.ownership = Ownership::Attached; + g.fips.control_socket = socket.to_string(); + g.fips.node_npub = st.npub.clone(); + g.fips.peer_count = st.peer_count; + g.fips.tun_name = st.tun_name.clone(); + g.fips.tun_active = st.tun_active; + g.fips.tree_state = st.tree_state; + g.fips.daemon_state = st.state; + g.fips.state = ServiceState::Ready; + g.fips_slot.generation + }; + println!( + "[net.service.fips] attached to {}: node={} peers={} tun={}", + socket, st.npub, st.peer_count, st.tun_name + ); + thread::spawn(move || supervisor(Which::Fips, gen, None)); + Ok(()) +} + +fn start_managed_fips(s: &BrowserSettings) -> Res<()> { + if !binary_has_net_admin(&s.fips_binary_path) { + return Err(fail(Which::Fips, "FIPS binary not found or lacks CAP_NET_ADMIN")); + } + let root = expand_path(&s.fips_config_dir); + let config_path = root.join("fips.yaml"); + let socket = root.join("control.sock"); + if let Err(e) = ensure_private_dir(&root) { + return Err(fail(Which::Fips, &format!("cannot prepare {}: {}", root.display(), e))); + } + let _ = std::fs::remove_file(&socket); + + let config = format!( + "node:\n identity:\n persistent: true\n control:\n enabled: true\n\ + \x20 socket_path: \"{}\"\ntun:\n device: fips0\ndns:\n enabled: true\n\ + \x20 bind_addr: \"::1\"\n port: 5354\ntransports:\n\ + \x20 - type: udp\n bind_addr: \"0.0.0.0:4242\"\n\ + \x20 - type: tcp\n listen: \"0.0.0.0:4243\"\npeers: []\n", + socket.display() + ); + if let Err(e) = write_private_file(&config_path, &config) { + return Err(fail(Which::Fips, &format!("cannot write fips.yaml: {}", e))); + } + + let child = match spawn_managed( + Which::Fips, + &s.fips_binary_path, + &["--config", &config_path.to_string_lossy()], + ) { + Ok(c) => c, + Err(e) => { + return Err(fail( + Which::Fips, + &format!("cannot start {}: {}", s.fips_binary_path, e), + )) + } + }; + + let socket_str = socket.to_string_lossy().to_string(); + fips_control::fips_control_set_socket(&socket_str); + let gen = { + let mut g = lock(); + g.fips_slot.generation += 1; + g.fips_slot.pid = Some(child.id()); + g.fips.ownership = Ownership::Managed; + g.fips.state = ServiceState::Bootstrapping; + g.fips.control_socket = socket_str; + g.fips_slot.generation + }; + thread::spawn(move || supervisor(Which::Fips, gen, Some(child))); + Ok(()) +} + +/// True if the FIPS binary can create its TUN device: running as root, or +/// the binary carries `cap_net_admin`. +fn binary_has_net_admin(binary: &str) -> bool { + // SAFETY: geteuid(2) has no preconditions. + if unsafe { libc::geteuid() } == 0 { + return true; + } + let Some(path) = find_program(binary) else { return false }; + Command::new("getcap") + .arg(&path) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).contains("cap_net_admin")) + .unwrap_or(false) +} + +// ── Helpers ───────────────────────────────────────────────────────── + +fn find_program(name: &str) -> Option { + if name.contains('/') { + let p = expand_path(name); + return p.is_file().then_some(p); + } + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|d| d.join(name)) + .find(|p| p.is_file()) + }) +} + +/// Expand a leading `~/`. +fn expand_path(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = std::env::var_os("HOME") { + return Path::new(&home).join(rest); + } + } + PathBuf::from(path) +} + +fn ensure_private_dir(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) +} + +fn write_private_file(path: &Path, contents: &str) -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(contents.as_bytes()) +} + +fn free_loopback_port() -> std::io::Result { + Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port()) +} + +/// Spawn a managed process and forward its output to our log. +fn spawn_managed(which: Which, binary: &str, args: &[&str]) -> std::io::Result { + let mut child = Command::new(binary) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + if let Some(out) = child.stdout.take() { + pipe_logs(which.name(), out); + } + if let Some(err) = child.stderr.take() { + pipe_logs(which.name(), err); + } + println!( + "[net.service.{}] spawned managed process pid={}", + which.name(), + child.id() + ); + Ok(child) +} + +fn pipe_logs(name: &'static str, reader: R) { + thread::spawn(move || { + for line in BufReader::new(reader).lines().map_while(Result::ok) { + if !line.is_empty() { + println!("[net.service.{}] {}", name, line); + } + } + }); +} diff --git a/src/nostr_bridge.rs b/src/nostr_bridge.rs index 4bd6fdf..a2895db 100644 --- a/src/nostr_bridge.rs +++ b/src/nostr_bridge.rs @@ -101,6 +101,15 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) { return; } + // ── FIPS mesh API (status/peers/network/tree/directory/...) ──── + // Blocks on daemon sockets / relays, so it runs on a worker thread. + if let Some(fips_route) = route.strip_prefix("fips/") { + let fips_route = fips_route.to_string(); + let query = path.split_once('?').map(|(_, q)| q).unwrap_or("").to_string(); + respond_json_async(request, move || crate::fips_api::handle(&fips_route, &query)); + return; + } + // ── Processes REST API ───────────────────────────────────────── if route.starts_with("processes/") { handle_processes_api(request, &route[10..]); @@ -428,19 +437,27 @@ fn handle_processes_api(request: &URISchemeRequest, sub: &str) { match route { "list" => { - // Layer 1: OS process list (simplified — just browser + webprocess). - let processes = crate::process_info::process_info_get_all(); - respond_json(request, &serde_json::to_string(&processes).unwrap_or_default()); + // Layer 1: OS process list (browser + all descendants, from /proc). + // Reads /proc for every process on the system, so do it off the + // GTK main thread. + respond_json_async(request, || { + serde_json::to_value(crate::process_info::process_info_get_all()) + .unwrap_or_else(|_| serde_json::json!([])) + }); } "tabs" => { - // Layer 2: per-tab list with probe data. + // Layer 2: per-tab list with probe data (probe is null unless + // the perf probe is enabled in settings). let tabs = crate::tab_manager::tab_manager_get_tabs(); let tab_infos: Vec = tabs.iter().map(|t| { serde_json::json!({ + "index": t.id, "id": t.id, "title": t.title, "url": t.url, "is_internal": t.url.starts_with("sovereign://") || t.url.starts_with("about:"), + "webprocess_pid": 0, + "probe": crate::perf_probe::perf_probe_get_report(t.id), }) }).collect(); respond_json(request, &serde_json::to_string(&tab_infos).unwrap_or_default()); @@ -461,10 +478,19 @@ fn handle_processes_api(request: &URISchemeRequest, sub: &str) { "tab_probe" => { // Drill-down for a single tab. let index = extract_query_param(query, "index") - .and_then(|s| s.parse::().ok()) + .and_then(|s| s.parse::().ok()) .unwrap_or(0); - let report = crate::perf_probe::perf_probe_get_report(index); - respond_json(request, &serde_json::to_string(&report).unwrap_or_default()); + let tab = crate::tab_manager::tab_manager_get_tabs() + .into_iter() + .find(|t| t.id == index); + let out = serde_json::json!({ + "index": index, + "title": tab.as_ref().map(|t| t.title.clone()).unwrap_or_default(), + "url": tab.as_ref().map(|t| t.url.clone()).unwrap_or_default(), + "webprocess_pid": 0, + "probe": crate::perf_probe::perf_probe_get_report(index), + }); + respond_json(request, &out.to_string()); } "tab_action" => { // ?index=N&action=reload|suspend|close @@ -971,6 +997,21 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) { settings::settings_save(); false } + "tor_mode" | "fips_mode" => { + if !matches!(value.as_str(), "auto" | "attach" | "manage") { + respond_error_json(request, 400, "Mode must be auto, attach or manage"); + return; + } + settings::settings_update(&serde_json::json!({ key.as_str(): value })); + settings::settings_save(); + false + } + "tor_binary_path" | "tor_attach_socks" | "tor_attach_control" | "tor_data_dir" + | "fips_binary_path" | "fips_control_socket" | "fips_config_dir" => { + settings::settings_update(&serde_json::json!({ key.as_str(): value })); + settings::settings_save(); + false + } "search_engine" => { let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave"); if !valid { respond_error_json(request, 400, "Unknown search engine"); return; } @@ -1036,6 +1077,17 @@ fn handle_settings_config_json(request: &URISchemeRequest) { "file_access": true, "universal_access": true, "perf_probe_enabled": s.perf_probe_enabled, + "tor_enabled": s.tor_enabled, + "tor_mode": s.tor_mode, + "tor_binary_path": s.tor_binary_path, + "tor_attach_socks": s.tor_attach_socks, + "tor_attach_control": s.tor_attach_control, + "tor_data_dir": s.tor_data_dir, + "fips_enabled": s.fips_enabled, + "fips_mode": s.fips_mode, + "fips_binary_path": s.fips_binary_path, + "fips_control_socket": s.fips_control_socket, + "fips_config_dir": s.fips_config_dir, "search_engines": search_engines, "shortcuts": shortcuts, }); diff --git a/src/perf_probe.rs b/src/perf_probe.rs index aba7039..8d6683c 100644 --- a/src/perf_probe.rs +++ b/src/perf_probe.rs @@ -58,40 +58,27 @@ pub fn perf_probe_page_load_end() { state.page_load_start = None; } -/// Per-tab probe report received from the injected perf-probe.js. -static G_PROBE_REPORTS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); - -/// A probe report from a single tab. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ProbeReport { - pub tab_index: i32, - pub cpu_busy_percent: f64, - pub fps: f64, - pub timer_count: u32, - pub net_inflight: u32, - pub heap_used: f64, - pub event_listener_count: u32, - pub worker_count: u32, - pub dom_node_count: u32, -} +/// Per-tab probe reports received from the injected perf-probe.js, keyed by +/// tab index (tab id). Stored as the raw JSON payload the probe sends so the +/// Processes page sees exactly the field names perf-probe.js emits +/// (cpu_busy_percent, fps, timer_count, net_in_flight, heap_used_mb, ...). +static G_PROBE_REPORTS: Lazy>> = + Lazy::new(|| Mutex::new(std::collections::HashMap::new())); /// Record a probe report from a tab. pub fn perf_probe_record_report(tab_index: i32, report: &serde_json::Value) { - let mut reports = G_PROBE_REPORTS.lock().unwrap(); - // Update or insert the report for this tab index. - if let Some(existing) = reports.iter_mut().find(|r| r.tab_index == tab_index) { - if let Ok(parsed) = serde_json::from_value::(report.clone()) { - *existing = parsed; - } - } else if let Ok(parsed) = serde_json::from_value::(report.clone()) { - reports.push(parsed); - } + G_PROBE_REPORTS.lock().unwrap().insert(tab_index, report.clone()); } /// Get the probe report for a specific tab index. -pub fn perf_probe_get_report(index: usize) -> Option { - let reports = G_PROBE_REPORTS.lock().unwrap(); - reports.iter().find(|r| r.tab_index as usize == index).cloned() +pub fn perf_probe_get_report(index: i32) -> Option { + G_PROBE_REPORTS.lock().unwrap().get(&index).cloned() +} + +/// Forget the report for a closed tab. +#[allow(dead_code)] +pub fn perf_probe_clear_report(index: i32) { + G_PROBE_REPORTS.lock().unwrap().remove(&index); } /// Get current performance metrics. diff --git a/src/process_info.rs b/src/process_info.rs index e2ff6a5..7f89512 100644 --- a/src/process_info.rs +++ b/src/process_info.rs @@ -1,61 +1,199 @@ //! Process information for the processes page //! -//! Port of `process_info.c` / `process_info.h` from the C project. +//! Scans `/proc` for the browser process and all of its descendants +//! (WebKit web/network/GPU processes, managed Tor/FIPS daemons) and reports +//! CPU, memory (RSS / PSS / swap), thread count and uptime for each. +//! +//! The Processes tab (`www/processes.js`) polls `sovereign://processes/list` +//! which serializes the result of [`process_info_get_all`]. -use std::sync::Mutex; use once_cell::sync::Lazy; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Instant; -/// Process info state. -static G_PROCESS_INFO: Lazy> = Lazy::new(|| Mutex::new(ProcessInfoState::default())); +/// Previous CPU sample per pid: (utime+stime ticks, sample time). +static G_CPU_SAMPLES: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); -struct ProcessInfoState { - web_processes: Vec, -} - -/// Information about a web process. +/// Information about one OS process belonging to the browser. #[derive(Debug, Clone, serde::Serialize)] -pub struct WebProcessInfo { +pub struct ProcInfo { pub pid: u32, - pub url: String, - pub title: String, - pub memory_mb: f64, + pub name: String, pub cpu_percent: f64, -} - -impl Default for ProcessInfoState { - fn default() -> Self { - ProcessInfoState { - web_processes: Vec::new(), - } - } + pub rss_kb: i64, + pub pss_kb: i64, + pub swap_kb: i64, + pub threads: u32, + pub uptime_sec: f64, + pub state: String, + /// "browser" | "webkit-renderer" | "webkit-network" | "webkit-gpu" | + /// "tor" | "fips" | "other" + pub ownership: String, + pub service_state: Option, + /// Tabs hosted by this renderer. WebKitGTK exposes no API mapping a + /// WebView to its WebProcess pid, so this is currently always empty. + pub hosted_tabs: Vec, } /// Initialize process info. pub fn process_info_init() { - let mut state = G_PROCESS_INFO.lock().unwrap(); - state.web_processes.clear(); + G_CPU_SAMPLES.lock().unwrap().clear(); } -/// Get all web processes. -pub fn process_info_get_all() -> Vec { - let state = G_PROCESS_INFO.lock().unwrap(); - state.web_processes.clone() +struct RawStat { + ppid: u32, + state: String, + ticks: u64, + threads: u32, + start_ticks: u64, } -/// Add a web process. -pub fn process_info_add(pid: u32, url: &str, title: &str) { - let mut state = G_PROCESS_INFO.lock().unwrap(); - state.web_processes.push(WebProcessInfo { - pid, - url: url.to_string(), - title: title.to_string(), - memory_mb: 0.0, - cpu_percent: 0.0, - }); +fn read_stat(pid: u32) -> Option { + let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // comm may contain spaces/parens; split after the *last* ')'. + let rest = s.rsplit_once(')')?.1; + let f: Vec<&str> = rest.split_whitespace().collect(); + if f.len() < 20 { + return None; + } + Some(RawStat { + state: f[0].to_string(), + ppid: f[1].parse().ok()?, + ticks: f[11].parse::().ok()? + f[12].parse::().ok()?, + threads: f[17].parse().ok()?, + start_ticks: f[19].parse().ok()?, + }) } -/// Remove a web process. -pub fn process_info_remove(pid: u32) { - let mut state = G_PROCESS_INFO.lock().unwrap(); - state.web_processes.retain(|p| p.pid != pid); +fn read_cmdline_name(pid: u32) -> String { + let raw = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default(); + let first = raw.split(|b| *b == 0).next().unwrap_or(&[]); + let path = String::from_utf8_lossy(first).to_string(); + let base = path.rsplit('/').next().unwrap_or("").to_string(); + if base.is_empty() { + std::fs::read_to_string(format!("/proc/{pid}/comm")) + .map(|s| s.trim().to_string()) + .unwrap_or_default() + } else { + base + } +} + +/// (rss_kb, pss_kb, swap_kb) from smaps_rollup, falling back to statm. +fn read_mem(pid: u32) -> (i64, i64, i64) { + let mut rss = -1; + let mut pss = -1; + let mut swap = 0; + if let Ok(s) = std::fs::read_to_string(format!("/proc/{pid}/smaps_rollup")) { + for line in s.lines() { + let mut it = line.split_whitespace(); + match (it.next(), it.next().and_then(|v| v.parse::().ok())) { + (Some("Rss:"), Some(v)) => rss = v, + (Some("Pss:"), Some(v)) => pss = v, + (Some("Swap:"), Some(v)) => swap = v, + _ => {} + } + } + } + if rss < 0 { + if let Ok(s) = std::fs::read_to_string(format!("/proc/{pid}/statm")) { + if let Some(pages) = s.split_whitespace().nth(1).and_then(|v| v.parse::().ok()) { + rss = pages * 4; + } + } + } + (rss, pss, swap) +} + +fn classify(name: &str) -> &'static str { + match name { + "WebKitWebProcess" => "webkit-renderer", + "WebKitNetworkProcess" => "webkit-network", + "WebKitGPUProcess" => "webkit-gpu", + "tor" => "tor", + n if n.contains("fips") => "fips", + _ => "other", + } +} + +/// Get the browser process plus all of its descendants. +pub fn process_info_get_all() -> Vec { + let me = std::process::id(); + let clk_tck = 100.0_f64; // sysconf(_SC_CLK_TCK) is 100 on Linux + let sys_uptime: f64 = std::fs::read_to_string("/proc/uptime") + .ok() + .and_then(|s| s.split_whitespace().next().and_then(|v| v.parse().ok())) + .unwrap_or(0.0); + + // pid -> ppid for every process, then walk descendants of `me`. + let mut parent: HashMap = HashMap::new(); + if let Ok(rd) = std::fs::read_dir("/proc") { + for e in rd.flatten() { + if let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::().ok()) { + if let Some(st) = read_stat(pid) { + parent.insert(pid, st.ppid); + } + } + } + } + let mut owned: Vec = vec![me]; + let mut i = 0; + while i < owned.len() { + let cur = owned[i]; + for (pid, ppid) in &parent { + if *ppid == cur && !owned.contains(pid) { + owned.push(*pid); + } + } + i += 1; + } + + let now = Instant::now(); + let mut samples = G_CPU_SAMPLES.lock().unwrap(); + let mut out = Vec::new(); + let mut seen = Vec::new(); + for pid in owned { + let Some(st) = read_stat(pid) else { continue }; + let name = read_cmdline_name(pid); + let cpu = match samples.get(&pid) { + Some((prev_ticks, prev_t)) => { + let dt = now.duration_since(*prev_t).as_secs_f64(); + if dt > 0.05 { + (st.ticks.saturating_sub(*prev_ticks) as f64 / clk_tck) / dt * 100.0 + } else { + 0.0 + } + } + None => 0.0, + }; + // Only advance the sample when enough time has passed, so rapid + // consecutive polls don't produce noisy deltas. + match samples.get(&pid) { + Some((_, prev_t)) if now.duration_since(*prev_t).as_secs_f64() <= 0.05 => {} + _ => { + samples.insert(pid, (st.ticks, now)); + } + } + seen.push(pid); + let (rss, pss, swap) = read_mem(pid); + let ownership = if pid == me { "browser" } else { classify(&name) }; + out.push(ProcInfo { + pid, + name, + cpu_percent: (cpu * 10.0).round() / 10.0, + rss_kb: rss, + pss_kb: pss, + swap_kb: swap, + threads: st.threads, + uptime_sec: (sys_uptime - st.start_ticks as f64 / clk_tck).max(0.0), + state: st.state, + ownership: ownership.to_string(), + service_state: None, + hosted_tabs: Vec::new(), + }); + } + samples.retain(|pid, _| seen.contains(pid)); + out } diff --git a/src/search.rs b/src/search.rs index ad14245..6b47215 100644 --- a/src/search.rs +++ b/src/search.rs @@ -32,6 +32,103 @@ pub fn search_is_url(input: &str) -> bool { || input.contains('.') } +#[cfg(test)] +mod tests { + use super::*; + + const NPUB: &str = "npub1crpldvy49ef8z34wlacwujnfudy4nd7k96aqdx5wgn6ckztz7z8q9t59ud"; + + #[test] + fn fips_adds_mesh_suffix() { + assert_eq!( + normalize_fips_url(&format!("fips://{NPUB}/")).unwrap(), + format!("http://{NPUB}.fips/") + ); + assert_eq!( + normalize_fips_url(&format!("fips://{NPUB}")).unwrap(), + format!("http://{NPUB}.fips") + ); + } + + #[test] + fn fips_keeps_port_path_query_fragment() { + assert_eq!( + normalize_fips_url("fips://node:8080/a/b?x=1#frag").unwrap(), + "http://node.fips:8080/a/b?x=1#frag" + ); + assert_eq!( + normalize_fips_url("fips://node?x=1").unwrap(), + "http://node.fips?x=1" + ); + } + + #[test] + fn fips_does_not_double_suffix() { + assert_eq!( + normalize_fips_url("fips://node.fips/p").unwrap(), + "http://node.fips/p" + ); + assert_eq!( + normalize_fips_url("fips://node.fips:81/p").unwrap(), + "http://node.fips:81/p" + ); + } + + #[test] + fn fips_rejects_non_fips_and_empty() { + assert!(normalize_fips_url("http://x/").is_none()); + assert!(normalize_fips_url("fips://").is_none()); + assert!(normalize_fips_url("fips:///path").is_none()); + } + + #[test] + fn normalize_url_handles_fips_forms() { + assert_eq!(normalize_url("fips://n/x"), "http://n.fips/x"); + // Bare mesh names default to http (no TLS inside the mesh). + assert_eq!(normalize_url("n.fips/x"), "http://n.fips/x"); + assert_eq!(normalize_url("n.fips:8080"), "http://n.fips:8080"); + // Ordinary hosts are unchanged. + assert_eq!(normalize_url("example.com"), "https://example.com"); + assert_eq!(normalize_url("https://a.b/"), "https://a.b/"); + } +} + +/// Convert `fips://host[:port]/path?query#fragment` into a plain HTTP URL, +/// inserting the mesh DNS suffix after the authority's host component. +/// `fips:///x` becomes `http://.fips/x`. Returns `None` if the +/// input is not a `fips://` URL. Port of `normalize_fips_url()` from +/// tab_manager.c:262. +/// +/// `fips://` is a shorthand rather than a WebKit URI scheme: the mesh +/// resolves `*.fips` names through the FIPS daemon's DNS listener and +/// routes traffic over the `fips0` TUN device, so no proxy is needed. +pub fn normalize_fips_url(input: &str) -> Option { + let rest = input.trim().strip_prefix("fips://")?; + let split = rest + .find(|c| c == '/' || c == '?' || c == '#') + .unwrap_or(rest.len()); + let (authority, suffix) = rest.split_at(split); + if authority.is_empty() { + return None; + } + let (host, port) = match authority.rsplit_once(':') { + // Not an IPv6 literal: a trailing ":digits" is a port. + Some((h, p)) if !h.contains(':') && p.chars().all(|c| c.is_ascii_digit()) => { + (h, Some(p)) + } + _ => (authority, None), + }; + let host = if host.ends_with(".fips") { + host.to_string() + } else { + format!("{}.fips", host) + }; + Some(match port { + Some(p) if !p.is_empty() => format!("http://{}:{}{}", host, p, suffix), + _ => format!("http://{}{}", host, suffix), + }) +} + /// Normalize a URL string: prepend `https://` if it has no scheme. /// Returns the normalized URL. pub fn normalize_url(input: &str) -> String { @@ -39,6 +136,23 @@ pub fn normalize_url(input: &str) -> String { if trimmed.is_empty() { return trimmed.to_string(); } + if trimmed.starts_with("fips://") { + if let Some(url) = normalize_fips_url(trimmed) { + return url; + } + } + // Bare `.fips` hosts are mesh names: they are served over plain + // HTTP inside the mesh, so default to http:// rather than https://. + if !trimmed.contains("://") { + let host_end = trimmed + .find(|c| c == '/' || c == '?' || c == '#') + .unwrap_or(trimmed.len()); + let authority = &trimmed[..host_end]; + let host = authority.rsplit_once(':').map(|(h, _)| h).unwrap_or(authority); + if host.ends_with(".fips") { + return format!("http://{}", trimmed); + } + } // If it already has a scheme, return as-is. // Note: nostr: (NIP-21) is a valid scheme without // if trimmed.contains("://") || trimmed.starts_with("about:") || trimmed.starts_with("nostr:") { diff --git a/src/settings.rs b/src/settings.rs index aeead34..56e3ffc 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -50,6 +50,26 @@ pub struct BrowserSettings { /// Enable the FIPS mesh service. Mirrors the C `fips_enabled` /// (default TRUE). pub fips_enabled: bool, + /// Tor service mode: "auto" (attach, else manage), "attach" or "manage". + pub tor_mode: String, + /// Tor binary used in managed mode. + pub tor_binary_path: String, + /// Explicit SOCKS endpoint to attach to (e.g. "127.0.0.1:9050" or + /// "unix:/run/tor/socks"). Empty = auto-discover. + pub tor_attach_socks: String, + /// Explicit control endpoint to attach to (e.g. "127.0.0.1:9051" or + /// "unix:/run/tor/control"). Empty = auto-discover. + pub tor_attach_control: String, + /// Data directory for a managed Tor. + pub tor_data_dir: String, + /// FIPS service mode: "auto", "attach" or "manage". + pub fips_mode: String, + /// FIPS binary used in managed mode. + pub fips_binary_path: String, + /// Explicit FIPS control socket. Empty = auto-discover. + pub fips_control_socket: String, + /// Config directory for a managed FIPS node. + pub fips_config_dir: String, } impl Default for BrowserSettings { @@ -89,6 +109,15 @@ impl Default for BrowserSettings { perf_probe_enabled: false, tor_enabled: true, fips_enabled: true, + tor_mode: "auto".to_string(), + tor_binary_path: "tor".to_string(), + tor_attach_socks: String::new(), + tor_attach_control: String::new(), + tor_data_dir: "~/.sovereign_browser/tor".to_string(), + fips_mode: "auto".to_string(), + fips_binary_path: "fips".to_string(), + fips_control_socket: String::new(), + fips_config_dir: "~/.sovereign_browser/fips".to_string(), } } } @@ -153,6 +182,34 @@ pub fn settings_update(json: &serde_json::Value) { if let Some(v) = json.get("fips_enabled").and_then(|v| v.as_bool()) { settings.fips_enabled = v; } + let s = |k: &str| json.get(k).and_then(|v| v.as_str()).map(|v| v.to_string()); + if let Some(v) = s("tor_mode") { + settings.tor_mode = v; + } + if let Some(v) = s("tor_binary_path") { + settings.tor_binary_path = v; + } + if let Some(v) = s("tor_attach_socks") { + settings.tor_attach_socks = v; + } + if let Some(v) = s("tor_attach_control") { + settings.tor_attach_control = v; + } + if let Some(v) = s("tor_data_dir") { + settings.tor_data_dir = v; + } + if let Some(v) = s("fips_mode") { + settings.fips_mode = v; + } + if let Some(v) = s("fips_binary_path") { + settings.fips_binary_path = v; + } + if let Some(v) = s("fips_control_socket") { + settings.fips_control_socket = v; + } + if let Some(v) = s("fips_config_dir") { + settings.fips_config_dir = v; + } } /// Set the Tor / FIPS enabled preference and persist it. @@ -198,6 +255,15 @@ pub fn settings_save() { "enable_write_console_messages_to_stdout": settings.enable_write_console_messages_to_stdout, "tor_enabled": settings.tor_enabled, "fips_enabled": settings.fips_enabled, + "tor_mode": settings.tor_mode, + "tor_binary_path": settings.tor_binary_path, + "tor_attach_socks": settings.tor_attach_socks, + "tor_attach_control": settings.tor_attach_control, + "tor_data_dir": settings.tor_data_dir, + "fips_mode": settings.fips_mode, + "fips_binary_path": settings.fips_binary_path, + "fips_control_socket": settings.fips_control_socket, + "fips_config_dir": settings.fips_config_dir, }); drop(settings); diff --git a/src/tab_manager.rs b/src/tab_manager.rs index 1e9a5ef..de77672 100644 --- a/src/tab_manager.rs +++ b/src/tab_manager.rs @@ -793,6 +793,21 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti return true; } + // ── fips:// → http://.fips normalization ───── + // fips:// is a shorthand, not a registered WebKit + // scheme; this catches page links in addition to + // URL-bar normalization. Mirrors tab_manager.c:1063. + if uri_str.starts_with("fips://") { + if let Some(normalized) = crate::search::normalize_fips_url(&uri_str) { + decision.ignore(); + let wv = wv_for_onion.clone(); + glib::idle_add_local_once(move || { + wv.load_uri(&normalized); + }); + return true; + } + } + // ── .onion HTTP(S) → tor:// rewriting ────────────── // Detect .onion hosts in http:// or https:// URLs and // rewrite to tor:// so the request goes through Tor's diff --git a/src/tor_control.rs b/src/tor_control.rs index 3deeb22..edc3cac 100644 --- a/src/tor_control.rs +++ b/src/tor_control.rs @@ -2,23 +2,32 @@ //! //! Port of `tor_control.c` / `tor_control.h` from the C project. //! Implements a synchronous, short-timeout Tor control-protocol client -//! supporting both TCP (host:port) and Unix socket endpoints. +//! supporting both TCP (`host:port`) and Unix socket (`unix:/path`) +//! endpoints, with cookie, password or null authentication. -use std::io::{Read, Write}; -use std::net::TcpStream; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::os::unix::net::UnixStream; use std::sync::Mutex; +use std::time::Duration; + use once_cell::sync::Lazy; -const TOR_RESPONSE_MAX: usize = 8192; const TOR_TIMEOUT_MS: u64 = 1500; +const TOR_CONNECT_TIMEOUT_MS: u64 = 500; +/// Upper bound on a single control reply (guards against a runaway peer). +const TOR_REPLY_MAX_BYTES: usize = 64 * 1024; + +type Result = std::result::Result>; /// Tor control state. -static G_TOR_CONTROL: Lazy> = Lazy::new(|| Mutex::new(TorControlState::default())); +static G_TOR_CONTROL: Lazy> = + Lazy::new(|| Mutex::new(TorControlState::default())); struct TorControlState { connected: bool, - control_host: String, - control_port: u16, + /// `host:port` or `unix:/path`. + endpoint: String, password: String, cookie_path: String, } @@ -27,145 +36,273 @@ impl Default for TorControlState { fn default() -> Self { TorControlState { connected: false, - control_host: "127.0.0.1".to_string(), - control_port: 9051, + endpoint: "127.0.0.1:9051".to_string(), password: String::new(), cookie_path: String::new(), } } } +// ── Transport ─────────────────────────────────────────────────────── + +enum Stream { + Tcp(TcpStream), + Unix(UnixStream), +} + +impl Stream { + fn connect(endpoint: &str) -> io::Result { + let timeout = Duration::from_millis(TOR_CONNECT_TIMEOUT_MS); + let stream = if let Some(path) = endpoint.strip_prefix("unix:") { + Stream::Unix(UnixStream::connect(path)?) + } else { + let addr = endpoint + .to_socket_addrs()? + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "bad Tor endpoint"))?; + Stream::Tcp(TcpStream::connect_timeout(&addr, timeout)?) + }; + stream.set_timeouts(Duration::from_millis(TOR_TIMEOUT_MS))?; + Ok(stream) + } + + fn set_timeouts(&self, d: Duration) -> io::Result<()> { + match self { + Stream::Tcp(s) => { + s.set_read_timeout(Some(d))?; + s.set_write_timeout(Some(d)) + } + Stream::Unix(s) => { + s.set_read_timeout(Some(d))?; + s.set_write_timeout(Some(d)) + } + } + } + + fn try_clone(&self) -> io::Result { + Ok(match self { + Stream::Tcp(s) => Stream::Tcp(s.try_clone()?), + Stream::Unix(s) => Stream::Unix(s.try_clone()?), + }) + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Stream::Tcp(s) => s.read(buf), + Stream::Unix(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Stream::Tcp(s) => s.write(buf), + Stream::Unix(s) => s.write(buf), + } + } + fn flush(&mut self) -> io::Result<()> { + match self { + Stream::Tcp(s) => s.flush(), + Stream::Unix(s) => s.flush(), + } + } +} + +/// An authenticated control-protocol session. +struct Session { + reader: BufReader, + writer: Stream, +} + +impl Session { + fn open(endpoint: &str, cookie_path: &str, password: &str) -> Result { + let stream = Stream::connect(endpoint) + .map_err(|e| format!("Tor control connect failed ({}): {}", endpoint, e))?; + let writer = stream.try_clone()?; + let mut session = Session { reader: BufReader::new(stream), writer }; + + let auth = if !cookie_path.is_empty() { + let cookie = std::fs::read(cookie_path) + .map_err(|e| format!("cannot read Tor cookie {}: {}", cookie_path, e))?; + let hex: String = cookie.iter().map(|b| format!("{:02X}", b)).collect(); + format!("AUTHENTICATE {}", hex) + } else if !password.is_empty() { + format!("AUTHENTICATE \"{}\"", password.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + "AUTHENTICATE".to_string() + }; + session + .command(&auth) + .map_err(|e| format!("Tor authentication failed: {}", e))?; + Ok(session) + } + + /// Send one command and read its complete reply. + fn command(&mut self, command: &str) -> Result { + self.writer.write_all(format!("{}\r\n", command).as_bytes())?; + self.writer.flush()?; + self.read_reply() + } + + /// Read a full reply. Mid lines look like `250-...`, data blocks like + /// `250+...` (terminated by a lone `.`), and the final line `250 ...`. + /// Returns the reply text on a 2xx status, an error otherwise. + fn read_reply(&mut self) -> Result { + let mut text = String::new(); + loop { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).map_err(|e| { + if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut { + "Tor control response timed out".to_string() + } else { + format!("Tor control read failed: {}", e) + } + })?; + if n == 0 { + return Err("Tor closed control connection".into()); + } + text.push_str(&line); + if text.len() > TOR_REPLY_MAX_BYTES { + return Err("Tor control reply too large".into()); + } + let trimmed = line.trim_end(); + if trimmed.len() < 4 { + continue; + } + match trimmed.as_bytes()[3] { + b'+' => { + // Data block: consume until a line containing only ".". + loop { + let mut data = String::new(); + if self.reader.read_line(&mut data)? == 0 { + return Err("Tor closed control connection".into()); + } + text.push_str(&data); + if data.trim_end() == "." { + break; + } + if text.len() > TOR_REPLY_MAX_BYTES { + return Err("Tor control reply too large".into()); + } + } + } + b' ' => { + return if trimmed.starts_with('2') { + Ok(text) + } else { + Err(format!("Tor control error: {}", trimmed).into()) + }; + } + _ => {} + } + } + } +} + +// ── Endpoint-explicit helpers (used by net_services) ──────────────── + +/// True if something accepts connections on the control endpoint. +pub fn tor_control_endpoint_available(endpoint: &str) -> bool { + Stream::connect(endpoint).is_ok() +} + +/// Run one command on a fresh authenticated connection. +pub fn tor_control_command_on( + endpoint: &str, + cookie_path: &str, + password: &str, + command: &str, +) -> Result { + Session::open(endpoint, cookie_path, password)?.command(command) +} + +/// Parse a `status/bootstrap-phase` reply into `(progress, summary)`. +fn parse_bootstrap(reply: &str) -> (i32, String) { + let progress = reply + .find("PROGRESS=") + .and_then(|i| reply[i + 9..].split(|c: char| !c.is_ascii_digit()).next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let summary = reply + .find("SUMMARY=\"") + .and_then(|i| reply[i + 9..].split('"').next()) + .unwrap_or("") + .to_string(); + (progress, summary) +} + +/// Bootstrap progress (0-100) and summary of the Tor at `endpoint`. +pub fn tor_control_bootstrap_on(endpoint: &str, cookie_path: &str) -> Result<(i32, String)> { + let reply = tor_control_command_on(endpoint, cookie_path, "", "GETINFO status/bootstrap-phase")?; + Ok(parse_bootstrap(&reply)) +} + +// ── Global-state API ──────────────────────────────────────────────── + /// Initialize Tor control. pub fn tor_control_init() { + G_TOR_CONTROL.lock().unwrap().connected = false; +} + +/// Point the global control client at a Tor control endpoint +/// (`host:port` or `unix:/path`) and its cookie file (may be empty). +pub fn tor_control_configure(endpoint: &str, cookie_path: &str) { let mut state = G_TOR_CONTROL.lock().unwrap(); + state.endpoint = endpoint.to_string(); + state.cookie_path = cookie_path.to_string(); state.connected = false; } -/// Connect to the Tor control port (TCP host:port). -pub fn tor_control_connect() -> Result<(), Box> { - let mut state = G_TOR_CONTROL.lock().unwrap(); - let host = state.control_host.clone(); - let port = state.control_port; - let cookie = state.cookie_path.clone(); - let password = state.password.clone(); - - // Connect to the control port. - let addr = format!("{}:{}", host, port); - let mut stream = TcpStream::connect(&addr)?; - stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; - stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; - - // Authenticate. - let auth_cmd = if !cookie.is_empty() { - // Read the cookie file and hex-encode it. - let cookie_bytes = std::fs::read(&cookie)?; - let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect(); - format!("AUTHENTICATE {}\r\n", hex) - } else if !password.is_empty() { - format!("AUTHENTICATE \"{}\"\r\n", password) - } else { - "AUTHENTICATE\r\n".to_string() +/// Verify that the configured control port is reachable and accepts our +/// credentials. +pub fn tor_control_connect() -> Result<()> { + let (endpoint, cookie, password) = { + let s = G_TOR_CONTROL.lock().unwrap(); + (s.endpoint.clone(), s.cookie_path.clone(), s.password.clone()) }; - - stream.write_all(auth_cmd.as_bytes())?; - let response = read_response(&mut stream)?; - if !response.starts_with("250") { - return Err(format!("Tor authentication failed: {}", response).into()); - } - - state.connected = true; - println!("[tor] Connected to control port {}:{}", host, port); + Session::open(&endpoint, &cookie, &password)?; + G_TOR_CONTROL.lock().unwrap().connected = true; + println!("[tor] Connected to control port {}", endpoint); Ok(()) } /// Disconnect from Tor control port. pub fn tor_control_disconnect() { - let mut state = G_TOR_CONTROL.lock().unwrap(); - state.connected = false; + G_TOR_CONTROL.lock().unwrap().connected = false; println!("[tor] Disconnected from control port"); } /// Check if connected to Tor control. +#[allow(dead_code)] pub fn tor_control_is_connected() -> bool { - let state = G_TOR_CONTROL.lock().unwrap(); - state.connected + G_TOR_CONTROL.lock().unwrap().connected } -/// Send a command to the Tor control port and return the response. -pub fn tor_control_send_command(command: &str) -> Result> { - let state = G_TOR_CONTROL.lock().unwrap(); - if !state.connected { +/// Send a command to the Tor control port and return the reply. Each +/// command uses a short-lived authenticated connection. +pub fn tor_control_send_command(command: &str) -> Result { + let (endpoint, cookie, password, connected) = { + let s = G_TOR_CONTROL.lock().unwrap(); + (s.endpoint.clone(), s.cookie_path.clone(), s.password.clone(), s.connected) + }; + if !connected { return Err("Not connected to Tor control port".into()); } - let host = state.control_host.clone(); - let port = state.control_port; - - // Open a fresh connection for the command (synchronous, short-lived). - let addr = format!("{}:{}", host, port); - let mut stream = TcpStream::connect(&addr)?; - stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; - stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; - - // Authenticate first (needed for most commands). - let auth_cmd = if !state.cookie_path.is_empty() { - let cookie_bytes = std::fs::read(&state.cookie_path)?; - let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect(); - format!("AUTHENTICATE {}\r\n", hex) - } else if !state.password.is_empty() { - format!("AUTHENTICATE \"{}\"\r\n", state.password) - } else { - "AUTHENTICATE\r\n".to_string() - }; - stream.write_all(auth_cmd.as_bytes())?; - let _ = read_response(&mut stream)?; - - // Send the actual command. - let wire = format!("{}\r\n", command); - stream.write_all(wire.as_bytes())?; - let response = read_response(&mut stream)?; - Ok(response) + tor_control_command_on(&endpoint, &cookie, &password, command) } /// Request a new Tor identity (SIGNAL NEWNYM). -pub fn tor_control_new_identity() -> Result> { +#[allow(dead_code)] +pub fn tor_control_new_identity() -> Result { tor_control_send_command("SIGNAL NEWNYM") } /// Get the Tor bootstrap progress. -pub fn tor_control_get_bootstrap() -> Result<(i32, String), Box> { - let response = tor_control_send_command("GETINFO status/bootstrap-phase")?; - // Parse "PROGRESS=NN" from the response. - let progress = response - .find("PROGRESS=") - .and_then(|i| response[i + 9..].split(|c: char| !c.is_ascii_digit()).next()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - Ok((progress, response)) -} - -/// Read a full Tor control response (until a line with "250 " or an error code). -fn read_response(stream: &mut dyn Read) -> Result> { - let mut buf = [0u8; TOR_RESPONSE_MAX]; - let mut used = 0usize; - while used < TOR_RESPONSE_MAX { - let n = stream.read(&mut buf[used..])?; - if n == 0 { - break; - } - used += n; - let text = String::from_utf8_lossy(&buf[..used]); - // Check for a final line: "250 " (success) or "5xx " (error). - for line in text.lines() { - if line.len() >= 4 && line.as_bytes()[3] == b' ' { - let code = &line[..3]; - if code == "250" { - return Ok(text.to_string()); - } - if code.starts_with('5') || code.starts_with('4') { - return Err(format!("Tor control error: {}", line).into()); - } - } - } - } - Ok(String::from_utf8_lossy(&buf[..used]).to_string()) +#[allow(dead_code)] +pub fn tor_control_get_bootstrap() -> Result<(i32, String)> { + let reply = tor_control_send_command("GETINFO status/bootstrap-phase")?; + Ok(parse_bootstrap(&reply)) } diff --git a/src/tor_scheme.rs b/src/tor_scheme.rs index 2fc9667..6cab7c0 100644 --- a/src/tor_scheme.rs +++ b/src/tor_scheme.rs @@ -14,8 +14,8 @@ use glib::Bytes; use gio::MemoryInputStream; use webkit2gtk::*; -/// Default Tor SOCKS endpoint (socks5h = remote DNS resolution through Tor). -const DEFAULT_SOCKS_ENDPOINT: &str = "socks5h://127.0.0.1:9050"; +/// How long a `tor://` request waits for Tor to become ready. +const TOR_READY_WAIT: std::time::Duration = std::time::Duration::from_secs(60); /// Maximum response size (16 MB, matching the C version). const TOR_FETCH_MAX_BYTES: u64 = 16 * 1024 * 1024; @@ -49,17 +49,16 @@ fn handle_tor_scheme(request: &URISchemeRequest) { } }; - // Get the Tor SOCKS endpoint from net_services, falling back to default. - let socks_endpoint = crate::net_services::net_services_get_tor_socks_endpoint() - .unwrap_or_else(|| DEFAULT_SOCKS_ENDPOINT.to_string()); - // Shared result buffer: worker thread writes, main thread reads. let result: Arc, Option), String>>>> = Arc::new(Mutex::new(None)); let result_worker = result.clone(); // Spawn a blocking worker thread (reqwest doesn't need async here). + // The worker also waits for Tor to finish bootstrapping (starting it + // on demand), so the GTK main thread never blocks. std::thread::spawn(move || { - let r = fetch_via_socks_blocking(&target_url, &socks_endpoint); + let r = crate::net_services::net_services_tor_socks_for_request(TOR_READY_WAIT) + .and_then(|socks| fetch_via_socks_blocking(&target_url, &socks)); *result_worker.lock().unwrap() = Some(r); }); @@ -169,7 +168,7 @@ fn fetch_via_socks_blocking(url: &str, proxy: &str) -> Result<(Vec, Option r, - Err(e) => return Err(e.to_string()), + Err(e) => return Err(error_chain(&e)), }; // Check content-length header before reading the body. @@ -196,6 +195,23 @@ fn fetch_via_socks_blocking(url: &str, proxy: &str) -> Result<(Vec, Option String { + let mut msg = e.to_string(); + let mut src = e.source(); + while let Some(s) = src { + let part = s.to_string(); + if !msg.contains(&part) { + msg.push_str(": "); + msg.push_str(&part); + } + src = s.source(); + } + msg +} + /// Respond with an error HTML page. fn respond_error(request: &URISchemeRequest, title: &str, message: &str) { let html = format!( diff --git a/src/version.rs b/src/version.rs index c1718c9..6913923 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,7 +1,7 @@ //! Version information for sovereign_browser /// The current version of sovereign_browser (with leading 'v'). -pub const VERSION: &str = "v0.0.12"; +pub const VERSION: &str = "v0.0.13"; /// Major version number. pub const VERSION_MAJOR: u32 = 0; @@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MINOR: u32 = 0; /// Patch version number. -pub const VERSION_PATCH: u32 = 12; +pub const VERSION_PATCH: u32 = 13; diff --git a/www/processes.html b/www/processes.html index e6afd9c..32494ad 100644 --- a/www/processes.html +++ b/www/processes.html @@ -33,6 +33,7 @@ CPU% RSS PSS + Swap Threads Uptime State diff --git a/www/processes.js b/www/processes.js index 3c2c383..c8688c5 100644 --- a/www/processes.js +++ b/www/processes.js @@ -153,6 +153,7 @@ function renderProcTable() { '' + heatHtml(p.cpu_percent) + '' + '' + fmtKb(p.rss_kb) + '' + '' + fmtKb(p.pss_kb) + '' + + '' + fmtKb(p.swap_kb) + '' + '' + (p.threads || 0) + '' + '' + fmtUptime(p.uptime_sec) + '' + '' + esc(p.state) + '' + @@ -173,7 +174,7 @@ function renderProcTable() { if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) { var sub = document.createElement('tr'); sub.className = 'hosted-tabs-row'; - var html = ''; + var html = ''; p.hosted_tabs.forEach(function (t) { html += '#' + t.index + '' + esc(t.title || '(untitled)') + @@ -186,7 +187,7 @@ function renderProcTable() { }); if (sorted.length === 0) { - body.innerHTML = 'No processes.'; + body.innerHTML = 'No processes.'; } }