use std::{collections::HashMap, io, net::SocketAddr};
use axum::Router;
use axum_core::BoxError;
use futures::stream::TryStreamExt;
use hyper::{body::Incoming, Request};
use hyper_util::{rt::TokioExecutor, server};
use ngrok::{config::HttpTunnelBuilder, prelude::*, Session};
use tower::{util::ServiceExt, Service};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Binding {
Public,
Internal,
Kubernetes,
}
impl Binding {
fn as_str(self) -> &'static str {
match self {
Binding::Public => "public",
Binding::Internal => "internal",
Binding::Kubernetes => "kubernetes",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Config {
pub url: Option<String>,
pub pooling: bool,
pub traffic_policy: Option<String>,
pub binding: Option<Binding>,
}
pub struct Endpoint {
pub app: Router,
pub config: Config,
}
pub async fn serve(app: Router, config: Config) -> Result<(), BoxError> {
serve_many(vec![Endpoint { app, config }]).await
}
pub async fn serve_many(endpoints: Vec<Endpoint>) -> Result<(), BoxError> {
validate_url_groups(&endpoints)?;
let session = Session::builder().authtoken_from_env().connect().await?;
let mut tasks = Vec::with_capacity(endpoints.len());
for endpoint in endpoints {
let mut builder = session.http_endpoint();
configure(&mut builder, &endpoint.config);
let mut listener = builder.listen().await?;
println!("Ingress established at: {:?}", listener.url());
let app = endpoint.app;
tasks.push(tokio::spawn(async move {
let mut make_service = app.into_make_service_with_connect_info::<SocketAddr>();
while let Some(conn) = listener.try_next().await? {
let remote_addr = conn.remote_addr();
let tower_service = make_service
.call(remote_addr)
.await
.unwrap_or_else(|err: std::convert::Infallible| match err {});
tokio::spawn(async move {
let hyper_service =
hyper::service::service_fn(move |request: Request<Incoming>| {
tower_service.clone().oneshot(request)
});
if let Err(err) = server::conn::auto::Builder::new(TokioExecutor::new())
.serve_connection_with_upgrades(conn, hyper_service)
.await
{
eprintln!("failed to serve connection: {err:#}");
}
});
}
Ok::<(), BoxError>(())
}));
}
for task in tasks {
task.await??;
}
Ok(())
}
fn configure(builder: &mut HttpTunnelBuilder, config: &Config) {
if let Some(url) = &config.url {
builder.domain(url.clone());
}
if config.pooling {
builder.pooling_enabled(true);
}
if let Some(policy) = &config.traffic_policy {
builder.traffic_policy(policy.clone());
}
if let Some(binding) = config.binding {
builder.binding(binding.as_str());
}
}
fn validate_url_groups(endpoints: &[Endpoint]) -> Result<(), BoxError> {
let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
for (i, endpoint) in endpoints.iter().enumerate() {
let key = endpoint.config.url.clone().unwrap_or_default();
groups.entry(key).or_default().push(i);
}
for (url, indices) in &groups {
if indices.len() <= 1 {
continue;
}
if indices.iter().all(|&i| endpoints[i].config.pooling) {
continue;
}
let label = if url.is_empty() {
"the account's default dev domain".to_string()
} else {
format!("\"{url}\"")
};
return Err(Box::new(io::Error::other(format!(
"Endpoints at indices {indices:?} would all share {label}. Without endpoint \
pooling, only the most recently opened listener actually receives traffic — the \
rest go silently unreachable. Set `pooling: true` on each of these endpoints to \
share it intentionally, or give each its own url."
))));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(url: Option<&str>, pooling: bool) -> Endpoint {
Endpoint {
app: Router::new(),
config: Config {
url: url.map(String::from),
pooling,
..Default::default()
},
}
}
#[test]
fn binding_as_str_matches_ngrok_valid_values() {
assert_eq!(Binding::Public.as_str(), "public");
assert_eq!(Binding::Internal.as_str(), "internal");
assert_eq!(Binding::Kubernetes.as_str(), "kubernetes");
}
#[test]
fn single_endpoint_never_errors() {
assert!(validate_url_groups(&[endpoint(None, false)]).is_ok());
}
#[test]
fn distinct_urls_never_error() {
let endpoints = [endpoint(Some("a.ngrok.app"), false), endpoint(Some("b.ngrok.app"), false)];
assert!(validate_url_groups(&endpoints).is_ok());
}
#[test]
fn two_url_less_endpoints_without_pooling_errors() {
let endpoints = [endpoint(None, false), endpoint(None, false)];
let err = validate_url_groups(&endpoints).unwrap_err();
assert!(err.to_string().contains("account's default dev domain"));
}
#[test]
fn two_endpoints_sharing_a_url_without_pooling_errors() {
let endpoints = [endpoint(Some("a.ngrok.app"), false), endpoint(Some("a.ngrok.app"), false)];
let err = validate_url_groups(&endpoints).unwrap_err();
assert!(err.to_string().contains("a.ngrok.app"));
}
#[test]
fn pooling_on_every_colliding_entry_does_not_error() {
let endpoints = [endpoint(None, true), endpoint(None, true)];
assert!(validate_url_groups(&endpoints).is_ok());
}
#[test]
fn pooling_on_only_some_colliding_entries_still_errors() {
let endpoints = [endpoint(None, true), endpoint(None, false)];
assert!(validate_url_groups(&endpoints).is_err());
}
}