Skip to main content

ngrok_axum/
lib.rs

1//! Serve an Axum app through an ngrok tunnel with one call.
2//!
3//! `axum::Server` was removed in axum 0.7, which broke the one-liner
4//! `ngrok-rust` used to offer (see <https://github.com/ngrok/ngrok-rust/issues/136>).
5//! The fix upstream was to replace the example with a ~30-line manual
6//! connection-serving loop, not to restore a one-liner. [`serve`] is that
7//! restored one-liner.
8//!
9//! ```no_run
10//! use axum::{routing::get, Router};
11//!
12//! #[tokio::main]
13//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14//!     let app = Router::new().route("/", get(|| async { "hello" }));
15//!     ngrok_axum::serve(app, ngrok_axum::Config::default()).await?;
16//!     Ok(())
17//! }
18//! ```
19
20use 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/// Ingress binding for the endpoint. Not part of ngrok's Traffic Policy —
31/// confirmed by checking the Traffic Policy actions reference directly, so
32/// this stays a standalone field rather than folded into `traffic_policy`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Binding {
35    Public,
36    /// Requires `url` to end in `.internal` — enforced by ngrok itself
37    /// (`ERR_NGROK_9029` if it doesn't), not something this crate validates.
38    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/// Configuration for a single ngrok endpoint.
53///
54/// Deliberately minimal, same reasoning as the `ngrok-nextjs` sibling
55/// project's config: the ~15 granular per-module builder methods on
56/// `HttpTunnelBuilder` (`basic_auth`, `oauth`, header manipulation, CIDR
57/// restrictions, etc.) mirror ngrok's old "Edge Modules" system, superseded
58/// by Traffic Policy — confirmed against ngrok's actions reference, not
59/// assumed. Use `traffic_policy` for all of that instead.
60#[derive(Debug, Default, Clone)]
61pub struct Config {
62    /// Reserved domain for this endpoint. `None` falls back to the
63    /// account's default dev domain.
64    pub url: Option<String>,
65    /// Opt in to ngrok endpoint pooling — required if another endpoint on
66    /// the same session would otherwise land on the same domain. ngrok does
67    /// not reject or warn on that collision by default: confirmed live
68    /// against the real SDK that two listeners opened with no domain and no
69    /// pooling both succeed silently, returning the identical URL, with
70    /// only the most recently opened one actually receiving traffic.
71    pub pooling: bool,
72    /// Raw ngrok Traffic Policy document (YAML or JSON), passed straight
73    /// through. See <https://ngrok.com/docs/traffic-policy/>.
74    pub traffic_policy: Option<String>,
75    pub binding: Option<Binding>,
76}
77
78/// One app bound to one endpoint, for [`serve_many`]. The same `Router` can
79/// be cloned across multiple `Endpoint`s to pool one app behind several
80/// listeners.
81pub struct Endpoint {
82    pub app: Router,
83    pub config: Config,
84}
85
86/// Opens a single ngrok HTTP endpoint per `config` and serves `app` on it.
87/// Shorthand for the one-endpoint case of [`serve_many`].
88pub async fn serve(app: Router, config: Config) -> Result<(), BoxError> {
89    serve_many(vec![Endpoint { app, config }]).await
90}
91
92/// Opens one ngrok HTTP endpoint per entry in `endpoints`, all on a single
93/// session, and serves each endpoint's app concurrently until every
94/// listener closes or a connection-level error occurs.
95pub 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
160/// ngrok does not reject or warn when two listeners in the same session end
161/// up on the same public url — it silently lets the most recently opened
162/// listener win, and the other becomes unreachable with zero indication of
163/// why. Confirmed live against the real SDK, isolated from this crate's own
164/// code (a standalone program opening two listeners with no domain, no
165/// pooling, saw both succeed with the identical URL, no error either time).
166/// An endpoint with no explicit `url` falls back to the account's default
167/// dev domain, so two url-less endpoints collide there just as surely as
168/// two hardcoding the same string. Either case is fine *if* every endpoint
169/// sharing that url opts into `pooling: true` — otherwise it's the
170/// silent-failure footgun, so fail fast instead.
171///
172/// Note this is a different failure mode than a *cross-session* claim on a
173/// domain (another running agent, or a dashboard-configured Cloud Endpoint)
174/// — ngrok rejects that loudly on its own (`ERR_NGROK_334`), confirmed live;
175/// this guard only covers the silent same-session case ngrok doesn't catch.
176fn 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}