summaryrefslogtreecommitdiffstats
path: root/webd/src/config.rs
blob: 44ce4ec8a1a536ae596ccc8142648a21637295cc (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
//! Gateway configuration from the environment.
//!
//! The upstream key is mandatory. There is no trust-on-first-use and no unsafe
//! mode: the browser's credentials ride on this connection.

use std::env;
use std::time::Duration;

pub const DEFAULT_BIND: &str = "127.0.0.1:8890";
pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525";
pub const DEFAULT_GRACE_SECONDS: u64 = 60;
pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz";

#[derive(Debug, Clone)]
pub struct Config {
    /// Loopback address nginx proxies `/ws` to.
    pub bind: String,
    /// Exact browser origin allowed on the WebSocket handshake.
    pub origin: String,
    /// Game server address, `host:port`.
    pub upstream: String,
    /// Lowercase hex SHA-256 of the upstream certificate's SPKI.
    pub fingerprint: String,
    /// How long a session survives without a browser socket.
    pub grace: Duration,
    /// `Secure` on the session cookie. Off only for local http testing.
    pub cookie_secure: bool,
}

impl Config {
    pub fn from_env() -> Result<Self, String> {
        let bind = value("ZAGAN_WEB_BIND", DEFAULT_BIND);
        let origin = value("ZAGAN_WEB_ORIGIN", DEFAULT_ORIGIN);
        if !origin.starts_with("http://") && !origin.starts_with("https://") {
            return Err("ZAGAN_WEB_ORIGIN must start with http:// or https://".to_string());
        }
        let origin = origin.trim_end_matches('/').to_ascii_lowercase();

        let upstream = value("ZAGAN_UPSTREAM", DEFAULT_UPSTREAM);
        let fingerprint = required("ZAGAN_UPSTREAM_FINGERPRINT")?;
        let fingerprint = fingerprint
            .trim()
            .replace([':', ' ', '-'], "")
            .to_ascii_lowercase();
        if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
            return Err(
                "ZAGAN_UPSTREAM_FINGERPRINT must be the 64-character SHA-256 of the upstream SPKI"
                    .to_string(),
            );
        }

        let grace_seconds = match env::var("ZAGAN_GRACE_SECONDS") {
            Ok(raw) => raw
                .trim()
                .parse::<u64>()
                .map_err(|_| "ZAGAN_GRACE_SECONDS must be a whole number of seconds".to_string())?,
            Err(_) => DEFAULT_GRACE_SECONDS,
        };

        let cookie_secure = match env::var("ZAGAN_WEB_COOKIE_SECURE") {
            Ok(raw) => matches!(
                raw.trim().to_ascii_lowercase().as_str(),
                "1" | "true" | "yes"
            ),
            Err(_) => origin.starts_with("https://"),
        };

        Ok(Self {
            bind,
            origin,
            upstream,
            fingerprint,
            grace: Duration::from_secs(grace_seconds),
            cookie_secure,
        })
    }
}

fn value(name: &str, default: &str) -> String {
    env::var(name)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| default.to_string())
}

fn required(name: &str) -> Result<String, String> {
    env::var(name)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            format!(
                "{name} is required: the gateway pins the upstream key. \
                 Use the SPKI fingerprint the desktop client shows."
            )
        })
}