dragoon-server 0.1.0

Public-relay server for the dragoon remote-executor: axum + rusqlite + ed25519 task signing + per-user message inbox.
Documentation
//! Server runtime settings, loaded from TOML or constructed in tests.
//!
//! Mirrors `python/.../server/settings.py`. Frozen-by-construction — no
//! interior mutability — so handlers can stash an `Arc<Settings>` and
//! never worry about racing config reloads.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;

use dragoon_proto::constants;

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct ServerSection {
    data_dir: PathBuf,
    #[serde(default = "default_bind_host")]
    bind_host: String,
    #[serde(default = "default_bind_port")]
    bind_port: u16,
    #[serde(default = "default_public_url")]
    public_url: String,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct StorageSection {
    soft_quota_bytes: Option<u64>,
    default_ttl_days: Option<u64>,
    hard_min_free_pct: Option<u32>,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct SecuritySection {
    session_ttl_hours: Option<i64>,
    totp_issuer: Option<String>,
    worker_register_code_ttl_sec: Option<i64>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
    server: ServerSection,
    #[serde(default)]
    storage: StorageSection,
    #[serde(default)]
    security: SecuritySection,
}

fn default_bind_host() -> String {
    "127.0.0.1".into()
}
fn default_bind_port() -> u16 {
    8000
}
fn default_public_url() -> String {
    "http://127.0.0.1:8000".into()
}

/// Frozen runtime settings shared across every axum handler.
#[derive(Debug, Clone)]
pub struct Settings {
    pub data_dir: PathBuf,
    pub public_url: String,
    pub bind_host: String,
    pub bind_port: u16,
    pub session_ttl_hours: i64,
    pub totp_issuer: String,
    pub worker_register_code_ttl_sec: i64,
    pub soft_quota_bytes: u64,
    pub default_ttl_days: u64,
    pub hard_min_free_pct: u32,
    pub log_long_poll_sec: f64,
    pub log_long_poll_step_sec: f64,
}

impl Settings {
    pub fn from_toml_path(path: impl AsRef<Path>) -> Result<Self> {
        let raw = std::fs::read_to_string(path.as_ref())
            .with_context(|| format!("read {}", path.as_ref().display()))?;
        let cfg: ConfigFile = toml::from_str(&raw).with_context(|| "parse server toml")?;
        Ok(Settings::from(cfg))
    }

    pub fn for_test(data_dir: impl Into<PathBuf>) -> Self {
        Self {
            data_dir: data_dir.into(),
            public_url: default_public_url(),
            bind_host: default_bind_host(),
            bind_port: 0,
            session_ttl_hours: constants::SESSION_TTL_HOURS_DEFAULT,
            totp_issuer: "RemoteExecutor".into(),
            worker_register_code_ttl_sec: constants::WORKER_REGISTER_CODE_TTL_SEC,
            soft_quota_bytes: constants::DEFAULT_SOFT_QUOTA_BYTES,
            default_ttl_days: constants::DEFAULT_TTL_DAYS,
            hard_min_free_pct: constants::DEFAULT_HARD_MIN_FREE_PCT,
            log_long_poll_sec: 0.5,
            log_long_poll_step_sec: 0.05,
        }
    }

    pub fn db_path(&self) -> PathBuf {
        self.data_dir.join("re.sqlite")
    }

    pub fn blobs_dir(&self) -> PathBuf {
        self.data_dir.join("blobs")
    }
}

impl From<ConfigFile> for Settings {
    fn from(cfg: ConfigFile) -> Self {
        Self {
            data_dir: cfg.server.data_dir,
            public_url: cfg.server.public_url,
            bind_host: cfg.server.bind_host,
            bind_port: cfg.server.bind_port,
            session_ttl_hours: cfg
                .security
                .session_ttl_hours
                .unwrap_or(constants::SESSION_TTL_HOURS_DEFAULT),
            totp_issuer: cfg
                .security
                .totp_issuer
                .unwrap_or_else(|| "RemoteExecutor".into()),
            worker_register_code_ttl_sec: cfg
                .security
                .worker_register_code_ttl_sec
                .unwrap_or(constants::WORKER_REGISTER_CODE_TTL_SEC),
            soft_quota_bytes: cfg
                .storage
                .soft_quota_bytes
                .unwrap_or(constants::DEFAULT_SOFT_QUOTA_BYTES),
            default_ttl_days: cfg
                .storage
                .default_ttl_days
                .unwrap_or(constants::DEFAULT_TTL_DAYS),
            hard_min_free_pct: cfg
                .storage
                .hard_min_free_pct
                .unwrap_or(constants::DEFAULT_HARD_MIN_FREE_PCT),
            log_long_poll_sec: 25.0,
            log_long_poll_step_sec: 0.5,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn loads_minimal_toml() {
        let dir = tempfile::tempdir().unwrap();
        let cfg_path = dir.path().join("server.toml");
        std::fs::write(
            &cfg_path,
            "[server]\ndata_dir = \"/tmp/re\"\nbind_port = 9000\n",
        )
        .unwrap();
        let s = Settings::from_toml_path(&cfg_path).unwrap();
        assert_eq!(s.data_dir, PathBuf::from("/tmp/re"));
        assert_eq!(s.bind_port, 9000);
        // defaults filled in
        assert_eq!(s.bind_host, "127.0.0.1");
        assert_eq!(s.session_ttl_hours, constants::SESSION_TTL_HOURS_DEFAULT);
    }

    #[test]
    fn rejects_unknown_keys() {
        let dir = tempfile::tempdir().unwrap();
        let cfg_path = dir.path().join("server.toml");
        std::fs::write(
            &cfg_path,
            "[server]\ndata_dir = \"/tmp/re\"\nbogus = true\n",
        )
        .unwrap();
        assert!(Settings::from_toml_path(&cfg_path).is_err());
    }
}