feat(proxy): add SSH TCP passthrough and graceful HTTP-only startup
Add optional [ssh] config block that proxies port 22 → Gitea SSH pod, running on a dedicated thread/runtime matching the cert-watcher pattern. Also start HTTP-only on first deploy when the TLS cert file doesn't exist yet — once ACME challenge completes and the cert watcher writes the file, a graceful upgrade adds the TLS listener without downtime. Fix ACME watcher to handle InitApply events (kube-runtime v3+) so Ingresses that existed before the proxy started are picked up correctly. Signed-off-by: Sienna Meridian Satterwhite <sienna@sunbeam.pt>
This commit is contained in:
@@ -43,7 +43,10 @@ pub async fn watch_ingresses(client: Client, routes: AcmeRoutes) {
|
|||||||
|
|
||||||
while let Some(result) = stream.next().await {
|
while let Some(result) = stream.next().await {
|
||||||
match result {
|
match result {
|
||||||
Ok(watcher::Event::Apply(ing)) => {
|
// InitApply fires for each Ingress during the initial list (kube v3+).
|
||||||
|
// Apply fires for subsequent creates/updates.
|
||||||
|
// Both must be handled to catch Ingresses that existed before the proxy started.
|
||||||
|
Ok(watcher::Event::InitApply(ing)) | Ok(watcher::Event::Apply(ing)) => {
|
||||||
let mut map = routes.write().unwrap_or_else(|e| e.into_inner());
|
let mut map = routes.write().unwrap_or_else(|e| e.into_inner());
|
||||||
upsert_routes(&ing, &mut map);
|
upsert_routes(&ing, &mut map);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ use anyhow::{Context, Result};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct SshConfig {
|
||||||
|
/// Address to bind the SSH listener on, e.g. "0.0.0.0:22".
|
||||||
|
pub listen: String,
|
||||||
|
/// Upstream backend address, e.g. "gitea-ssh.devtools.svc.cluster.local:2222".
|
||||||
|
pub backend: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub listen: ListenConfig,
|
pub listen: ListenConfig,
|
||||||
pub tls: TlsFileConfig,
|
pub tls: TlsFileConfig,
|
||||||
pub telemetry: TelemetryConfig,
|
pub telemetry: TelemetryConfig,
|
||||||
pub routes: Vec<RouteConfig>,
|
pub routes: Vec<RouteConfig>,
|
||||||
|
/// Optional SSH TCP passthrough (port 22 → Gitea SSH).
|
||||||
|
pub ssh: Option<SshConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
pub mod acme;
|
pub mod acme;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
pub mod ssh;
|
||||||
|
|||||||
36
src/main.rs
36
src/main.rs
@@ -86,13 +86,43 @@ fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
let mut svc = http_proxy_service(&server.configuration, proxy);
|
let mut svc = http_proxy_service(&server.configuration, proxy);
|
||||||
|
|
||||||
// Port 80: plain HTTP — 301 → HTTPS, except for ACME HTTP-01 challenges.
|
// Port 80: always serve plain HTTP (ACME challenges + redirect to HTTPS).
|
||||||
// Port 443: TLS-terminated HTTPS. Cert written to /etc/tls/ by cert::* above.
|
|
||||||
svc.add_tcp(&cfg.listen.http);
|
svc.add_tcp(&cfg.listen.http);
|
||||||
svc.add_tls(&cfg.listen.https, &cfg.tls.cert_path, &cfg.tls.key_path)?;
|
|
||||||
|
// Port 443: only add the TLS listener if the cert files exist.
|
||||||
|
// On first deploy cert-manager hasn't issued the cert yet, so we start
|
||||||
|
// HTTP-only. Once the pingora-tls Secret is created (ACME challenge
|
||||||
|
// completes), the watcher in step 6 writes the cert files and triggers
|
||||||
|
// a graceful upgrade. The upgrade process finds the cert files and adds
|
||||||
|
// the TLS listener, inheriting the port-80 socket from the old process.
|
||||||
|
let cert_exists = std::path::Path::new(&cfg.tls.cert_path).exists();
|
||||||
|
if cert_exists {
|
||||||
|
svc.add_tls(&cfg.listen.https, &cfg.tls.cert_path, &cfg.tls.key_path)?;
|
||||||
|
tracing::info!("TLS listener added on {}", cfg.listen.https);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
cert_path = %cfg.tls.cert_path,
|
||||||
|
"cert not found — starting HTTP-only; ACME challenge will complete and trigger upgrade"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
server.add_service(svc);
|
server.add_service(svc);
|
||||||
|
|
||||||
|
// 5b. SSH TCP passthrough (port 22 → Gitea SSH), if configured.
|
||||||
|
// Runs on its own OS thread + Tokio runtime — same pattern as the cert/ingress watcher.
|
||||||
|
if let Some(ssh_cfg) = &cfg.ssh {
|
||||||
|
let listen = ssh_cfg.listen.clone();
|
||||||
|
let backend = ssh_cfg.backend.clone();
|
||||||
|
tracing::info!(%listen, %backend, "SSH TCP proxy enabled");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("ssh proxy runtime");
|
||||||
|
rt.block_on(sunbeam_proxy::ssh::run_tcp_proxy(&listen, &backend));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Background K8s watchers on their own OS thread + tokio runtime so they
|
// 6. Background K8s watchers on their own OS thread + tokio runtime so they
|
||||||
// don't interfere with Pingora's internal runtime. A fresh Client is
|
// don't interfere with Pingora's internal runtime. A fresh Client is
|
||||||
// created here so its tower workers live on this runtime (not the
|
// created here so its tower workers live on this runtime (not the
|
||||||
|
|||||||
41
src/ssh.rs
Normal file
41
src/ssh.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use tokio::io::copy_bidirectional;
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
Ok(l) => {
|
||||||
|
tracing::info!(%listen, %backend, "SSH TCP proxy listening");
|
||||||
|
l
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, %listen, "SSH TCP proxy: bind failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((mut socket, peer_addr)) => {
|
||||||
|
let backend = backend.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match TcpStream::connect(&backend).await {
|
||||||
|
Ok(mut upstream) => {
|
||||||
|
if let Err(e) = copy_bidirectional(&mut socket, &mut upstream).await {
|
||||||
|
tracing::debug!(error = %e, %peer_addr, "ssh: session ended");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, %peer_addr, %backend, "ssh: upstream connect failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "ssh: accept failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user