Skip to main content

hypha/config/
synapse_nodes.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use super::{HyphaConfig, files::write_text_file_atomic, hypha_dir};
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8#[serde(deny_unknown_fields)]
9pub struct SynapseNode {
10    pub synapse_url: String,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub token_secret: Option<String>,
13}
14
15/// Resolved synapse: URL + optional auth token
16pub struct ResolvedSynapse {
17    pub synapse_url: String,
18    pub token_secret: Option<String>,
19}
20
21fn validate_synapse_domain(domain: &str) -> Result<(), crate::sink::HyphaError> {
22    use crate::sink::HyphaError;
23
24    if domain.is_empty() {
25        return Err(HyphaError::new(
26            "invalid_synapse_domain",
27            "Synapse domain must not be empty",
28        ));
29    }
30    if domain.chars().any(|c| c.is_control()) {
31        return Err(HyphaError::new(
32            "invalid_synapse_domain",
33            format!(
34                "Invalid synapse domain '{}': contains control characters",
35                domain
36            ),
37        ));
38    }
39
40    let mut components = std::path::Path::new(domain).components();
41    let single_normal_component =
42        matches!(components.next(), Some(std::path::Component::Normal(_)))
43            && components.next().is_none();
44    if !single_normal_component {
45        return Err(HyphaError::new(
46            "invalid_synapse_domain",
47            format!(
48                "Invalid synapse domain '{}': must be a single path segment",
49                domain
50            ),
51        ));
52    }
53
54    Ok(())
55}
56
57/// Directory for a synapse node: $CMN_HOME/hypha/synapse/<domain>/
58pub fn synapse_node_dir(domain: &str) -> PathBuf {
59    hypha_dir().join("synapse").join(domain)
60}
61
62/// Load a synapse node config from its directory
63pub fn load_synapse_node(domain: &str) -> Option<SynapseNode> {
64    if validate_synapse_domain(domain).is_err() {
65        return None;
66    }
67    let path = synapse_node_dir(domain).join("config.toml");
68    let content = std::fs::read_to_string(&path).ok()?;
69    toml::from_str(&content).ok()
70}
71
72/// Save a synapse node config to its directory (0600 permissions)
73pub fn save_synapse_node(domain: &str, node: &SynapseNode) -> Result<(), crate::sink::HyphaError> {
74    use crate::sink::HyphaError;
75
76    validate_synapse_domain(domain)?;
77    let dir = synapse_node_dir(domain);
78    std::fs::create_dir_all(&dir).map_err(|e| {
79        HyphaError::new(
80            "synapse_node_save_failed",
81            format!("Failed to create synapse node directory: {}", e),
82        )
83    })?;
84    #[cfg(unix)]
85    {
86        use std::os::unix::fs::PermissionsExt;
87        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
88            HyphaError::new(
89                "synapse_node_save_failed",
90                format!("Failed to protect synapse node directory: {}", e),
91            )
92        })?;
93    }
94
95    let content = toml::to_string_pretty(node).map_err(|e| {
96        HyphaError::new(
97            "synapse_node_save_failed",
98            format!("Failed to serialize node config: {}", e),
99        )
100    })?;
101    let path = dir.join("config.toml");
102    write_text_file_atomic(
103        &path,
104        &content,
105        0o600,
106        "synapse_node_save_failed",
107        "synapse node config",
108    )
109}
110
111/// Remove a synapse node directory
112pub fn remove_synapse_node(domain: &str) -> Result<(), crate::sink::HyphaError> {
113    use crate::sink::HyphaError;
114
115    validate_synapse_domain(domain)?;
116    let dir = synapse_node_dir(domain);
117    if dir.exists() {
118        std::fs::remove_dir_all(&dir).map_err(|e| {
119            HyphaError::new(
120                "synapse_node_remove_failed",
121                format!("Failed to remove synapse node directory: {}", e),
122            )
123        })?;
124    }
125    Ok(())
126}
127
128/// List all configured synapse node domains by scanning the synapse directory
129pub fn list_synapse_domains() -> Vec<String> {
130    let synapse_dir = hypha_dir().join("synapse");
131    let entries = match std::fs::read_dir(&synapse_dir) {
132        Ok(e) => e,
133        Err(_) => return Vec::new(),
134    };
135
136    let mut domains: Vec<String> = entries
137        .filter_map(|e| e.ok())
138        .filter(|e| e.path().join("config.toml").exists())
139        .filter_map(|e| e.file_name().into_string().ok())
140        .collect();
141    domains.sort();
142    domains
143}
144
145/// Extract domain (host) from a URL
146pub fn domain_from_url(url: &str) -> Result<String, crate::sink::HyphaError> {
147    use crate::sink::HyphaError;
148
149    let safe_url = agent_first_data::redact_url_secrets(url);
150    let parsed = reqwest::Url::parse(url)
151        .map_err(|e| HyphaError::new("invalid_url", format!("Invalid URL: {}", e)))?;
152    let domain = parsed
153        .host_str()
154        .map(|h| h.to_string())
155        .ok_or_else(|| HyphaError::new("invalid_url", format!("URL '{}' has no host", safe_url)))?;
156    validate_synapse_domain(&domain)?;
157    Ok(domain)
158}
159
160fn is_anonymous_transport_host(host: &str) -> bool {
161    let host_lc = host.to_ascii_lowercase();
162    host_lc.ends_with(".onion") || host_lc.ends_with(".i2p")
163}
164
165fn is_ip_literal_host(host: &str) -> bool {
166    let host = host
167        .strip_prefix('[')
168        .and_then(|h| h.strip_suffix(']'))
169        .unwrap_or(host);
170    host.parse::<std::net::IpAddr>().is_ok()
171}
172
173/// Validate a Synapse URL without closing the door on future secure schemes.
174///
175/// Today `https` is the common safe transport. Plain `http` is rejected on
176/// clearnet, but remains allowed for `.onion`/`.i2p`, matching substrate URL
177/// validation.
178pub fn validate_synapse_url(url: &str) -> Result<(), crate::sink::HyphaError> {
179    use crate::sink::HyphaError;
180
181    let safe_url = agent_first_data::redact_url_secrets(url);
182    let parsed = reqwest::Url::parse(url).map_err(|e| {
183        HyphaError::new("invalid_synapse_url", format!("Invalid synapse URL: {}", e))
184    })?;
185    let host = parsed.host_str().ok_or_else(|| {
186        HyphaError::new(
187            "invalid_synapse_url",
188            format!("Invalid synapse URL '{}': missing host", safe_url),
189        )
190    })?;
191
192    if !parsed.username().is_empty() || parsed.password().is_some() {
193        return Err(HyphaError::new(
194            "invalid_synapse_url",
195            format!(
196                "Credentials are rejected in synapse URL '{}'; use --token-secret instead",
197                safe_url
198            ),
199        ));
200    }
201
202    if is_ip_literal_host(host) {
203        return Err(HyphaError::new(
204            "invalid_synapse_url",
205            format!(
206                "IP literal hosts are rejected for synapse URL '{}'; use a domain name",
207                safe_url
208            ),
209        ));
210    }
211
212    match parsed.scheme() {
213        "https" => {}
214        "http" if is_anonymous_transport_host(host) => {}
215        "http" => {
216            return Err(HyphaError::new(
217                "invalid_synapse_url",
218                format!(
219                    "Insecure cleartext transport rejected for synapse URL '{}'; use https, or http only for .onion/.i2p",
220                    safe_url
221                ),
222            ));
223        }
224        "ws" | "ftp" => {
225            return Err(HyphaError::new(
226                "invalid_synapse_url",
227                format!(
228                    "Insecure cleartext scheme '{}' rejected for synapse URL '{}'",
229                    parsed.scheme(),
230                    safe_url
231                ),
232            ));
233        }
234        _ => {}
235    }
236
237    Ok(())
238}
239
240/// Resolve a synapse CLI argument (domain or URL) to a URL + optional token.
241pub fn resolve_synapse(
242    value: Option<&str>,
243    token_override: Option<&str>,
244) -> Result<ResolvedSynapse, crate::sink::HyphaError> {
245    use crate::sink::HyphaError;
246
247    let mut resolved = match value {
248        Some(v) if reqwest::Url::parse(v).is_ok() => {
249            validate_synapse_url(v)?;
250            let domain = domain_from_url(v)?;
251            let node = load_synapse_node(&domain);
252            ResolvedSynapse {
253                synapse_url: v.to_string(),
254                token_secret: node.and_then(|n| n.token_secret),
255            }
256        }
257        Some(domain) => {
258            validate_synapse_domain(domain)?;
259            match load_synapse_node(domain) {
260                Some(node) => {
261                    validate_synapse_url(&node.synapse_url)?;
262                    ResolvedSynapse {
263                        synapse_url: node.synapse_url,
264                        token_secret: node.token_secret,
265                    }
266                }
267                None => {
268                    return Err(HyphaError::with_hint(
269                        "synapse_not_found",
270                        format!("Synapse '{}' not found", domain),
271                        "run: hypha synapse add <url>",
272                    ));
273                }
274            }
275        }
276        None => {
277            let config = HyphaConfig::load()?;
278            match &config.defaults.synapse_domain {
279                Some(default_domain) => match load_synapse_node(default_domain) {
280                    Some(node) => {
281                        validate_synapse_url(&node.synapse_url)?;
282                        ResolvedSynapse {
283                            synapse_url: node.synapse_url,
284                            token_secret: node.token_secret,
285                        }
286                    }
287                    None => {
288                        return Err(HyphaError::with_hint(
289                            "synapse_not_found",
290                            format!("Default synapse '{}' not found", default_domain),
291                            "run: hypha synapse add <url>",
292                        ));
293                    }
294                },
295                None => {
296                    return Err(HyphaError::with_hint(
297                        "synapse_not_configured",
298                        "No synapse specified and no default configured",
299                        "use -s <url> or run: hypha synapse add <url> && hypha synapse use <domain>",
300                    ));
301                }
302            }
303        }
304    };
305
306    if let Ok(ts) = crate::env::var("SYNAPSE_TOKEN_SECRET") {
307        resolved.token_secret = if ts.is_empty() { None } else { Some(ts) };
308    }
309
310    if let Some(ts) = token_override {
311        resolved.token_secret = if ts.is_empty() {
312            None
313        } else {
314            Some(ts.to_string())
315        };
316    }
317
318    Ok(resolved)
319}