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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions rustatio-server/src/api/routes/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::api::{
common::{ApiError, ApiSuccess},
ServerState,
};
use crate::services::GluetunAuth;

#[derive(Serialize, ToSchema)]
pub struct NetworkStatus {
Expand Down Expand Up @@ -55,6 +56,7 @@ struct GluetunForwardedPort {
pub async fn get_network_status(State(state): State<ServerState>) -> 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,
Expand All @@ -72,6 +74,7 @@ pub async fn get_network_status(State(state): State<ServerState>) -> Response {
}

async fn try_gluetun_detection(
auth: &GluetunAuth,
current_forwarded_port: Option<u16>,
vpn_port_sync_enabled: bool,
listener_status: rustatio_core::PeerListenerStatus,
Expand All @@ -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()?
Expand All @@ -91,16 +94,16 @@ 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()?
.json::<GluetunPublicIp>()
.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::<GluetunForwardedPort>().await {
Ok(data) if data.port > 0 => Some(data.port),
Expand Down
106 changes: 106 additions & 0 deletions rustatio-server/src/services/gluetun.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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<Mutex<()>> = 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());
}
}
2 changes: 2 additions & 0 deletions rustatio-server/src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod events;
pub mod gluetun;
pub mod instance;
pub mod lifecycle;
pub mod persistence;
Expand All @@ -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;
Expand Down
19 changes: 12 additions & 7 deletions rustatio-server/src/services/vpn_port_sync.rs
Original file line number Diff line number Diff line change
@@ -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<mpsc::Sender<()>>,
Expand Down Expand Up @@ -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) => {
Expand All @@ -87,16 +88,20 @@ 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);
}
}
}
}
}

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(());
}
Expand All @@ -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<u16, String> {
let response = client
.get(GLUETUN_PORTFORWARD_URL)
async fn fetch_forwarded_port(client: &reqwest::Client, auth: &GluetunAuth) -> Result<u16, String> {
let response = auth
.get(client, "/v1/portforward")
.send()
.await
.map_err(|e| format!("Failed to query Gluetun port forward endpoint: {e}"))?;
Expand Down
Loading