1use std::{collections::HashMap, io, net::SocketAddr};
21
22use axum::Router;
23use axum_core::BoxError;
24use futures::stream::TryStreamExt;
25use hyper::{body::Incoming, Request};
26use hyper_util::{rt::TokioExecutor, server};
27use ngrok::{config::HttpTunnelBuilder, prelude::*, Session};
28use tower::{util::ServiceExt, Service};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Binding {
35 Public,
36 Internal,
39 Kubernetes,
40}
41
42impl Binding {
43 fn as_str(self) -> &'static str {
44 match self {
45 Binding::Public => "public",
46 Binding::Internal => "internal",
47 Binding::Kubernetes => "kubernetes",
48 }
49 }
50}
51
52#[derive(Debug, Default, Clone)]
61pub struct Config {
62 pub url: Option<String>,
65 pub pooling: bool,
72 pub traffic_policy: Option<String>,
75 pub binding: Option<Binding>,
76}
77
78pub struct Endpoint {
82 pub app: Router,
83 pub config: Config,
84}
85
86pub async fn serve(app: Router, config: Config) -> Result<(), BoxError> {
89 serve_many(vec![Endpoint { app, config }]).await
90}
91
92pub async fn serve_many(endpoints: Vec<Endpoint>) -> Result<(), BoxError> {
96 validate_url_groups(&endpoints)?;
97
98 let session = Session::builder().authtoken_from_env().connect().await?;
99
100 let mut tasks = Vec::with_capacity(endpoints.len());
101 for endpoint in endpoints {
102 let mut builder = session.http_endpoint();
103 configure(&mut builder, &endpoint.config);
104
105 let mut listener = builder.listen().await?;
106 println!("Ingress established at: {:?}", listener.url());
107
108 let app = endpoint.app;
109 tasks.push(tokio::spawn(async move {
110 let mut make_service = app.into_make_service_with_connect_info::<SocketAddr>();
111
112 while let Some(conn) = listener.try_next().await? {
113 let remote_addr = conn.remote_addr();
114 let tower_service = make_service
115 .call(remote_addr)
116 .await
117 .unwrap_or_else(|err: std::convert::Infallible| match err {});
118
119 tokio::spawn(async move {
120 let hyper_service =
121 hyper::service::service_fn(move |request: Request<Incoming>| {
122 tower_service.clone().oneshot(request)
123 });
124
125 if let Err(err) = server::conn::auto::Builder::new(TokioExecutor::new())
126 .serve_connection_with_upgrades(conn, hyper_service)
127 .await
128 {
129 eprintln!("failed to serve connection: {err:#}");
130 }
131 });
132 }
133
134 Ok::<(), BoxError>(())
135 }));
136 }
137
138 for task in tasks {
139 task.await??;
140 }
141
142 Ok(())
143}
144
145fn configure(builder: &mut HttpTunnelBuilder, config: &Config) {
146 if let Some(url) = &config.url {
147 builder.domain(url.clone());
148 }
149 if config.pooling {
150 builder.pooling_enabled(true);
151 }
152 if let Some(policy) = &config.traffic_policy {
153 builder.traffic_policy(policy.clone());
154 }
155 if let Some(binding) = config.binding {
156 builder.binding(binding.as_str());
157 }
158}
159
160fn validate_url_groups(endpoints: &[Endpoint]) -> Result<(), BoxError> {
177 let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
178 for (i, endpoint) in endpoints.iter().enumerate() {
179 let key = endpoint.config.url.clone().unwrap_or_default();
180 groups.entry(key).or_default().push(i);
181 }
182
183 for (url, indices) in &groups {
184 if indices.len() <= 1 {
185 continue;
186 }
187 if indices.iter().all(|&i| endpoints[i].config.pooling) {
188 continue;
189 }
190
191 let label = if url.is_empty() {
192 "the account's default dev domain".to_string()
193 } else {
194 format!("\"{url}\"")
195 };
196 return Err(Box::new(io::Error::other(format!(
197 "Endpoints at indices {indices:?} would all share {label}. Without endpoint \
198 pooling, only the most recently opened listener actually receives traffic — the \
199 rest go silently unreachable. Set `pooling: true` on each of these endpoints to \
200 share it intentionally, or give each its own url."
201 ))));
202 }
203
204 Ok(())
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn endpoint(url: Option<&str>, pooling: bool) -> Endpoint {
212 Endpoint {
213 app: Router::new(),
214 config: Config {
215 url: url.map(String::from),
216 pooling,
217 ..Default::default()
218 },
219 }
220 }
221
222 #[test]
223 fn binding_as_str_matches_ngrok_valid_values() {
224 assert_eq!(Binding::Public.as_str(), "public");
225 assert_eq!(Binding::Internal.as_str(), "internal");
226 assert_eq!(Binding::Kubernetes.as_str(), "kubernetes");
227 }
228
229 #[test]
230 fn single_endpoint_never_errors() {
231 assert!(validate_url_groups(&[endpoint(None, false)]).is_ok());
232 }
233
234 #[test]
235 fn distinct_urls_never_error() {
236 let endpoints = [endpoint(Some("a.ngrok.app"), false), endpoint(Some("b.ngrok.app"), false)];
237 assert!(validate_url_groups(&endpoints).is_ok());
238 }
239
240 #[test]
241 fn two_url_less_endpoints_without_pooling_errors() {
242 let endpoints = [endpoint(None, false), endpoint(None, false)];
243 let err = validate_url_groups(&endpoints).unwrap_err();
244 assert!(err.to_string().contains("account's default dev domain"));
245 }
246
247 #[test]
248 fn two_endpoints_sharing_a_url_without_pooling_errors() {
249 let endpoints = [endpoint(Some("a.ngrok.app"), false), endpoint(Some("a.ngrok.app"), false)];
250 let err = validate_url_groups(&endpoints).unwrap_err();
251 assert!(err.to_string().contains("a.ngrok.app"));
252 }
253
254 #[test]
255 fn pooling_on_every_colliding_entry_does_not_error() {
256 let endpoints = [endpoint(None, true), endpoint(None, true)];
257 assert!(validate_url_groups(&endpoints).is_ok());
258 }
259
260 #[test]
261 fn pooling_on_only_some_colliding_entries_still_errors() {
262 let endpoints = [endpoint(None, true), endpoint(None, false)];
263 assert!(validate_url_groups(&endpoints).is_err());
264 }
265}