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