Skip to main content

pylon_runtime/
tls.rs

1use serde::{Deserialize, Serialize};
2
3// ---------------------------------------------------------------------------
4// TLS configuration types
5// ---------------------------------------------------------------------------
6
7/// TLS configuration for production deployments.
8///
9/// pylon itself runs plain HTTP (via tiny_http), so TLS termination is
10/// handled by a reverse proxy such as nginx or Caddy.  This struct captures
11/// the certificate paths and listen port so we can generate working proxy
12/// configs automatically.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TlsConfig {
15    /// Path to the PEM-encoded certificate file.
16    pub cert_path: String,
17    /// Path to the PEM-encoded private key file.
18    pub key_path: String,
19    /// Port the reverse proxy should listen on for HTTPS traffic.
20    pub port: u16,
21}
22
23// ---------------------------------------------------------------------------
24// Reverse proxy config generators
25// ---------------------------------------------------------------------------
26
27/// Generate an nginx reverse proxy configuration.
28///
29/// When `tls` is `Some`, the config listens on the TLS port with
30/// `ssl_certificate` / `ssl_certificate_key` directives.  When `None`, it
31/// listens on port 80 in plain HTTP mode.
32///
33/// WebSocket traffic is routed to `app_port + 1`.
34pub fn generate_nginx_config(app_port: u16, tls: Option<&TlsConfig>) -> String {
35    if let Some(tls) = tls {
36        // Production config:
37        //   - listen :80 only to redirect to HTTPS (no plain-HTTP routes).
38        //   - TLS 1.2+ only; weak ciphers (RC4, 3DES, CBC) are gone from the
39        //     TLSv1.2 default list on modern OpenSSL.
40        //   - HSTS (1y + includeSubDomains + preload) on the TLS vhost.
41        //   - Long proxy_read_timeout for SSE / WebSocket streams.
42        //   - X-Forwarded-* headers so the app sees the real client IP.
43        format!(
44            r#"# Redirect plain HTTP to HTTPS.
45server {{
46    listen 80;
47    listen [::]:80;
48    return 301 https://$host$request_uri;
49}}
50
51server {{
52    listen {ssl_port} ssl http2;
53    listen [::]:{ssl_port} ssl http2;
54
55    ssl_certificate {cert};
56    ssl_certificate_key {key};
57    ssl_protocols TLSv1.2 TLSv1.3;
58    ssl_prefer_server_ciphers off;
59    ssl_session_cache shared:SSL:10m;
60    ssl_session_timeout 1d;
61    ssl_session_tickets off;
62
63    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
64    add_header X-Content-Type-Options "nosniff" always;
65    add_header X-Frame-Options "SAMEORIGIN" always;
66    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
67
68    # SSE / fn streaming / AI streaming need long read windows — default 60s
69    # chops live responses.
70    proxy_read_timeout 3600s;
71    proxy_send_timeout 3600s;
72
73    # Cap request bodies matching the server's 10 MB limit; nginx's default
74    # is 1 MB and will 413 longer uploads before they reach the app.
75    client_max_body_size 10M;
76
77    location / {{
78        proxy_pass http://127.0.0.1:{port};
79        proxy_http_version 1.1;
80        proxy_set_header Upgrade $http_upgrade;
81        proxy_set_header Connection "upgrade";
82        proxy_set_header Host $host;
83        proxy_set_header X-Real-IP $remote_addr;
84        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
85        proxy_set_header X-Forwarded-Proto $scheme;
86        proxy_buffering off; # required for SSE chunked responses
87    }}
88
89    location /ws {{
90        proxy_pass http://127.0.0.1:{ws_port};
91        proxy_http_version 1.1;
92        proxy_set_header Upgrade $http_upgrade;
93        proxy_set_header Connection "upgrade";
94        proxy_read_timeout 3600s;
95        proxy_send_timeout 3600s;
96    }}
97}}"#,
98            ssl_port = tls.port,
99            cert = tls.cert_path,
100            key = tls.key_path,
101            port = app_port,
102            ws_port = app_port + 1,
103        )
104    } else {
105        format!(
106            r#"server {{
107    listen 80;
108
109    # Dev-only plain-HTTP snippet. For production, pass a TlsConfig so the
110    # generator adds HSTS, TLS version pinning, and the HTTP -> HTTPS
111    # redirect.
112
113    proxy_read_timeout 3600s;
114    proxy_send_timeout 3600s;
115    client_max_body_size 10M;
116
117    location / {{
118        proxy_pass http://127.0.0.1:{port};
119        proxy_http_version 1.1;
120        proxy_set_header Upgrade $http_upgrade;
121        proxy_set_header Connection "upgrade";
122        proxy_set_header Host $host;
123        proxy_set_header X-Real-IP $remote_addr;
124        proxy_buffering off;
125    }}
126}}"#,
127            port = app_port,
128        )
129    }
130}
131
132/// Generate a Caddy reverse proxy configuration with automatic TLS.
133///
134/// Caddy handles certificate provisioning via ACME, so only the domain
135/// name is required.
136pub fn generate_caddy_config(domain: &str, app_port: u16) -> String {
137    // Caddy auto-redirects HTTP to HTTPS and provisions certificates via
138    // ACME when a domain is specified, so this config is short on purpose.
139    // The extras here: HSTS, long flush interval for SSE, bumped read
140    // timeout so streaming responses don't get cut at the default 30s.
141    format!(
142        r#"{domain} {{
143    header {{
144        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
145        X-Content-Type-Options "nosniff"
146        X-Frame-Options "SAMEORIGIN"
147        Referrer-Policy "strict-origin-when-cross-origin"
148    }}
149
150    # Match app body-size cap (10 MB).
151    request_body {{
152        max_size 10MB
153    }}
154
155    # Long-lived SSE / function streaming responses. Caddy's default
156    # write_timeout would terminate live streams after 30s.
157    servers {{
158        timeouts {{
159            read_body   30s
160            read_header 10s
161            write       1h
162            idle        2m
163        }}
164    }}
165
166    @websocket {{
167        header Connection *Upgrade*
168        header Upgrade websocket
169    }}
170    reverse_proxy @websocket localhost:{ws_port}
171
172    reverse_proxy localhost:{port} {{
173        flush_interval -1
174        transport http {{
175            read_timeout 1h
176        }}
177    }}
178}}"#,
179        domain = domain,
180        port = app_port,
181        ws_port = app_port + 1,
182    )
183}
184
185// ---------------------------------------------------------------------------
186// Tests
187// ---------------------------------------------------------------------------
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn nginx_config_with_tls() {
195        let tls = TlsConfig {
196            cert_path: "/etc/ssl/certs/app.pem".into(),
197            key_path: "/etc/ssl/private/app.key".into(),
198            port: 443,
199        };
200        let config = generate_nginx_config(4321, Some(&tls));
201
202        assert!(config.contains("listen 443 ssl http2;"));
203        assert!(config.contains("Strict-Transport-Security"));
204        assert!(config.contains("TLSv1.2"));
205        assert!(config.contains("return 301 https://"));
206        assert!(config.contains("ssl_certificate /etc/ssl/certs/app.pem;"));
207        assert!(config.contains("ssl_certificate_key /etc/ssl/private/app.key;"));
208        assert!(config.contains("proxy_pass http://127.0.0.1:4321;"));
209        assert!(config.contains("proxy_pass http://127.0.0.1:4322;"));
210        assert!(config.contains("X-Forwarded-Proto"));
211    }
212
213    #[test]
214    fn nginx_config_without_tls() {
215        let config = generate_nginx_config(4321, None);
216
217        assert!(config.contains("listen 80;"));
218        assert!(config.contains("proxy_pass http://127.0.0.1:4321;"));
219        assert!(!config.contains("ssl_certificate"));
220        assert!(!config.contains("443"));
221    }
222
223    #[test]
224    fn caddy_config_contains_domain_and_ports() {
225        let config = generate_caddy_config("example.com", 4321);
226
227        assert!(config.contains("example.com {"));
228        assert!(config.contains("reverse_proxy localhost:4321"));
229        assert!(config.contains("reverse_proxy @websocket localhost:4322"));
230        assert!(config.contains("header Upgrade websocket"));
231    }
232
233    #[test]
234    fn nginx_config_correct_ws_port() {
235        let tls = TlsConfig {
236            cert_path: "/cert.pem".into(),
237            key_path: "/key.pem".into(),
238            port: 8443,
239        };
240        let config = generate_nginx_config(9000, Some(&tls));
241
242        assert!(config.contains("listen 8443 ssl http2;"));
243        assert!(config.contains("proxy_pass http://127.0.0.1:9000;"));
244        assert!(config.contains("proxy_pass http://127.0.0.1:9001;"));
245    }
246
247    #[test]
248    fn tls_config_serialization_roundtrip() {
249        let tls = TlsConfig {
250            cert_path: "/cert.pem".into(),
251            key_path: "/key.pem".into(),
252            port: 443,
253        };
254        let json = serde_json::to_string(&tls).unwrap();
255        let parsed: TlsConfig = serde_json::from_str(&json).unwrap();
256        assert_eq!(parsed.cert_path, "/cert.pem");
257        assert_eq!(parsed.key_path, "/key.pem");
258        assert_eq!(parsed.port, 443);
259    }
260}