init
This commit is contained in:
commit
5c5a9b1c9a
6 changed files with 3391 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
config.toml
|
3086
Cargo.lock
generated
Normal file
3086
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "proxmox-mtls-gateway"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
async-trait="0.1"
|
||||
pingora-proxy = { path = "../pingora/pingora-proxy" }
|
||||
pingora-core = { path = "../pingora/pingora-core" }
|
||||
pingora-http = { path = "../pingora/pingora-http" }
|
||||
log = "0.4"
|
||||
clap = "3.2"
|
||||
env_logger = "0.11"
|
||||
urlencoding = "2.1.3"
|
||||
bytes = "1.6.0"
|
||||
|
||||
reqwest = { version = "0.12", features = [ "json" ] }
|
||||
url = "2.5.2"
|
||||
serde = "1.0.210"
|
||||
serde_urlencoded = "0.7.1"
|
||||
toml = "0.8.19"
|
||||
http = "1.1.0"
|
||||
serde_json = "1.0.128"
|
4
README.md
Normal file
4
README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
[Proxmox VE](https://www.proxmox.com/en/proxmox-ve) currently doesn't support authentication with TLS client certificates.
|
||||
This project implements a reverse proxy that authenticates a user using TLS client certificates.
|
||||
After successful authentication the proxy creates a new session ticket using the PVE API using the mapped credentials in `config.toml` and injects it into the session.
|
||||
|
15
config.example.toml
Normal file
15
config.example.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
listen_addr = "[::]:8080"
|
||||
|
||||
[tls]
|
||||
ca_file = "/foo/ca.pem"
|
||||
cert_file = "/foo/server.crt"
|
||||
key_file = "/foo/server.key"
|
||||
|
||||
[proxmox]
|
||||
host = "127.0.0.1"
|
||||
port = 8006
|
||||
|
||||
[users."john@example.com"]
|
||||
username = "john"
|
||||
password = "verysecureandlongpassword"
|
||||
realm = "pve"
|
259
src/main.rs
Normal file
259
src/main.rs
Normal file
|
@ -0,0 +1,259 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use clap::Parser;
|
||||
use log::{debug, info, warn};
|
||||
use pingora_core::server::configuration::Opt;
|
||||
use pingora_core::Result;
|
||||
use pingora_core::{protocols::ALPN, server::Server, upstreams::peer::Peer};
|
||||
use pingora_core::{tls::ssl::SslVerifyMode, upstreams::peer::HttpPeer};
|
||||
use pingora_http::{RequestHeader, ResponseHeader};
|
||||
use pingora_proxy::{ProxyHttp, Session};
|
||||
use reqwest::{Client, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/*
|
||||
* Configuration file
|
||||
*/
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
listen_addr: String,
|
||||
tls: TlsConfig,
|
||||
proxmox: ProxmoxConfig,
|
||||
users: HashMap<String, UserConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
ca_file: String,
|
||||
cert_file: String,
|
||||
key_file: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProxmoxConfig {
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct UserConfig {
|
||||
username: String,
|
||||
password: String,
|
||||
realm: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProxmoxLoginReq<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
realm: &'a str,
|
||||
}
|
||||
|
||||
/*
|
||||
* ProxmoxVE API
|
||||
*/
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ProxmoxRes<T> {
|
||||
data: T,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ProxmoxSession {
|
||||
pub username: String,
|
||||
#[serde(rename = "CSRFPreventionToken")]
|
||||
pub csrf_token: String,
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
pub struct MTLSGateway {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
subject_name: String,
|
||||
user: UserConfig,
|
||||
session: Option<ProxmoxSession>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyHttp for MTLSGateway {
|
||||
type CTX = Context;
|
||||
fn new_ctx(&self) -> Self::CTX {
|
||||
Context {
|
||||
subject_name: "".to_string(),
|
||||
user: UserConfig {
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
realm: "".to_string(),
|
||||
},
|
||||
session: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_filter(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<bool> {
|
||||
if let Some(cert) = &session
|
||||
.downstream_session
|
||||
.digest()
|
||||
.and_then(|x| x.ssl_digest.as_ref())
|
||||
.map(|x| &x.certificate)
|
||||
{
|
||||
let subjects = cert
|
||||
.subject_name()
|
||||
.entries()
|
||||
.filter_map(|x| x.data().as_utf8().ok())
|
||||
.map(|x| x.to_string());
|
||||
if let Some((subject_name, user_config)) = subjects
|
||||
.filter_map(|x| self.config.users.get(&x).map(|y| (x, y)))
|
||||
.next()
|
||||
{
|
||||
ctx.user = user_config.clone();
|
||||
ctx.subject_name = subject_name;
|
||||
Ok(false)
|
||||
} else {
|
||||
session.respond_error(403 /* FORBIDDEN */).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
} else {
|
||||
session.respond_error(401 /* UNAUTHORIZED */).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
async fn upstream_peer(
|
||||
&self,
|
||||
_session: &mut Session,
|
||||
_ctx: &mut Self::CTX,
|
||||
) -> Result<Box<HttpPeer>> {
|
||||
let mut peer = HttpPeer::new(
|
||||
(self.config.proxmox.host.as_str(), self.config.proxmox.port),
|
||||
true,
|
||||
self.config.proxmox.host.clone(),
|
||||
);
|
||||
|
||||
peer.get_mut_peer_options().unwrap().alpn = ALPN::H2;
|
||||
|
||||
Ok(Box::new(peer))
|
||||
}
|
||||
|
||||
async fn upstream_request_filter(
|
||||
&self,
|
||||
_session: &mut Session,
|
||||
upstream_request: &mut RequestHeader,
|
||||
ctx: &mut Self::CTX,
|
||||
) -> Result<()> {
|
||||
if !upstream_request.headers.contains_key("Cookie") {
|
||||
info!(
|
||||
"Bootstrapping session for {} using {}@{}",
|
||||
ctx.subject_name, ctx.user.username, ctx.user.realm
|
||||
);
|
||||
let session = login(&ctx.user, &self.config).await.unwrap();
|
||||
upstream_request.insert_header(
|
||||
"Cookie",
|
||||
format!("PVEAuthCookie={}", urlencoding::encode(&session.ticket)),
|
||||
)?;
|
||||
ctx.session = Some(session);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn response_filter(
|
||||
&self,
|
||||
_session: &mut Session,
|
||||
upstream_response: &mut ResponseHeader,
|
||||
ctx: &mut Self::CTX,
|
||||
) -> Result<()>
|
||||
where
|
||||
Self::CTX: Send + Sync,
|
||||
{
|
||||
// replace existing header if any
|
||||
upstream_response
|
||||
.insert_header("Server", "PVE mTLS Gateway")
|
||||
.unwrap();
|
||||
if let Some(session) = &ctx.session {
|
||||
upstream_response.append_header(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"PVEAuthCookie={};Path=/;SameSite=Strict;Secure",
|
||||
urlencoding::encode(&session.ticket)
|
||||
),
|
||||
)?;
|
||||
}
|
||||
|
||||
// because we don't support h3
|
||||
upstream_response.remove_header("alt-svc");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn logging(
|
||||
&self,
|
||||
session: &mut Session,
|
||||
_e: Option<&pingora_core::Error>,
|
||||
ctx: &mut Self::CTX,
|
||||
) {
|
||||
let response_code = session
|
||||
.response_written()
|
||||
.map_or(0, |resp| resp.status.as_u16());
|
||||
debug!(
|
||||
"{} response code: {response_code}",
|
||||
self.request_summary(session, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let config = std::fs::read_to_string(std::env::var("CONFIG").expect("config file path"))
|
||||
.expect("user mapping file");
|
||||
let config: Config = toml::from_str(&config).expect("valid user mapping");
|
||||
let listen_addr = config.listen_addr.clone();
|
||||
|
||||
// read command line arguments
|
||||
let opt = Opt::parse();
|
||||
let mut my_server = Server::new(Some(opt)).unwrap();
|
||||
my_server.bootstrap();
|
||||
|
||||
let mut tls_settings = pingora_core::listeners::TlsSettings::intermediate(
|
||||
&config.tls.cert_file,
|
||||
&config.tls.key_file,
|
||||
)
|
||||
.unwrap();
|
||||
tls_settings.enable_h2();
|
||||
tls_settings.set_verify(SslVerifyMode::all());
|
||||
tls_settings.set_ca_file(&config.tls.ca_file).unwrap();
|
||||
|
||||
let mut my_proxy =
|
||||
pingora_proxy::http_proxy_service(&my_server.configuration, MTLSGateway { config });
|
||||
my_proxy.add_tls_with_settings(&listen_addr, None, tls_settings);
|
||||
|
||||
my_server.add_service(my_proxy);
|
||||
my_server.run_forever()
|
||||
}
|
||||
|
||||
fn proxmox_url(path: &str, config: &Config) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&format!(
|
||||
"https://{}:{}{}",
|
||||
config.proxmox.host, config.proxmox.port, path
|
||||
))
|
||||
}
|
||||
async fn login(user: &UserConfig, config: &Config) -> Result<ProxmoxSession, reqwest::Error> {
|
||||
let client = Client::new();
|
||||
let res = client
|
||||
.post(proxmox_url("/api2/json/access/ticket", config).unwrap())
|
||||
.json(&ProxmoxLoginReq {
|
||||
username: &user.username,
|
||||
password: &user.password,
|
||||
realm: &user.realm,
|
||||
})
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let sess = res.json::<ProxmoxRes<ProxmoxSession>>().await?.data;
|
||||
Ok(sess)
|
||||
}
|
Loading…
Reference in a new issue