Skip to content

Commit beea801

Browse files
committed
feat: Allows Http / Https Traffic on Proxy
1 parent 2ee62b3 commit beea801

8 files changed

Lines changed: 218 additions & 129 deletions

File tree

PROXY.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,37 @@ Dispenser includes a built-in, high-performance reverse proxy powered by [Pingor
55
## Overview
66

77
The Dispenser proxy listens on ports 80 (HTTP) and 443 (HTTPS). It automatically:
8-
1. Redirects all HTTP traffic to HTTPS.
8+
1. Optionally redirects HTTP traffic to HTTPS (configurable).
99
2. Routes incoming requests to the correct container based on the `Host` header.
1010
3. Manages SSL/TLS certificates via Let's Encrypt or self-signed "simulation" mode.
1111
4. Handles Zero-Downtime reloads when configuration changes or certificates are updated.
1212

13-
## Global Toggle
13+
## Global Configuration
1414

15-
The reverse proxy is enabled by default. You can explicitly enable or disable it in your main `dispenser.toml` file. When disabled, both the proxy server (ports 80/443) and the automatic certificate maintenance tasks are turned off.
15+
The reverse proxy is enabled by default and defaults to enforcing HTTPS. You can configure the behavior in your main `dispenser.toml` file.
16+
17+
### Proxy Strategy
18+
19+
The `strategy` field determines how Dispenser handles HTTP (80) and HTTPS (443) traffic.
20+
21+
```toml
22+
# dispenser.toml
23+
24+
[proxy]
25+
enabled = true
26+
# Available options: "https-only", "http-only", "both"
27+
strategy = "https-only"
28+
```
29+
30+
| Strategy | Behavior |
31+
| :--- | :--- |
32+
| `https-only` | (Default) Port 80 redirects all traffic to Port 443. SSL is required. |
33+
| `http-only` | Port 80 serves application traffic. Port 443 and SSL management are disabled. |
34+
| `both` | Both ports serve application traffic. No automatic redirects occur. |
35+
36+
### Global Toggle
37+
38+
When the `enabled` flag is set to `false`, both the proxy server and the automatic certificate maintenance tasks are turned off.
1639

1740
```toml
1841
# dispenser.toml

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ In addition to the default network, you can declare custom networks in `dispense
341341
enabled = false
342342
```
343343

344+
4. (Optional) Configure the proxy strategy in `dispenser.toml`. Choose between `https-only` (default), `http-only`, or `both`.
345+
346+
```toml
347+
[proxy]
348+
strategy = "http-only"
349+
```
350+
344351
For more details, see the [Reverse Proxy Guide](PROXY.md).
345352

346353
### Step 10: Validating Configuration

src/main.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ async fn main() -> ExitCode {
158158

159159
// Abort manager-bound tasks
160160
polling_handle.abort();
161-
acme_handle.map(|t| t.abort());
161+
if let Some(t) = &acme_handle {
162+
t.abort();
163+
}
162164

163165
if let Err(e) = signals::reload_manager(manager_holder.clone(), service_filter).await {
164166
log::error!("Reload failed: {e}");
@@ -171,7 +173,9 @@ async fn main() -> ExitCode {
171173

172174
// Abort manager-bound tasks
173175
polling_handle.abort();
174-
acme_handle.map(|t| t.abort());
176+
if let Some(t) = &acme_handle {
177+
t.abort();
178+
}
175179

176180
let manager = manager_holder.lock().await;
177181
manager.cancel().await;

src/proxy/acme.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ pub async fn maintain_certificates(
2828
let _ = tokio::fs::create_dir_all(CHALLENGES_DIR).await;
2929
let settings = manager.get_certbot_settings();
3030

31+
if manager.get_proxy_strategy() == crate::service::file::ProxyStrategy::HttpOnly {
32+
info!("Proxy strategy is HttpOnly, skipping certificate maintenance.");
33+
return;
34+
}
35+
3136
loop {
3237
info!("Starting certificate maintenance check...");
3338
let mut changed = false;
@@ -45,13 +50,11 @@ pub async fn maintain_certificates(
4550
if ensure_simulated_cert(host).await {
4651
changed = true;
4752
}
48-
} else {
49-
if let Some(settings) = &settings {
50-
match ensure_acme_cert(&settings, host).await {
51-
Ok(true) => changed = true,
52-
Ok(false) => {}
53-
Err(e) => error!("ACME error for {}: {}", host, e),
54-
}
53+
} else if let Some(settings) = &settings {
54+
match ensure_acme_cert(settings, host).await {
55+
Ok(true) => changed = true,
56+
Ok(false) => {}
57+
Err(e) => error!("ACME error for {}: {}", host, e),
5558
}
5659
}
5760
}

src/proxy/mod.rs

Lines changed: 127 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,97 +2,117 @@ pub mod acme;
22
pub mod certs;
33

44
use async_trait::async_trait;
5-
use http::{header, HeaderValue, Response, StatusCode};
5+
use http::{header, HeaderValue, StatusCode};
66
use log::{debug, info};
77
use openssl::ssl::{NameType, SniError};
8-
use pingora::apps::http_app::ServeHttp;
98
use pingora::listeners::tls::TlsSettings;
10-
use pingora::protocols::http::ServerSession;
119
use pingora::server::{RunArgs, ShutdownSignal};
12-
use pingora::services::listening::Service;
1310
use pingora_error::ErrorType::HTTPStatus;
1411
use std::sync::Arc;
1512

1613
use pingora_core::server::configuration::Opt;
1714
use pingora_core::server::Server;
15+
use pingora_http::ResponseHeader;
1816

1917
use pingora::prelude::*;
2018

19+
use crate::service::file::ProxyStrategy;
2120
use crate::service::manager::ServicesManager;
2221

23-
pub struct AcmeService;
22+
/// Router that holds multiple Service configurations and routes based on SNI/Host
23+
pub struct DispenserProxy {
24+
pub services_manager: Arc<ServicesManager>,
25+
pub is_ssl: bool,
26+
pub strategy: ProxyStrategy,
27+
}
2428

2529
#[async_trait]
26-
impl ServeHttp for AcmeService {
27-
async fn response(&self, http_stream: &mut ServerSession) -> Response<Vec<u8>> {
28-
let path = http_stream.req_header().uri.path();
30+
impl ProxyHttp for DispenserProxy {
31+
type CTX = ();
32+
33+
fn new_ctx(&self) {}
34+
35+
async fn request_filter(&self, session: &mut Session, _ctx: &mut Self::CTX) -> Result<bool> {
36+
let path = session.req_header().uri.path();
2937

30-
// 1. Handle ACME challenges
31-
if path.starts_with("/.well-known/acme-challenge/") {
38+
// 1. Handle ACME challenges (only on Port 80)
39+
if !self.is_ssl && path.starts_with("/.well-known/acme-challenge/") {
3240
let token = path
3341
.strip_prefix("/.well-known/acme-challenge/")
3442
.unwrap_or("");
3543
let challenge_path = std::path::Path::new(".dispenser/challenges").join(token);
3644

3745
if let Ok(content) = tokio::fs::read(challenge_path).await {
38-
return Response::builder()
39-
.status(StatusCode::OK)
40-
.header(header::CONTENT_TYPE, "text/plain")
41-
.header(header::CONTENT_LENGTH, content.len())
42-
.body(content)
46+
let mut resp_header = ResponseHeader::build(StatusCode::OK, None).unwrap();
47+
resp_header
48+
.insert_header(header::CONTENT_TYPE, "text/plain")
4349
.unwrap();
50+
resp_header
51+
.insert_header(header::CONTENT_LENGTH, content.len())
52+
.unwrap();
53+
session.set_keepalive(None);
54+
session
55+
.write_response_header(Box::new(resp_header), false)
56+
.await?;
57+
session
58+
.write_response_body(Some(content.into()), true)
59+
.await?;
60+
return Ok(true);
4461
}
4562
}
4663

47-
let host_header = http_stream
48-
.get_header(header::HOST)
49-
.unwrap()
50-
.to_str()
51-
.unwrap();
52-
debug!("host header: {host_header}");
53-
54-
let path_and_query = http_stream
55-
.req_header()
56-
.uri
57-
.path_and_query()
58-
.map(|pq| pq.as_str())
59-
.unwrap_or("/");
60-
61-
let body = "<html><body>301 Moved Permanently</body></html>"
62-
.as_bytes()
63-
.to_owned();
64-
65-
Response::builder()
66-
.status(StatusCode::MOVED_PERMANENTLY)
67-
.header(header::CONTENT_TYPE, "text/html")
68-
.header(header::CONTENT_LENGTH, body.len())
69-
.header(
70-
header::LOCATION,
71-
format!("https://{}{}", host_header, path_and_query),
72-
)
73-
.body(body)
74-
.unwrap()
75-
}
76-
}
64+
// 2. Handle HTTPS Redirects
65+
if !self.is_ssl && self.strategy == ProxyStrategy::HttpsOnly {
66+
let host_header = session
67+
.get_header(header::HOST)
68+
.and_then(|h| h.to_str().ok())
69+
.unwrap_or("localhost");
7770

78-
/// Router that holds multiple Service configurations and routes based on SNI/Host
79-
pub struct DispenserProxy {
80-
pub services_manager: Arc<ServicesManager>,
81-
}
71+
let path_and_query = session
72+
.req_header()
73+
.uri
74+
.path_and_query()
75+
.map(|pq| pq.as_str())
76+
.unwrap_or("/");
77+
78+
let body = "<html><body>301 Moved Permanently</body></html>"
79+
.as_bytes()
80+
.to_owned();
81+
82+
let mut resp_header =
83+
ResponseHeader::build(StatusCode::MOVED_PERMANENTLY, None).unwrap();
84+
resp_header
85+
.insert_header(header::CONTENT_TYPE, "text/html")
86+
.unwrap();
87+
resp_header
88+
.insert_header(header::CONTENT_LENGTH, body.len())
89+
.unwrap();
90+
resp_header
91+
.insert_header(
92+
header::LOCATION,
93+
format!("https://{}{}", host_header, path_and_query),
94+
)
95+
.unwrap();
8296

83-
#[async_trait]
84-
impl ProxyHttp for DispenserProxy {
85-
type CTX = ();
97+
session.set_keepalive(None);
98+
session
99+
.write_response_header(Box::new(resp_header), false)
100+
.await?;
101+
session.write_response_body(Some(body.into()), true).await?;
102+
return Ok(true);
103+
}
86104

87-
fn new_ctx(&self) {}
105+
Ok(false)
106+
}
88107

89108
async fn upstream_request_filter(
90109
&self,
91110
_session: &mut Session,
92111
upstream_request: &mut RequestHeader,
93112
_ctx: &mut Self::CTX,
94113
) -> Result<()> {
95-
upstream_request.insert_header("X-Forwarded-Proto", HeaderValue::from_static("https"))?;
114+
let proto = if self.is_ssl { "https" } else { "http" };
115+
upstream_request.insert_header("X-Forwarded-Proto", HeaderValue::from_static(proto))?;
96116
Ok(())
97117
}
98118

@@ -163,52 +183,64 @@ pub fn run_dummy_proxy(signals: ProxySignals) {
163183
pub fn run_proxy(services_manager: Arc<ServicesManager>, signals: ProxySignals) {
164184
let opt = Opt::default();
165185
let mut my_server = Server::new(Some(opt)).unwrap();
166-
167-
// 1. Load certificates
168-
let cert_map = Arc::new(certs::load_all_certificates(&services_manager));
169-
let (default_cert, default_key) = certs::ensure_default_cert();
170-
171-
// 2. Setup Proxy
172-
let mut proxy_service = http_proxy_service(
173-
&my_server.configuration,
174-
DispenserProxy {
186+
let strategy = services_manager.get_proxy_strategy();
187+
188+
// 1. Setup HTTP Proxy (Port 80)
189+
let http_proxy = DispenserProxy {
190+
services_manager: services_manager.clone(),
191+
is_ssl: false,
192+
strategy,
193+
};
194+
let mut http_service = http_proxy_service(&my_server.configuration, http_proxy);
195+
http_service.add_tcp("0.0.0.0:80");
196+
my_server.add_service(http_service);
197+
198+
// 2. Setup HTTPS Proxy (Port 443) if enabled by strategy
199+
if strategy != ProxyStrategy::HttpOnly {
200+
// Load certificates
201+
let cert_map = Arc::new(certs::load_all_certificates(&services_manager));
202+
let (default_cert, default_key) = certs::ensure_default_cert();
203+
204+
let https_proxy = DispenserProxy {
175205
services_manager: services_manager.clone(),
176-
},
177-
);
178-
179-
// 3. Configure TLS with SNI callback
180-
// We use intermediate settings and then override with callback
181-
let mut tls_settings = TlsSettings::intermediate(
182-
default_cert.to_str().unwrap(),
183-
default_key.to_str().unwrap(),
184-
)
185-
.expect("Failed to load default fallback certificate");
186-
187-
tls_settings.enable_h2();
188-
189-
// Set SNI callback
190-
let cert_map_for_sni = cert_map.clone();
191-
tls_settings.set_servername_callback(move |ssl, _| {
192-
let host = ssl.servername(NameType::HOST_NAME);
193-
debug!("SNI callback for host: {:?}", host);
194-
if let Some(host) = host {
195-
if let Some(ctx) = cert_map_for_sni.get(host) {
196-
let _ = ssl.set_ssl_context(ctx);
206+
is_ssl: true,
207+
strategy,
208+
};
209+
let mut https_service = http_proxy_service(&my_server.configuration, https_proxy);
210+
211+
// Configure TLS with SNI callback
212+
let mut tls_settings = TlsSettings::intermediate(
213+
default_cert.to_str().unwrap(),
214+
default_key.to_str().unwrap(),
215+
)
216+
.expect("Failed to load default fallback certificate");
217+
218+
tls_settings.enable_h2();
219+
220+
// Set SNI callback
221+
let cert_map_for_sni = cert_map.clone();
222+
tls_settings.set_servername_callback(move |ssl, _| {
223+
let host = ssl.servername(NameType::HOST_NAME);
224+
debug!("SNI callback for host: {:?}", host);
225+
if let Some(host) = host {
226+
if let Some(ctx) = cert_map_for_sni.get(host) {
227+
let _ = ssl.set_ssl_context(ctx);
228+
}
197229
}
198-
}
199-
Ok::<(), SniError>(())
200-
});
201-
202-
proxy_service.add_tls_with_settings("0.0.0.0:443", None, tls_settings);
230+
Ok::<(), SniError>(())
231+
});
203232

204-
let mut acme_service = Service::new("Echo Service HTTP".to_string(), AcmeService);
205-
acme_service.add_tcp("0.0.0.0:80");
233+
https_service.add_tls_with_settings("0.0.0.0:443", None, tls_settings);
234+
my_server.add_service(https_service);
235+
info!(
236+
"Proxy starting on port 80 and 443 (Strategy: {:?})",
237+
strategy
238+
);
239+
} else {
240+
info!("Proxy starting on port 80 (Strategy: HttpOnly)");
241+
}
206242

207-
my_server.add_service(proxy_service);
208-
my_server.add_service(acme_service);
209243
my_server.bootstrap();
210-
211-
info!("Proxy starting on port 443");
212244
my_server.run(RunArgs {
213245
shutdown_signal: Box::new(signals),
214246
});

0 commit comments

Comments
 (0)