feat: add native dual-stack IPv4/IPv6 support
This commit implements comprehensive dual-stack support for the proxy, allowing it to listen on both IPv4 and IPv6 addresses simultaneously. Key changes: - Added new dual_stack.rs module with DualStackTcpListener implementation - Updated SSH module to use dual-stack listener - Updated configuration documentation to reflect IPv6 support - Added comprehensive tests for dual-stack functionality The implementation is inspired by tokio_dual_stack but implemented natively without external dependencies. It provides fair connection distribution between IPv4 and IPv6 clients while maintaining full backward compatibility with existing IPv4-only configurations. Signed-off-by: Sienna Meridian Satterwhite <sienna@sunbeam.pt>
This commit is contained in:
@@ -4,7 +4,7 @@ use std::fs;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SshConfig {
|
||||
/// Address to bind the SSH listener on, e.g. "0.0.0.0:22".
|
||||
/// Address to bind the SSH listener on, e.g. "0.0.0.0:22" or "[::]:22".
|
||||
pub listen: String,
|
||||
/// Upstream backend address, e.g. "gitea-ssh.devtools.svc.cluster.local:2222".
|
||||
pub backend: String,
|
||||
@@ -22,7 +22,9 @@ pub struct Config {
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ListenConfig {
|
||||
/// HTTP listener address, e.g., "0.0.0.0:80" or "[::]:80".
|
||||
pub http: String,
|
||||
/// HTTPS listener address, e.g., "0.0.0.0:443" or "[::]:443".
|
||||
pub https: String,
|
||||
}
|
||||
|
||||
|
||||
133
src/dual_stack.rs
Normal file
133
src/dual_stack.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Dual-stack TCP listener implementation inspired by `tokio_dual_stack`.
|
||||
//!
|
||||
//! This module provides a `DualStackTcpListener` that can listen on both IPv4 and IPv6
|
||||
//! addresses simultaneously, ensuring fair distribution of connections between both stacks.
|
||||
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
pin_project_lite::pin_project! {
|
||||
/// Future returned by [`DualStackTcpListener::accept`].
|
||||
struct AcceptFut<
|
||||
F: std::future::Future<Output = Result<(TcpStream, SocketAddr)>>,
|
||||
F2: std::future::Future<Output = Result<(TcpStream, SocketAddr)>>,
|
||||
> {
|
||||
#[pin]
|
||||
fut_1: F,
|
||||
#[pin]
|
||||
fut_2: F2,
|
||||
}
|
||||
}
|
||||
|
||||
impl<
|
||||
F: std::future::Future<Output = Result<(TcpStream, SocketAddr)>>,
|
||||
F2: std::future::Future<Output = Result<(TcpStream, SocketAddr)>>,
|
||||
> std::future::Future for AcceptFut<F, F2>
|
||||
{
|
||||
type Output = Result<(TcpStream, SocketAddr)>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
match this.fut_1.poll(cx) {
|
||||
Poll::Ready(res) => Poll::Ready(res),
|
||||
Poll::Pending => this.fut_2.poll(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dual-stack TCP listener that can handle both IPv4 and IPv6 connections.
|
||||
#[derive(Debug)]
|
||||
pub struct DualStackTcpListener {
|
||||
/// IPv6 TCP listener.
|
||||
ip6: TcpListener,
|
||||
/// IPv4 TCP listener.
|
||||
ip4: TcpListener,
|
||||
/// Alternates between IPv6 and IPv4 to ensure fair distribution of connections.
|
||||
ip6_first: AtomicBool,
|
||||
}
|
||||
|
||||
impl DualStackTcpListener {
|
||||
/// Creates a new dual-stack listener by binding to both IPv4 and IPv6 addresses.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ipv6_addr` - The IPv6 address to bind to (e.g., "[::]:80").
|
||||
/// * `ipv4_addr` - The IPv4 address to bind to (e.g., "0.0.0.0:80").
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Result` containing the dual-stack listener if successful.
|
||||
pub async fn bind(ipv6_addr: &str, ipv4_addr: &str) -> Result<Self> {
|
||||
let ip6 = TcpListener::bind(ipv6_addr).await?;
|
||||
let ip4 = TcpListener::bind(ipv4_addr).await?;
|
||||
|
||||
Ok(Self {
|
||||
ip6,
|
||||
ip4,
|
||||
ip6_first: AtomicBool::new(true),
|
||||
})
|
||||
}
|
||||
|
||||
/// Accepts a new incoming connection from either the IPv4 or IPv6 listener.
|
||||
///
|
||||
/// This method alternates between the IPv6 and IPv4 listeners to ensure
|
||||
/// fair distribution of connections.
|
||||
pub async fn accept(&self) -> Result<(TcpStream, SocketAddr)> {
|
||||
if self.ip6_first.swap(false, Ordering::Relaxed) {
|
||||
AcceptFut {
|
||||
fut_1: self.ip6.accept(),
|
||||
fut_2: self.ip4.accept(),
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
self.ip6_first.store(true, Ordering::Relaxed);
|
||||
AcceptFut {
|
||||
fut_1: self.ip4.accept(),
|
||||
fut_2: self.ip6.accept(),
|
||||
}
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the local addresses of the IPv6 and IPv4 listeners.
|
||||
pub fn local_addr(&self) -> Result<(SocketAddrV6, SocketAddrV4)> {
|
||||
let ip6_addr = self.ip6.local_addr()?;
|
||||
let ip4_addr = self.ip4.local_addr()?;
|
||||
|
||||
match (ip6_addr, ip4_addr) {
|
||||
(SocketAddr::V6(ip6), SocketAddr::V4(ip4)) => Ok((ip6, ip4)),
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
"Unexpected address types for dual-stack listener",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait to add dual-stack binding capability to configuration structs.
|
||||
pub trait DualStackBind {
|
||||
/// Binds to both IPv4 and IPv6 addresses for dual-stack support.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ipv6_addr` - The IPv6 address to bind to.
|
||||
/// * `ipv4_addr` - The IPv4 address to bind to.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Result` containing the dual-stack listener if successful.
|
||||
fn bind_dual_stack(
|
||||
ipv6_addr: &str,
|
||||
ipv4_addr: &str,
|
||||
) -> impl std::future::Future<Output = Result<DualStackTcpListener>> + Send;
|
||||
}
|
||||
|
||||
impl DualStackBind for str {
|
||||
fn bind_dual_stack(
|
||||
ipv6_addr: &str,
|
||||
ipv4_addr: &str,
|
||||
) -> impl std::future::Future<Output = Result<DualStackTcpListener>> + Send {
|
||||
DualStackTcpListener::bind(ipv6_addr, ipv4_addr)
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
// without going through the binary entry point.
|
||||
pub mod acme;
|
||||
pub mod config;
|
||||
pub mod dual_stack;
|
||||
pub mod proxy;
|
||||
pub mod ssh;
|
||||
|
||||
23
src/ssh.rs
23
src/ssh.rs
@@ -1,13 +1,30 @@
|
||||
use tokio::io::copy_bidirectional;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::dual_stack::DualStackTcpListener;
|
||||
|
||||
/// Listens on `listen` and proxies every TCP connection to `backend`.
|
||||
/// Runs forever; intended to be spawned on a dedicated OS thread + Tokio runtime,
|
||||
/// matching the pattern used for the cert/ingress watcher.
|
||||
pub async fn run_tcp_proxy(listen: &str, backend: &str) {
|
||||
let listener = match TcpListener::bind(listen).await {
|
||||
// Parse the listen address to determine if it's IPv6 or IPv4
|
||||
let ipv6_addr = if listen.starts_with('[') {
|
||||
listen.to_string()
|
||||
} else {
|
||||
format!("[::]:{}", listen.split(':').last().unwrap_or("22"))
|
||||
};
|
||||
|
||||
let ipv4_addr = if listen.contains(':') {
|
||||
// Extract port from the original address
|
||||
let port = listen.split(':').last().unwrap_or("22");
|
||||
format!("0.0.0.0:{}", port)
|
||||
} else {
|
||||
"0.0.0.0:22".to_string()
|
||||
};
|
||||
|
||||
let listener = match DualStackTcpListener::bind(&ipv6_addr, &ipv4_addr).await {
|
||||
Ok(l) => {
|
||||
tracing::info!(%listen, %backend, "SSH TCP proxy listening");
|
||||
tracing::info!(%listen, %backend, "SSH TCP proxy listening (dual-stack)");
|
||||
l
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user