1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::logging::DEFAULT_LOG_LEVEL;
6use crate::util::parse_hex_16;
7use crate::Result;
8
9#[derive(Debug, Clone)]
10pub struct ServerConfig {
11 pub dir: PathBuf,
12 pub reticulum_dir: Option<PathBuf>,
13 pub repositories_dir: PathBuf,
14 pub identity_path: PathBuf,
15 pub client_identity_path: PathBuf,
16 pub node_name: String,
17 pub announce_interval_secs: u64,
18 pub serve_nomadnet: bool,
19 pub templates_dir: PathBuf,
20 pub unicode_icons: bool,
21 pub record_stats: bool,
22 pub stats_ignore_identities: Vec<[u8; 16]>,
23 pub stats_push_ignore_identities: Vec<[u8; 16]>,
24 pub blocked_identities: Vec<[u8; 16]>,
25 pub identity_aliases: BTreeMap<String, [u8; 16]>,
26 pub allow_read: Vec<String>,
27 pub allow_write: Vec<String>,
28 pub allow_create: Vec<String>,
29 pub allow_stats: Vec<String>,
30 pub allow_release: Vec<String>,
31 pub allow_interact: Vec<String>,
32 pub allow_propose: Vec<String>,
33 pub allow_admin: Vec<String>,
34 pub log_level: u8,
35}
36
37#[derive(Debug, Clone)]
38pub struct ClientConfig {
39 pub dir: PathBuf,
40 pub reticulum_dir: Option<PathBuf>,
41 pub identity_path: PathBuf,
42 pub connect_timeout_secs: u64,
43 pub request_timeout_secs: u64,
44 pub destination_aliases: BTreeMap<String, [u8; 16]>,
45 pub log_level: u8,
46}
47
48impl ServerConfig {
49 pub fn load_or_create_for_run(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<Self> {
50 let (config, _created) = Self::load_or_create(dir, reticulum_dir)?;
51 Ok(config)
52 }
53
54 pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
55 fs::create_dir_all(&dir)?;
56 let path = dir.join("server_config");
57 if !path.exists() {
58 fs::write(&path, default_server_config())?;
59 return Ok((Self::defaults(dir, reticulum_dir), true));
60 }
61 let ini = parse_ini(&fs::read_to_string(&path)?)?;
62 let mut cfg = Self::defaults(dir, reticulum_dir);
63 if let Some(v) = get(&ini, "repositories", "path") {
64 cfg.repositories_dir = resolve_path(&cfg.dir, v);
65 }
66 if let Some(v) = get(&ini, "rngit", "identity") {
67 cfg.identity_path = resolve_path(&cfg.dir, v);
68 }
69 if let Some(v) = get(&ini, "rngit", "client_identity") {
70 cfg.client_identity_path = resolve_path(&cfg.dir, v);
71 }
72 if let Some(v) = get(&ini, "rngit", "node_name") {
73 cfg.node_name = v.to_string();
74 }
75 if let Some(v) = get(&ini, "rngit", "announce_interval") {
76 cfg.announce_interval_secs = v.parse().unwrap_or(cfg.announce_interval_secs);
77 }
78 if let Some(v) = get(&ini, "rngit", "record_stats") {
79 cfg.record_stats = parse_bool(v, cfg.record_stats);
80 }
81 cfg.identity_aliases = parse_aliases(&ini, "aliases");
82 if let Some(v) = get(&ini, "rngit", "stats_ignore_identities") {
83 cfg.stats_ignore_identities = parse_identity_list(v, &cfg.identity_aliases);
84 }
85 if let Some(v) = get(&ini, "rngit", "stats_push_ignore_identities") {
86 cfg.stats_push_ignore_identities = parse_identity_list(v, &cfg.identity_aliases);
87 }
88 if let Some(v) = get(&ini, "rngit", "blocked_identities") {
89 cfg.blocked_identities = parse_identity_list(v, &cfg.identity_aliases);
90 }
91 if let Some(v) = get(&ini, "pages", "serve_nomadnet") {
92 cfg.serve_nomadnet = parse_bool(v, cfg.serve_nomadnet);
93 }
94 if let Some(v) = get(&ini, "pages", "templates_dir") {
95 cfg.templates_dir = resolve_path(&cfg.dir, v);
96 }
97 if let Some(v) = get(&ini, "pages", "unicode_icons") {
98 cfg.unicode_icons = parse_bool(v, cfg.unicode_icons);
99 }
100 if let Some(v) = get(&ini, "logging", "loglevel") {
101 cfg.log_level = parse_log_level(v, cfg.log_level);
102 }
103 cfg.allow_read = split_list(get(&ini, "access", "read").unwrap_or("all"));
104 cfg.allow_write = split_list(get(&ini, "access", "write").unwrap_or("none"));
105 cfg.allow_create = split_list(get(&ini, "access", "create").unwrap_or("none"));
106 cfg.allow_stats = split_list(get(&ini, "access", "stats").unwrap_or("none"));
107 cfg.allow_release = split_list(get(&ini, "access", "release").unwrap_or("none"));
108 cfg.allow_interact = split_list(get(&ini, "access", "interact").unwrap_or("none"));
109 cfg.allow_propose = split_list(get(&ini, "access", "propose").unwrap_or("none"));
110 cfg.allow_admin = split_list(get(&ini, "access", "admin").unwrap_or("none"));
111 Ok((cfg, false))
112 }
113
114 fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
115 Self {
116 repositories_dir: dir.join("repositories"),
117 identity_path: dir.join("repositories_identity"),
118 client_identity_path: dir.join("client_identity"),
119 node_name: "Anonymous Git Node".to_string(),
120 dir: dir.clone(),
121 reticulum_dir,
122 announce_interval_secs: 300,
123 serve_nomadnet: false,
124 templates_dir: dir.join("templates"),
125 unicode_icons: false,
126 record_stats: false,
127 stats_ignore_identities: Vec::new(),
128 stats_push_ignore_identities: Vec::new(),
129 blocked_identities: Vec::new(),
130 identity_aliases: BTreeMap::new(),
131 allow_read: vec!["all".to_string()],
132 allow_write: vec!["none".to_string()],
133 allow_create: vec!["none".to_string()],
134 allow_stats: vec!["none".to_string()],
135 allow_release: vec!["none".to_string()],
136 allow_interact: vec!["none".to_string()],
137 allow_propose: vec!["none".to_string()],
138 allow_admin: vec!["none".to_string()],
139 log_level: DEFAULT_LOG_LEVEL,
140 }
141 }
142}
143
144impl ClientConfig {
145 pub fn load_or_create_for_run(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<Self> {
146 let (config, _created) = Self::load_or_create(dir, reticulum_dir)?;
147 Ok(config)
148 }
149
150 pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
151 fs::create_dir_all(&dir)?;
152 let path = dir.join("client_config");
153 if !path.exists() {
154 fs::write(&path, default_client_config())?;
155 return Ok((Self::defaults(dir, reticulum_dir), true));
156 }
157 let ini = parse_ini(&fs::read_to_string(&path)?)?;
158 let mut cfg = Self::defaults(dir, reticulum_dir);
159 if let Some(v) = get(&ini, "client", "identity") {
160 cfg.identity_path = resolve_path(&cfg.dir, v);
161 }
162 if let Some(v) = get(&ini, "client", "connect_timeout") {
163 cfg.connect_timeout_secs = v.parse().unwrap_or(cfg.connect_timeout_secs);
164 }
165 if let Some(v) = get(&ini, "client", "request_timeout") {
166 cfg.request_timeout_secs = v.parse().unwrap_or(cfg.request_timeout_secs);
167 }
168 cfg.destination_aliases = parse_aliases(&ini, "aliases");
169 if let Some(v) = get(&ini, "logging", "loglevel") {
170 cfg.log_level = parse_log_level(v, cfg.log_level);
171 }
172 Ok((cfg, false))
173 }
174
175 fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
176 Self {
177 identity_path: dir.join("client_identity"),
178 dir,
179 reticulum_dir,
180 connect_timeout_secs: 30,
181 request_timeout_secs: 300,
182 destination_aliases: BTreeMap::new(),
183 log_level: DEFAULT_LOG_LEVEL,
184 }
185 }
186}
187
188type Ini = BTreeMap<String, BTreeMap<String, String>>;
189
190fn parse_ini(input: &str) -> Result<Ini> {
191 let mut section = "rngit".to_string();
192 let mut out = Ini::new();
193 for raw in input.lines() {
194 let line = raw.split('#').next().unwrap_or("").trim();
195 if line.is_empty() {
196 continue;
197 }
198 if line.starts_with('[') && line.ends_with(']') {
199 section = line[1..line.len() - 1].trim().to_string();
200 continue;
201 }
202 if let Some((key, value)) = line.split_once('=') {
203 out.entry(section.clone())
204 .or_default()
205 .insert(key.trim().to_string(), value.trim().to_string());
206 }
207 }
208 Ok(out)
209}
210
211fn get<'a>(ini: &'a Ini, section: &str, key: &str) -> Option<&'a str> {
212 ini.get(section)?.get(key).map(String::as_str)
213}
214
215fn parse_identity_list(value: &str, aliases: &BTreeMap<String, [u8; 16]>) -> Vec<[u8; 16]> {
216 split_list(value)
217 .into_iter()
218 .filter_map(|value| {
219 aliases
220 .get(&value)
221 .copied()
222 .or_else(|| parse_hex_16(&value).ok())
223 })
224 .collect()
225}
226
227fn parse_aliases(ini: &Ini, section: &str) -> BTreeMap<String, [u8; 16]> {
228 ini.get(section)
229 .into_iter()
230 .flat_map(|section| section.iter())
231 .filter_map(|(alias, value)| parse_hex_16(value).ok().map(|hash| (alias.clone(), hash)))
232 .collect()
233}
234
235fn split_list(value: &str) -> Vec<String> {
236 value
237 .split(',')
238 .map(str::trim)
239 .filter(|v| !v.is_empty())
240 .map(ToOwned::to_owned)
241 .collect()
242}
243
244fn parse_log_level(value: &str, fallback: u8) -> u8 {
245 value
246 .parse::<u8>()
247 .map(|level| level.min(7))
248 .unwrap_or(fallback)
249}
250
251fn parse_bool(value: &str, fallback: bool) -> bool {
252 match value.trim().to_ascii_lowercase().as_str() {
253 "1" | "true" | "yes" | "on" => true,
254 "0" | "false" | "no" | "off" => false,
255 _ => fallback,
256 }
257}
258
259fn expand_home(value: &str) -> PathBuf {
260 if let Some(rest) = value.strip_prefix("~/") {
261 if let Some(home) = std::env::var_os("HOME") {
262 return PathBuf::from(home).join(rest);
263 }
264 }
265 PathBuf::from(value)
266}
267
268fn resolve_path(base: &Path, value: &str) -> PathBuf {
269 let path = expand_home(value);
270 if path.is_absolute() {
271 path
272 } else {
273 base.join(path)
274 }
275}
276
277fn default_server_config() -> &'static str {
278 "[rngit]\nannounce_interval = 300\nidentity = repositories_identity\nclient_identity = client_identity\n# node_name = Anonymous Git Node\n# record_stats = no\n# stats_ignore_identities = 00112233445566778899aabbccddeeff\n# stats_push_ignore_identities = 0102030405060708090a0b0c0d0e0f10\n# blocked_identities = 00112233445566778899aabbccddeeff\n\n[repositories]\npath = repositories\n\n[aliases]\n# alice = 00112233445566778899aabbccddeeff\n\n[access]\nread = all\nwrite = none\ncreate = none\nstats = none\nrelease = none\ninteract = none\npropose = none\nadmin = none\n\n[pages]\n# serve_nomadnet = no\n# templates_dir = templates\n# unicode_icons = no\n\n[logging]\nloglevel = 4\n"
279}
280
281fn default_client_config() -> &'static str {
282 "[client]\nidentity = client_identity\nconnect_timeout = 30\nrequest_timeout = 300\n\n[aliases]\n# my_node = 00112233445566778899aabbccddeeff\n\n[logging]\nloglevel = 4\n"
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn parses_sections_and_lists() {
291 let ini = parse_ini("[access]\nread = all, 0011\nwrite = none\ncreate = all\n").unwrap();
292 assert_eq!(get(&ini, "access", "write"), Some("none"));
293 assert_eq!(get(&ini, "access", "create"), Some("all"));
294 assert_eq!(split_list(get(&ini, "access", "read").unwrap()).len(), 2);
295 }
296
297 #[test]
298 fn parses_and_clamps_log_level() {
299 let tmp = tempfile::tempdir().unwrap();
300 fs::write(
301 tmp.path().join("client_config"),
302 "[client]\nrequest_timeout = 5\n[logging]\nloglevel = 99\n",
303 )
304 .unwrap();
305 let (cfg, created) = ClientConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
306 assert!(!created);
307 assert_eq!(cfg.log_level, 7);
308 }
309
310 #[test]
311 fn client_run_config_continues_after_creating_default_config() {
312 let tmp = tempfile::tempdir().unwrap();
313
314 let cfg = ClientConfig::load_or_create_for_run(tmp.path().to_path_buf(), None).unwrap();
315
316 assert_eq!(cfg.dir, tmp.path());
317 assert!(tmp.path().join("client_config").exists());
318 }
319
320 #[test]
321 fn server_run_config_continues_after_creating_default_config() {
322 let tmp = tempfile::tempdir().unwrap();
323
324 let cfg = ServerConfig::load_or_create_for_run(tmp.path().to_path_buf(), None).unwrap();
325
326 assert_eq!(cfg.dir, tmp.path());
327 assert!(tmp.path().join("server_config").exists());
328 }
329
330 #[test]
331 fn creates_default_server_config_once() {
332 let tmp = tempfile::tempdir().unwrap();
333 let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
334 assert!(created);
335 let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
336 assert!(!created);
337 }
338
339 #[test]
340 fn parses_nomadnet_page_config() {
341 let tmp = tempfile::tempdir().unwrap();
342 fs::write(
343 tmp.path().join("server_config"),
344 "[rngit]\nnode_name = Public Git Node\n[pages]\nserve_nomadnet = yes\ntemplates_dir = custom_templates\nunicode_icons = yes\n",
345 )
346 .unwrap();
347 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
348 assert!(!created);
349 assert_eq!(cfg.node_name, "Public Git Node");
350 assert!(cfg.serve_nomadnet);
351 assert_eq!(cfg.templates_dir, tmp.path().join("custom_templates"));
352 assert!(cfg.unicode_icons);
353 }
354
355 #[test]
356 fn parses_stats_config_and_permissions() {
357 let tmp = tempfile::tempdir().unwrap();
358 fs::write(
359 tmp.path().join("server_config"),
360 "[rngit]\nrecord_stats = yes\nstats_ignore_identities = alice\nstats_push_ignore_identities = 0102030405060708090a0b0c0d0e0f10\nblocked_identities = alice\n[aliases]\nalice = 00112233445566778899aabbccddeeff\n[access]\nstats = all, 0102030405060708090a0b0c0d0e0f10\n",
361 )
362 .unwrap();
363
364 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
365 assert!(!created);
366 assert!(cfg.record_stats);
367 assert_eq!(
368 cfg.stats_ignore_identities,
369 vec![[
370 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
371 0xee, 0xff
372 ]]
373 );
374 assert_eq!(
375 cfg.stats_push_ignore_identities,
376 vec![[
377 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
378 0x0f, 0x10
379 ]]
380 );
381 assert_eq!(cfg.blocked_identities, cfg.stats_ignore_identities);
382 assert_eq!(
383 cfg.allow_stats,
384 vec![
385 "all".to_string(),
386 "0102030405060708090a0b0c0d0e0f10".to_string()
387 ]
388 );
389 }
390
391 #[test]
392 fn parses_release_permission_config() {
393 let tmp = tempfile::tempdir().unwrap();
394 fs::write(
395 tmp.path().join("server_config"),
396 "[access]\nrelease = all, 0102030405060708090a0b0c0d0e0f10\ninteract = all\nadmin = 00112233445566778899aabbccddeeff\n",
397 )
398 .unwrap();
399
400 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
401 assert!(!created);
402 assert_eq!(
403 cfg.allow_release,
404 vec![
405 "all".to_string(),
406 "0102030405060708090a0b0c0d0e0f10".to_string()
407 ]
408 );
409 assert_eq!(cfg.allow_interact, vec!["all".to_string()]);
410 assert_eq!(
411 cfg.allow_admin,
412 vec!["00112233445566778899aabbccddeeff".to_string()]
413 );
414 }
415
416 #[test]
417 fn parses_client_destination_aliases() {
418 let tmp = tempfile::tempdir().unwrap();
419 fs::write(
420 tmp.path().join("client_config"),
421 "[client]\n[aliases]\nhome = 00112233445566778899aabbccddeeff\nbad = not-hex\n",
422 )
423 .unwrap();
424
425 let (cfg, created) = ClientConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
426
427 assert!(!created);
428 assert_eq!(cfg.destination_aliases.len(), 1);
429 assert_eq!(cfg.destination_aliases["home"][15], 0xff);
430 }
431
432 #[test]
433 fn parses_server_identity_aliases() {
434 let tmp = tempfile::tempdir().unwrap();
435 fs::write(
436 tmp.path().join("server_config"),
437 "[aliases]\nalice = 00112233445566778899aabbccddeeff\nbad = not-hex\n",
438 )
439 .unwrap();
440
441 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
442
443 assert!(!created);
444 assert_eq!(cfg.identity_aliases.len(), 1);
445 assert_eq!(cfg.identity_aliases["alice"][15], 0xff);
446 }
447
448 #[test]
449 fn missing_pages_section_keeps_nomadnet_disabled() {
450 let tmp = tempfile::tempdir().unwrap();
451 fs::write(tmp.path().join("server_config"), "[rngit]\n").unwrap();
452 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
453 assert!(!created);
454 assert_eq!(cfg.node_name, "Anonymous Git Node");
455 assert!(!cfg.serve_nomadnet);
456 assert_eq!(cfg.templates_dir, tmp.path().join("templates"));
457 assert!(!cfg.unicode_icons);
458 assert!(!cfg.record_stats);
459 assert!(cfg.stats_ignore_identities.is_empty());
460 assert_eq!(cfg.allow_stats, vec!["none".to_string()]);
461 assert_eq!(cfg.allow_release, vec!["none".to_string()]);
462 assert_eq!(cfg.allow_interact, vec!["none".to_string()]);
463 assert_eq!(cfg.allow_admin, vec!["none".to_string()]);
464 }
465}