1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TlsConfig {
15 pub cert_path: String,
17 pub key_path: String,
19 pub port: u16,
21}
22
23pub fn generate_nginx_config(app_port: u16, tls: Option<&TlsConfig>) -> String {
35 if let Some(tls) = tls {
36 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
132pub fn generate_caddy_config(domain: &str, app_port: u16) -> String {
137 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#[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}