Skip to main content

rns_git/
config.rs

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(rns_net::logging::LOG_EXTREME))
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# To reject unidentified scrapers and crawlers, add the null identity hash:\n# blocked_identities = d7db22f63b453c23bb0688dde565b7c1\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, 8);
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        let generated = fs::read_to_string(tmp.path().join("server_config")).unwrap();
338        assert!(generated.contains("blocked_identities = d7db22f63b453c23bb0688dde565b7c1"));
339    }
340
341    #[test]
342    fn parses_nomadnet_page_config() {
343        let tmp = tempfile::tempdir().unwrap();
344        fs::write(
345            tmp.path().join("server_config"),
346            "[rngit]\nnode_name = Public Git Node\n[pages]\nserve_nomadnet = yes\ntemplates_dir = custom_templates\nunicode_icons = yes\n",
347        )
348        .unwrap();
349        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
350        assert!(!created);
351        assert_eq!(cfg.node_name, "Public Git Node");
352        assert!(cfg.serve_nomadnet);
353        assert_eq!(cfg.templates_dir, tmp.path().join("custom_templates"));
354        assert!(cfg.unicode_icons);
355    }
356
357    #[test]
358    fn parses_stats_config_and_permissions() {
359        let tmp = tempfile::tempdir().unwrap();
360        fs::write(
361            tmp.path().join("server_config"),
362            "[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",
363        )
364        .unwrap();
365
366        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
367        assert!(!created);
368        assert!(cfg.record_stats);
369        assert_eq!(
370            cfg.stats_ignore_identities,
371            vec![[
372                0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
373                0xee, 0xff
374            ]]
375        );
376        assert_eq!(
377            cfg.stats_push_ignore_identities,
378            vec![[
379                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
380                0x0f, 0x10
381            ]]
382        );
383        assert_eq!(cfg.blocked_identities, cfg.stats_ignore_identities);
384        assert_eq!(
385            cfg.allow_stats,
386            vec![
387                "all".to_string(),
388                "0102030405060708090a0b0c0d0e0f10".to_string()
389            ]
390        );
391    }
392
393    #[test]
394    fn parses_release_permission_config() {
395        let tmp = tempfile::tempdir().unwrap();
396        fs::write(
397            tmp.path().join("server_config"),
398            "[access]\nrelease = all, 0102030405060708090a0b0c0d0e0f10\ninteract = all\nadmin = 00112233445566778899aabbccddeeff\n",
399        )
400        .unwrap();
401
402        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
403        assert!(!created);
404        assert_eq!(
405            cfg.allow_release,
406            vec![
407                "all".to_string(),
408                "0102030405060708090a0b0c0d0e0f10".to_string()
409            ]
410        );
411        assert_eq!(cfg.allow_interact, vec!["all".to_string()]);
412        assert_eq!(
413            cfg.allow_admin,
414            vec!["00112233445566778899aabbccddeeff".to_string()]
415        );
416    }
417
418    #[test]
419    fn parses_client_destination_aliases() {
420        let tmp = tempfile::tempdir().unwrap();
421        fs::write(
422            tmp.path().join("client_config"),
423            "[client]\n[aliases]\nhome = 00112233445566778899aabbccddeeff\nbad = not-hex\n",
424        )
425        .unwrap();
426
427        let (cfg, created) = ClientConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
428
429        assert!(!created);
430        assert_eq!(cfg.destination_aliases.len(), 1);
431        assert_eq!(cfg.destination_aliases["home"][15], 0xff);
432    }
433
434    #[test]
435    fn parses_server_identity_aliases() {
436        let tmp = tempfile::tempdir().unwrap();
437        fs::write(
438            tmp.path().join("server_config"),
439            "[aliases]\nalice = 00112233445566778899aabbccddeeff\nbad = not-hex\n",
440        )
441        .unwrap();
442
443        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
444
445        assert!(!created);
446        assert_eq!(cfg.identity_aliases.len(), 1);
447        assert_eq!(cfg.identity_aliases["alice"][15], 0xff);
448    }
449
450    #[test]
451    fn missing_pages_section_keeps_nomadnet_disabled() {
452        let tmp = tempfile::tempdir().unwrap();
453        fs::write(tmp.path().join("server_config"), "[rngit]\n").unwrap();
454        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
455        assert!(!created);
456        assert_eq!(cfg.node_name, "Anonymous Git Node");
457        assert!(!cfg.serve_nomadnet);
458        assert_eq!(cfg.templates_dir, tmp.path().join("templates"));
459        assert!(!cfg.unicode_icons);
460        assert!(!cfg.record_stats);
461        assert!(cfg.stats_ignore_identities.is_empty());
462        assert_eq!(cfg.allow_stats, vec!["none".to_string()]);
463        assert_eq!(cfg.allow_release, vec!["none".to_string()]);
464        assert_eq!(cfg.allow_interact, vec!["none".to_string()]);
465        assert_eq!(cfg.allow_admin, vec!["none".to_string()]);
466    }
467}