From 949916c1748f9a03094b7368c27027b26b1298bb Mon Sep 17 00:00:00 2001
From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 25 Jul 2026 11:10:35 -0500
Subject: [PATCH 1/7] update deps, pac, edit filters, other stuff (#44)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* test: add characterization tests before dependency upgrade
Lock current behavior of CSP augmentation, request-type detection, URL
normalization, opaque-upgrade detection, TOML config round-trip, and CA-signed
cert/server-config assembly so the dependency upgrade can be verified as
behavior-preserving.
build(deps): phase 1 — low-risk bumps (thiserror 2, toml 1, dirs 6)
- privaxy: toml 0.8->1, thiserror 1->2, dirs 5->6, serde_with 3.8->3.21,
env_logger 0.11.3->0.11.10, uluru 3.0->3.1, async-compression 0.4.11->0.4.42,
tera pin relaxed to 1.x (argon2 kept on 0.5, tera on 1.x: latest are
pre-releases, excluded per semver).
- filterlists-api: thiserror 1->2, reqwest 0.12->0.13.
- web_frontend: thiserror 1->2.
- thiserror 2 dropped raw-identifier (r#type) support in #[error] format
strings; updated FilterListAPIError accordingly.
Also retains the reqwest client tcp_keepalive removal in server/lib.rs.
Characterization tests remain green (cargo test -p privaxy --lib).
feat(deps): phase 2 — migrate server to hyper 1.0 / rustls 0.23 / warp 0.4
Upgrades the proxy/web-server HTTP+TLS stack to current majors:
- hyper 0.14 -> 1, http 0.2 -> 1, add hyper-util + http-body-util.
- rustls 0.21 -> 0.23, tokio-rustls 0.24 -> 0.26, hyper-rustls 0.24 -> 0.27,
all pinned to the ring provider (default-features=false) so the tier-3
MIPS/musl cross builds keep working (aws-lc-rs needs a C toolchain).
- reqwest 0.11 -> 0.13 (rustls-no-provider + ring, process-default provider).
Key code changes:
- Install the ring CryptoProvider once at startup (rustls 0.23 requires a
process default before any TLS config is built).
- Replace hyper's removed Server/Client/Body: the proxy now hand-accepts
connections and drives them with hyper-util's auto builder (HTTP/1+2 +
upgrades), preserving header-case and tcp_keepalive(600s); the upgrade
client uses hyper-util's legacy Client. Streaming response bodies use an
mpsc + http_body_util::StreamBody channel (replacing hyper::body::Sender),
wrapping upgraded streams in TokioIo.
- cert.rs: rustls 0.23 CertificateDer/PrivateKeyDer + builder safe-defaults.
- warp 0.3 -> 0.4 (built on hyper 1). warp 0.4 removed built-in TLS and the
graceful-shutdown server, so the web GUI is now served via hyper-util with
optional tokio-rustls termination (WebSocket live feeds still upgrade).
- argon2: enable std feature to restore OsRng after feature unification shift.
Characterization tests remain green (cargo test -p privaxy --lib).
feat(deps): phase 3 — migrate frontend to yew 0.23 / gloo-net
- yew 0.19 -> 0.23 (csr feature), yew-router 0.16 -> 0.20, gloo-utils 0.1
-> 0.3, gloo-timers 0.2 -> 0.4, web-sys 0.3.69 -> 0.3.77, wasm-bindgen
-> 0.2.125.
- Replace the deprecated reqwasm with gloo-net (http + websocket); gloo-net's
RequestBuilder::body/json now return Result, so call sites unwrap and order
header() before body().
- yew API migration: start_app -> Renderer::render; Component::changed gains
the old_props parameter;
+ #{exclude_section}#
diff --git a/privaxy/src/resources/head.html b/privaxy/src/resources/head.html
index be9f6179..36ba11db 100644
--- a/privaxy/src/resources/head.html
+++ b/privaxy/src/resources/head.html
@@ -609,6 +609,33 @@
outline-style: solid;
}
+ .mt-4 {
+ margin-top: 1rem;
+ }
+
+ .inline-block {
+ display: inline-block;
+ }
+
+ .py-2 {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ }
+
+ .bg-blue-600 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(37 99 235 / var(--tw-bg-opacity));
+ }
+
+ .text-white {
+ --tw-text-opacity: 1;
+ color: rgb(255 255 255 / var(--tw-text-opacity));
+ }
+
+ .font-medium {
+ font-weight: 500;
+ }
+
@media (min-width: 640px) {
.sm\:ml-6 {
margin-left: 1.5rem;
diff --git a/privaxy/src/server/blocker_utils.rs b/privaxy/src/server/blocker_utils.rs
index 283b4bb8..4fed9049 100644
--- a/privaxy/src/server/blocker_utils.rs
+++ b/privaxy/src/server/blocker_utils.rs
@@ -6,21 +6,24 @@ use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
+#[allow(dead_code)]
static TOP_COMMENT_RE: Lazy = Lazy::new(|| Regex::new(r#"^/\*[\S\s]+?\n\*/\s*"#).unwrap());
+#[allow(dead_code)]
static NON_EMPTY_LINE_RE: Lazy = Lazy::new(|| Regex::new(r#"\S"#).unwrap());
/// Represents a single entry of the `Map` from uBlock Origin's `redirect-resources.js`.
///
/// - `name` is the name of a resource, corresponding to its path in the `web_accessible_resources`
-/// directory
+/// directory
///
/// - `alias` is a list of optional additional names that can be used to reference the resource
///
/// - `data` is either `"text"` or `"blob"`, but is currently unused in `adblock-rust`. Within
-/// uBlock Origin, it's used to prevent text files from being encoded in base64 in a data URL.
+/// uBlock Origin, it's used to prevent text files from being encoded in base64 in a data URL.
pub struct ResourceProperties {
pub name: String,
pub alias: Vec,
+ #[allow(dead_code)]
pub data: Option,
}
use base64::{engine::general_purpose, Engine};
@@ -125,6 +128,7 @@ pub fn read_redirectable_resource_mapping(mapfile_data: &str) -> Vec Vec {
let mut resources = Vec::new();
diff --git a/privaxy/src/server/cert.rs b/privaxy/src/server/cert.rs
index fc8c2e87..0c83d58b 100644
--- a/privaxy/src/server/cert.rs
+++ b/privaxy/src/server/cert.rs
@@ -13,7 +13,8 @@ use openssl::{
X509NameBuilder, X509Ref, X509Req, X509ReqBuilder, X509,
},
};
-use rustls::{Certificate, PrivateKey, ServerConfig};
+use rustls::pki_types::{CertificateDer, PrivateKeyDer};
+use rustls::ServerConfig;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{str::FromStr, sync::Arc};
use tokio::sync::Mutex;
@@ -38,17 +39,19 @@ impl SignedWithCaCert {
Self::build_ca_signed_cert(&ca_certificate, &ca_private_key, &authority, &private_key);
let certs = vec![
- Certificate(x509.to_der().unwrap()),
- Certificate(ca_certificate.to_der().unwrap()),
+ CertificateDer::from(x509.to_der().unwrap()),
+ CertificateDer::from(ca_certificate.to_der().unwrap()),
];
+ // rustls 0.23 folded the cipher-suite / kx-group / protocol-version
+ // selection into safe defaults on `ServerConfig::builder()`, so the
+ // explicit `with_safe_default_*` chain is gone. The crypto provider
+ // (ring) is installed once as the process default at startup.
+ let private_key =
+ PrivateKeyDer::try_from(private_key.private_key_to_der().unwrap()).unwrap();
let server_configuration = ServerConfig::builder()
- .with_safe_default_cipher_suites()
- .with_safe_default_kx_groups()
- .with_safe_default_protocol_versions()
- .unwrap()
.with_no_client_auth()
- .with_single_cert(certs, PrivateKey(private_key.private_key_to_der().unwrap()))
+ .with_single_cert(certs, private_key)
.unwrap();
Self {
@@ -117,6 +120,7 @@ impl SignedWithCaCert {
// patch NotValidBefore
// try_into() coerces to the platform's time_t (i64 on 64-bit
// targets, i32 on 32-bit MIPS glibc) instead of a hardcoded cast.
+ #[allow(clippy::useless_conversion)]
Asn1Time::from_unix((since_epoch.as_secs() as i64 - 60).try_into().unwrap()).unwrap()
};
cert_builder.set_not_before(¬_before).unwrap();
@@ -238,3 +242,29 @@ impl CertCache {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use openssl::rsa::Rsa;
+
+ /// Building a leaf certificate signed by the CA and assembling its rustls
+ /// `ServerConfig` must succeed without panicking. Post-upgrade to rustls
+ /// 0.23 this also exercises that a `CryptoProvider` is installed before the
+ /// `ServerConfig` builder runs.
+ #[test]
+ fn builds_ca_signed_server_config() {
+ // rustls 0.23 requires a process-default CryptoProvider before any
+ // `ServerConfig::builder()`; production installs this once at startup.
+ let _ = rustls::crypto::ring::default_provider().install_default();
+
+ let (ca_certificate, ca_private_key) = crate::ca::make_ca_certificate();
+ let leaf_key = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap();
+ let authority = Authority::from_static("example.com");
+
+ let signed =
+ SignedWithCaCert::new(authority.clone(), leaf_key, ca_certificate, ca_private_key);
+
+ assert_eq!(signed.authority, authority);
+ }
+}
diff --git a/privaxy/src/server/configuration/auth.rs b/privaxy/src/server/configuration/auth.rs
index 8dbf5579..a24c1b8a 100644
--- a/privaxy/src/server/configuration/auth.rs
+++ b/privaxy/src/server/configuration/auth.rs
@@ -4,7 +4,7 @@ use argon2::Argon2;
use openssl::rand::rand_bytes;
use serde::{Deserialize, Serialize};
-#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
pub struct Auth {
#[serde(default)]
pub username: Option,
@@ -16,17 +16,6 @@ pub struct Auth {
pub session_signing_key: String,
}
-impl Default for Auth {
- fn default() -> Self {
- Self {
- username: None,
- password_hash: None,
- api_key: String::new(),
- session_signing_key: String::new(),
- }
- }
-}
-
impl Auth {
pub fn new_initialized() -> Self {
Self {
diff --git a/privaxy/src/server/configuration/filter.rs b/privaxy/src/server/configuration/filter.rs
index da51d152..62f0cf12 100644
--- a/privaxy/src/server/configuration/filter.rs
+++ b/privaxy/src/server/configuration/filter.rs
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
+use std::collections::BTreeSet;
use std::env;
use std::path::PathBuf;
use tokio::fs;
@@ -18,17 +19,17 @@ pub enum FilterGroup {
Social,
}
-impl ToString for FilterGroup {
- fn to_string(&self) -> String {
- match self {
+impl std::fmt::Display for FilterGroup {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let name = match self {
FilterGroup::Default => "default",
FilterGroup::Regional => "regional",
FilterGroup::Ads => "ads",
FilterGroup::Privacy => "privacy",
FilterGroup::Malware => "malware",
FilterGroup::Social => "social",
- }
- .to_string()
+ };
+ write!(f, "{name}")
}
}
@@ -44,7 +45,7 @@ pub struct DefaultFilter {
}
#[serde_as]
-#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
+#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Filter {
/// If the filter is enabled
pub enabled: bool,
@@ -62,6 +63,12 @@ pub struct Filter {
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DefaultFilters(Vec);
+impl Default for DefaultFilters {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
impl DefaultFilters {
pub fn new() -> Self {
let mut filters = Vec::new();
@@ -78,6 +85,16 @@ impl DefaultFilters {
self.0.clone()
}
+ /// File names (derived from the URLs) of the filter lists shipped with
+ /// the package. Configuration entries matching one of these are built-in:
+ /// they can be enabled or disabled, but not edited or removed.
+ pub fn file_names(&self) -> BTreeSet {
+ self.0
+ .iter()
+ .map(|filter| filter.file_name.clone())
+ .collect()
+ }
+
fn parse_filter(
url: &'static str,
title: &'static str,
@@ -284,6 +301,34 @@ impl Filter {
}
}
+impl std::fmt::Display for Filter {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "Filter {{ enabled: {}, title: {}, group: {}, file_name: {}, url: {} }}",
+ self.enabled,
+ self.title,
+ self.group,
+ self.file_name,
+ self.url.as_str()
+ )
+ }
+}
+
+impl std::fmt::Debug for Filter {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "Filter {{ enabled: {}, title: {}, group: {}, file_name: {}, url: {} }}",
+ self.enabled,
+ self.title,
+ self.group,
+ self.file_name,
+ self.url.as_str()
+ )
+ }
+}
+
impl From for Filter {
fn from(default_filter: DefaultFilter) -> Self {
Self {
@@ -384,30 +429,36 @@ fn get_filter_directory() -> PathBuf {
// Assume home directory
Err(_) => PathBuf::from(FILTERS_DIRECTORY_NAME),
};
- return super::get_base_directory().unwrap().join(filter_dir);
+ super::get_base_directory().unwrap().join(filter_dir)
}
pub(crate) async fn get_filters_content(
configuration: &mut super::Configuration,
http_client: &reqwest::Client,
+ filter_failure_store: &super::FilterFailureStore,
) -> Vec {
let mut filters = Vec::new();
- let mut futures = vec![];
- for filter in configuration.get_enabled_filters() {
- let future = filter.get_contents(http_client);
- futures.push(future);
- }
+ let futures = configuration.get_enabled_filters().map(|filter| {
+ let http_client = http_client.clone();
+ async move {
+ let result = filter.get_contents(&http_client).await;
+ (filter, result)
+ }
+ });
let results = futures::future::join_all(futures).await;
- for result in results {
+ for (filter, result) in results {
match result {
Ok(filter_content) => filters.push(filter_content),
// A fetch failure here includes a filter whose URL has stopped serving a
- // `text/plain` list (see `validate_filter_content_type`); we warn and drop it from
- // the engine rather than aborting the whole rebuild.
+ // `text/plain` list (see `validate_filter_content_type`); we record it for the
+ // web UI and drop it from the engine rather than aborting the whole rebuild.
+ // No entry is cleared on success: a filter can load fine from its on-disk
+ // copy while its URL is still failing to update.
Err(err) => {
- log::warn!("Dropping filter that could not be loaded: {err:?}")
+ log::warn!("Dropping filter that could not be loaded: {err:?}");
+ filter_failure_store.record(filter, &err.to_string());
}
}
}
@@ -418,3 +469,24 @@ pub(crate) async fn get_filters_content(
filters.dedup();
filters
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The `is_default` API check identifies built-in lists by file name, so
+ /// every default filter's file name must stay derived from its URL and
+ /// the set must not silently collapse through duplicates.
+ #[test]
+ fn default_filter_file_names_are_derived_from_urls() {
+ let defaults = DefaultFilters::new();
+ let list = defaults.list();
+ let file_names = defaults.file_names();
+
+ assert!(!list.is_empty());
+ assert_eq!(file_names.len(), list.len());
+ for filter in list {
+ assert_eq!(filter.file_name, calc_filter_filename(filter.url.as_str()));
+ }
+ }
+}
diff --git a/privaxy/src/server/configuration/filter_failures.rs b/privaxy/src/server/configuration/filter_failures.rs
new file mode 100644
index 00000000..919516b1
--- /dev/null
+++ b/privaxy/src/server/configuration/filter_failures.rs
@@ -0,0 +1,184 @@
+//! In-memory record of filter lists that failed to download or validate.
+//!
+//! Filter lists are refreshed in the background (periodically and on
+//! configuration changes), so a list whose URL went stale fails silently from
+//! the operator's point of view. The updater records those failures here; the
+//! web GUI surfaces them via `GET /api/filters/failures` so the operator can
+//! edit the entry's URL or remove it.
+
+use super::filter::{Filter, FilterGroup};
+use chrono::{DateTime, Utc};
+use serde::Serialize;
+use std::sync::{Arc, Mutex};
+
+/// A single failing filter list, most recent failure details included.
+#[derive(Debug, Clone, Serialize)]
+pub struct FilterFailureEntry {
+ /// Local file name of the filter; stable identifier tying the entry to
+ /// the configuration entry it describes.
+ pub file_name: String,
+ /// Title of the filter, for display.
+ pub title: String,
+ /// Remote URL the download failed for.
+ pub url: String,
+ /// Group of the filter.
+ pub group: FilterGroup,
+ /// The last download/validation error, verbatim.
+ pub last_error: String,
+ /// When the filter last failed to update (RFC 3339 via chrono's serde).
+ pub last_seen: DateTime,
+ /// How many consecutive failed update attempts have been observed.
+ pub count: u64,
+}
+
+/// Cheaply-cloneable shared store of filter update failures. Lock scopes are
+/// short and never held across an await point. The store is inherently
+/// bounded: entries only exist for filters present in the configuration.
+#[derive(Debug, Clone, Default)]
+pub struct FilterFailureStore(Arc>>);
+
+impl FilterFailureStore {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Record a failed update attempt for `filter`, deduplicating by file
+ /// name: an already-failing filter is bumped to the front with its
+ /// counter incremented and failure details refreshed.
+ pub fn record(&self, filter: &Filter, error: &str) {
+ let mut entries = self.0.lock().unwrap();
+
+ let last_seen = Utc::now();
+ let count = match entries
+ .iter()
+ .position(|entry| entry.file_name == filter.file_name)
+ {
+ Some(index) => entries.remove(index).count + 1,
+ None => 1,
+ };
+
+ entries.insert(
+ 0,
+ FilterFailureEntry {
+ file_name: filter.file_name.clone(),
+ title: filter.title.clone(),
+ url: filter.url.to_string(),
+ group: filter.group,
+ last_error: error.to_string(),
+ last_seen,
+ count,
+ },
+ );
+ }
+
+ /// Drop the entry for `file_name`, if any. Called after a successful
+ /// update and after the operator edits or removes the filter.
+ pub fn clear(&self, file_name: &str) {
+ self.0
+ .lock()
+ .unwrap()
+ .retain(|entry| entry.file_name != file_name);
+ }
+
+ /// Snapshot of the tracked failures, most recently failed first.
+ pub fn entries(&self) -> Vec {
+ self.0.lock().unwrap().clone()
+ }
+
+ /// Reconcile the store with a freshly-applied configuration: drop entries
+ /// whose filter no longer exists or was disabled, and refresh the display
+ /// metadata of the ones that remain (a title or group edit keeps the same
+ /// file name).
+ pub fn sync_with_filters(&self, filters: &[Filter]) {
+ let mut entries = self.0.lock().unwrap();
+ entries.retain_mut(|entry| {
+ match filters
+ .iter()
+ .find(|filter| filter.enabled && filter.file_name == entry.file_name)
+ {
+ Some(filter) => {
+ entry.title = filter.title.clone();
+ entry.url = filter.url.to_string();
+ entry.group = filter.group;
+ true
+ }
+ None => false,
+ }
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use url::Url;
+
+ fn make_filter(file_name: &str, title: &str, enabled: bool) -> Filter {
+ Filter {
+ enabled,
+ title: title.to_string(),
+ group: FilterGroup::Ads,
+ file_name: file_name.to_string(),
+ url: Url::parse("https://example.com/list.txt").unwrap(),
+ }
+ }
+
+ #[test]
+ fn record_dedupes_by_file_name_and_bumps_count() {
+ let store = FilterFailureStore::new();
+ let filter = make_filter("abc.txt", "Some list", true);
+
+ store.record(&filter, "first error");
+ store.record(&filter, "second error");
+
+ let entries = store.entries();
+ assert_eq!(entries.len(), 1);
+ let entry = &entries[0];
+ assert_eq!(entry.file_name, "abc.txt");
+ assert_eq!(entry.count, 2);
+ assert_eq!(entry.last_error, "second error");
+ }
+
+ #[test]
+ fn record_moves_repeated_filter_to_front() {
+ let store = FilterFailureStore::new();
+ store.record(&make_filter("a.txt", "A", true), "error");
+ store.record(&make_filter("b.txt", "B", true), "error");
+ store.record(&make_filter("a.txt", "A", true), "error");
+
+ let file_names: Vec = store
+ .entries()
+ .into_iter()
+ .map(|entry| entry.file_name)
+ .collect();
+ assert_eq!(file_names, vec!["a.txt", "b.txt"]);
+ }
+
+ #[test]
+ fn clear_drops_entry() {
+ let store = FilterFailureStore::new();
+ store.record(&make_filter("a.txt", "A", true), "error");
+
+ store.clear("a.txt");
+ assert!(store.entries().is_empty());
+ }
+
+ #[test]
+ fn sync_with_filters_prunes_removed_and_disabled_filters() {
+ let store = FilterFailureStore::new();
+ store.record(&make_filter("removed.txt", "Removed", true), "error");
+ store.record(&make_filter("disabled.txt", "Disabled", true), "error");
+ store.record(&make_filter("kept.txt", "Old title", true), "error");
+
+ store.sync_with_filters(&[
+ make_filter("disabled.txt", "Disabled", false),
+ make_filter("kept.txt", "New title", true),
+ ]);
+
+ let entries = store.entries();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].file_name, "kept.txt");
+ // Metadata is refreshed from the configuration entry.
+ assert_eq!(entries[0].title, "New title");
+ }
+}
diff --git a/privaxy/src/server/configuration/mod.rs b/privaxy/src/server/configuration/mod.rs
index 99315ef0..123971fd 100644
--- a/privaxy/src/server/configuration/mod.rs
+++ b/privaxy/src/server/configuration/mod.rs
@@ -5,18 +5,21 @@ use tokio::fs;
mod auth;
mod ca;
mod filter;
+mod filter_failures;
mod network;
mod updater;
pub use auth::*;
pub use ca::*;
pub use filter::*;
-use futures::future::try_join_all;
+pub use filter_failures::*;
+use futures::future::join_all;
pub use network::*;
use std::env;
use std::path::{Path, PathBuf};
pub use updater::*;
use crate::proxy::exclusions::recommended_exclusions;
+use crate::proxy::tls_failures::TlsFailureStore;
pub(crate) type ConfigurationResult = Result;
pub(crate) const FILTERS_UPDATE_AFTER: Duration = Duration::from_secs(60 * 60 * 24); // 24h
@@ -55,6 +58,11 @@ pub enum ConfigurationError {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Configuration {
pub exclusions: BTreeSet,
+ /// Hosts the operator chose to hide from the TLS-failure report in the
+ /// web UI. Absent from configuration files written before this feature
+ /// existed, hence the serde default.
+ #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+ pub ignored_tls_failures: BTreeSet,
pub custom_filters: Vec,
pub ca: Ca,
pub network: NetworkConfig,
@@ -210,8 +218,26 @@ impl Configuration {
self.save().await?;
- local_exclusion_store
- .replace_exclusions(Vec::from_iter(self.exclusions.clone().into_iter()));
+ local_exclusion_store.replace_exclusions(Vec::from_iter(self.exclusions.clone()));
+
+ Ok(())
+ }
+
+ /// Persist `host` into the ignored TLS-failure list and update the
+ /// in-memory store immediately, so the report reflects the change without
+ /// a reload.
+ pub async fn ignore_tls_failure(
+ &mut self,
+ host: &str,
+ tls_failure_store: TlsFailureStore,
+ ) -> ConfigurationResult<()> {
+ let host = host.trim();
+
+ self.ignored_tls_failures.insert(host.to_string());
+
+ self.save().await?;
+
+ tls_failure_store.ignore(host);
Ok(())
}
@@ -238,23 +264,38 @@ impl Configuration {
self.filters.iter_mut().filter(|f| f.enabled)
}
+ /// Refresh every enabled filter from its URL. Per-filter failures are
+ /// expected here (remote lists go stale or move); they are recorded in
+ /// `filter_failure_store` for the web UI and logged, while the remaining
+ /// filters keep updating.
pub async fn update_filters(
&mut self,
http_client: reqwest::Client,
- ) -> ConfigurationResult<()> {
+ filter_failure_store: &FilterFailureStore,
+ ) {
log::debug!("Updating filters");
- let futures = self.filters.iter_mut().filter_map(|filter| {
- if filter.enabled {
- Some(filter.update(&http_client))
- } else {
- None
+ let futures = self
+ .filters
+ .iter_mut()
+ .filter(|filter| filter.enabled)
+ .map(|filter| {
+ let http_client = http_client.clone();
+ async move {
+ let result = filter.update(&http_client).await;
+ (filter, result)
+ }
+ });
+
+ for (filter, result) in join_all(futures).await {
+ match result {
+ Ok(_) => filter_failure_store.clear(&filter.file_name),
+ Err(err) => {
+ log::error!("Failed to update filter '{}': {err}", filter.title);
+ filter_failure_store.record(filter, &err.to_string());
+ }
}
- });
-
- try_join_all(futures).await?;
-
- Ok(())
+ }
}
pub async fn add_filter(
@@ -282,6 +323,30 @@ impl Configuration {
}
}
+ /// Replace the filter identified by `old_file_name` with `filter`,
+ /// keeping its enabled status. The replacement is validated the same way
+ /// an added filter is: its URL must serve a parseable filter list.
+ pub async fn replace_filter(
+ &mut self,
+ old_file_name: &str,
+ filter: &mut Filter,
+ http_client: &reqwest::Client,
+ ) -> ConfigurationResult<()> {
+ let index = self
+ .filters
+ .iter()
+ .position(|existing| existing.file_name == old_file_name)
+ .ok_or_else(|| {
+ ConfigurationError::FilterError(format!("no filter with file name {old_file_name}"))
+ })?;
+
+ filter.enabled = self.filters[index].enabled;
+ filter.update(http_client).await?;
+ self.filters[index] = filter.clone();
+
+ Ok(())
+ }
+
pub async fn set_network_settings(
&mut self,
network_config: &NetworkConfig,
@@ -335,6 +400,7 @@ impl Configuration {
tls_cert_path: None,
tls_key_path: None,
listen_url: None,
+ gui_url: None,
pac_enabled: false,
pac_proxy_host: None,
pac_direct_ips: Vec::new(),
@@ -347,6 +413,7 @@ impl Configuration {
.iter()
.map(|entry| entry.to_string()),
),
+ ignored_tls_failures: BTreeSet::new(),
custom_filters: Vec::new(),
auth: Auth::new_initialized(),
debug: DebugConfig::default(),
@@ -369,3 +436,71 @@ fn get_base_directory() -> ConfigurationResult {
false => Err(ConfigurationError::DirectoryNotFound),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A freshly generated default configuration must survive a
+ /// `to_string_pretty` -> `from_str` round-trip unchanged. This guards the
+ /// TOML (de)serialization behavior across the toml crate upgrade.
+ #[tokio::test]
+ async fn configuration_toml_round_trips() {
+ let configuration = Configuration::new_default()
+ .await
+ .expect("default configuration");
+
+ let serialized = toml::to_string_pretty(&configuration).expect("serialize");
+ let deserialized: Configuration = toml::from_str(&serialized).expect("deserialize");
+
+ assert_eq!(configuration, deserialized);
+ }
+
+ /// Configuration files written before the TLS-failure ignore list existed
+ /// carry no `ignored_tls_failures` key; they must keep parsing, yielding
+ /// an empty set (serde default). The empty set is also omitted on save so
+ /// untouched configurations stay byte-identical.
+ #[tokio::test]
+ async fn configuration_without_ignored_tls_failures_parses() {
+ let configuration = Configuration::new_default()
+ .await
+ .expect("default configuration");
+
+ let serialized = toml::to_string_pretty(&configuration).expect("serialize");
+ assert!(
+ !serialized.contains("ignored_tls_failures"),
+ "empty ignore set must be omitted from the serialized configuration"
+ );
+
+ let deserialized: Configuration = toml::from_str(&serialized).expect("deserialize");
+ assert!(deserialized.ignored_tls_failures.is_empty());
+ }
+
+ /// A populated ignore list survives a TOML round-trip unchanged.
+ #[tokio::test]
+ async fn configuration_round_trips_ignored_tls_failures() {
+ let mut configuration = Configuration::new_default()
+ .await
+ .expect("default configuration");
+ configuration.ignored_tls_failures = BTreeSet::from([
+ "pinned.example.com".to_string(),
+ "other.example.org".to_string(),
+ ]);
+
+ let serialized = toml::to_string_pretty(&configuration).expect("serialize");
+ let deserialized: Configuration = toml::from_str(&serialized).expect("deserialize");
+
+ assert_eq!(configuration, deserialized);
+ }
+
+ /// Blank lines and surrounding whitespace are stripped when turning the
+ /// textarea contents into the stored list.
+ #[test]
+ fn deserialize_lines_trims_and_drops_blanks() {
+ let parsed: Vec = Configuration::deserialize_lines(" a \n\n b\n \nc\n");
+ assert_eq!(
+ parsed,
+ vec!["a".to_string(), "b".to_string(), "c".to_string()]
+ );
+ }
+}
diff --git a/privaxy/src/server/configuration/network.rs b/privaxy/src/server/configuration/network.rs
index 41334e2c..72c31f02 100644
--- a/privaxy/src/server/configuration/network.rs
+++ b/privaxy/src/server/configuration/network.rs
@@ -48,6 +48,13 @@ pub struct NetworkConfig {
/// URL to listen on. Only used when TLS is enabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub listen_url: Option,
+ /// Full base URL of the web GUI as reachable by proxied clients
+ /// (e.g. `http://proxy.example.lan` when fronted by a reverse proxy).
+ /// Used verbatim for links back to the GUI on proxy error pages,
+ /// winning over `listen_url` and any bound/dialed address. Include a
+ /// port if the GUI is not on the URL scheme's default port.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub gui_url: Option,
/// Serve a proxy auto-config (PAC) file at `/proxy.pac`.
#[serde(default)]
pub pac_enabled: bool,
@@ -122,6 +129,8 @@ pub enum NetworkConfigError {
WebPortError(String),
#[error("port collision: {0}")]
PortCollisionError(String),
+ #[error("GUI URL error: {0}")]
+ GuiUrlError(String),
#[error("failed to read TLS certificate: {0}")]
TlsCertError(String),
#[error("failed to read TLS certificate key: {0}")]
@@ -159,6 +168,15 @@ impl NetworkConfig {
)
.into());
};
+ if let Some(gui_url) = &self.gui_url {
+ let trimmed = gui_url.trim();
+ if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
+ return Err(NetworkConfigError::GuiUrlError(
+ "GUI URL must start with http:// or https://".to_string(),
+ )
+ .into());
+ }
+ };
Ok(())
}
@@ -251,7 +269,7 @@ impl NetworkConfig {
Ok(cert) => Ok(cert),
Err(err) => {
log::error!("Failed to read TLS certificate: {err}");
- return Err(err);
+ Err(err)
}
}
}
@@ -298,7 +316,7 @@ impl NetworkConfig {
};
let renew_threshold = Asn1Time::days_from_now(30)?;
- let needs_renewal = cert.not_after() <= &renew_threshold;
+ let needs_renewal = cert.not_after() <= renew_threshold;
if !needs_renewal {
return Ok(cert);
}
@@ -382,6 +400,7 @@ fn build_ca_signed_cert(
// patch NotValidBefore
// try_into() coerces to the platform's time_t (i64 on 64-bit targets,
// i32 on 32-bit MIPS glibc) instead of a hardcoded cast.
+ #[allow(clippy::useless_conversion)]
Asn1Time::from_unix((since_epoch.as_secs() as i64 - 60).try_into().unwrap()).unwrap()
};
cert_builder.set_not_before(¬_before).unwrap();
@@ -405,7 +424,7 @@ fn build_ca_signed_cert(
)
.unwrap();
let subject_alternative_name = SubjectAlternativeName::new()
- .ip(bind_addr.as_str().into())
+ .ip(bind_addr.as_str())
.build(&cert_builder.x509v3_context(Some(ca_cert), None))
.unwrap();
diff --git a/privaxy/src/server/configuration/updater.rs b/privaxy/src/server/configuration/updater.rs
index a715dc9d..4d2428a9 100644
--- a/privaxy/src/server/configuration/updater.rs
+++ b/privaxy/src/server/configuration/updater.rs
@@ -1,3 +1,4 @@
+use super::FilterFailureStore;
use crate::blocker::AdblockRequester;
use futures::future::{AbortHandle, Abortable};
@@ -10,6 +11,7 @@ pub struct ConfigurationUpdater {
pub tx: Sender,
http_client: reqwest::Client,
adblock_requester: AdblockRequester,
+ filter_failure_store: FilterFailureStore,
}
impl ConfigurationUpdater {
@@ -17,6 +19,7 @@ impl ConfigurationUpdater {
configuration: super::Configuration,
http_client: reqwest::Client,
adblock_requester: AdblockRequester,
+ filter_failure_store: FilterFailureStore,
tx_rx: Option<(
sync::mpsc::Sender,
sync::mpsc::Receiver,
@@ -31,6 +34,7 @@ impl ConfigurationUpdater {
let http_client_clone = http_client.clone();
let adblock_requester_clone = adblock_requester.clone();
+ let filter_failure_store_clone = filter_failure_store.clone();
let filters_updater = Abortable::new(
async move {
@@ -38,6 +42,7 @@ impl ConfigurationUpdater {
configuration,
adblock_requester_clone,
http_client_clone.clone(),
+ filter_failure_store_clone,
)
.await
},
@@ -52,10 +57,11 @@ impl ConfigurationUpdater {
tx,
http_client,
adblock_requester,
+ filter_failure_store,
}
}
- pub(crate) fn start(mut self: Self) {
+ pub(crate) fn start(mut self) {
tokio::spawn(async move {
loop {
let mut configuration = self.rx.recv().await.unwrap();
@@ -64,12 +70,22 @@ impl ConfigurationUpdater {
// clone and racing to replace the engine on its own timer.
self.filters_updater_abort_handle.abort();
- let filters =
- super::filter::get_filters_content(&mut configuration, &self.http_client).await;
+ // Failure entries for filters that were removed or disabled
+ // by this configuration change are no longer actionable.
+ self.filter_failure_store
+ .sync_with_filters(&configuration.filters);
+
+ let filters = super::filter::get_filters_content(
+ &mut configuration,
+ &self.http_client,
+ &self.filter_failure_store,
+ )
+ .await;
self.adblock_requester.replace_engine(filters).await;
let adblock_requester_clone = self.adblock_requester.clone();
let http_client_clone = self.http_client.clone();
+ let filter_failure_store_clone = self.filter_failure_store.clone();
let (abort_handle, abort_registration) = AbortHandle::new_pair();
self.filters_updater_abort_handle = abort_handle;
@@ -80,6 +96,7 @@ impl ConfigurationUpdater {
configuration,
adblock_requester_clone,
http_client_clone,
+ filter_failure_store_clone,
)
.await;
},
@@ -95,18 +112,23 @@ impl ConfigurationUpdater {
mut configuration: super::Configuration,
adblock_requester: AdblockRequester,
http_client: reqwest::Client,
+ filter_failure_store: FilterFailureStore,
) {
loop {
tokio::time::sleep(super::FILTERS_UPDATE_AFTER).await;
- if let Err(err) = configuration.update_filters(http_client.clone()).await {
- log::error!("An error occured while trying to update filters: {:?}", err);
- }
+ configuration
+ .update_filters(http_client.clone(), &filter_failure_store)
+ .await;
// We don't bother diffing the filters as replacing the engine is very cheap and
// filters are not updated often enough that the cost would matter.
- let filters =
- super::filter::get_filters_content(&mut configuration, &http_client).await;
+ let filters = super::filter::get_filters_content(
+ &mut configuration,
+ &http_client,
+ &filter_failure_store,
+ )
+ .await;
adblock_requester.replace_engine(filters).await;
log::info!("Updated filters");
diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs
index 9a87fe86..91b1f353 100644
--- a/privaxy/src/server/lib.rs
+++ b/privaxy/src/server/lib.rs
@@ -1,14 +1,17 @@
use crate::blocker::AdblockRequester;
-use crate::configuration::NetworkConfig;
+use crate::configuration::{FilterFailureStore, NetworkConfig};
use crate::proxy::exclusions::LocalExclusionStore;
use crate::web_gui::events::Event;
-use hyper::server::conn::AddrStream;
-use hyper::service::{make_service_fn, service_fn};
-use hyper::{Client, Server};
+use hyper::service::service_fn;
+use hyper_util::client::legacy::Client;
+use hyper_util::rt::{TokioExecutor, TokioIo};
+use hyper_util::server::conn::auto;
+use hyper_util::server::graceful::GracefulShutdown;
use include_dir::{include_dir, Dir};
use proxy::exclusions;
+use proxy::serve::UpgradeClient;
+use proxy::tls_failures::TlsFailureStore;
use reqwest::redirect::Policy;
-use std::convert::Infallible;
use std::env;
use std::net::IpAddr;
use std::net::SocketAddr;
@@ -16,6 +19,7 @@ use std::str::FromStr;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
+use tokio::net::TcpListener;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::broadcast;
use tokio::sync::Notify;
@@ -32,6 +36,53 @@ mod web_gui;
pub const WEBAPP_FRONTEND_DIR: Dir<'_> = include_dir!("web_frontend/dist");
+/// Custom `getrandom` backend for the `mips{,el}-unknown-linux-gnu` cross
+/// targets.
+///
+/// getrandom 0.3's default Linux backend for these targets calls the libc
+/// `getrandom()` wrapper, which the cross-toolchain's pre-2.25 glibc does not
+/// export — so the binary fails to link (`undefined reference to getrandom`).
+/// We point getrandom at its `custom` backend for these triples (see
+/// `.cargo/config.toml`) and service it here with the raw `SYS_getrandom`
+/// syscall. The syscall is independent of the libc version, so the resulting
+/// binary still runs on the old-glibc routers we target; pre-3.17 kernels that
+/// lack the syscall fall back to `/dev/urandom`, matching getrandom 0.2's old
+/// behavior. musl MIPS and every non-MIPS target keep getrandom's stock
+/// backend and never reach this code.
+#[cfg(all(target_os = "linux", target_arch = "mips", target_env = "gnu"))]
+#[no_mangle]
+unsafe extern "Rust" fn __getrandom_v03_custom(
+ dest: *mut u8,
+ len: usize,
+) -> Result<(), getrandom::Error> {
+ let mut filled = 0usize;
+ while filled < len {
+ let ret = libc::syscall(libc::SYS_getrandom, dest.add(filled), len - filled, 0u32);
+ if ret < 0 {
+ match std::io::Error::last_os_error().raw_os_error() {
+ // Interrupted before any bytes were read; retry.
+ Some(libc::EINTR) => continue,
+ // Kernel predates getrandom(2) (pre-3.17): use /dev/urandom.
+ Some(libc::ENOSYS) => return urandom_fallback(dest, len),
+ _ => return Err(getrandom::Error::UNEXPECTED),
+ }
+ }
+ filled += ret as usize;
+ }
+ Ok(())
+}
+
+#[cfg(all(target_os = "linux", target_arch = "mips", target_env = "gnu"))]
+unsafe fn urandom_fallback(dest: *mut u8, len: usize) -> Result<(), getrandom::Error> {
+ use std::io::Read;
+
+ let buf = std::slice::from_raw_parts_mut(dest, len);
+ let mut file = std::fs::File::open("/dev/urandom").map_err(|_| getrandom::Error::UNEXPECTED)?;
+ file.read_exact(buf)
+ .map_err(|_| getrandom::Error::UNEXPECTED)?;
+ Ok(())
+}
+
#[derive(Debug)]
pub struct PrivaxyServer {
pub ca_certificate_pem: String,
@@ -40,6 +91,8 @@ pub struct PrivaxyServer {
pub blocking_disabled_store: blocker::BlockingDisabledStore,
pub statistics: statistics::Statistics,
pub local_exclusion_store: exclusions::LocalExclusionStore,
+ pub tls_failure_store: TlsFailureStore,
+ pub filter_failure_store: FilterFailureStore,
// A Sender is required to subscribe to broadcasted messages
pub requests_broadcast_sender: broadcast::Sender,
}
@@ -79,6 +132,13 @@ async fn handle_signals() -> (Arc, Arc) {
}
pub async fn start_privaxy() -> PrivaxyServer {
+ // rustls 0.23 no longer bakes in a crypto provider: a process-wide default
+ // must be installed before any TLS config is built (the proxy's per-host
+ // certs, the upstream HTTPS connector, the reqwest client, and the web GUI
+ // TLS listener all rely on it). We pin the `ring` provider so the tier-3
+ // MIPS/musl cross builds keep working (aws-lc-rs needs a C toolchain).
+ let _ = rustls::crypto::ring::default_provider().install_default();
+
// Install the global logger first so every subsequent record is both
// written to stderr and made available to the `/api/logs` stream. The
// configured level is applied once the configuration is read below.
@@ -101,7 +161,6 @@ pub async fn start_privaxy() -> PrivaxyServer {
// additionally fails fast on unreachable hosts.
.connect_timeout(Duration::from_secs(10))
.pool_idle_timeout(Duration::from_secs(30))
- .tcp_keepalive(Duration::from_secs(60))
// h2-heavy origins multiplex every subresource over a
// single connection whose flow-control window defaults to a small,
// shared 64 KB. When we drain one stream's body slowly (the browser
@@ -131,7 +190,7 @@ pub async fn start_privaxy() -> PrivaxyServer {
log_handle.set_level(configuration.debug.log_level.to_level_filter());
let local_exclusion_store =
- LocalExclusionStore::new(Vec::from_iter(configuration.exclusions.clone().into_iter()));
+ LocalExclusionStore::new(Vec::from_iter(configuration.exclusions.clone()));
let local_exclusion_store_clone = local_exclusion_store.clone();
let ca_certificate = match configuration.ca.get_ca_certificate().await {
@@ -157,6 +216,12 @@ pub async fn start_privaxy() -> PrivaxyServer {
let statistics = statistics::Statistics::new();
let statistics_clone = statistics.clone();
+ let tls_failure_store = TlsFailureStore::new(configuration.ignored_tls_failures.clone());
+ let tls_failure_store_clone = tls_failure_store.clone();
+
+ let filter_failure_store = FilterFailureStore::new();
+ let filter_failure_store_clone = filter_failure_store.clone();
+
let (broadcast_tx, _broadcast_rx) = broadcast::channel(32);
let broadcast_tx_clone = broadcast_tx.clone();
@@ -173,6 +238,7 @@ pub async fn start_privaxy() -> PrivaxyServer {
configuration.clone(),
client.clone(),
blocker_requester.clone(),
+ filter_failure_store.clone(),
None,
)
.await;
@@ -188,6 +254,8 @@ pub async fn start_privaxy() -> PrivaxyServer {
let block_disable_ref = blocking_disabled_store.clone();
let local_exclusion_store_ref = local_exclusion_store.clone();
+ let tls_failure_store_ref = tls_failure_store.clone();
+ let filter_failure_store_ref = filter_failure_store.clone();
let stats_clone = statistics.clone();
let configuration_updater_tx_ref = configuration_updater_tx.clone();
let configuration_save_lock_ref = configuration_save_lock.clone();
@@ -203,6 +271,8 @@ pub async fn start_privaxy() -> PrivaxyServer {
privaxy_frontend(
broadcast_tx_ref.clone(),
local_exclusion_store_ref.clone(),
+ tls_failure_store_ref.clone(),
+ filter_failure_store_ref.clone(),
stats_clone.clone(),
block_disable_ref.clone(),
configuration_updater_tx_ref.clone(),
@@ -230,6 +300,7 @@ pub async fn start_privaxy() -> PrivaxyServer {
tokio::spawn(async move {
let notify_reload_backend = notify_reload_clone.clone();
let cfg_lock_backend = configuration_save_lock_ref.clone();
+ let mut local_exclusion_store = local_exclusion_store;
let mut rt_cert_cache =
cert::CertCache::new(ca_certificate.clone(), ca_private_key.clone());
let mut rt_ca_certificate = ca_certificate;
@@ -242,11 +313,19 @@ pub async fn start_privaxy() -> PrivaxyServer {
broadcast_tx.clone(),
statistics.clone(),
local_exclusion_store.clone(),
+ tls_failure_store.clone(),
cfg_lock_backend.clone(),
notify_reload_backend.clone(),
)
.await;
let cfg = read_configuration(&cfg_lock_backend).await;
+ // The exclusion store is only otherwise mutated by the web UI
+ // route; without this refresh, exclusions edited in the
+ // configuration file never take effect on SIGHUP reload.
+ local_exclusion_store.replace_exclusions(Vec::from_iter(cfg.exclusions.clone()));
+ // Same for the TLS-failure ignore set: pick up hand-edited
+ // `ignored_tls_failures` entries on SIGHUP reload.
+ tls_failure_store.set_ignored(cfg.ignored_tls_failures.clone());
let ca_cert = cfg.ca.get_ca_certificate().await.unwrap();
let ca_key = cfg.ca.get_ca_private_key().await.unwrap();
if !ca_key.public_eq(&rt_ca_certificate.public_key().unwrap()) {
@@ -262,13 +341,18 @@ pub async fn start_privaxy() -> PrivaxyServer {
blocking_disabled_store: blocking_disabled_store_clone,
statistics: statistics_clone,
local_exclusion_store: local_exclusion_store_clone,
+ tls_failure_store: tls_failure_store_clone,
+ filter_failure_store: filter_failure_store_clone,
requests_broadcast_sender: broadcast_tx_clone,
}
}
+#[allow(clippy::too_many_arguments)]
async fn privaxy_frontend(
broadcast_tx: tokio::sync::broadcast::Sender,
local_exclusion_store: LocalExclusionStore,
+ tls_failure_store: TlsFailureStore,
+ filter_failure_store: FilterFailureStore,
statistics: statistics::Statistics,
block_disable_ref: blocker::BlockingDisabledStore,
configuration_updater_tx: tokio::sync::mpsc::Sender,
@@ -283,10 +367,11 @@ async fn privaxy_frontend(
&configuration_updater_tx,
&configuration_save_lock,
&local_exclusion_store,
+ &tls_failure_store,
+ &filter_failure_store,
notify_reload.clone(),
log_handle.clone(),
);
- let frontend_server = warp::serve(frontend);
let config = read_configuration(&configuration_save_lock).await;
let ip = env_or_config_ip(&config.network).await;
let web_api_server_addr = SocketAddr::from((ip, config.network.web_port));
@@ -311,32 +396,135 @@ async fn privaxy_frontend(
panic!("Failed to read or create TLS key: {err}");
}
};
+ let server_config = web_tls_server_config(&tls_cert, &tls_key);
tokio::spawn(async move {
- let (_, task) = frontend_server
- .tls()
- .cert(tls_cert.to_pem().unwrap())
- .key(tls_key.private_key_to_pem_pkcs8().unwrap())
- .bind_with_graceful_shutdown(web_api_server_addr, async move {
+ serve_frontend(
+ frontend,
+ web_api_server_addr,
+ Some(server_config),
+ async move {
notify_reload.clone().notified().await;
- });
- log::info!("Web server available at https://{web_api_server_addr}/");
- log::info!("API server available at https://{web_api_server_addr}/api");
-
- task.await;
+ },
+ )
+ .await;
});
} else {
tokio::spawn(async move {
- let (_, task) =
- frontend_server.bind_with_graceful_shutdown(web_api_server_addr, async move {
- let _ = notify_reload.clone().notified().await;
- });
- log::info!("Web server available at http://{web_api_server_addr}/");
- log::info!("API server available at http://{web_api_server_addr}/api");
- task.await
+ serve_frontend(frontend, web_api_server_addr, None, async move {
+ let _ = notify_reload.clone().notified().await;
+ })
+ .await;
});
}
}
+/// Build a rustls `ServerConfig` for the web GUI from the OpenSSL-generated
+/// TLS leaf certificate and key. warp 0.4 dropped its built-in `.tls()`
+/// support, so the HTTPS web GUI now terminates TLS via `tokio-rustls` and is
+/// served through hyper-util (see `serve_frontend_tls`).
+fn web_tls_server_config(
+ cert: &openssl::x509::X509,
+ key: &openssl::pkey::PKeyRef,
+) -> rustls::ServerConfig {
+ use rustls::pki_types::{CertificateDer, PrivateKeyDer};
+
+ let certs = vec![CertificateDer::from(cert.to_der().unwrap())];
+ let key = PrivateKeyDer::try_from(key.private_key_to_der().unwrap()).unwrap();
+ rustls::ServerConfig::builder()
+ .with_no_client_auth()
+ .with_single_cert(certs, key)
+ .expect("valid web GUI TLS certificate/key")
+}
+
+/// Serve the warp frontend filter, optionally over TLS. warp 0.4 dropped both
+/// its built-in `.tls()` support and the high-level graceful-shutdown server,
+/// so we accept connections ourselves, optionally terminate TLS with
+/// `tokio-rustls`, and drive each connection with hyper-util's auto builder
+/// (HTTP/1+2, with upgrade support so the WebSocket-based live feeds keep
+/// working).
+async fn serve_frontend(
+ frontend: F,
+ addr: SocketAddr,
+ tls_config: Option,
+ shutdown: S,
+) where
+ F: warp::Filter + Clone + Send + Sync + 'static,
+ F::Extract: warp::reply::Reply,
+ S: std::future::Future