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)]
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
15pub 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
57pub fn synapse_node_dir(domain: &str) -> PathBuf {
59 hypha_dir().join("synapse").join(domain)
60}
61
62pub 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
72pub 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
111pub 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
128pub 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
145pub 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
173pub 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
240pub 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) = std::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}