From 0ee24cea9c27dc2bc4effa54be2084d2561c5e57 Mon Sep 17 00:00:00 2001 From: Dylann Batisse Date: Thu, 9 Apr 2026 20:03:45 +0200 Subject: [PATCH] feat(server): support Gluetun control server API key auth --- README.md | 8 +- docker-compose.yml | 6 +- rustatio-server/src/api/routes/network.rs | 13 ++- rustatio-server/src/services/gluetun.rs | 106 ++++++++++++++++++ rustatio-server/src/services/mod.rs | 2 + rustatio-server/src/services/vpn_port_sync.rs | 19 ++-- 6 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 rustatio-server/src/services/gluetun.rs diff --git a/README.md b/README.md index bdd5148..6c2be52 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,8 @@ services: - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} - SERVER_COUNTRIES=${SERVER_COUNTRIES:-Switzerland} # Gluetun control server auth (v3.39.1+ defaults to private routes) - # If you want to have your network status available you have to disable the gluetun auth - # Since the control server is only reachable within the container network namespace it is safe - - HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE={"auth":"none"} + # Generate a key with: docker run --rm qmcgaw/gluetun genkey + - HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE={"auth":"apikey","apikey":"${GLUETUN_API_KEY:-CHANGE_ME}"} ports: - "${WEBUI_PORT:-8080}:8080" # Rustatio Web UI restart: unless-stopped @@ -252,6 +251,7 @@ services: - PORT=8080 - RUST_LOG=${RUST_LOG:-trace} - VPN_PORT_SYNC=${VPN_PORT_SYNC:-on} + - GLUETUN_CONTROL_SERVER_API_KEY=${GLUETUN_API_KEY:-CHANGE_ME} - PUID=${PUID:-1000} - PGID=${PGID:-1000} # Optional authentication for your server (Recommended if exposing on internet) @@ -270,6 +270,8 @@ volumes: rustatio_data: ``` +Rustatio reads the Gluetun API key from `GLUETUN_CONTROL_SERVER_API_KEY` and sends it as the `X-API-Key` header for control server requests. This example enables Gluetun auth by default with the `CHANGE_ME` placeholder, so replace it with a real key before running the stack. + > **Note**: The `ports` are defined on the `gluetun` container since Rustatio uses its network stack. See the [gluetun wiki](https://github.com/qdm12/gluetun-wiki) for VPN provider-specific configuration. > If you change `PORT` from `8080`, update the published port on the `gluetun` service to the same internal port. diff --git a/docker-compose.yml b/docker-compose.yml index 381f8ae..42b6699 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,8 @@ services: - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} - SERVER_COUNTRIES=${SERVER_COUNTRIES:-Switzerland} # Gluetun control server auth (v3.39.1+ defaults to private routes) - # If you want to have your network status available you have to disable the gluetun auth - # Since the control server is only reachable within the container network namespace it is safe - - HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE={"auth":"none"} + # Generate a key with: docker run --rm qmcgaw/gluetun genkey + - HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE={"auth":"apikey","apikey":"${GLUETUN_API_KEY:-CHANGE_ME}"} ports: - "${WEBUI_PORT:-8080}:8080" # Rustatio Web UI restart: unless-stopped @@ -28,6 +27,7 @@ services: - PORT=8080 - RUST_LOG=${RUST_LOG:-trace} - VPN_PORT_SYNC=${VPN_PORT_SYNC:-on} # must be enabled alongisde with gluetun VPN_PORT_FORWARDING + - GLUETUN_CONTROL_SERVER_API_KEY=${GLUETUN_API_KEY:-CHANGE_ME} - PUID=${PUID:-1000} - PGID=${PGID:-1000} # Optional authentication for your server (Recommended if exposing on internet) diff --git a/rustatio-server/src/api/routes/network.rs b/rustatio-server/src/api/routes/network.rs index b70ed89..e7b11dd 100644 --- a/rustatio-server/src/api/routes/network.rs +++ b/rustatio-server/src/api/routes/network.rs @@ -8,6 +8,7 @@ use crate::api::{ common::{ApiError, ApiSuccess}, ServerState, }; +use crate::services::GluetunAuth; #[derive(Serialize, ToSchema)] pub struct NetworkStatus { @@ -55,6 +56,7 @@ struct GluetunForwardedPort { pub async fn get_network_status(State(state): State) -> Response { let listener_status = state.app.peer_listener_status().await; try_gluetun_detection( + &GluetunAuth::from_env(), state.app.current_forwarded_port(), state.app.vpn_port_sync_enabled(), listener_status, @@ -72,6 +74,7 @@ pub async fn get_network_status(State(state): State) -> Response { } async fn try_gluetun_detection( + auth: &GluetunAuth, current_forwarded_port: Option, vpn_port_sync_enabled: bool, listener_status: rustatio_core::PeerListenerStatus, @@ -80,8 +83,8 @@ async fn try_gluetun_detection( reqwest::Client::builder().timeout(std::time::Duration::from_millis(1000)).build().ok()?; // Get VPN status - let vpn_status = client - .get("http://localhost:8000/v1/vpn/status") + let vpn_status = auth + .get(&client, "/v1/vpn/status") .send() .await .ok()? @@ -91,8 +94,8 @@ async fn try_gluetun_detection( let is_vpn = vpn_status.status == "running"; - let public_ip = client - .get("http://localhost:8000/v1/publicip/ip") + let public_ip = auth + .get(&client, "/v1/publicip/ip") .send() .await .ok()? @@ -100,7 +103,7 @@ async fn try_gluetun_detection( .await .ok()?; - let forwarded_port = match client.get("http://localhost:8000/v1/portforward").send().await { + let forwarded_port = match auth.get(&client, "/v1/portforward").send().await { Ok(response) => match response.error_for_status() { Ok(response) => match response.json::().await { Ok(data) if data.port > 0 => Some(data.port), diff --git a/rustatio-server/src/services/gluetun.rs b/rustatio-server/src/services/gluetun.rs new file mode 100644 index 0000000..8b09eb4 --- /dev/null +++ b/rustatio-server/src/services/gluetun.rs @@ -0,0 +1,106 @@ +use reqwest::header::{HeaderMap, HeaderValue}; + +const GLUETUN_BASE_URL: &str = "http://localhost:8000"; +const GLUETUN_API_KEY_ENV: &str = "GLUETUN_CONTROL_SERVER_API_KEY"; +const GLUETUN_API_KEY_HEADER: &str = "X-API-Key"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GluetunAuth { + api_key: Option, +} + +impl GluetunAuth { + pub fn from_env() -> Self { + let api_key = std::env::var(GLUETUN_API_KEY_ENV) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + + Self { api_key } + } + + pub fn get(&self, client: &reqwest::Client, path: &str) -> reqwest::RequestBuilder { + let url = format!("{GLUETUN_BASE_URL}{path}"); + let mut headers = HeaderMap::new(); + + if let Some(key) = self.api_key.as_deref() { + if let Ok(value) = HeaderValue::from_str(key) { + headers.insert(GLUETUN_API_KEY_HEADER, value); + } + } + + client.get(url).headers(headers) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn from_env_reads_api_key() { + let guard = env_lock().lock(); + assert!(guard.is_ok(), "failed to acquire env mutex"); + + std::env::set_var(GLUETUN_API_KEY_ENV, "secret-key"); + let auth = GluetunAuth::from_env(); + assert_eq!(auth.api_key.as_deref(), Some("secret-key")); + + std::env::remove_var(GLUETUN_API_KEY_ENV); + } + + #[test] + fn from_env_ignores_blank_api_key() { + let guard = env_lock().lock(); + assert!(guard.is_ok(), "failed to acquire env mutex"); + + std::env::set_var(GLUETUN_API_KEY_ENV, " "); + let auth = GluetunAuth::from_env(); + assert_eq!(auth.api_key, None); + + std::env::remove_var(GLUETUN_API_KEY_ENV); + } + + #[test] + fn get_adds_api_key_header_when_present() { + let client = reqwest::Client::new(); + let auth = GluetunAuth { api_key: Some("secret-key".to_string()) }; + + let request = auth.get(&client, "/v1/portforward").build(); + assert!(request.is_ok()); + + let request = match request { + Ok(value) => value, + Err(e) => panic!("failed to build request: {e}"), + }; + + assert_eq!(request.url().as_str(), "http://localhost:8000/v1/portforward"); + assert_eq!( + request.headers().get(GLUETUN_API_KEY_HEADER).and_then(|v| v.to_str().ok()), + Some("secret-key") + ); + } + + #[test] + fn get_skips_api_key_header_when_missing() { + let client = reqwest::Client::new(); + let auth = GluetunAuth::default(); + + let request = auth.get(&client, "/v1/publicip/ip").build(); + assert!(request.is_ok()); + + let request = match request { + Ok(value) => value, + Err(e) => panic!("failed to build request: {e}"), + }; + + assert_eq!(request.url().as_str(), "http://localhost:8000/v1/publicip/ip"); + assert!(request.headers().get(GLUETUN_API_KEY_HEADER).is_none()); + } +} diff --git a/rustatio-server/src/services/mod.rs b/rustatio-server/src/services/mod.rs index 401e323..325d389 100644 --- a/rustatio-server/src/services/mod.rs +++ b/rustatio-server/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod events; +pub mod gluetun; pub mod instance; pub mod lifecycle; pub mod persistence; @@ -8,6 +9,7 @@ pub mod vpn_port_sync; pub mod watch; pub use events::{EventBroadcaster, InstanceEvent, LogEvent}; +pub use gluetun::GluetunAuth; pub use instance::{InstanceInfo, ServerPeerLookup}; pub use lifecycle::InstanceLifecycle; pub use scheduler::Scheduler; diff --git a/rustatio-server/src/services/vpn_port_sync.rs b/rustatio-server/src/services/vpn_port_sync.rs index e2be5a8..870674b 100644 --- a/rustatio-server/src/services/vpn_port_sync.rs +++ b/rustatio-server/src/services/vpn_port_sync.rs @@ -1,10 +1,10 @@ use super::state::AppState; +use crate::services::GluetunAuth; use serde::Deserialize; use std::time::Duration; use tokio::sync::mpsc; const DEFAULT_INTERVAL_SECS: u64 = 15; -const GLUETUN_PORTFORWARD_URL: &str = "http://localhost:8000/v1/portforward"; pub struct VpnPortSync { shutdown_tx: Option>, @@ -72,6 +72,7 @@ async fn sync_loop( config: VpnPortSyncConfig, mut shutdown_rx: mpsc::Receiver<()>, ) { + let auth = GluetunAuth::from_env(); let client = match reqwest::Client::builder().timeout(Duration::from_secs(2)).build() { Ok(client) => client, Err(err) => { @@ -87,7 +88,7 @@ async fn sync_loop( tokio::select! { _ = shutdown_rx.recv() => break, _ = ticker.tick() => { - if let Err(err) = sync_once(&state, &client).await { + if let Err(err) = sync_once(&state, &client, &auth).await { tracing::debug!("VPN port sync skipped update: {}", err); } } @@ -95,8 +96,12 @@ async fn sync_loop( } } -async fn sync_once(state: &AppState, client: &reqwest::Client) -> Result<(), String> { - let port = fetch_forwarded_port(client).await?; +async fn sync_once( + state: &AppState, + client: &reqwest::Client, + auth: &GluetunAuth, +) -> Result<(), String> { + let port = fetch_forwarded_port(client, auth).await?; if state.current_forwarded_port() == Some(port) { return Ok(()); } @@ -115,9 +120,9 @@ async fn sync_once(state: &AppState, client: &reqwest::Client) -> Result<(), Str Ok(()) } -async fn fetch_forwarded_port(client: &reqwest::Client) -> Result { - let response = client - .get(GLUETUN_PORTFORWARD_URL) +async fn fetch_forwarded_port(client: &reqwest::Client, auth: &GluetunAuth) -> Result { + let response = auth + .get(client, "/v1/portforward") .send() .await .map_err(|e| format!("Failed to query Gluetun port forward endpoint: {e}"))?;