diff --git a/.sqlx/query-177b2b38a47b77f7eac5c0a1feb5715130ef0fc8a401ffe1dc7991abf3d025ba.json b/.sqlx/query-177b2b38a47b77f7eac5c0a1feb5715130ef0fc8a401ffe1dc7991abf3d025ba.json new file mode 100644 index 0000000000..dff5996737 --- /dev/null +++ b/.sqlx/query-177b2b38a47b77f7eac5c0a1feb5715130ef0fc8a401ffe1dc7991abf3d025ba.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"proxy\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "177b2b38a47b77f7eac5c0a1feb5715130ef0fc8a401ffe1dc7991abf3d025ba" +} diff --git a/.sqlx/query-57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a.json b/.sqlx/query-57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a.json new file mode 100644 index 0000000000..1727fc222a --- /dev/null +++ b/.sqlx/query-57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\" FROM \"proxy\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "port", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "public_address", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "connected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 6, + "name": "disconnected_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a" +} diff --git a/.sqlx/query-7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257.json b/.sqlx/query-7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257.json new file mode 100644 index 0000000000..108c81e565 --- /dev/null +++ b/.sqlx/query-7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"proxy\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"public_address\" = $5,\"connected_at\" = $6,\"disconnected_at\" = $7 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Int4", + "Text", + "Timestamp", + "Timestamp" + ] + }, + "nullable": [] + }, + "hash": "7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257" +} diff --git a/.sqlx/query-a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8.json b/.sqlx/query-a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8.json new file mode 100644 index 0000000000..e736156836 --- /dev/null +++ b/.sqlx/query-a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"proxy\" (\"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\") VALUES ($1,$2,$3,$4,$5,$6) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4", + "Text", + "Timestamp", + "Timestamp" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8" +} diff --git a/.sqlx/query-acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04.json b/.sqlx/query-acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04.json new file mode 100644 index 0000000000..305f3f71fa --- /dev/null +++ b/.sqlx/query-acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\" FROM \"proxy\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "port", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "public_address", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "connected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 6, + "name": "disconnected_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04" +} diff --git a/Cargo.lock b/Cargo.lock index ce1a729fa3..1b407a520e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -804,8 +804,10 @@ checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "aes-gcm", "base64 0.22.1", + "hkdf", "percent-encoding", "rand 0.8.5", + "sha2", "subtle", "time", "version_check", @@ -1328,6 +1330,7 @@ name = "defguard_proxy_manager" version = "0.0.0" dependencies = [ "axum", + "axum-extra", "defguard_certs", "defguard_common", "defguard_core", @@ -1336,6 +1339,7 @@ dependencies = [ "defguard_version", "openidconnect", "reqwest", + "secrecy", "semver", "sqlx", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index e2a2d38e23..5d2ad055ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ axum = "0.8" axum-client-ip = "0.7" axum-extra = { version = "0.12", features = [ "cookie-private", + "cookie-key-expansion", "typed-header", "query", ] } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 4f3b12a635..2fdc572297 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -40,7 +40,7 @@ use defguard_core::{ use defguard_event_logger::{message::EventLoggerMessage, run_event_logger}; use defguard_event_router::{RouterReceiverSet, run_event_router}; use defguard_mail::{Mail, run_mail_handler}; -use defguard_proxy_manager::{ProxyOrchestrator, ProxyTxSet}; +use defguard_proxy_manager::{ProxyManager, ProxyTxSet}; // use defguard_session_manager::run_session_manager; use secrecy::ExposeSecret; use tokio::sync::{broadcast, mpsc::unbounded_channel}; @@ -175,12 +175,12 @@ async fn main() -> Result<(), anyhow::Error> { } let proxy_tx = ProxyTxSet::new(wireguard_tx.clone(), mail_tx.clone(), bidi_event_tx.clone()); - let proxy_orchestrator = - ProxyOrchestrator::new(pool.clone(), proxy_tx, Arc::clone(&incompatible_components)); + let proxy_manager = + ProxyManager::new(pool.clone(), proxy_tx, Arc::clone(&incompatible_components)); // run services tokio::select! { - res = proxy_orchestrator.run(&config.proxy_url) => error!("ProxyOrchestrator returned early: {res:?}"), + res = proxy_manager.run() => error!("ProxyManager returned early: {res:?}"), res = run_grpc_gateway_stream( pool.clone(), client_state, diff --git a/crates/defguard_common/src/db/models/mod.rs b/crates/defguard_common/src/db/models/mod.rs index 9908ea120e..107cfe3147 100644 --- a/crates/defguard_common/src/db/models/mod.rs +++ b/crates/defguard_common/src/db/models/mod.rs @@ -11,6 +11,7 @@ pub mod oauth2authorizedapp; pub mod oauth2client; pub mod oauth2token; pub mod polling_token; +pub mod proxy; pub mod session; pub mod settings; pub mod user; diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs new file mode 100644 index 0000000000..b95e126a1a --- /dev/null +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -0,0 +1,15 @@ +use chrono::NaiveDateTime; +use model_derive::Model; + +use crate::db::{Id, NoId}; + +#[derive(Model)] +pub struct Proxy { + pub id: I, + pub name: String, + pub address: String, + pub port: i32, + pub public_address: String, + pub connected_at: Option, + pub disconnected_at: Option, +} diff --git a/crates/defguard_proxy_manager/Cargo.toml b/crates/defguard_proxy_manager/Cargo.toml index 1950480789..eec4a56c65 100644 --- a/crates/defguard_proxy_manager/Cargo.toml +++ b/crates/defguard_proxy_manager/Cargo.toml @@ -21,6 +21,8 @@ semver.workspace = true tokio-stream.workspace = true axum.workspace = true +axum-extra.workspace = true +secrecy.workspace = true sqlx.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index 1ca2649c3c..ea4ffef294 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -5,8 +5,16 @@ use std::{ time::Duration, }; +use axum_extra::extract::cookie::Key; use defguard_certs::der_to_pem; -use defguard_common::{VERSION, config::server_config, db::models::Settings}; +use defguard_common::{ + VERSION, + config::server_config, + db::{ + Id, + models::{Settings, proxy::Proxy}, + }, +}; use defguard_core::{ db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}, enrollment_management::clear_unused_enrollment_tokens, @@ -35,6 +43,7 @@ use defguard_version::{ }; use openidconnect::{AuthorizationCode, Nonce, Scope, core::CoreAuthenticationFlow, url}; use reqwest::Url; +use secrecy::ExposeSecret; use semver::Version; use sqlx::PgPool; use thiserror::Error; @@ -49,6 +58,7 @@ use tokio::{ use tokio_stream::wrappers::UnboundedReceiverStream; use tonic::{ Code, Streaming, + metadata::MetadataValue, transport::{Certificate, ClientTlsConfig, Endpoint}, }; @@ -63,6 +73,7 @@ extern crate tracing; const TEN_SECS: Duration = Duration::from_secs(10); const PROXY_AFTER_SETUP_CONNECT_DELAY: Duration = Duration::from_secs(1); static VERSION_ZERO: Version = Version::new(0, 0, 0); +static COOKIE_KEY_HEADER: &str = "dg-cookie-key-bin"; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub(crate) enum Scheme { @@ -168,14 +179,14 @@ impl ProxyRouter { /// - instantiating and supervising proxy connections, /// - routing responses to the appropriate proxy based on correlation state, /// - providing shared infrastructure (database access, outbound channels), -pub struct ProxyOrchestrator { +pub struct ProxyManager { pool: PgPool, tx: ProxyTxSet, incompatible_components: Arc>, router: Arc>, } -impl ProxyOrchestrator { +impl ProxyManager { pub fn new( pool: PgPool, tx: ProxyTxSet, @@ -193,20 +204,43 @@ impl ProxyOrchestrator { /// /// Each proxy runs in its own task and shares Core-side infrastructure /// such as routing state and compatibility tracking. - pub async fn run(self, url: &Option) -> Result<(), ProxyError> { - // TODO retrieve proxies from db - let Some(url) = url else { + pub async fn run(self) -> Result<(), ProxyError> { + debug!("ProxyManager starting"); + // Retrieve proxies from DB. + let mut proxies: Vec = Proxy::all(&self.pool) + .await? + .iter() + .map(|proxy| { + ProxyServer::from_proxy( + proxy, + self.pool.clone(), + &self.tx, + Arc::clone(&self.router), + ) + }) + .collect::>()?; + debug!("Retrieved {} proxies from the DB", proxies.len()); + + // For backwards compatibility add the proxy specified in cli arg as well. + if let Some(ref url) = server_config().proxy_url { + debug!("Adding proxy from cli arg: {url}"); + let url = Url::from_str(url)?; + let proxy = + ProxyServer::new(self.pool.clone(), url, &self.tx, Arc::clone(&self.router)); + proxies.push(proxy); + } + + // TODO setup a channel to allow dynamic proxy connections + if proxies.is_empty() { + debug!("No proxies to connect to, waiting for changes"); tokio::time::sleep(Duration::MAX).await; return Ok(()); - }; - let proxies = vec![Proxy::new( - self.pool.clone(), - Url::from_str(url)?, - &self.tx, - Arc::clone(&self.router), - )]; + } + + // Connect to all proxies. let mut tasks = JoinSet::>::new(); for proxy in proxies { + debug!("Spawning proxy task for proxy {}", proxy.url); tasks.spawn(proxy.run(self.tx.clone(), self.incompatible_components.clone())); } while let Some(result) = tasks.join_next().await { @@ -250,18 +284,18 @@ impl ProxyTxSet { /// bidirectional stream to one proxy instance, handling incoming requests /// from that proxy, and forwarding responses back through the same stream. /// Each `Proxy` runs independently and is supervised by the -/// `ProxyOrchestrator`. -struct Proxy { +/// `ProxyManager`. +struct ProxyServer { pool: PgPool, /// gRPC servers services: ProxyServices, - /// Router shared between proxies and the orchestrator + /// Router shared between proxies and the proxy manager router: Arc>, /// Proxy server gRPC URL url: Url, } -impl Proxy { +impl ProxyServer { pub fn new(pool: PgPool, url: Url, tx: &ProxyTxSet, router: Arc>) -> Self { // Instantiate gRPC servers. let services = ProxyServices::new(&pool, tx); @@ -274,6 +308,16 @@ impl Proxy { } } + fn from_proxy( + proxy: &Proxy, + pool: PgPool, + tx: &ProxyTxSet, + router: Arc>, + ) -> Result { + let url = Url::from_str(&format!("http://{}:{}", proxy.address, proxy.port))?; + Ok(Self::new(pool, url, tx, router)) + } + fn endpoint(&self, scheme: Scheme) -> Result { let mut url = self.url.clone(); @@ -332,7 +376,16 @@ impl Proxy { let interceptor = ClientVersionInterceptor::new(Version::parse(VERSION)?); let mut client = ProxyClient::with_interceptor(endpoint.connect_lazy(), interceptor); let (tx, rx) = mpsc::unbounded_channel(); - let response = match client.bidi(UnboundedReceiverStream::new(rx)).await { + let mut request = tonic::Request::new(UnboundedReceiverStream::new(rx)); + let config = server_config(); + + // Derive proxy cookie key from core secret to avoid transmitting it. + let proxy_cookie_key = Key::derive_from(config.secret_key.expose_secret().as_bytes()); + request.metadata_mut().insert_bin( + COOKIE_KEY_HEADER, + MetadataValue::from_bytes(proxy_cookie_key.master()), + ); + let response = match client.bidi(request).await { Ok(response) => response, Err(err) => { match err.code() { diff --git a/migrations/20260113114719_proxy_management.down.sql b/migrations/20260113114719_proxy_management.down.sql new file mode 100644 index 0000000000..06d501493f --- /dev/null +++ b/migrations/20260113114719_proxy_management.down.sql @@ -0,0 +1 @@ +DROP TABLE proxy; diff --git a/migrations/20260113114719_proxy_management.up.sql b/migrations/20260113114719_proxy_management.up.sql new file mode 100644 index 0000000000..c82036abf8 --- /dev/null +++ b/migrations/20260113114719_proxy_management.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE proxy ( + id bigserial PRIMARY KEY, + name text NOT NULL, + address text NOT NULL, + port integer NOT NULL, + public_address text NOT NULL, + connected_at timestamp without time zone NULL, + disconnected_at timestamp without time zone NULL +);