v0.0.13 - Fix Processes tab: read /proc for browser and child processes, add swap column, align tab/probe JSON with processes.js

This commit is contained in:
Laan Tungir
2026-09-30 08:51:26 -04:00
parent 786f367006
commit b4f519d8ed
21 changed files with 2369 additions and 472 deletions
Generated
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
0.0.12
0.0.13
+23 -4
View File
@@ -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://<host>.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.
---
+3 -3
View File
@@ -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).
---
+282
View File
@@ -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/<route>?<query>`.
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<Value> {
if net_services::net_services_fips_status().state == ServiceState::Ready {
None
} else {
Some(json!({ "error": "FIPS not ready" }))
}
}
fn peer_npubs(peers: &Value) -> HashSet<String> {
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<Value> = 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<String, nostr_core::Event> =
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<Value> = 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::<Value>(&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<Vec<nostr_core::Event>, 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))
})
}
+189 -140
View File
@@ -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<Mutex<FipsControlState>> = 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<Mutex<String>> = Lazy::new(|| Mutex::new(String::new()));
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
/// 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.
/// Initialize FIPS control (forgets any previously resolved socket).
pub fn fips_control_init() {
let mut state = G_FIPS_CONTROL.lock().unwrap();
state.running = false;
G_FIPS_SOCKET.lock().unwrap().clear();
}
/// Start the FIPS mesh node.
pub fn fips_control_start() -> Result<(), Box<dyn std::error::Error>> {
let mut state = G_FIPS_CONTROL.lock().unwrap();
if state.running {
return Ok(());
/// 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<UnixStream> {
if path.is_empty() {
return Err("empty FIPS control socket path".into());
}
// 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())
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<String> {
let mut candidates: Vec<String> = 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))
}
/// 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");
}
/// 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<serde_json::Value>) -> Result<serde_json::Value> {
let mut stream = connect(socket)?;
/// 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<serde_json::Value, Box<dyn std::error::Error>> {
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 mut req = serde_json::json!({ "command": command });
if let Some(p) = params {
req["params"] = p;
}
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<serde_json::Value, Box<dyn std::error::Error>> {
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<serde_json::Value>) -> Result<serde_json::Value> {
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<serde_json::Value, Box<dyn std::error::Error>> {
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<FipsStatus> {
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<FipsStatus> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value, Box<dyn std::error::Error>> {
let root = fips_request("show_tree")?;
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
pub fn fips_control_show_tree() -> Result<serde_json::Value> {
request("show_tree", None)
}
/// Get the FIPS identity cache.
pub fn fips_control_show_identity_cache() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
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<serde_json::Value> {
request("show_identity_cache", None)
}
/// Connect to a FIPS peer.
pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
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",
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,
});
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)
})),
)?;
Ok(())
}
/// Disconnect from a FIPS peer.
pub fn fips_control_disconnect_peer(npub: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
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)
pub fn fips_control_disconnect_peer(npub: &str) -> Result<()> {
if npub.is_empty() {
return Err("FIPS peer npub is required".into());
}
request("disconnect", Some(serde_json::json!({ "npub": npub })))?;
Ok(())
}
/// Resolve a FIPS address (npub) to a reachable endpoint.
/// Resolve a FIPS address (npub) to a reachable endpoint, using the
/// identity cache.
#[allow(dead_code)]
pub fn fips_control_resolve(npub: &str) -> Option<String> {
// 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());
}
}
}
}
}
None
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())
}
+1
View File
@@ -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;
+22
View File
@@ -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();
}
+1108 -113
View File
File diff suppressed because it is too large Load Diff
+59 -7
View File
@@ -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<serde_json::Value> = 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::<usize>().ok())
.and_then(|s| s.parse::<i32>().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,
});
+15 -28
View File
@@ -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<Mutex<Vec<ProbeReport>>> = 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<Mutex<std::collections::HashMap<i32, serde_json::Value>>> =
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::<ProbeReport>(report.clone()) {
*existing = parsed;
}
} else if let Ok(parsed) = serde_json::from_value::<ProbeReport>(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<ProbeReport> {
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<serde_json::Value> {
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.
+179 -41
View File
@@ -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<Mutex<ProcessInfoState>> = Lazy::new(|| Mutex::new(ProcessInfoState::default()));
/// Previous CPU sample per pid: (utime+stime ticks, sample time).
static G_CPU_SAMPLES: Lazy<Mutex<HashMap<u32, (u64, Instant)>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
struct ProcessInfoState {
web_processes: Vec<WebProcessInfo>,
}
/// 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<String>,
/// 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<serde_json::Value>,
}
/// 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<WebProcessInfo> {
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 {
fn read_stat(pid: u32) -> Option<RawStat> {
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::<u64>().ok()? + f[12].parse::<u64>().ok()?,
threads: f[17].parse().ok()?,
start_ticks: f[19].parse().ok()?,
})
}
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::<i64>().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::<i64>().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<ProcInfo> {
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<u32, u32> = 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::<u32>().ok()) {
if let Some(st) = read_stat(pid) {
parent.insert(pid, st.ppid);
}
}
}
}
let mut owned: Vec<u32> = 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,
url: url.to_string(),
title: title.to_string(),
memory_mb: 0.0,
cpu_percent: 0.0,
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(),
});
}
/// 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);
}
samples.retain(|pid, _| seen.contains(pid));
out
}
+114
View File
@@ -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://<npub>/x` becomes `http://<npub>.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<String> {
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 `<name>.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:") {
+66
View File
@@ -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);
+15
View File
@@ -793,6 +793,21 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
return true;
}
// ── fips:// → http://<host>.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
+251 -114
View File
@@ -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<T> = std::result::Result<T, Box<dyn std::error::Error>>;
/// Tor control state.
static G_TOR_CONTROL: Lazy<Mutex<TorControlState>> = Lazy::new(|| Mutex::new(TorControlState::default()));
static G_TOR_CONTROL: Lazy<Mutex<TorControlState>> =
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<Stream> {
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<Stream> {
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<usize> {
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<usize> {
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<Stream>,
writer: Stream,
}
impl Session {
fn open(endpoint: &str, cookie_path: &str, password: &str) -> Result<Session> {
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<String> {
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<String> {
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<String> {
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::<i32>().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<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<String> {
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<String, Box<dyn std::error::Error>> {
#[allow(dead_code)]
pub fn tor_control_new_identity() -> Result<String> {
tor_control_send_command("SIGNAL NEWNYM")
}
/// Get the Tor bootstrap progress.
pub fn tor_control_get_bootstrap() -> Result<(i32, String), Box<dyn std::error::Error>> {
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::<i32>().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<String, Box<dyn std::error::Error>> {
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))
}
+24 -8
View File
@@ -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<Mutex<Option<Result<(Vec<u8>, Option<String>), 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<u8>, Option<S
let response: reqwest::blocking::Response = match client.get(url).send() {
Ok(r) => 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<u8>, Option<S
Ok((bytes, content_type))
}
/// Render an error with its full `source()` chain. reqwest's own
/// `Display` stops at "error sending request", hiding the SOCKS failure
/// (e.g. "host unreachable", "TTL expired") that explains it.
fn error_chain(e: &dyn std::error::Error) -> 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!(
+2 -2
View File
@@ -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;
+1
View File
@@ -33,6 +33,7 @@
<th data-sort="cpu_percent">CPU%</th>
<th data-sort="rss_kb">RSS</th>
<th data-sort="pss_kb">PSS</th>
<th data-sort="swap_kb">Swap</th>
<th data-sort="threads">Threads</th>
<th data-sort="uptime_sec">Uptime</th>
<th data-sort="state">State</th>
+3 -2
View File
@@ -153,6 +153,7 @@ function renderProcTable() {
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
'<td>' + fmtKb(p.rss_kb) + '</td>' +
'<td>' + fmtKb(p.pss_kb) + '</td>' +
'<td>' + fmtKb(p.swap_kb) + '</td>' +
'<td>' + (p.threads || 0) + '</td>' +
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
'<td>' + esc(p.state) + '</td>' +
@@ -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 = '<td colspan="10">';
var html = '<td colspan="11">';
p.hosted_tabs.forEach(function (t) {
html += '<span class="ht-entry"><span class="ht-idx">#' +
t.index + '</span>' + esc(t.title || '(untitled)') +
@@ -186,7 +187,7 @@ function renderProcTable() {
});
if (sorted.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="empty">No processes.</td></tr>';
body.innerHTML = '<tr><td colspan="11" class="empty">No processes.</td></tr>';
}
}