Skip to main content

hypha/config/
synapse_nodes.rs

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