1
use server::config::{ConfigError, system_config};
2

            
3
/// Public-facing SSH endpoint advertised in the
4
/// `/account/ssh-key` "key added" snippet. All three values are
5
/// optional — operators set them via env (`SSH_HOSTNAME`,
6
/// `SSH_PORT`, `SSH_HOST_FINGERPRINT`) on the deployment ConfigMap.
7
#[derive(Debug, Clone)]
8
pub struct SshConnectInfo {
9
    pub hostname: String,
10
    pub port: u16,
11
    pub host_fingerprint: String,
12
}
13

            
14
impl SshConnectInfo {
15
    fn from_env() -> Self {
16
        Self {
17
            hostname: std::env::var("SSH_HOSTNAME")
18
                .unwrap_or_else(|_| "ssh.example.invalid".to_string()),
19
            port: std::env::var("SSH_PORT")
20
                .ok()
21
                .and_then(|raw| raw.parse::<u16>().ok())
22
                .unwrap_or(2222),
23
            host_fingerprint: std::env::var("SSH_HOST_FINGERPRINT")
24
                .unwrap_or_else(|_| "(not configured)".to_string()),
25
        }
26
    }
27
}
28

            
29
/// An environment value, if the deployment actually supplied one.
30
///
31
/// A key set to nothing counts as unset: a ConfigMap can carry an empty
32
/// string, and taking that literally would override good configuration with a
33
/// URL that cannot parse.
34
///
35
/// Takes the looked-up value rather than reading the environment so it is
36
/// testable without mutating process-global state.
37
4
pub fn env_override(raw: Option<String>) -> Option<String> {
38
4
    raw.filter(|value| !value.trim().is_empty())
39
4
}
40

            
41
#[derive(Debug, Clone)]
42
pub struct Config {
43
    pub site_url: String,
44

            
45
    pub redis_url: String,
46

            
47
    // Token lifetimes only. Signing keys are no longer global config — each user
48
    // signs with their own keypair (see `crate::auth_keys` / `server::auth_keys`).
49
    pub access_token_max_age: i64,
50

            
51
    pub refresh_token_max_age: i64,
52

            
53
    pub ssh: SshConnectInfo,
54
}
55

            
56
impl Config {
57
    pub async fn init() -> Result<Config, ConfigError> {
58
        let site_url = system_config("site_url")
59
            .await?
60
            .ok_or(ConfigError::NoConfig("site_url".to_string()))?
61
            .to_string();
62

            
63
        // The environment wins for the session store: it is a deployment
64
        // endpoint, like DATABASE_URL, not something an operator edits in the
65
        // running site. The seeded row is redis://127.0.0.1:6379/, so without
66
        // this every fresh install crash-loops on any host where Redis is not
67
        // on loopback, and the only cure is rewriting the row by hand after
68
        // the first boot.
69
        let redis_url = match env_override(std::env::var("REDIS_URL").ok()) {
70
            Some(url) => url,
71
            None => system_config("redis_url")
72
                .await?
73
                .ok_or(ConfigError::NoConfig("redis_url".to_string()))?
74
                .to_string(),
75
        };
76

            
77
        let access_token_max_age = system_config("access_token_maxage")
78
            .await?
79
            .ok_or(ConfigError::NoConfig("access_token_maxage".to_string()))?
80
            .to_string();
81

            
82
        let refresh_token_max_age = system_config("refresh_token_maxage")
83
            .await?
84
            .ok_or(ConfigError::NoConfig("refresh_token_maxage".to_string()))?
85
            .to_string();
86

            
87
        Ok(Config {
88
            site_url,
89
            redis_url,
90
            access_token_max_age: parse_age(access_token_max_age, "access_token_maxage")?,
91
            refresh_token_max_age: parse_age(refresh_token_max_age, "refresh_token_maxage")?,
92
            ssh: SshConnectInfo::from_env(),
93
        })
94
    }
95
}
96

            
97
/// Longest token lifetime accepted, in minutes: one year.
98
///
99
/// A bound, not a policy preference: the minutes are added to `now` when a
100
/// token is minted and multiplied into a Redis TTL, and a large enough value
101
/// overflows the date (a panic in every login) before it overflows `i64`.
102
const MAX_TOKEN_AGE_MINUTES: i64 = 525_600;
103

            
104
/// A token lifetime in minutes, refused unless it is in `1..=MAX_TOKEN_AGE_MINUTES`.
105
///
106
/// # Errors
107
/// [`ConfigError::NoConfig`] naming `field` when `raw` is not such a number.
108
16
fn parse_age(raw: String, field: &str) -> Result<i64, ConfigError> {
109
16
    raw.parse::<i64>()
110
16
        .ok()
111
16
        .filter(|minutes| (1..=MAX_TOKEN_AGE_MINUTES).contains(minutes))
112
16
        .ok_or_else(|| ConfigError::NoConfig(field.to_string()))
113
16
}
114

            
115
#[cfg(test)]
116
mod parse_age_tests {
117
    use super::{MAX_TOKEN_AGE_MINUTES, parse_age};
118

            
119
    #[test]
120
2
    fn a_lifetime_that_cannot_be_minted_is_refused() {
121
12
        for bad in ["0", "-15", "abc", "", "200000000000", &i64::MAX.to_string()] {
122
12
            assert!(
123
12
                parse_age(bad.to_string(), "access_token_maxage").is_err(),
124
                "{bad:?} must be refused"
125
            );
126
        }
127
2
    }
128

            
129
    #[test]
130
2
    fn an_ordinary_lifetime_is_accepted() {
131
2
        assert_eq!(parse_age("15".to_string(), "f").ok(), Some(15));
132
2
        assert_eq!(
133
2
            parse_age(MAX_TOKEN_AGE_MINUTES.to_string(), "f").ok(),
134
            Some(MAX_TOKEN_AGE_MINUTES)
135
        );
136
2
    }
137
}