Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ log_level = "info"
rate_limit_per_second = 0
rate_limit_burst = 0
url = "http://localhost:8080"
acme_staging = false
2 changes: 1 addition & 1 deletion proto
Submodule proto updated 1 files
+1 −1 core/proxy.proto
12 changes: 9 additions & 3 deletions src/acme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,18 @@ async fn check_domain_resolves(domain: &str) -> anyhow::Result<()> {
pub async fn run_acme_http01(
domain: String,
existing_credentials_json: String,
use_staging: bool,
port80_permit: Option<Port80Permit>,
progress_tx: mpsc::UnboundedSender<AcmeStep>,
) -> anyhow::Result<AcmeCertResult> {
info!("Starting ACME HTTP-01 certificate issuance for domain: {domain}");
info!("Using Let's Encrypt production environment");
let dir_url = if use_staging {
info!("Using Let's Encrypt staging environment");
LetsEncrypt::Staging.url().to_owned()
} else {
info!("Using Let's Encrypt production environment");
LetsEncrypt::Production.url().to_owned()
};

// DNS pre-flight: verify the domain resolves before attempting ACME.
let _ = progress_tx.send(AcmeStep::CheckingDomain);
Expand All @@ -140,7 +147,6 @@ pub async fn run_acme_http01(
let (account, credentials) = if existing_credentials_json.is_empty() {
info!("No stored ACME account found; creating a new one with Let's Encrypt");
let builder = Account::builder().context("Failed to create ACME account builder")?;
let dir_url = LetsEncrypt::Production.url().to_owned();
info!("Registering account at ACME directory: {dir_url}");
let (account, credentials) = builder
.create(
Expand All @@ -149,7 +155,7 @@ pub async fn run_acme_http01(
contact: &[],
only_return_existing: false,
},
dir_url,
dir_url.clone(),
None,
)
.await
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ pub struct EnvConfig {
/// server is restarted on this port using those certificates.
#[arg(long, env = "DEFGUARD_PROXY_HTTPS_PORT", default_value_t = 443)]
pub https_port: u16,

/// Use Let's Encrypt staging environment for ACME issuance.
#[arg(long, env = "DEFGUARD_PROXY_ACME_STAGING", default_value_t = false)]
pub acme_staging: bool,
}

#[derive(thiserror::Error, Debug)]
Expand Down
29 changes: 26 additions & 3 deletions src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,29 @@ pub(crate) struct ProxyServer {
cert_dir: PathBuf,
reset_tx: broadcast::Sender<()>,
https_cert_tx: broadcast::Sender<(String, String)>,
clear_https_tx: broadcast::Sender<()>,
/// `Some` only when the main HTTP server is bound to port 80.
/// Used to hand off port 80 gracefully during ACME HTTP-01 challenges.
port80_pause_tx: Option<mpsc::Sender<(oneshot::Sender<()>, oneshot::Receiver<()>)>>,
/// Shared log receiver - written by `GrpcLogLayer` for every tracing event.
/// Drained during ACME execution to collect proxy log lines for error reporting.
logs_rx: LogsReceiver,
acme_staging: bool,
}

impl ProxyServer {
#[must_use]
/// Create new `ProxyServer`.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
cookie_key: Arc<RwLock<Option<Key>>>,
cert_dir: PathBuf,
reset_tx: broadcast::Sender<()>,
https_cert_tx: broadcast::Sender<(String, String)>,
clear_https_tx: broadcast::Sender<()>,
port80_pause_tx: Option<mpsc::Sender<(oneshot::Sender<()>, oneshot::Receiver<()>)>>,
logs_rx: LogsReceiver,
acme_staging: bool,
) -> Self {
Self {
cookie_key,
Expand All @@ -86,8 +91,10 @@ impl ProxyServer {
cert_dir,
reset_tx,
https_cert_tx,
clear_https_tx,
port80_pause_tx,
logs_rx,
acme_staging,
}
}

Expand Down Expand Up @@ -210,8 +217,10 @@ impl Clone for ProxyServer {
cert_dir: self.cert_dir.clone(),
reset_tx: self.reset_tx.clone(),
https_cert_tx: self.https_cert_tx.clone(),
clear_https_tx: self.clear_https_tx.clone(),
port80_pause_tx: self.port80_pause_tx.clone(),
logs_rx: Arc::clone(&self.logs_rx),
acme_staging: self.acme_staging,
}
}
}
Expand Down Expand Up @@ -263,6 +272,7 @@ impl proxy_server::Proxy for ProxyServer {
let connected = Arc::clone(&self.connected);
let cookie_key = Arc::clone(&self.cookie_key);
let https_cert_tx = self.https_cert_tx.clone();
let clear_https_tx = self.clear_https_tx.clone();
tokio::spawn(
async move {
let mut stream = request.into_inner();
Expand All @@ -288,6 +298,12 @@ impl proxy_server::Proxy for ProxyServer {
);
}
}
core_response::Payload::ClearHttpsCerts(_) => {
info!("Received ClearHttpsCerts from Core");
if let Err(err) = clear_https_tx.send(()) {
error!("Failed to broadcast ClearHttpsCerts: {err}");
}
}
other => {
let maybe_rx = results.write().expect("Failed to acquire lock on results hashmap when processing response").remove(&response.id);
if let Some(rx) = maybe_rx {
Expand Down Expand Up @@ -389,6 +405,7 @@ impl proxy_server::Proxy for ProxyServer {

let pause_tx = self.port80_pause_tx.clone();
let logs_rx = Arc::clone(&self.logs_rx);
let acme_staging = self.acme_staging;
tokio::spawn(async move {
// Request a graceful hand-off of port 80 from the main HTTP server if it is bound
// there, so the ACME challenge listener can bind.
Expand Down Expand Up @@ -432,8 +449,14 @@ impl proxy_server::Proxy for ProxyServer {
}
});

match acme::run_acme_http01(domain.clone(), existing_credentials, permit, progress_tx)
.await
match acme::run_acme_http01(
domain.clone(),
existing_credentials,
acme_staging,
permit,
progress_tx,
)
.await
{
Ok(acme_result) => {
let cert_event = AcmeIssueEvent {
Expand Down
23 changes: 22 additions & 1 deletion src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ pub async fn run_server(
let cookie_key = Arc::default();
let (reset_tx, mut reset_rx) = tokio::sync::broadcast::channel(1);
let (https_cert_tx, https_cert_rx) = broadcast::channel::<(String, String)>(1);
let (clear_https_tx, clear_https_rx) = broadcast::channel::<()>(1);

// When the main HTTP server is on port 80, create a channel so the ACME task can request
// a graceful hand-off of port 80 before binding its temporary challenge listener.
Expand All @@ -337,8 +338,10 @@ pub async fn run_server(
env_config.cert_dir.clone(),
reset_tx,
https_cert_tx,
clear_https_tx,
port80_pause_tx,
Arc::clone(&logs_rx),
env_config.acme_staging,
);

// Preload existing TLS configuration so /api/v1/info can report "disconnected"
Expand Down Expand Up @@ -515,6 +518,7 @@ pub async fn run_server(
);
let mut current_tls: Option<(String, String)> = None;
let mut https_cert_rx = https_cert_rx;
let mut clear_https_rx = clear_https_rx;
let mut port80_pause_rx = port80_pause_rx;

loop {
Expand Down Expand Up @@ -570,6 +574,23 @@ pub async fn run_server(
}
}
}
result = clear_https_rx.recv() => {
match result {
Ok(()) => {
info!("Received ClearHttpsCerts, restarting web server without TLS");
current_tls = None;
handle.graceful_shutdown(Some(Duration::from_secs(30)));
let _ = server_task.await;
}
Err(broadcast::error::RecvError::Lagged(_)) => {
warn!("Missed ClearHttpsCerts update; will apply next one");
}
Err(broadcast::error::RecvError::Closed) => {
error!("ClearHttpsCerts channel closed unexpectedly");
break;
}
}
}
// An ACME task needs port 80: gracefully stop the current HTTP server,
// signal the task that port 80 is free, wait until it's done, then let
// the loop restart the server.
Expand All @@ -579,7 +600,7 @@ pub async fn run_server(
} else {
std::future::pending().await
}
}, if current_tls.is_none() => {
} => {
info!("ACME task requested port 80; pausing HTTP server");
handle.graceful_shutdown(Some(Duration::from_secs(10)));
let _ = server_task.await;
Expand Down
16 changes: 8 additions & 8 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,19 @@
"dependencies": {
"@axa-ch/react-polymorphic-types": "^1.4.1",
"@floating-ui/react": "^0.27.19",
"@inlang/paraglide-js": "^2.15.1",
"@inlang/paraglide-js": "^2.15.2",
"@tanstack/react-devtools": "^0.9.13",
"@tanstack/react-form": "^1.28.6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-router": "^1.168.10",
"@tanstack/react-router-devtools": "^1.166.11",
"@uidotdev/usehooks": "^2.4.1",
"axios": "^1.14.0",
"change-case": "^5.4.4",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"lodash-es": "^4.17.23",
"lodash-es": "^4.18.1",
"motion": "^12.38.0",
"qrcode.react": "^4.2.0",
"qs": "^6.15.0",
Expand All @@ -44,21 +44,21 @@
"@tanstack/devtools-vite": "^0.5.5",
"@tanstack/router-plugin": "^1.167.12",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/qs": "^6.15.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"sass": "^1.98.0",
"sass": "^1.99.0",
"sharp": "^0.34.5",
"stylelint": "^17.6.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-scss": "^7.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.58.0",
"vite": "^7.3.1",
"typescript-eslint": "^8.58.1",
"vite": "^7.3.2",
"vite-plugin-image-optimizer": "^2.0.3"
},
"pnpm": {
Expand Down
Loading
Loading