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:
Generated
+2
-1
@@ -3055,7 +3055,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sovereign_browser"
|
name = "sovereign_browser"
|
||||||
version = "0.0.11"
|
version = "0.0.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3075,6 +3075,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"nostr-core",
|
"nostr-core",
|
||||||
"nostr-nips",
|
"nostr-nips",
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ members = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "sovereign_browser"
|
name = "sovereign_browser"
|
||||||
version = "0.0.12"
|
version = "0.0.13"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
|
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
|
||||||
@@ -69,6 +69,7 @@ env_logger = "0.11"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
|
libc = "0.2"
|
||||||
lazy_static = "1"
|
lazy_static = "1"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
qrcode = "0.14"
|
qrcode = "0.14"
|
||||||
|
|||||||
@@ -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://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://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://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` | 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://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)) |
|
| `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 |
|
| Service | C Status | Rust Status |
|
||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
| **Tor control** | TCP/Unix socket, authenticate, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.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 | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) |
|
| **FIPS control** | Unix socket JSON-line protocol, socket discovery | ✅ 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)) |
|
| **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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** |
|
| **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** |
|
| **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** |
|
| **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** |
|
| **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**:
|
1. **Tor Control**:
|
||||||
- **C**: Connects to Tor ControlPort (default 9051), authenticates, checks circuit status, and requests new identity (`SIGNAL NEWNYM`).
|
- **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**:
|
2. **FIPS Mesh Control**:
|
||||||
- **C**: Connects via Unix domain socket to the local FIPS daemon, queries peer tables, resolves `.fips` and npub overlay addresses.
|
- **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
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
+196
-147
@@ -2,193 +2,242 @@
|
|||||||
//!
|
//!
|
||||||
//! Port of `fips_control.c` / `fips_control.h` from the C project.
|
//! Port of `fips_control.c` / `fips_control.h` from the C project.
|
||||||
//! Implements a synchronous FIPS JSON-line control client over a Unix
|
//! Implements a synchronous FIPS JSON-line control client over a Unix
|
||||||
//! domain socket. Requests are `{"command": "..."}` JSON lines; responses
|
//! domain socket. Requests are `{"command": "...", "params": {...}}` JSON
|
||||||
//! are `{"status": "ok", "data": {...}}` JSON lines.
|
//! 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::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
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;
|
const FIPS_TIMEOUT_MS: u64 = 1500;
|
||||||
|
|
||||||
/// FIPS control state.
|
/// Well-known control socket locations, in discovery order (after any
|
||||||
static G_FIPS_CONTROL: Lazy<Mutex<FipsControlState>> = Lazy::new(|| Mutex::new(FipsControlState::default()));
|
/// explicitly configured path). Mirrors `net_service_enable()` in C.
|
||||||
|
const FIPS_SOCKET_CANDIDATES: &[&str] = &["/run/fips/control.sock", "/tmp/fips-control.sock"];
|
||||||
|
|
||||||
struct FipsControlState {
|
/// The control socket the browser currently talks to. Empty until a
|
||||||
running: bool,
|
/// service attach/spawn resolves one.
|
||||||
fips_path: String,
|
static G_FIPS_SOCKET: Lazy<Mutex<String>> = Lazy::new(|| Mutex::new(String::new()));
|
||||||
socket_path: String,
|
|
||||||
|
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 {
|
/// Initialize FIPS control (forgets any previously resolved socket).
|
||||||
fn default() -> Self {
|
pub fn fips_control_init() {
|
||||||
FipsControlState {
|
G_FIPS_SOCKET.lock().unwrap().clear();
|
||||||
running: false,
|
}
|
||||||
fips_path: String::new(),
|
|
||||||
socket_path: "/tmp/fips_control.sock".to_string(),
|
/// 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());
|
||||||
|
}
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize FIPS control.
|
/// Send one command on a fresh connection to `socket` and return the
|
||||||
pub fn fips_control_init() {
|
/// response's `data` field.
|
||||||
let mut state = G_FIPS_CONTROL.lock().unwrap();
|
fn request_on(socket: &str, command: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
state.running = false;
|
let mut stream = connect(socket)?;
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the FIPS mesh node.
|
let mut req = serde_json::json!({ "command": command });
|
||||||
pub fn fips_control_start() -> Result<(), Box<dyn std::error::Error>> {
|
if let Some(p) = params {
|
||||||
let mut state = G_FIPS_CONTROL.lock().unwrap();
|
req["params"] = p;
|
||||||
if state.running {
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
// Try to connect to the FIPS control socket to verify it's running.
|
let line = format!("{}\n", serde_json::to_string(&req)?);
|
||||||
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<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)?);
|
|
||||||
stream.write_all(line.as_bytes())?;
|
stream.write_all(line.as_bytes())?;
|
||||||
|
|
||||||
// Read the response (until newline).
|
let mut reader = BufReader::new(stream.take(FIPS_RESPONSE_MAX));
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
let mut response = String::new();
|
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 {
|
if n == 0 {
|
||||||
return Err("FIPS closed control connection".into());
|
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") {
|
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());
|
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.
|
/// Send a command using the current control socket.
|
||||||
pub fn fips_control_show_status() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
fn request(command: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
let root = fips_request("show_status")?;
|
let socket = fips_control_socket();
|
||||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
if socket.is_empty() {
|
||||||
|
return Err("FIPS control socket unavailable".into());
|
||||||
|
}
|
||||||
|
request_on(&socket, command, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the FIPS peer list.
|
/// Parse a `show_status` payload.
|
||||||
pub fn fips_control_show_peers() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
fn parse_status(data: &serde_json::Value) -> FipsStatus {
|
||||||
let root = fips_request("show_peers")?;
|
let s = |k: &str| data.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
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.
|
/// Get the FIPS spanning tree.
|
||||||
pub fn fips_control_show_tree() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
pub fn fips_control_show_tree() -> Result<serde_json::Value> {
|
||||||
let root = fips_request("show_tree")?;
|
request("show_tree", None)
|
||||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the FIPS identity cache.
|
/// Get the FIPS identity cache (`{"entries": [...], "count": N, ...}`).
|
||||||
pub fn fips_control_show_identity_cache() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
pub fn fips_control_show_identity_cache() -> Result<serde_json::Value> {
|
||||||
let root = fips_request("show_identity_cache")?;
|
request("show_identity_cache", None)
|
||||||
Ok(root.get("data").cloned().unwrap_or(serde_json::json!({})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to a FIPS peer.
|
/// 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>> {
|
pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result<()> {
|
||||||
let state = G_FIPS_CONTROL.lock().unwrap();
|
if npub.is_empty() || address.is_empty() {
|
||||||
let socket_path = state.socket_path.clone();
|
return Err("FIPS peer npub and address are required".into());
|
||||||
drop(state);
|
}
|
||||||
|
let transport = if transport.is_empty() { "udp" } else { transport };
|
||||||
let mut stream = UnixStream::connect(&socket_path)?;
|
request(
|
||||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
"connect",
|
||||||
stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?;
|
Some(serde_json::json!({
|
||||||
|
"npub": npub,
|
||||||
let request = serde_json::json!({
|
"address": address,
|
||||||
"command": "connect_peer",
|
"transport": transport,
|
||||||
"npub": npub,
|
})),
|
||||||
"address": address,
|
)?;
|
||||||
"transport": transport,
|
Ok(())
|
||||||
});
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from a FIPS peer.
|
/// Disconnect from a FIPS peer.
|
||||||
pub fn fips_control_disconnect_peer(npub: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
pub fn fips_control_disconnect_peer(npub: &str) -> Result<()> {
|
||||||
let state = G_FIPS_CONTROL.lock().unwrap();
|
if npub.is_empty() {
|
||||||
let socket_path = state.socket_path.clone();
|
return Err("FIPS peer npub is required".into());
|
||||||
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<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
|
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<String> {
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub mod net_services;
|
|||||||
pub mod tor_control;
|
pub mod tor_control;
|
||||||
pub mod tor_scheme;
|
pub mod tor_scheme;
|
||||||
pub mod fips_control;
|
pub mod fips_control;
|
||||||
|
pub mod fips_api;
|
||||||
pub mod web_context;
|
pub mod web_context;
|
||||||
pub mod webkit_data;
|
pub mod webkit_data;
|
||||||
pub mod site_downloader;
|
pub mod site_downloader;
|
||||||
|
|||||||
+22
@@ -46,6 +46,7 @@ mod net_services;
|
|||||||
mod tor_control;
|
mod tor_control;
|
||||||
mod tor_scheme;
|
mod tor_scheme;
|
||||||
mod fips_control;
|
mod fips_control;
|
||||||
|
mod fips_api;
|
||||||
mod web_context;
|
mod web_context;
|
||||||
mod webkit_data;
|
mod webkit_data;
|
||||||
mod site_downloader;
|
mod site_downloader;
|
||||||
@@ -339,6 +340,9 @@ fn main() {
|
|||||||
if args.no_login {
|
if args.no_login {
|
||||||
println!("[login] No-login mode (browsing without Nostr identity)");
|
println!("[login] No-login mode (browsing without Nostr identity)");
|
||||||
menu::app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true);
|
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() {
|
} else if args.login_method.is_some() {
|
||||||
// Auto-login from CLI args (no dialog).
|
// Auto-login from CLI args (no dialog).
|
||||||
if !do_cli_login(&args) {
|
if !do_cli_login(&args) {
|
||||||
@@ -351,6 +355,10 @@ fn main() {
|
|||||||
do_login(&window);
|
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,
|
// Now build the UI. Each tab carries its own toolbar (URL entry,
|
||||||
// back/forward/refresh, bookmark, hamburger menu), so there is no
|
// back/forward/refresh, bookmark, hamburger menu), so there is no
|
||||||
// shared top-level toolbar.
|
// 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...");
|
println!("[browser] Starting GTK main loop...");
|
||||||
gtk::main();
|
gtk::main();
|
||||||
|
|
||||||
|
// Stop managed Tor/FIPS processes (attached daemons are left alone).
|
||||||
|
net_services::net_services_shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
+1108
-113
File diff suppressed because it is too large
Load Diff
+59
-7
@@ -101,6 +101,15 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) {
|
|||||||
return;
|
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 ─────────────────────────────────────────
|
// ── Processes REST API ─────────────────────────────────────────
|
||||||
if route.starts_with("processes/") {
|
if route.starts_with("processes/") {
|
||||||
handle_processes_api(request, &route[10..]);
|
handle_processes_api(request, &route[10..]);
|
||||||
@@ -428,19 +437,27 @@ fn handle_processes_api(request: &URISchemeRequest, sub: &str) {
|
|||||||
|
|
||||||
match route {
|
match route {
|
||||||
"list" => {
|
"list" => {
|
||||||
// Layer 1: OS process list (simplified — just browser + webprocess).
|
// Layer 1: OS process list (browser + all descendants, from /proc).
|
||||||
let processes = crate::process_info::process_info_get_all();
|
// Reads /proc for every process on the system, so do it off the
|
||||||
respond_json(request, &serde_json::to_string(&processes).unwrap_or_default());
|
// 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" => {
|
"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 tabs = crate::tab_manager::tab_manager_get_tabs();
|
||||||
let tab_infos: Vec<serde_json::Value> = tabs.iter().map(|t| {
|
let tab_infos: Vec<serde_json::Value> = tabs.iter().map(|t| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"index": t.id,
|
||||||
"id": t.id,
|
"id": t.id,
|
||||||
"title": t.title,
|
"title": t.title,
|
||||||
"url": t.url,
|
"url": t.url,
|
||||||
"is_internal": t.url.starts_with("sovereign://") || t.url.starts_with("about:"),
|
"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();
|
}).collect();
|
||||||
respond_json(request, &serde_json::to_string(&tab_infos).unwrap_or_default());
|
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" => {
|
"tab_probe" => {
|
||||||
// Drill-down for a single tab.
|
// Drill-down for a single tab.
|
||||||
let index = extract_query_param(query, "index")
|
let index = extract_query_param(query, "index")
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<i32>().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let report = crate::perf_probe::perf_probe_get_report(index);
|
let tab = crate::tab_manager::tab_manager_get_tabs()
|
||||||
respond_json(request, &serde_json::to_string(&report).unwrap_or_default());
|
.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" => {
|
"tab_action" => {
|
||||||
// ?index=N&action=reload|suspend|close
|
// ?index=N&action=reload|suspend|close
|
||||||
@@ -971,6 +997,21 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) {
|
|||||||
settings::settings_save();
|
settings::settings_save();
|
||||||
false
|
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" => {
|
"search_engine" => {
|
||||||
let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave");
|
let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave");
|
||||||
if !valid { respond_error_json(request, 400, "Unknown search engine"); return; }
|
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,
|
"file_access": true,
|
||||||
"universal_access": true,
|
"universal_access": true,
|
||||||
"perf_probe_enabled": s.perf_probe_enabled,
|
"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,
|
"search_engines": search_engines,
|
||||||
"shortcuts": shortcuts,
|
"shortcuts": shortcuts,
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-28
@@ -58,40 +58,27 @@ pub fn perf_probe_page_load_end() {
|
|||||||
state.page_load_start = None;
|
state.page_load_start = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-tab probe report received from the injected perf-probe.js.
|
/// Per-tab probe reports received from the injected perf-probe.js, keyed by
|
||||||
static G_PROBE_REPORTS: Lazy<Mutex<Vec<ProbeReport>>> = Lazy::new(|| Mutex::new(Vec::new()));
|
/// 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
|
||||||
/// A probe report from a single tab.
|
/// (cpu_busy_percent, fps, timer_count, net_in_flight, heap_used_mb, ...).
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
static G_PROBE_REPORTS: Lazy<Mutex<std::collections::HashMap<i32, serde_json::Value>>> =
|
||||||
pub struct ProbeReport {
|
Lazy::new(|| Mutex::new(std::collections::HashMap::new()));
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a probe report from a tab.
|
/// Record a probe report from a tab.
|
||||||
pub fn perf_probe_record_report(tab_index: i32, report: &serde_json::Value) {
|
pub fn perf_probe_record_report(tab_index: i32, report: &serde_json::Value) {
|
||||||
let mut reports = G_PROBE_REPORTS.lock().unwrap();
|
G_PROBE_REPORTS.lock().unwrap().insert(tab_index, report.clone());
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the probe report for a specific tab index.
|
/// Get the probe report for a specific tab index.
|
||||||
pub fn perf_probe_get_report(index: usize) -> Option<ProbeReport> {
|
pub fn perf_probe_get_report(index: i32) -> Option<serde_json::Value> {
|
||||||
let reports = G_PROBE_REPORTS.lock().unwrap();
|
G_PROBE_REPORTS.lock().unwrap().get(&index).cloned()
|
||||||
reports.iter().find(|r| r.tab_index as usize == 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.
|
/// Get current performance metrics.
|
||||||
|
|||||||
+179
-41
@@ -1,61 +1,199 @@
|
|||||||
//! Process information for the processes page
|
//! 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 once_cell::sync::Lazy;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Process info state.
|
/// Previous CPU sample per pid: (utime+stime ticks, sample time).
|
||||||
static G_PROCESS_INFO: Lazy<Mutex<ProcessInfoState>> = Lazy::new(|| Mutex::new(ProcessInfoState::default()));
|
static G_CPU_SAMPLES: Lazy<Mutex<HashMap<u32, (u64, Instant)>>> =
|
||||||
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
struct ProcessInfoState {
|
/// Information about one OS process belonging to the browser.
|
||||||
web_processes: Vec<WebProcessInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Information about a web process.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct WebProcessInfo {
|
pub struct ProcInfo {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
pub url: String,
|
pub name: String,
|
||||||
pub title: String,
|
|
||||||
pub memory_mb: f64,
|
|
||||||
pub cpu_percent: f64,
|
pub cpu_percent: f64,
|
||||||
}
|
pub rss_kb: i64,
|
||||||
|
pub pss_kb: i64,
|
||||||
impl Default for ProcessInfoState {
|
pub swap_kb: i64,
|
||||||
fn default() -> Self {
|
pub threads: u32,
|
||||||
ProcessInfoState {
|
pub uptime_sec: f64,
|
||||||
web_processes: Vec::new(),
|
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.
|
/// Initialize process info.
|
||||||
pub fn process_info_init() {
|
pub fn process_info_init() {
|
||||||
let mut state = G_PROCESS_INFO.lock().unwrap();
|
G_CPU_SAMPLES.lock().unwrap().clear();
|
||||||
state.web_processes.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all web processes.
|
struct RawStat {
|
||||||
pub fn process_info_get_all() -> Vec<WebProcessInfo> {
|
ppid: u32,
|
||||||
let state = G_PROCESS_INFO.lock().unwrap();
|
state: String,
|
||||||
state.web_processes.clone()
|
ticks: u64,
|
||||||
|
threads: u32,
|
||||||
|
start_ticks: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a web process.
|
fn read_stat(pid: u32) -> Option<RawStat> {
|
||||||
pub fn process_info_add(pid: u32, url: &str, title: &str) {
|
let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
let mut state = G_PROCESS_INFO.lock().unwrap();
|
// comm may contain spaces/parens; split after the *last* ')'.
|
||||||
state.web_processes.push(WebProcessInfo {
|
let rest = s.rsplit_once(')')?.1;
|
||||||
pid,
|
let f: Vec<&str> = rest.split_whitespace().collect();
|
||||||
url: url.to_string(),
|
if f.len() < 20 {
|
||||||
title: title.to_string(),
|
return None;
|
||||||
memory_mb: 0.0,
|
}
|
||||||
cpu_percent: 0.0,
|
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()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a web process.
|
fn read_cmdline_name(pid: u32) -> String {
|
||||||
pub fn process_info_remove(pid: u32) {
|
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
|
||||||
let mut state = G_PROCESS_INFO.lock().unwrap();
|
let first = raw.split(|b| *b == 0).next().unwrap_or(&[]);
|
||||||
state.web_processes.retain(|p| p.pid != pid);
|
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,
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+114
@@ -32,6 +32,103 @@ pub fn search_is_url(input: &str) -> bool {
|
|||||||
|| input.contains('.')
|
|| 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.
|
/// Normalize a URL string: prepend `https://` if it has no scheme.
|
||||||
/// Returns the normalized URL.
|
/// Returns the normalized URL.
|
||||||
pub fn normalize_url(input: &str) -> String {
|
pub fn normalize_url(input: &str) -> String {
|
||||||
@@ -39,6 +136,23 @@ pub fn normalize_url(input: &str) -> String {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return trimmed.to_string();
|
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.
|
// If it already has a scheme, return as-is.
|
||||||
// Note: nostr: (NIP-21) is a valid scheme without //
|
// Note: nostr: (NIP-21) is a valid scheme without //
|
||||||
if trimmed.contains("://") || trimmed.starts_with("about:") || trimmed.starts_with("nostr:") {
|
if trimmed.contains("://") || trimmed.starts_with("about:") || trimmed.starts_with("nostr:") {
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ pub struct BrowserSettings {
|
|||||||
/// Enable the FIPS mesh service. Mirrors the C `fips_enabled`
|
/// Enable the FIPS mesh service. Mirrors the C `fips_enabled`
|
||||||
/// (default TRUE).
|
/// (default TRUE).
|
||||||
pub fips_enabled: bool,
|
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 {
|
impl Default for BrowserSettings {
|
||||||
@@ -89,6 +109,15 @@ impl Default for BrowserSettings {
|
|||||||
perf_probe_enabled: false,
|
perf_probe_enabled: false,
|
||||||
tor_enabled: true,
|
tor_enabled: true,
|
||||||
fips_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()) {
|
if let Some(v) = json.get("fips_enabled").and_then(|v| v.as_bool()) {
|
||||||
settings.fips_enabled = v;
|
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.
|
/// 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,
|
"enable_write_console_messages_to_stdout": settings.enable_write_console_messages_to_stdout,
|
||||||
"tor_enabled": settings.tor_enabled,
|
"tor_enabled": settings.tor_enabled,
|
||||||
"fips_enabled": settings.fips_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);
|
drop(settings);
|
||||||
|
|
||||||
|
|||||||
@@ -793,6 +793,21 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti
|
|||||||
return true;
|
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 ──────────────
|
// ── .onion HTTP(S) → tor:// rewriting ──────────────
|
||||||
// Detect .onion hosts in http:// or https:// URLs and
|
// Detect .onion hosts in http:// or https:// URLs and
|
||||||
// rewrite to tor:// so the request goes through Tor's
|
// rewrite to tor:// so the request goes through Tor's
|
||||||
|
|||||||
+251
-114
@@ -2,23 +2,32 @@
|
|||||||
//!
|
//!
|
||||||
//! Port of `tor_control.c` / `tor_control.h` from the C project.
|
//! Port of `tor_control.c` / `tor_control.h` from the C project.
|
||||||
//! Implements a synchronous, short-timeout Tor control-protocol client
|
//! 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::io::{self, BufRead, BufReader, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
const TOR_RESPONSE_MAX: usize = 8192;
|
|
||||||
const TOR_TIMEOUT_MS: u64 = 1500;
|
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.
|
/// 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 {
|
struct TorControlState {
|
||||||
connected: bool,
|
connected: bool,
|
||||||
control_host: String,
|
/// `host:port` or `unix:/path`.
|
||||||
control_port: u16,
|
endpoint: String,
|
||||||
password: String,
|
password: String,
|
||||||
cookie_path: String,
|
cookie_path: String,
|
||||||
}
|
}
|
||||||
@@ -27,145 +36,273 @@ impl Default for TorControlState {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
TorControlState {
|
TorControlState {
|
||||||
connected: false,
|
connected: false,
|
||||||
control_host: "127.0.0.1".to_string(),
|
endpoint: "127.0.0.1:9051".to_string(),
|
||||||
control_port: 9051,
|
|
||||||
password: String::new(),
|
password: String::new(),
|
||||||
cookie_path: 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.
|
/// Initialize Tor control.
|
||||||
pub fn tor_control_init() {
|
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();
|
let mut state = G_TOR_CONTROL.lock().unwrap();
|
||||||
|
state.endpoint = endpoint.to_string();
|
||||||
|
state.cookie_path = cookie_path.to_string();
|
||||||
state.connected = false;
|
state.connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to the Tor control port (TCP host:port).
|
/// Verify that the configured control port is reachable and accepts our
|
||||||
pub fn tor_control_connect() -> Result<(), Box<dyn std::error::Error>> {
|
/// credentials.
|
||||||
let mut state = G_TOR_CONTROL.lock().unwrap();
|
pub fn tor_control_connect() -> Result<()> {
|
||||||
let host = state.control_host.clone();
|
let (endpoint, cookie, password) = {
|
||||||
let port = state.control_port;
|
let s = G_TOR_CONTROL.lock().unwrap();
|
||||||
let cookie = state.cookie_path.clone();
|
(s.endpoint.clone(), s.cookie_path.clone(), s.password.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()
|
|
||||||
};
|
};
|
||||||
|
Session::open(&endpoint, &cookie, &password)?;
|
||||||
stream.write_all(auth_cmd.as_bytes())?;
|
G_TOR_CONTROL.lock().unwrap().connected = true;
|
||||||
let response = read_response(&mut stream)?;
|
println!("[tor] Connected to control port {}", endpoint);
|
||||||
if !response.starts_with("250") {
|
|
||||||
return Err(format!("Tor authentication failed: {}", response).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
state.connected = true;
|
|
||||||
println!("[tor] Connected to control port {}:{}", host, port);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from Tor control port.
|
/// Disconnect from Tor control port.
|
||||||
pub fn tor_control_disconnect() {
|
pub fn tor_control_disconnect() {
|
||||||
let mut state = G_TOR_CONTROL.lock().unwrap();
|
G_TOR_CONTROL.lock().unwrap().connected = false;
|
||||||
state.connected = false;
|
|
||||||
println!("[tor] Disconnected from control port");
|
println!("[tor] Disconnected from control port");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if connected to Tor control.
|
/// Check if connected to Tor control.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn tor_control_is_connected() -> bool {
|
pub fn tor_control_is_connected() -> bool {
|
||||||
let state = G_TOR_CONTROL.lock().unwrap();
|
G_TOR_CONTROL.lock().unwrap().connected
|
||||||
state.connected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a command to the Tor control port and return the response.
|
/// Send a command to the Tor control port and return the reply. Each
|
||||||
pub fn tor_control_send_command(command: &str) -> Result<String, Box<dyn std::error::Error>> {
|
/// command uses a short-lived authenticated connection.
|
||||||
let state = G_TOR_CONTROL.lock().unwrap();
|
pub fn tor_control_send_command(command: &str) -> Result<String> {
|
||||||
if !state.connected {
|
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());
|
return Err("Not connected to Tor control port".into());
|
||||||
}
|
}
|
||||||
let host = state.control_host.clone();
|
tor_control_command_on(&endpoint, &cookie, &password, command)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request a new Tor identity (SIGNAL NEWNYM).
|
/// 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")
|
tor_control_send_command("SIGNAL NEWNYM")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the Tor bootstrap progress.
|
/// Get the Tor bootstrap progress.
|
||||||
pub fn tor_control_get_bootstrap() -> Result<(i32, String), Box<dyn std::error::Error>> {
|
#[allow(dead_code)]
|
||||||
let response = tor_control_send_command("GETINFO status/bootstrap-phase")?;
|
pub fn tor_control_get_bootstrap() -> Result<(i32, String)> {
|
||||||
// Parse "PROGRESS=NN" from the response.
|
let reply = tor_control_send_command("GETINFO status/bootstrap-phase")?;
|
||||||
let progress = response
|
Ok(parse_bootstrap(&reply))
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-8
@@ -14,8 +14,8 @@ use glib::Bytes;
|
|||||||
use gio::MemoryInputStream;
|
use gio::MemoryInputStream;
|
||||||
use webkit2gtk::*;
|
use webkit2gtk::*;
|
||||||
|
|
||||||
/// Default Tor SOCKS endpoint (socks5h = remote DNS resolution through Tor).
|
/// How long a `tor://` request waits for Tor to become ready.
|
||||||
const DEFAULT_SOCKS_ENDPOINT: &str = "socks5h://127.0.0.1:9050";
|
const TOR_READY_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
/// Maximum response size (16 MB, matching the C version).
|
/// Maximum response size (16 MB, matching the C version).
|
||||||
const TOR_FETCH_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
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.
|
// 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: Arc<Mutex<Option<Result<(Vec<u8>, Option<String>), String>>>> = Arc::new(Mutex::new(None));
|
||||||
let result_worker = result.clone();
|
let result_worker = result.clone();
|
||||||
|
|
||||||
// Spawn a blocking worker thread (reqwest doesn't need async here).
|
// 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 || {
|
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);
|
*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() {
|
let response: reqwest::blocking::Response = match client.get(url).send() {
|
||||||
Ok(r) => r,
|
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.
|
// 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))
|
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.
|
/// Respond with an error HTML page.
|
||||||
fn respond_error(request: &URISchemeRequest, title: &str, message: &str) {
|
fn respond_error(request: &URISchemeRequest, title: &str, message: &str) {
|
||||||
let html = format!(
|
let html = format!(
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
//! Version information for sovereign_browser
|
//! Version information for sovereign_browser
|
||||||
|
|
||||||
/// The current version of sovereign_browser (with leading 'v').
|
/// 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.
|
/// Major version number.
|
||||||
pub const VERSION_MAJOR: u32 = 0;
|
pub const VERSION_MAJOR: u32 = 0;
|
||||||
@@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0;
|
|||||||
pub const VERSION_MINOR: u32 = 0;
|
pub const VERSION_MINOR: u32 = 0;
|
||||||
|
|
||||||
/// Patch version number.
|
/// Patch version number.
|
||||||
pub const VERSION_PATCH: u32 = 12;
|
pub const VERSION_PATCH: u32 = 13;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<th data-sort="cpu_percent">CPU%</th>
|
<th data-sort="cpu_percent">CPU%</th>
|
||||||
<th data-sort="rss_kb">RSS</th>
|
<th data-sort="rss_kb">RSS</th>
|
||||||
<th data-sort="pss_kb">PSS</th>
|
<th data-sort="pss_kb">PSS</th>
|
||||||
|
<th data-sort="swap_kb">Swap</th>
|
||||||
<th data-sort="threads">Threads</th>
|
<th data-sort="threads">Threads</th>
|
||||||
<th data-sort="uptime_sec">Uptime</th>
|
<th data-sort="uptime_sec">Uptime</th>
|
||||||
<th data-sort="state">State</th>
|
<th data-sort="state">State</th>
|
||||||
|
|||||||
+3
-2
@@ -153,6 +153,7 @@ function renderProcTable() {
|
|||||||
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
|
'<td>' + heatHtml(p.cpu_percent) + '</td>' +
|
||||||
'<td>' + fmtKb(p.rss_kb) + '</td>' +
|
'<td>' + fmtKb(p.rss_kb) + '</td>' +
|
||||||
'<td>' + fmtKb(p.pss_kb) + '</td>' +
|
'<td>' + fmtKb(p.pss_kb) + '</td>' +
|
||||||
|
'<td>' + fmtKb(p.swap_kb) + '</td>' +
|
||||||
'<td>' + (p.threads || 0) + '</td>' +
|
'<td>' + (p.threads || 0) + '</td>' +
|
||||||
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
|
'<td>' + fmtUptime(p.uptime_sec) + '</td>' +
|
||||||
'<td>' + esc(p.state) + '</td>' +
|
'<td>' + esc(p.state) + '</td>' +
|
||||||
@@ -173,7 +174,7 @@ function renderProcTable() {
|
|||||||
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
|
if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) {
|
||||||
var sub = document.createElement('tr');
|
var sub = document.createElement('tr');
|
||||||
sub.className = 'hosted-tabs-row';
|
sub.className = 'hosted-tabs-row';
|
||||||
var html = '<td colspan="10">';
|
var html = '<td colspan="11">';
|
||||||
p.hosted_tabs.forEach(function (t) {
|
p.hosted_tabs.forEach(function (t) {
|
||||||
html += '<span class="ht-entry"><span class="ht-idx">#' +
|
html += '<span class="ht-entry"><span class="ht-idx">#' +
|
||||||
t.index + '</span>' + esc(t.title || '(untitled)') +
|
t.index + '</span>' + esc(t.title || '(untitled)') +
|
||||||
@@ -186,7 +187,7 @@ function renderProcTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user