Skip to main content

dsc/
config.rs

1use anyhow::{anyhow, Context, Result};
2use serde::de::Deserializer;
3use serde::{Deserialize, Serialize};
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Env var pointing at an explicit config file path. Wins over the discovered
9/// search hierarchy; missing-file is an error, not a silent fall-through.
10pub const ENV_CONFIG: &str = "DSC_CONFIG";
11
12/// Env var pointing at the user config-home directory. Defaults to
13/// `$XDG_CONFIG_HOME/dsc`, which itself defaults to `~/.config/dsc`.
14/// `dsc` looks for `dsc.toml` inside this directory.
15pub const ENV_CONFIG_HOME: &str = "DSC_CONFIG_HOME";
16
17fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
18where
19    D: Deserializer<'de>,
20{
21    let value = Option::<String>::deserialize(deserializer)?;
22    Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
23}
24
25fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
26where
27    D: Deserializer<'de>,
28{
29    let value = Option::<u64>::deserialize(deserializer)?;
30    Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
31}
32
33/// Top-level configuration for dsc.
34#[derive(Debug, Serialize, Deserialize, Default, Clone)]
35pub struct Config {
36    #[serde(default)]
37    pub discourse: Vec<DiscourseConfig>,
38    #[serde(default)]
39    pub harden: HardenConfig,
40}
41
42/// User overrides for `dsc harden` defaults. Every field is optional;
43/// anything left unset falls back to the built-in defaults applied in
44/// `commands::harden::resolve_options`. CLI flags override this block on
45/// a per-run basis.
46#[derive(Debug, Serialize, Deserialize, Default, Clone)]
47pub struct HardenConfig {
48    /// Username for the new sudo-enabled non-root account. Default: `discourse`.
49    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
50    pub new_user: Option<String>,
51    /// SSH port to move the daemon to in stage 2. Default: 2227.
52    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
53    pub ssh_port: Option<u64>,
54    /// URL to fetch the Docker installer from. Default: `https://get.docker.com`.
55    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
56    pub docker_install_url: Option<String>,
57    /// Whether to install Docker rootless. Default: true.
58    #[serde(default)]
59    pub docker_rootless: Option<bool>,
60    /// Swap file size in GB. 0 to skip. Default: 2.
61    #[serde(default)]
62    pub swap_size_gb: Option<u32>,
63    /// Cap on journald disk use. Default: `500M`.
64    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
65    pub journald_max_use: Option<String>,
66    /// Timezone to set via `timedatectl`. Default: `UTC`.
67    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
68    pub timezone: Option<String>,
69    /// Whether to enable unattended security upgrades. Default: true.
70    #[serde(default)]
71    pub unattended_security_upgrades: Option<bool>,
72    /// Whether to install fail2ban. Default: true.
73    #[serde(default)]
74    pub fail2ban: Option<bool>,
75    /// Whether to install mosh and open UDP 60000-61000. Default: false.
76    #[serde(default)]
77    pub mosh: Option<bool>,
78    /// Override sshd `Ciphers` line. Defaults to dsc's policy overlay
79    /// (drop legacy algorithms while preserving upstream defaults).
80    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
81    pub sshd_ciphers: Option<String>,
82    /// Override sshd `KexAlgorithms` line. Defaults to dsc's policy overlay
83    /// (prefer PQ-hybrid first, disable legacy SHA-1 DH groups).
84    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
85    pub sshd_kex: Option<String>,
86    /// Override sshd `MACs` line. Defaults to dsc's policy overlay
87    /// (disable legacy SHA-1/MD5 and short UMAC variants).
88    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
89    pub sshd_macs: Option<String>,
90    /// Extra ufw `allow` rules applied after the standard set
91    /// (e.g. `["3000/tcp", "192.168.1.0/24"]`).
92    #[serde(default)]
93    pub extra_ufw_allow: Option<Vec<String>>,
94}
95
96/// Configuration for a single Discourse install.
97#[derive(Debug, Serialize, Deserialize, Default, Clone)]
98pub struct DiscourseConfig {
99    pub name: String,
100    pub baseurl: String,
101    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
102    pub fullname: Option<String>,
103    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
104    pub apikey: Option<String>,
105    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
106    pub api_username: Option<String>,
107    #[serde(default)]
108    pub tags: Option<Vec<String>>,
109    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
110    pub changelog_topic_id: Option<u64>,
111    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
112    pub ssh_host: Option<String>,
113    #[serde(default)]
114    pub docker_rootless: Option<bool>,
115}
116
117/// Load configuration from a TOML file.
118pub fn load_config(path: &Path) -> Result<Config> {
119    if !path.exists() {
120        return Ok(Config::default());
121    }
122    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
123    let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
124    warn_on_discourse_names(&config);
125    Ok(config)
126}
127
128/// Save configuration to a TOML file.
129pub fn save_config(path: &Path, config: &Config) -> Result<()> {
130    let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
131    write_config_file(path, raw.as_bytes())?;
132    Ok(())
133}
134
135#[cfg(unix)]
136fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
137    use std::io::Write;
138    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
139
140    let mut file = fs::OpenOptions::new()
141        .create(true)
142        .truncate(true)
143        .write(true)
144        .mode(0o600)
145        .open(path)
146        .with_context(|| format!("writing {}", path.display()))?;
147    file.write_all(raw)
148        .with_context(|| format!("writing {}", path.display()))?;
149
150    let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
151    let mode = metadata.permissions().mode() & 0o777;
152    if mode & 0o077 != 0 {
153        if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
154            eprintln!(
155                "Warning: unable to tighten permissions on {}: {}",
156                path.display(),
157                err
158            );
159        }
160    }
161    Ok(())
162}
163
164#[cfg(not(unix))]
165fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
166    fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
167    Ok(())
168}
169
170/// Find a discourse by name.
171pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
172    config.discourse.iter().find(|d| d.name == name)
173}
174
175/// Find a discourse by name (mutable).
176pub fn find_discourse_mut<'a>(
177    config: &'a mut Config,
178    name: &str,
179) -> Option<&'a mut DiscourseConfig> {
180    config.discourse.iter_mut().find(|d| d.name == name)
181}
182
183fn warn_on_discourse_names(config: &Config) {
184    for discourse in &config.discourse {
185        if discourse.name.chars().any(|ch| ch.is_whitespace()) {
186            eprintln!(
187                "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
188                discourse.name
189            );
190        }
191    }
192}
193
194/// Where the active config came from. Used by `dsc config` to label the
195/// active path so the user understands why a file outside the standard
196/// hierarchy is in use.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ConfigSource {
199    /// Explicit `--config`/`-c` flag.
200    Flag(PathBuf),
201    /// `$DSC_CONFIG` env var.
202    EnvVar(PathBuf),
203    /// First existing path from the search hierarchy.
204    Discovered(PathBuf),
205    /// No file found anywhere; fallback to `./dsc.toml` (created on first
206    /// write command).
207    Default(PathBuf),
208}
209
210impl ConfigSource {
211    /// Resolved path, regardless of how it was selected.
212    pub fn path(&self) -> &Path {
213        match self {
214            Self::Flag(p) | Self::EnvVar(p) | Self::Discovered(p) | Self::Default(p) => p,
215        }
216    }
217
218    /// Short human label for the source, e.g. `via --config flag`.
219    pub fn label(&self) -> &'static str {
220        match self {
221            Self::Flag(_) => "via --config flag",
222            Self::EnvVar(_) => "via $DSC_CONFIG",
223            Self::Discovered(_) => "from search hierarchy",
224            Self::Default(_) => "default (no config found)",
225        }
226    }
227}
228
229/// Resolve which config file to use, honouring the documented precedence:
230///
231/// 1. `--config <path>` / `-c` flag
232/// 2. `$DSC_CONFIG` env var
233/// 3. `./dsc.toml`
234/// 4. `$DSC_CONFIG_HOME/dsc.toml` (default: `$XDG_CONFIG_HOME/dsc` -> `~/.config/dsc`)
235/// 5. `$XDG_CONFIG_DIRS` entries (Unix only)
236/// 6. `/etc/dsc/dsc.toml`, `/etc/dsc.toml`, `/usr/local/etc/dsc.toml` (Unix only)
237///
238/// Explicit selectors (1, 2) error if the named file does not exist; the
239/// discovered hierarchy (3-6) silently skips missing entries. If nothing
240/// matches, falls back to `./dsc.toml`.
241pub fn resolve_config_source(flag: Option<PathBuf>) -> Result<ConfigSource> {
242    resolve_config_source_with_env(flag, |k| std::env::var_os(k))
243}
244
245fn resolve_config_source_with_env<F>(flag: Option<PathBuf>, env: F) -> Result<ConfigSource>
246where
247    F: Fn(&str) -> Option<OsString> + Copy,
248{
249    if let Some(path) = flag {
250        if !path.exists() {
251            return Err(anyhow!(
252                "config file not found: {} (specified via --config)",
253                path.display()
254            ));
255        }
256        return Ok(ConfigSource::Flag(path));
257    }
258
259    if let Some(raw) = env(ENV_CONFIG) {
260        let path = PathBuf::from(raw);
261        if !path.exists() {
262            return Err(anyhow!(
263                "config file not found: {} (specified via ${})",
264                path.display(),
265                ENV_CONFIG
266            ));
267        }
268        return Ok(ConfigSource::EnvVar(path));
269    }
270
271    let candidates = config_search_paths_with_env(env);
272    if let Some(found) = candidates.into_iter().find(|c| c.exists()) {
273        return Ok(ConfigSource::Discovered(found));
274    }
275
276    Ok(ConfigSource::Default(PathBuf::from("dsc.toml")))
277}
278
279/// Returns the ordered list of candidate paths that `dsc` searches for a
280/// config file when neither `--config` nor `$DSC_CONFIG` is set.
281///
282/// Order (first match wins):
283/// 1. `./dsc.toml`
284/// 2. `$DSC_CONFIG_HOME/dsc.toml` (default: `$XDG_CONFIG_HOME/dsc` -> `~/.config/dsc`)
285/// 3. `$XDG_CONFIG_DIRS` entries as `<dir>/dsc/dsc.toml` (Unix only)
286/// 4. `/etc/dsc/dsc.toml` (Unix only)
287/// 5. `/etc/dsc.toml` (Unix only)
288/// 6. `/usr/local/etc/dsc.toml` (Unix only)
289pub fn config_search_paths() -> Vec<PathBuf> {
290    config_search_paths_with_env(|k| std::env::var_os(k))
291}
292
293fn config_search_paths_with_env<F>(env: F) -> Vec<PathBuf>
294where
295    F: Fn(&str) -> Option<OsString>,
296{
297    let mut candidates = vec![PathBuf::from("dsc.toml")];
298
299    // $DSC_CONFIG_HOME -> $XDG_CONFIG_HOME/dsc -> $HOME/.config/dsc
300    let config_home: Option<PathBuf> = env(ENV_CONFIG_HOME)
301        .map(PathBuf::from)
302        .or_else(|| env("XDG_CONFIG_HOME").map(|x| PathBuf::from(x).join("dsc")))
303        .or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config").join("dsc")));
304    if let Some(dir) = config_home {
305        candidates.push(dir.join("dsc.toml"));
306    }
307
308    #[cfg(unix)]
309    {
310        if let Some(xdg_config_dirs) = env("XDG_CONFIG_DIRS") {
311            for dir in std::env::split_paths(&xdg_config_dirs) {
312                candidates.push(dir.join("dsc").join("dsc.toml"));
313            }
314        } else {
315            candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
316        }
317        candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
318        candidates.push(PathBuf::from("/etc/dsc.toml"));
319        candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
320    }
321
322    candidates
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::collections::HashMap;
329    use std::ffi::{OsStr, OsString};
330    use std::path::PathBuf;
331
332    /// Build an env lookup closure over a fixed map. `None` for missing.
333    fn env_from<'a>(
334        pairs: &'a HashMap<&'static str, OsString>,
335    ) -> impl Fn(&str) -> Option<OsString> + Copy + 'a {
336        move |k: &str| pairs.get(k).cloned()
337    }
338
339    fn osstr<S: AsRef<OsStr>>(s: S) -> OsString {
340        s.as_ref().to_os_string()
341    }
342
343    #[test]
344    fn flag_wins_over_env_and_discovery() {
345        let dir = tempfile::tempdir().expect("tempdir");
346        let flag_file = dir.path().join("flag.toml");
347        let env_file = dir.path().join("env.toml");
348        std::fs::write(&flag_file, "").unwrap();
349        std::fs::write(&env_file, "").unwrap();
350
351        let mut env = HashMap::new();
352        env.insert(ENV_CONFIG, osstr(&env_file));
353        let source =
354            resolve_config_source_with_env(Some(flag_file.clone()), env_from(&env)).unwrap();
355        assert!(matches!(source, ConfigSource::Flag(_)));
356        assert_eq!(source.path(), flag_file);
357    }
358
359    #[test]
360    fn missing_flag_path_errors() {
361        let dir = tempfile::tempdir().expect("tempdir");
362        let missing = dir.path().join("nope.toml");
363        let env: HashMap<&'static str, OsString> = HashMap::new();
364        let err = resolve_config_source_with_env(Some(missing), env_from(&env)).unwrap_err();
365        assert!(err.to_string().contains("--config"));
366    }
367
368    #[test]
369    fn dsc_config_env_wins_over_discovery() {
370        let dir = tempfile::tempdir().expect("tempdir");
371        let env_file = dir.path().join("env.toml");
372        std::fs::write(&env_file, "").unwrap();
373        let home_dir = dir.path().join("home");
374        let dsc_dir = home_dir.join(".config").join("dsc");
375        std::fs::create_dir_all(&dsc_dir).unwrap();
376        std::fs::write(dsc_dir.join("dsc.toml"), "").unwrap();
377
378        let mut env = HashMap::new();
379        env.insert(ENV_CONFIG, osstr(&env_file));
380        env.insert("HOME", osstr(&home_dir));
381        let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
382        assert!(matches!(source, ConfigSource::EnvVar(_)));
383        assert_eq!(source.path(), env_file);
384    }
385
386    #[test]
387    fn missing_dsc_config_env_path_errors() {
388        let dir = tempfile::tempdir().expect("tempdir");
389        let missing = dir.path().join("missing.toml");
390        let mut env = HashMap::new();
391        env.insert(ENV_CONFIG, osstr(&missing));
392        let err = resolve_config_source_with_env(None, env_from(&env)).unwrap_err();
393        assert!(err.to_string().contains("$DSC_CONFIG"));
394    }
395
396    #[test]
397    fn dsc_config_home_redirects_step_4() {
398        let dir = tempfile::tempdir().expect("tempdir");
399        let custom_home = dir.path().join("custom");
400        std::fs::create_dir_all(&custom_home).unwrap();
401        std::fs::write(custom_home.join("dsc.toml"), "").unwrap();
402
403        let mut env = HashMap::new();
404        env.insert(ENV_CONFIG_HOME, osstr(&custom_home));
405        let candidates = config_search_paths_with_env(env_from(&env));
406
407        // Step 1: ./dsc.toml; step 2: $DSC_CONFIG_HOME/dsc.toml
408        assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
409        assert_eq!(candidates[1], custom_home.join("dsc.toml"));
410    }
411
412    #[test]
413    fn unset_config_home_reproduces_home_config_dsc() {
414        // With nothing set except HOME, step 2 must resolve to
415        // $HOME/.config/dsc/dsc.toml (today's behaviour).
416        let dir = tempfile::tempdir().expect("tempdir");
417        let home = dir.path().to_path_buf();
418        let mut env = HashMap::new();
419        env.insert("HOME", osstr(&home));
420        let candidates = config_search_paths_with_env(env_from(&env));
421        assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
422        assert_eq!(
423            candidates[1],
424            home.join(".config").join("dsc").join("dsc.toml")
425        );
426    }
427
428    #[test]
429    fn xdg_config_home_default_used_when_dsc_config_home_unset() {
430        // $XDG_CONFIG_HOME set, $DSC_CONFIG_HOME unset -> step 2 is
431        // $XDG_CONFIG_HOME/dsc/dsc.toml.
432        let dir = tempfile::tempdir().expect("tempdir");
433        let xdg = dir.path().join("xdg");
434        let mut env = HashMap::new();
435        env.insert("XDG_CONFIG_HOME", osstr(&xdg));
436        let candidates = config_search_paths_with_env(env_from(&env));
437        assert_eq!(candidates[1], xdg.join("dsc").join("dsc.toml"));
438    }
439
440    #[test]
441    fn dsc_config_home_overrides_xdg_config_home() {
442        let dir = tempfile::tempdir().expect("tempdir");
443        let xdg = dir.path().join("xdg");
444        let dsc_home = dir.path().join("custom_dsc_home");
445        let mut env = HashMap::new();
446        env.insert("XDG_CONFIG_HOME", osstr(&xdg));
447        env.insert(ENV_CONFIG_HOME, osstr(&dsc_home));
448        let candidates = config_search_paths_with_env(env_from(&env));
449        assert_eq!(candidates[1], dsc_home.join("dsc.toml"));
450    }
451
452    #[test]
453    fn unset_everything_resolution_matches_legacy_order() {
454        // Regression guard: with no env set, search order must be
455        // exactly:
456        //   1. ./dsc.toml
457        //   (no step 2: no HOME -> no config-home candidate)
458        //   3+. Unix system paths
459        let env: HashMap<&'static str, OsString> = HashMap::new();
460        let candidates = config_search_paths_with_env(env_from(&env));
461        assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
462        #[cfg(unix)]
463        {
464            assert!(candidates.contains(&PathBuf::from("/etc/xdg/dsc/dsc.toml")));
465            assert!(candidates.contains(&PathBuf::from("/etc/dsc/dsc.toml")));
466            assert!(candidates.contains(&PathBuf::from("/etc/dsc.toml")));
467            assert!(candidates.contains(&PathBuf::from("/usr/local/etc/dsc.toml")));
468        }
469    }
470
471    #[test]
472    fn no_config_anywhere_returns_default() {
473        let dir = tempfile::tempdir().expect("tempdir");
474        // Point HOME at an empty dir so step 2 misses too. CWD-relative
475        // `dsc.toml` may or may not exist depending on test runner pwd,
476        // so we just assert the Default variant is reachable when nothing
477        // is set.
478        let mut env = HashMap::new();
479        env.insert("HOME", osstr(dir.path()));
480        // Only assert the source type when ./dsc.toml truly does not exist.
481        if !PathBuf::from("dsc.toml").exists() {
482            let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
483            assert!(matches!(source, ConfigSource::Default(_)));
484        }
485    }
486}