feat: add Wireguard interface model

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2025-03-29 11:56:35 +00:00
parent d5e1b1b437
commit 1aa2852885
12 changed files with 528 additions and 78 deletions

View File

@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT name, address, port, private_key, default_network_netmask\n FROM interfaces\n WHERE name = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "address",
"type_info": "Inet"
},
{
"ordinal": 2,
"name": "port",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "private_key",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "default_network_netmask",
"type_info": "Int2"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "ac624e63c0bb3b353838703deeaf1f1724edfa5462000f9d6148602d6f3d2431"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO interfaces (name, address, port, private_key, default_network_netmask)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Inet",
"Int4",
"Bytea",
"Int2"
]
},
"nullable": []
},
"hash": "f4bf4e45fbd5a0590653d6d89ecab8019f08e7f22c3cf7c45c164afe57017cb2"
}

70
Cargo.lock generated
View File

@@ -566,6 +566,7 @@ dependencies = [
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"serde",
"subtle",
"zeroize",
]
@@ -1396,6 +1397,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "ipnetwork"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
dependencies = [
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -1571,6 +1581,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "mktemp"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69fed8fbcd01affec44ac226784c6476a6006d98d13e33bc0ca7977aaf046bd8"
dependencies = [
"uuid",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
@@ -2342,6 +2361,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
@@ -2430,6 +2458,7 @@ dependencies = [
"hashbrown 0.15.2",
"hashlink",
"indexmap 2.8.0",
"ipnetwork",
"log",
"memchr",
"once_cell",
@@ -2548,6 +2577,7 @@ dependencies = [
"hkdf",
"hmac 0.12.1",
"home",
"ipnetwork",
"itoa",
"log",
"md-5",
@@ -2791,6 +2821,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
@@ -2987,6 +3018,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
dependencies = [
"getrandom 0.2.15",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -3000,15 +3040,19 @@ dependencies = [
"async-session",
"axum",
"axum-extra",
"base64 0.22.1",
"clap",
"log",
"log4rs",
"mktemp",
"openidconnect",
"serde",
"serde_json",
"serde_yaml",
"sqlx",
"tokio",
"x25519-dalek",
"zeroize",
]
[[package]]
@@ -3456,6 +3500,18 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "yoke"
version = "0.7.5"
@@ -3526,6 +3582,20 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "zerovec"

View File

@@ -17,12 +17,16 @@ codegen-units = 1
async-session = "3.0.0"
axum = "0.8.1"
axum-extra = { version = "0.10.0", features = ["typed-header"] }
base64 = "0.22.1"
clap = { version = "4.5.32", features = ["derive"] }
log = "0.4.27"
log4rs = "1.3.0"
mktemp = "0.5.1"
openidconnect = { version = "4.0.0", features = ["reqwest"] }
serde = "1.0.219"
serde_json = "1.0.140"
serde_yaml = "0.9.34"
sqlx = { version = "0.8.3", features = ["postgres", "runtime-tokio", "time"] }
tokio = { version = "1.44.1", features = ["rt-multi-thread"] }
sqlx = { version = "0.8.3", features = ["ipnetwork", "postgres", "runtime-tokio", "time"] }
tokio = { version = "1.44.1", features = ["process", "rt-multi-thread"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom", "serde", "static_secrets"] }
zeroize = "1.8.1"

View File

@@ -18,7 +18,7 @@ RUN cargo build --target=x86_64-unknown-linux-musl --release
FROM docker.io/library/alpine
RUN apk add --no-cache wireguard-tools iptables
RUN apk add --no-cache wireguard-tools iptables iproute2
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/veil /usr/local/bin/veil

View File

@@ -4,6 +4,25 @@ metadata:
name: veil
spec:
containers:
- name: veil
image: registry.karaolidis.com/karaolidis/veil:latest
volumeMounts:
- name: veil-config
mountPath: /etc/veil
command:
[
"veil",
"--config",
"/etc/veil/default.yml",
--log-config,
"/etc/veil/log4rs.yml",
]
securityContext:
capabilities:
add:
- NET_ADMIN
- NET_RAW
- name: postgresql
image: docker.io/library/postgres:latest
env:
@@ -23,20 +42,6 @@ spec:
- name: authelia-config
mountPath: /config
- name: veil
image: registry.karaolidis.com/karaolidis/veil:latest
volumeMounts:
- name: veil-config
mountPath: /etc/veil
command:
[
"veil",
"--config",
"/etc/veil/default.yml",
--log-config,
"/etc/veil/log4rs.yml",
]
- name: traefik
image: docker.io/library/traefik:latest
args:

View File

@@ -0,0 +1,7 @@
CREATE TABLE interfaces (
name TEXT PRIMARY KEY,
address INET NOT NULL,
port INTEGER NOT NULL CHECK (port > 0 AND port <= 65535),
private_key BYTEA NOT NULL,
default_network_netmask SMALLINT NOT NULL CHECK (default_network_netmask >= 0 AND default_network_netmask <= 32)
);

View File

@@ -1,27 +1,30 @@
use base64::{Engine, prelude::BASE64_STANDARD};
use clap::Parser;
use serde::Deserialize;
use sqlx::types::ipnetwork::IpNetwork;
use std::{
fs,
net::{IpAddr, Ipv4Addr},
path::PathBuf,
};
use x25519_dalek::StaticSecret;
#[derive(Clone, Deserialize)]
pub struct ServerConfig {
pub host: String,
#[serde(default = "default_address")]
#[serde(default = "default_server_address")]
pub address: std::net::IpAddr,
#[serde(default = "default_port")]
#[serde(default = "default_server_port")]
pub port: u16,
#[serde(default)]
pub subpath: String,
}
const fn default_address() -> IpAddr {
const fn default_server_address() -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
const fn default_port() -> u16 {
const fn default_server_port() -> u16 {
51821
}
@@ -34,7 +37,7 @@ pub struct DatabaseConfig {
pub database: String,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Clone, Deserialize)]
pub struct OAuthConfig {
pub issuer_url: String,
pub client_id: String,
@@ -45,11 +48,60 @@ pub struct OAuthConfig {
pub admin_group: Option<String>,
}
#[derive(Clone, Deserialize)]
pub struct WireguardConfig {
#[serde(default = "default_wireguard_address")]
pub address: IpNetwork,
#[serde(default = "default_wireguard_port")]
pub port: u16,
#[serde(default = "default_wireguard_interface")]
pub interface: String,
#[serde(default = "default_wireguard_private_key")]
pub private_key: String,
#[serde(default = "default_wireguard_default_network_netmask")]
pub default_network_netmask: u8,
}
fn default_wireguard_address() -> IpNetwork {
IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8).unwrap()
}
const fn default_wireguard_port() -> u16 {
51820
}
fn default_wireguard_interface() -> String {
"wg0".to_string()
}
fn default_wireguard_private_key() -> String {
let private_key = StaticSecret::random();
BASE64_STANDARD.encode(private_key.as_bytes())
}
const fn default_wireguard_default_network_netmask() -> u8 {
24
}
impl Default for WireguardConfig {
fn default() -> Self {
Self {
address: default_wireguard_address(),
port: default_wireguard_port(),
interface: default_wireguard_interface(),
private_key: default_wireguard_private_key(),
default_network_netmask: default_wireguard_default_network_netmask(),
}
}
}
#[derive(Clone, Deserialize)]
pub struct Config {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub oauth: OAuthConfig,
#[serde(default)]
pub wireguard: WireguardConfig,
}
impl Config {

View File

@@ -7,11 +7,14 @@ mod routes;
mod state;
use axum::serve;
use base64::{Engine, prelude::BASE64_STANDARD};
use clap::Parser;
use log::info;
use log4rs::config::Deserializers;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use mktemp::Temp;
use models::interface::Interface;
use std::{error::Error, fs::File, io::Write, net::SocketAddr};
use tokio::{net::TcpListener, process::Command};
use config::{Args, Config};
use state::State;
@@ -23,10 +26,7 @@ async fn main() {
let config = Config::from_yaml(&args.config).unwrap();
let state = State::from_config(config.clone()).await.unwrap();
sqlx::migrate!("./migrations")
.run(&state.pg_pool)
.await
.expect("Failed to run migrations");
init(&state).await.unwrap();
let routes = routes::routes(state);
let app = axum::Router::new().nest(&format!("{}/api", config.server.subpath), routes);
@@ -37,3 +37,113 @@ async fn main() {
info!("Listening on {}", listener.local_addr().unwrap());
serve(listener, app).await.unwrap();
}
async fn init(state: &State) -> Result<(), Box<dyn Error + Send + Sync>> {
sqlx::migrate!("./migrations")
.run(&state.pg_pool)
.await
.expect("Failed to run migrations");
let interface_name = &state.config.wireguard.interface;
let interface = {
let maybe_interface = Interface::select_by_name(&state.pg_pool, interface_name).await?;
if let Some(interface) = maybe_interface {
interface
} else {
let interface = Interface::try_from(state.config.wireguard.clone())?;
Interface::insert(&state.pg_pool, interface.clone()).await?;
interface
}
};
let private_key_file_path = Temp::new_file()?;
File::options()
.write(true)
.open(&private_key_file_path)?
.write_all(
BASE64_STANDARD
.encode(interface.private_key.to_bytes())
.as_bytes(),
)?;
if !Command::new("ip")
.args(["link", "add", "dev", interface_name, "type", "wireguard"])
.status()
.await?
.success()
{
return Err("Failed to create WireGuard interface".into());
}
if !Command::new("ip")
.args([
"address",
"add",
&interface.address.to_string(),
"dev",
interface_name,
])
.status()
.await?
.success()
{
return Err("Failed to assign IP address".into());
}
if !Command::new("wg")
.args([
"set",
interface_name,
"listen-port",
&interface.port.to_string(),
"private-key",
private_key_file_path
.to_str()
.ok_or("Invalid private key file path")?,
])
.status()
.await?
.success()
{
return Err("Failed to set WireGuard interface options".into());
}
if !Command::new("ip")
.args(["link", "set", "up", "dev", interface_name])
.status()
.await?
.success()
{
return Err("Failed to set WireGuard interface up".into());
}
if !Command::new("iptables")
.args([
"-t",
"nat",
"-A",
"POSTROUTING",
"-o",
"eth0",
"-j",
"MASQUERADE",
])
.status()
.await?
.success()
{
return Err("Failed to set iptables NAT rule".into());
}
if !Command::new("iptables")
.args(["-P", "FORWARD", "DROP"])
.status()
.await?
.success()
{
return Err("Failed to set FORWARD policy to DROP".into());
}
Ok(())
}

116
src/models/interface.rs Normal file
View File

@@ -0,0 +1,116 @@
use std::error::Error;
use base64::{Engine, prelude::BASE64_STANDARD};
use sqlx::{PgPool, query, query_as, types::ipnetwork::IpNetwork};
use x25519_dalek::StaticSecret;
use crate::config::WireguardConfig;
#[derive(Clone)]
pub struct Interface {
pub name: String,
pub address: IpNetwork,
pub port: u16,
pub private_key: StaticSecret,
pub default_network_netmask: u8,
}
struct InterfacePostgres {
name: String,
address: IpNetwork,
port: i32,
private_key: Vec<u8>,
default_network_netmask: i16,
}
impl TryFrom<WireguardConfig> for Interface {
type Error = Box<dyn Error + Send + Sync>;
fn try_from(config: WireguardConfig) -> Result<Self, Self::Error> {
Ok(Self {
name: config.interface,
address: config.address,
port: config.port,
private_key: {
let decoded_key = BASE64_STANDARD.decode(config.private_key)?;
let key_array: [u8; 32] =
decoded_key.try_into().map_err(|_| "Invalid key length")?;
StaticSecret::from(key_array)
},
default_network_netmask: config.default_network_netmask,
})
}
}
// We allow .unwrap() here because we set the lengths of the variables ourselves
#[allow(clippy::fallible_impl_from)]
impl From<InterfacePostgres> for Interface {
fn from(row: InterfacePostgres) -> Self {
Self {
name: row.name,
address: row.address,
port: row.port.try_into().unwrap(),
private_key: {
let key_array: [u8; 32] = row.private_key.try_into().unwrap();
StaticSecret::from(key_array)
},
default_network_netmask: row.default_network_netmask.try_into().unwrap(),
}
}
}
impl From<Interface> for InterfacePostgres {
fn from(interface: Interface) -> Self {
Self {
name: interface.name,
address: interface.address,
port: i32::from(interface.port),
private_key: interface.private_key.to_bytes().to_vec(),
default_network_netmask: i16::from(interface.default_network_netmask),
}
}
}
impl Interface {
pub async fn insert(
pool: &PgPool,
interface: Self,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let interface = InterfacePostgres::from(interface);
query!(
r#"
INSERT INTO interfaces (name, address, port, private_key, default_network_netmask)
VALUES ($1, $2, $3, $4, $5)
"#,
interface.name,
interface.address,
interface.port,
interface.private_key,
interface.default_network_netmask,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn select_by_name(
pool: &PgPool,
name: &str,
) -> Result<Option<Self>, Box<dyn Error + Send + Sync>> {
let row = query_as!(
InterfacePostgres,
r#"
SELECT name, address, port, private_key, default_network_netmask
FROM interfaces
WHERE name = $1
"#,
name
)
.fetch_optional(pool)
.await?;
row.map_or_else(|| Ok(None), |row| Ok(Some(Self::from(row))))
}
}

View File

@@ -0,0 +1 @@
pub mod interface;

View File

@@ -1,3 +1,5 @@
use std::error::Error;
use async_session::MemoryStore;
use axum::extract::FromRef;
use log::error;
@@ -11,6 +13,7 @@ use openidconnect::{
},
reqwest,
};
use sqlx::{PgPool, postgres::PgPoolOptions};
use tokio::{
spawn,
time::{Duration, sleep},
@@ -48,59 +51,17 @@ pub type OAuthClient<
#[derive(Clone)]
pub struct State {
pub config: Config,
pub pg_pool: sqlx::PgPool,
pub pg_pool: PgPool,
pub oauth_http_client: reqwest::Client,
pub oauth_client: OAuthClient,
pub session_store: async_session::MemoryStore,
pub session_store: MemoryStore,
}
impl State {
pub async fn from_config(config: Config) -> Result<Self, Box<dyn std::error::Error>> {
let pg_pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&format!(
"postgres://{}:{}@{}:{}/{}",
config.database.user,
config.database.password,
config.database.host,
config.database.port,
config.database.database
))
.await?;
let oauth_http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.danger_accept_invalid_certs(config.oauth.insecure)
.build()?;
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(config.oauth.issuer_url.clone())?,
&oauth_http_client,
)
.await?;
let oauth_client = OAuthClient::from_provider_metadata(
provider_metadata,
ClientId::new(config.oauth.client_id.clone()),
Some(ClientSecret::new(config.oauth.client_secret.clone())),
)
.set_redirect_uri(RedirectUrl::new(format!(
"{}{}/api/auth/callback",
config.server.host, config.server.subpath
))?);
let session_store = MemoryStore::new();
let session_store_clone = session_store.clone();
spawn(async move {
loop {
match session_store_clone.cleanup().await {
Ok(()) => {}
Err(e) => error!("Failed to clean up session store: {e}"),
}
sleep(Duration::from_secs(60)).await;
}
});
pub async fn from_config(config: Config) -> Result<Self, Box<dyn Error + Send + Sync>> {
let pg_pool = pg_pool(&config).await?;
let (oauth_http_client, oauth_client) = oauth(&config).await?;
let session_store = session_store();
Ok(Self {
config,
@@ -118,7 +79,7 @@ impl FromRef<State> for Config {
}
}
impl FromRef<State> for sqlx::PgPool {
impl FromRef<State> for PgPool {
fn from_ref(state: &State) -> Self {
state.pg_pool.clone()
}
@@ -136,8 +97,68 @@ impl FromRef<State> for OAuthClient {
}
}
impl FromRef<State> for async_session::MemoryStore {
impl FromRef<State> for MemoryStore {
fn from_ref(state: &State) -> Self {
state.session_store.clone()
}
}
async fn pg_pool(config: &Config) -> Result<PgPool, Box<dyn Error + Send + Sync>> {
let pg_pool = PgPoolOptions::new()
.max_connections(5)
.connect(&format!(
"postgres://{}:{}@{}:{}/{}",
config.database.user,
config.database.password,
config.database.host,
config.database.port,
config.database.database
))
.await?;
Ok(pg_pool)
}
async fn oauth(
config: &Config,
) -> Result<(reqwest::Client, OAuthClient), Box<dyn Error + Send + Sync>> {
let oauth_http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.danger_accept_invalid_certs(config.oauth.insecure)
.build()?;
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(config.oauth.issuer_url.clone())?,
&oauth_http_client,
)
.await?;
let oauth_client = OAuthClient::from_provider_metadata(
provider_metadata,
ClientId::new(config.oauth.client_id.clone()),
Some(ClientSecret::new(config.oauth.client_secret.clone())),
)
.set_redirect_uri(RedirectUrl::new(format!(
"{}{}/api/auth/callback",
config.server.host, config.server.subpath
))?);
Ok((oauth_http_client, oauth_client))
}
fn session_store() -> MemoryStore {
let session_store = MemoryStore::new();
let session_store_clone = session_store.clone();
spawn(async move {
loop {
match session_store_clone.cleanup().await {
Ok(()) => {}
Err(e) => error!("Failed to clean up session store: {e}"),
}
sleep(Duration::from_secs(60)).await;
}
});
session_store
}