Skip to main content

dsc/
config.rs

1use anyhow::{Context, Result};
2use serde::de::Deserializer;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
8where
9    D: Deserializer<'de>,
10{
11    let value = Option::<String>::deserialize(deserializer)?;
12    Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
13}
14
15fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
16where
17    D: Deserializer<'de>,
18{
19    let value = Option::<u64>::deserialize(deserializer)?;
20    Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
21}
22
23/// Top-level configuration for dsc.
24#[derive(Debug, Serialize, Deserialize, Default, Clone)]
25pub struct Config {
26    #[serde(default)]
27    pub discourse: Vec<DiscourseConfig>,
28    #[serde(default)]
29    pub harden: HardenConfig,
30}
31
32/// User overrides for `dsc harden` defaults. Every field is optional;
33/// anything left unset falls back to the built-in defaults applied in
34/// `commands::harden::resolve_options`. CLI flags override this block on
35/// a per-run basis.
36#[derive(Debug, Serialize, Deserialize, Default, Clone)]
37pub struct HardenConfig {
38    /// Username for the new sudo-enabled non-root account. Default: `discourse`.
39    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
40    pub new_user: Option<String>,
41    /// SSH port to move the daemon to in stage 2. Default: 2227.
42    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
43    pub ssh_port: Option<u64>,
44    /// URL to fetch the Docker installer from. Default: `https://get.docker.com`.
45    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
46    pub docker_install_url: Option<String>,
47    /// Whether to install Docker rootless. Default: true.
48    #[serde(default)]
49    pub docker_rootless: Option<bool>,
50    /// Swap file size in GB. 0 to skip. Default: 2.
51    #[serde(default)]
52    pub swap_size_gb: Option<u32>,
53    /// Cap on journald disk use. Default: `500M`.
54    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
55    pub journald_max_use: Option<String>,
56    /// Timezone to set via `timedatectl`. Default: `UTC`.
57    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
58    pub timezone: Option<String>,
59    /// Whether to enable unattended security upgrades. Default: true.
60    #[serde(default)]
61    pub unattended_security_upgrades: Option<bool>,
62    /// Whether to install fail2ban. Default: true.
63    #[serde(default)]
64    pub fail2ban: Option<bool>,
65    /// Whether to install mosh and open UDP 60000-61000. Default: false.
66    #[serde(default)]
67    pub mosh: Option<bool>,
68    /// Override sshd `Ciphers` line. Defaults to dsc's pinned modern set.
69    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
70    pub sshd_ciphers: Option<String>,
71    /// Override sshd `KexAlgorithms` line. Defaults to dsc's pinned modern set.
72    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
73    pub sshd_kex: Option<String>,
74    /// Override sshd `MACs` line. Defaults to dsc's pinned modern set.
75    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
76    pub sshd_macs: Option<String>,
77    /// Extra ufw `allow` rules applied after the standard set
78    /// (e.g. `["3000/tcp", "192.168.1.0/24"]`).
79    #[serde(default)]
80    pub extra_ufw_allow: Option<Vec<String>>,
81}
82
83/// Configuration for a single Discourse install.
84#[derive(Debug, Serialize, Deserialize, Default, Clone)]
85pub struct DiscourseConfig {
86    pub name: String,
87    pub baseurl: String,
88    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
89    pub fullname: Option<String>,
90    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
91    pub apikey: Option<String>,
92    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
93    pub api_username: Option<String>,
94    #[serde(default)]
95    pub tags: Option<Vec<String>>,
96    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
97    pub changelog_topic_id: Option<u64>,
98    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
99    pub ssh_host: Option<String>,
100}
101
102/// Load configuration from a TOML file.
103pub fn load_config(path: &Path) -> Result<Config> {
104    if !path.exists() {
105        return Ok(Config::default());
106    }
107    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
108    let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
109    warn_on_discourse_names(&config);
110    Ok(config)
111}
112
113/// Save configuration to a TOML file.
114pub fn save_config(path: &Path, config: &Config) -> Result<()> {
115    let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
116    write_config_file(path, raw.as_bytes())?;
117    Ok(())
118}
119
120#[cfg(unix)]
121fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
122    use std::io::Write;
123    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
124
125    let mut file = fs::OpenOptions::new()
126        .create(true)
127        .truncate(true)
128        .write(true)
129        .mode(0o600)
130        .open(path)
131        .with_context(|| format!("writing {}", path.display()))?;
132    file.write_all(raw)
133        .with_context(|| format!("writing {}", path.display()))?;
134
135    let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
136    let mode = metadata.permissions().mode() & 0o777;
137    if mode & 0o077 != 0 {
138        if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
139            eprintln!(
140                "Warning: unable to tighten permissions on {}: {}",
141                path.display(),
142                err
143            );
144        }
145    }
146    Ok(())
147}
148
149#[cfg(not(unix))]
150fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
151    fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
152    Ok(())
153}
154
155/// Find a discourse by name.
156pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
157    config.discourse.iter().find(|d| d.name == name)
158}
159
160/// Find a discourse by name (mutable).
161pub fn find_discourse_mut<'a>(
162    config: &'a mut Config,
163    name: &str,
164) -> Option<&'a mut DiscourseConfig> {
165    config.discourse.iter_mut().find(|d| d.name == name)
166}
167
168fn warn_on_discourse_names(config: &Config) {
169    for discourse in &config.discourse {
170        if discourse.name.chars().any(|ch| ch.is_whitespace()) {
171            eprintln!(
172                "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
173                discourse.name
174            );
175        }
176    }
177}
178
179/// Resolve the default config path when `--config` is not provided.
180///
181/// Search order:
182/// 1. `./dsc.toml`
183/// 2. `$XDG_CONFIG_HOME/dsc/dsc.toml` (or `~/.config/dsc/dsc.toml`)
184/// 3. System config locations (`$XDG_CONFIG_DIRS` + common Unix paths)
185///
186/// If none exist, defaults to `./dsc.toml`.
187pub fn resolve_default_config_path() -> PathBuf {
188    let local = PathBuf::from("dsc.toml");
189    let mut candidates = vec![local.clone()];
190
191    if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") {
192        candidates.push(PathBuf::from(xdg_config_home).join("dsc").join("dsc.toml"));
193    } else if let Some(home) = std::env::var_os("HOME") {
194        candidates.push(
195            PathBuf::from(home)
196                .join(".config")
197                .join("dsc")
198                .join("dsc.toml"),
199        );
200    }
201
202    #[cfg(unix)]
203    {
204        if let Some(xdg_config_dirs) = std::env::var_os("XDG_CONFIG_DIRS") {
205            for dir in std::env::split_paths(&xdg_config_dirs) {
206                candidates.push(dir.join("dsc").join("dsc.toml"));
207            }
208        } else {
209            candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
210        }
211        candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
212        candidates.push(PathBuf::from("/etc/dsc.toml"));
213        candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
214    }
215
216    first_existing_config_path(candidates).unwrap_or(local)
217}
218
219fn first_existing_config_path<I>(candidates: I) -> Option<PathBuf>
220where
221    I: IntoIterator<Item = PathBuf>,
222{
223    candidates.into_iter().find(|candidate| candidate.exists())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::first_existing_config_path;
229    use std::path::PathBuf;
230
231    #[test]
232    fn returns_first_existing_path_in_order() {
233        let dir = tempfile::tempdir().expect("tempdir");
234        let first = dir.path().join("first.toml");
235        let second = dir.path().join("second.toml");
236        std::fs::write(&second, "").expect("write");
237        std::fs::write(&first, "").expect("write");
238
239        let selected = first_existing_config_path(vec![first.clone(), second]).expect("selected");
240        assert_eq!(selected, first);
241    }
242
243    #[test]
244    fn returns_none_when_no_candidates_exist() {
245        let dir = tempfile::tempdir().expect("tempdir");
246        let missing = dir.path().join("missing.toml");
247        let selected =
248            first_existing_config_path(vec![missing, PathBuf::from("/definitely/missing")]);
249        assert!(selected.is_none());
250    }
251}