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:
2026-03-10 23:38:19 +00:00
parent 41cf6ccc49
commit e16299068f
9 changed files with 396 additions and 4 deletions

View File

@@ -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
View 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)
}
}

View File

@@ -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;

View File

@@ -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) => {