1use anyhow::{Context, Result};
2use hashtree_cli::daemon::{EmbeddedDaemonInfo, EmbeddedDaemonOptions};
3use hashtree_cli::Config;
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub struct HostDaemonOptions {
9 pub state_root: PathBuf,
10 pub bind_address: String,
11}
12
13impl HostDaemonOptions {
14 pub fn new(state_root: impl Into<PathBuf>) -> Self {
15 Self {
16 state_root: state_root.into(),
17 bind_address: "127.0.0.1:0".to_string(),
18 }
19 }
20}
21
22#[derive(Debug, Clone)]
23pub struct HostDaemonStatus {
24 pub base_url: String,
25 pub self_npub: String,
26 pub config_dir: PathBuf,
27 pub data_dir: PathBuf,
28}
29
30pub struct HostDaemonRuntime {
31 runtime: tokio::runtime::Runtime,
32 info: EmbeddedDaemonInfo,
33 bind_address: String,
34 config_dir: PathBuf,
35 data_dir: PathBuf,
36}
37
38#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40#[serde(rename_all = "camelCase")]
41struct BrowserSettings {
42 nostr_relays: Option<Vec<String>>,
43 blossom_read_servers: Option<Vec<String>>,
44 blossom_write_servers: Option<Vec<String>>,
45 enable_webrtc: Option<bool>,
46 enable_multicast: Option<bool>,
47 max_multicast_peers: Option<usize>,
48 enable_fips: Option<bool>,
49 enable_fips_udp: Option<bool>,
50 enable_fips_webrtc: Option<bool>,
51 fetch_from_fips_peers: Option<bool>,
52 social_graph_crawl_depth: Option<u32>,
53 sync_enabled: Option<bool>,
54 sync_own: Option<bool>,
55 sync_followed: Option<bool>,
56 sync_max_concurrent: Option<usize>,
57 public_writes: Option<bool>,
58 allowed_npubs: Option<Vec<String>>,
59 socialgraph_root: Option<String>,
60}
61
62impl HostDaemonRuntime {
63 pub fn start(options: HostDaemonOptions) -> Result<Self> {
64 let runtime = tokio::runtime::Builder::new_multi_thread()
65 .enable_all()
66 .build()
67 .context("build embedded host runtime")?;
68
69 let config_dir = options.state_root.join("config");
70 let data_dir = options.state_root.join("data");
71 std::fs::create_dir_all(&config_dir).context("create embedded config dir")?;
72 std::fs::create_dir_all(&data_dir).context("create embedded data dir")?;
73
74 let config = browser_config(&data_dir, &config_dir);
75 let info = runtime
76 .block_on(hashtree_cli::daemon::start_embedded(
77 EmbeddedDaemonOptions {
78 config,
79 data_dir: data_dir.clone(),
80 config_dir: Some(config_dir.clone()),
81 bind_address: options.bind_address,
82 relays: None,
83 extra_routes: None,
84 cors: None,
85 },
86 ))
87 .context("start embedded hashtree daemon")?;
88
89 let bind_address = info.addr.clone();
90
91 Ok(Self {
92 runtime,
93 info,
94 bind_address,
95 config_dir,
96 data_dir,
97 })
98 }
99
100 pub fn status(&self) -> HostDaemonStatus {
101 HostDaemonStatus {
102 base_url: format!("http://{}", self.info.addr),
103 self_npub: self.info.npub.clone(),
104 config_dir: self.config_dir.clone(),
105 data_dir: self.data_dir.clone(),
106 }
107 }
108
109 pub fn base_url(&self) -> String {
110 self.status().base_url
111 }
112
113 pub fn self_npub(&self) -> &str {
114 &self.info.npub
115 }
116
117 pub fn reload(&mut self) -> Result<HostDaemonStatus> {
118 let controller = self.info.daemon_controller.clone();
119 self.runtime.block_on(async move {
120 controller.shutdown().await;
121 });
122
123 let config = browser_config(&self.data_dir, &self.config_dir);
124 self.info = self
125 .runtime
126 .block_on(hashtree_cli::daemon::start_embedded(
127 EmbeddedDaemonOptions {
128 config,
129 data_dir: self.data_dir.clone(),
130 config_dir: Some(self.config_dir.clone()),
131 bind_address: self.bind_address.clone(),
132 relays: None,
133 extra_routes: None,
134 cors: None,
135 },
136 ))
137 .context("reload embedded hashtree daemon")?;
138 self.bind_address = self.info.addr.clone();
139 Ok(self.status())
140 }
141
142 pub fn shutdown(&mut self) {
143 let controller = self.info.daemon_controller.clone();
144 self.runtime.block_on(async move {
145 controller.shutdown().await;
146 });
147 }
148}
149
150impl Drop for HostDaemonRuntime {
151 fn drop(&mut self) {
152 self.shutdown();
153 }
154}
155
156fn browser_config(data_dir: &Path, config_dir: &Path) -> Config {
157 let mut config = Config::default();
158 let settings = load_browser_settings(config_dir).unwrap_or_else(default_browser_settings);
159
160 if let Some(nostr_relays) = settings.nostr_relays {
161 config.nostr.relays = normalize_server_list(nostr_relays);
162 config.nostr.enabled = !config.nostr.relays.is_empty();
163 }
164
165 if let Some(blossom_read_servers) = settings.blossom_read_servers {
166 config.blossom.read_servers = normalize_server_list(blossom_read_servers);
167 config.blossom.servers.clear();
168 }
169 if let Some(blossom_write_servers) = settings.blossom_write_servers {
170 config.blossom.write_servers = normalize_server_list(blossom_write_servers);
171 config.blossom.servers.clear();
172 }
173 config.blossom.enabled =
174 !config.blossom.read_servers.is_empty() || !config.blossom.write_servers.is_empty();
175
176 config.server.enable_webrtc = settings.enable_webrtc.unwrap_or(false);
177 if !config.server.enable_webrtc {
178 config.server.stun_port = 0;
179 }
180
181 config.server.enable_multicast = settings.enable_multicast.unwrap_or(false);
182 if let Some(max_multicast_peers) = settings.max_multicast_peers {
183 config.server.max_multicast_peers = max_multicast_peers;
184 }
185
186 if let Some(enable_fips) = settings.enable_fips {
187 config.server.enable_fips = enable_fips;
188 }
189 if let Some(enable_fips_udp) = settings.enable_fips_udp {
190 config.server.enable_fips_udp = enable_fips_udp;
191 }
192 if let Some(enable_fips_webrtc) = settings.enable_fips_webrtc {
193 config.server.enable_fips_webrtc = enable_fips_webrtc;
194 }
195 if let Some(fetch_from_fips_peers) = settings.fetch_from_fips_peers {
196 config.server.fetch_from_fips_peers = fetch_from_fips_peers;
197 }
198 if let Some(social_graph_crawl_depth) = settings.social_graph_crawl_depth {
199 config.nostr.social_graph_crawl_depth = social_graph_crawl_depth;
200 }
201
202 config.sync.enabled = settings.sync_enabled.unwrap_or(false);
203 if let Some(sync_own) = settings.sync_own {
204 config.sync.sync_own = sync_own;
205 }
206 if let Some(sync_followed) = settings.sync_followed {
207 config.sync.sync_followed = sync_followed;
208 }
209 if let Some(sync_max_concurrent) = settings.sync_max_concurrent {
210 config.sync.max_concurrent = sync_max_concurrent.max(1);
211 }
212
213 config.server.public_writes = settings.public_writes.unwrap_or(false);
214 if let Some(allowed_npubs) = settings.allowed_npubs {
215 config.nostr.allowed_npubs = normalize_server_list(allowed_npubs);
216 }
217 if let Some(socialgraph_root) = settings.socialgraph_root {
218 let socialgraph_root = socialgraph_root.trim().to_string();
219 config.nostr.socialgraph_root = if socialgraph_root.is_empty() {
220 None
221 } else {
222 Some(socialgraph_root)
223 };
224 }
225 config.storage.data_dir = data_dir.to_string_lossy().to_string();
226 config.server.enable_auth = false;
227 config.server.enable_bluetooth = false;
228 config.server.max_bluetooth_peers = 0;
229 config
230}
231
232fn load_browser_settings(config_dir: &Path) -> Option<BrowserSettings> {
233 let settings_path = config_dir.join("browser_settings.json");
234 let raw = std::fs::read_to_string(settings_path).ok()?;
235 serde_json::from_str(&raw).ok()
236}
237
238fn default_browser_settings() -> BrowserSettings {
239 BrowserSettings {
240 nostr_relays: None,
241 blossom_read_servers: None,
242 blossom_write_servers: None,
243 enable_webrtc: Some(false),
244 enable_multicast: Some(false),
245 max_multicast_peers: None,
246 enable_fips: None,
247 enable_fips_udp: None,
248 enable_fips_webrtc: None,
249 fetch_from_fips_peers: None,
250 social_graph_crawl_depth: None,
251 sync_enabled: Some(false),
252 sync_own: Some(true),
253 sync_followed: Some(true),
254 sync_max_concurrent: Some(3),
255 public_writes: Some(false),
256 allowed_npubs: None,
257 socialgraph_root: None,
258 }
259}
260
261fn normalize_server_list(values: Vec<String>) -> Vec<String> {
262 let mut normalized = values
263 .into_iter()
264 .map(|value| value.trim().to_string())
265 .filter(|value| !value.is_empty())
266 .collect::<Vec<_>>();
267 normalized.sort();
268 normalized.dedup();
269 normalized
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use base64::Engine;
276 use nostr::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
277 use reqwest::blocking::Client;
278 use serde_json::json;
279 use tempfile::TempDir;
280
281 fn create_blossom_auth(keys: &Keys, action: &str) -> String {
282 let expiration = Timestamp::from(Timestamp::now().as_secs() + 300);
283 let tags = vec![
284 Tag::custom(TagKind::Custom("t".into()), vec![action.to_string()]),
285 Tag::custom(
286 TagKind::Custom("expiration".into()),
287 vec![expiration.to_string()],
288 ),
289 ];
290 let event = EventBuilder::new(Kind::Custom(24242), "")
291 .tags(tags)
292 .sign_with_keys(keys)
293 .expect("sign blossom auth");
294 let encoded = base64::engine::general_purpose::STANDARD
295 .encode(serde_json::to_string(&event).expect("serialize auth event"));
296 format!("Nostr {encoded}")
297 }
298
299 #[test]
300 fn host_runtime_starts_and_shuts_down() {
301 let temp = TempDir::new().expect("temp dir");
302 let mut runtime =
303 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
304
305 let status = runtime.status();
306 assert!(
307 status.config_dir.join("keys").exists(),
308 "expected host daemon to materialize keys in its config dir"
309 );
310
311 let response = reqwest::blocking::get(format!("{}/htree/test", status.base_url))
312 .expect("fetch test endpoint");
313 assert!(
314 response.status().is_success(),
315 "embedded daemon should answer"
316 );
317
318 runtime.shutdown();
319
320 let stopped = reqwest::blocking::get(format!("{}/htree/test", status.base_url)).is_err();
321 assert!(stopped, "expected host daemon shutdown to stop serving");
322 }
323
324 #[test]
325 fn host_runtime_rejects_public_blossom_uploads() {
326 let temp = TempDir::new().expect("temp dir");
327 let runtime =
328 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
329 let status = runtime.status();
330
331 let keys = Keys::generate();
332 let response = Client::new()
333 .put(format!("{}/upload", status.base_url))
334 .header("Authorization", create_blossom_auth(&keys, "upload"))
335 .header("Content-Type", "text/plain")
336 .body("browser-mode upload probe")
337 .send()
338 .expect("upload request");
339
340 assert_eq!(
341 response.status(),
342 reqwest::StatusCode::FORBIDDEN,
343 "browser-mode daemon must not accept public Blossom uploads"
344 );
345 }
346
347 #[test]
348 fn host_runtime_keeps_default_relays_and_file_servers() {
349 let temp = TempDir::new().expect("temp dir");
350 let runtime =
351 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
352 let status = runtime.status();
353
354 let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
355 .expect("fetch daemon status");
356 assert!(
357 response.status().is_success(),
358 "status endpoint should answer"
359 );
360 let payload: serde_json::Value = response.json().expect("parse daemon status");
361
362 assert!(
363 payload["upstream"]["nostr_relays"]
364 .as_u64()
365 .unwrap_or_default()
366 > 0,
367 "browser mode should keep default relays for profile lookups",
368 );
369 assert!(
370 payload["upstream"]["blossom_servers"]
371 .as_u64()
372 .unwrap_or_default()
373 > 0,
374 "browser mode should keep default file servers for content fetches",
375 );
376 assert_eq!(
377 payload["mesh"]["enabled"].as_bool(),
378 Some(false),
379 "browser mode should still keep peer discovery off until enabled",
380 );
381 }
382
383 #[test]
384 fn host_runtime_applies_browser_settings_overrides() {
385 let temp = TempDir::new().expect("temp dir");
386 let config_dir = temp.path().join("config");
387 std::fs::create_dir_all(&config_dir).expect("create config dir");
388 std::fs::write(
389 config_dir.join("browser_settings.json"),
390 serde_json::to_vec_pretty(&json!({
391 "nostrRelays": [
392 "wss://relay.example-two",
393 "wss://relay.example-one",
394 "wss://relay.example-two"
395 ],
396 "blossomReadServers": [
397 "https://cdn.example"
398 ],
399 "blossomWriteServers": [
400 "https://upload.example"
401 ],
402 "enableWebrtc": true,
403 "enableFips": false,
404 "enableFipsUdp": false,
405 "enableFipsWebrtc": false,
406 "fetchFromFipsPeers": false,
407 "socialGraphCrawlDepth": 0,
408 "syncEnabled": false,
409 "syncOwn": false,
410 "syncFollowed": false
411 }))
412 .expect("serialize browser settings"),
413 )
414 .expect("write browser settings");
415
416 let runtime =
417 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
418 let status = runtime.status();
419
420 let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
421 .expect("fetch daemon status");
422 assert!(
423 response.status().is_success(),
424 "status endpoint should answer"
425 );
426 let payload: serde_json::Value = response.json().expect("parse daemon status");
427
428 assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(2));
429 assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
430 assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(false));
431 }
432
433 #[test]
434 fn browser_config_applies_background_service_overrides() {
435 let temp = TempDir::new().expect("temp dir");
436 let config_dir = temp.path().join("config");
437 let data_dir = temp.path().join("data");
438 std::fs::create_dir_all(&config_dir).expect("create config dir");
439 std::fs::write(
440 config_dir.join("browser_settings.json"),
441 serde_json::to_vec_pretty(&json!({
442 "enableFips": false,
443 "enableFipsUdp": false,
444 "enableFipsWebrtc": false,
445 "fetchFromFipsPeers": false,
446 "socialGraphCrawlDepth": 0
447 }))
448 .expect("serialize browser settings"),
449 )
450 .expect("write browser settings");
451
452 let config = browser_config(&data_dir, &config_dir);
453
454 assert!(!config.server.enable_fips);
455 assert!(!config.server.enable_fips_udp);
456 assert!(!config.server.enable_fips_webrtc);
457 assert!(!config.server.fetch_from_fips_peers);
458 assert_eq!(config.nostr.social_graph_crawl_depth, 0);
459 }
460
461 #[test]
462 fn host_runtime_reload_applies_updated_browser_settings() {
463 let temp = TempDir::new().expect("temp dir");
464 let mut runtime =
465 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
466 let initial_status = runtime.status();
467
468 let config_dir = temp.path().join("config");
469 std::fs::create_dir_all(&config_dir).expect("create config dir");
470 std::fs::write(
471 config_dir.join("browser_settings.json"),
472 serde_json::to_vec_pretty(&json!({
473 "nostrRelays": ["wss://relay.example-one"],
474 "blossomReadServers": ["https://cdn.example"],
475 "blossomWriteServers": ["https://upload.example"],
476 "enableWebrtc": true
477 }))
478 .expect("serialize browser settings"),
479 )
480 .expect("write browser settings");
481
482 let reloaded_status = runtime.reload().expect("reload daemon");
483 let response = reqwest::blocking::get(format!("{}/api/status", reloaded_status.base_url))
484 .expect("fetch daemon status after reload");
485 assert!(
486 response.status().is_success(),
487 "reloaded daemon should answer"
488 );
489 let payload: serde_json::Value = response.json().expect("parse daemon status");
490
491 assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(1));
492 assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
493 assert_eq!(
494 payload["mesh"]["enabled"].as_bool(),
495 Some(false),
496 "embedded non-P2P builds keep mesh disabled even when browser settings request WebRTC",
497 );
498 assert!(
499 !reloaded_status.base_url.is_empty(),
500 "reload should keep serving from some loopback endpoint"
501 );
502 assert_eq!(
503 reloaded_status.self_npub, initial_status.self_npub,
504 "reload should keep the same browser-owned identity"
505 );
506 }
507}