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_u64() + 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), "", tags)
291 .to_event(keys)
292 .expect("sign blossom auth");
293 let encoded = base64::engine::general_purpose::STANDARD
294 .encode(serde_json::to_string(&event).expect("serialize auth event"));
295 format!("Nostr {encoded}")
296 }
297
298 #[test]
299 fn host_runtime_starts_and_shuts_down() {
300 let temp = TempDir::new().expect("temp dir");
301 let mut runtime =
302 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
303
304 let status = runtime.status();
305 assert!(
306 status.config_dir.join("keys").exists(),
307 "expected host daemon to materialize keys in its config dir"
308 );
309
310 let response = reqwest::blocking::get(format!("{}/htree/test", status.base_url))
311 .expect("fetch test endpoint");
312 assert!(
313 response.status().is_success(),
314 "embedded daemon should answer"
315 );
316
317 runtime.shutdown();
318
319 let stopped = reqwest::blocking::get(format!("{}/htree/test", status.base_url)).is_err();
320 assert!(stopped, "expected host daemon shutdown to stop serving");
321 }
322
323 #[test]
324 fn host_runtime_rejects_public_blossom_uploads() {
325 let temp = TempDir::new().expect("temp dir");
326 let runtime =
327 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
328 let status = runtime.status();
329
330 let keys = Keys::generate();
331 let response = Client::new()
332 .put(format!("{}/upload", status.base_url))
333 .header("Authorization", create_blossom_auth(&keys, "upload"))
334 .header("Content-Type", "text/plain")
335 .body("browser-mode upload probe")
336 .send()
337 .expect("upload request");
338
339 assert_eq!(
340 response.status(),
341 reqwest::StatusCode::FORBIDDEN,
342 "browser-mode daemon must not accept public Blossom uploads"
343 );
344 }
345
346 #[test]
347 fn host_runtime_keeps_default_relays_and_file_servers() {
348 let temp = TempDir::new().expect("temp dir");
349 let runtime =
350 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
351 let status = runtime.status();
352
353 let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
354 .expect("fetch daemon status");
355 assert!(
356 response.status().is_success(),
357 "status endpoint should answer"
358 );
359 let payload: serde_json::Value = response.json().expect("parse daemon status");
360
361 assert!(
362 payload["upstream"]["nostr_relays"]
363 .as_u64()
364 .unwrap_or_default()
365 > 0,
366 "browser mode should keep default relays for profile lookups",
367 );
368 assert!(
369 payload["upstream"]["blossom_servers"]
370 .as_u64()
371 .unwrap_or_default()
372 > 0,
373 "browser mode should keep default file servers for content fetches",
374 );
375 assert_eq!(
376 payload["mesh"]["enabled"].as_bool(),
377 Some(false),
378 "browser mode should still keep peer discovery off until enabled",
379 );
380 }
381
382 #[test]
383 fn host_runtime_applies_browser_settings_overrides() {
384 let temp = TempDir::new().expect("temp dir");
385 let config_dir = temp.path().join("config");
386 std::fs::create_dir_all(&config_dir).expect("create config dir");
387 std::fs::write(
388 config_dir.join("browser_settings.json"),
389 serde_json::to_vec_pretty(&json!({
390 "nostrRelays": [
391 "wss://relay.example-two",
392 "wss://relay.example-one",
393 "wss://relay.example-two"
394 ],
395 "blossomReadServers": [
396 "https://cdn.example"
397 ],
398 "blossomWriteServers": [
399 "https://upload.example"
400 ],
401 "enableWebrtc": true,
402 "enableFips": false,
403 "enableFipsUdp": false,
404 "enableFipsWebrtc": false,
405 "fetchFromFipsPeers": false,
406 "socialGraphCrawlDepth": 0,
407 "syncEnabled": false,
408 "syncOwn": false,
409 "syncFollowed": false
410 }))
411 .expect("serialize browser settings"),
412 )
413 .expect("write browser settings");
414
415 let runtime =
416 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
417 let status = runtime.status();
418
419 let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
420 .expect("fetch daemon status");
421 assert!(
422 response.status().is_success(),
423 "status endpoint should answer"
424 );
425 let payload: serde_json::Value = response.json().expect("parse daemon status");
426
427 assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(2));
428 assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
429 assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(false));
430 }
431
432 #[test]
433 fn browser_config_applies_background_service_overrides() {
434 let temp = TempDir::new().expect("temp dir");
435 let config_dir = temp.path().join("config");
436 let data_dir = temp.path().join("data");
437 std::fs::create_dir_all(&config_dir).expect("create config dir");
438 std::fs::write(
439 config_dir.join("browser_settings.json"),
440 serde_json::to_vec_pretty(&json!({
441 "enableFips": false,
442 "enableFipsUdp": false,
443 "enableFipsWebrtc": false,
444 "fetchFromFipsPeers": false,
445 "socialGraphCrawlDepth": 0
446 }))
447 .expect("serialize browser settings"),
448 )
449 .expect("write browser settings");
450
451 let config = browser_config(&data_dir, &config_dir);
452
453 assert!(!config.server.enable_fips);
454 assert!(!config.server.enable_fips_udp);
455 assert!(!config.server.enable_fips_webrtc);
456 assert!(!config.server.fetch_from_fips_peers);
457 assert_eq!(config.nostr.social_graph_crawl_depth, 0);
458 }
459
460 #[test]
461 fn host_runtime_reload_applies_updated_browser_settings() {
462 let temp = TempDir::new().expect("temp dir");
463 let mut runtime =
464 HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
465 let initial_status = runtime.status();
466
467 let config_dir = temp.path().join("config");
468 std::fs::create_dir_all(&config_dir).expect("create config dir");
469 std::fs::write(
470 config_dir.join("browser_settings.json"),
471 serde_json::to_vec_pretty(&json!({
472 "nostrRelays": ["wss://relay.example-one"],
473 "blossomReadServers": ["https://cdn.example"],
474 "blossomWriteServers": ["https://upload.example"],
475 "enableWebrtc": true
476 }))
477 .expect("serialize browser settings"),
478 )
479 .expect("write browser settings");
480
481 let reloaded_status = runtime.reload().expect("reload daemon");
482 let response = reqwest::blocking::get(format!("{}/api/status", reloaded_status.base_url))
483 .expect("fetch daemon status after reload");
484 assert!(
485 response.status().is_success(),
486 "reloaded daemon should answer"
487 );
488 let payload: serde_json::Value = response.json().expect("parse daemon status");
489
490 assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(1));
491 assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
492 assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(true));
493 assert!(
494 !reloaded_status.base_url.is_empty(),
495 "reload should keep serving from some loopback endpoint"
496 );
497 assert_eq!(
498 reloaded_status.self_npub, initial_status.self_npub,
499 "reload should keep the same browser-owned identity"
500 );
501 }
502}