Skip to content

Commit 605ec86

Browse files
committed
allow implicit bindings
ngrok now supports several different types of bindings (internal, public,and kubernetes), but we were forcing all bindings to be public, which is...not ideal. This commit changes things so that bindings are no longer sent by default (which will allow ngrok to infer them), but can also optionally be specified. The combination of these should fix not only .internal domains, but also allow the use of the Rust SDK in a Kubernetes context.
1 parent 614f1db commit 605ec86

File tree

6 files changed

+370
-16
lines changed

6 files changed

+370
-16
lines changed

ngrok/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
## Unreleased
2+
3+
### Breaking Changes
4+
- **Binding is now optional**: Tests no longer hardcode `binding("public")`. The ngrok service will use its default binding configuration when not explicitly specified.
5+
- **Binding validation**: The `binding()` method now validates input values and panics on invalid values or multiple calls.
6+
7+
### Added
8+
- Added `Binding` enum with three variants: `Public`, `Internal`, and `Kubernetes`
9+
- Added validation for binding values - only "public", "internal", and "kubernetes" are accepted (case-insensitive)
10+
- Added `binding()` method documentation with examples for both string and typed enum usage
11+
- Added panic behavior when `binding()` is called more than once (only one binding allowed)
12+
13+
### Changed
14+
- `binding()` method now accepts both strings and the `Binding` enum via `Into<String>`
15+
- Removed hardcoded "public" binding from all tests - bindings are now truly optional
16+
117
## 0.15.0
218
- - Removes `hyper-proxy` and `ring` dependencies
319

ngrok/src/config/common.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,69 @@ use crate::{
2323
Tunnel,
2424
};
2525

26+
/// Represents the ingress configuration for an ngrok endpoint.
27+
///
28+
/// Bindings determine where and how your endpoint is exposed.
29+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30+
pub enum Binding {
31+
/// Publicly accessible endpoint (default for most configurations).
32+
Public,
33+
/// Internal-only endpoint, not accessible from the public internet.
34+
Internal,
35+
/// Kubernetes cluster binding for service mesh integration.
36+
Kubernetes,
37+
}
38+
39+
impl Binding {
40+
/// Returns the string representation of this binding.
41+
pub fn as_str(&self) -> &'static str {
42+
match self {
43+
Binding::Public => "public",
44+
Binding::Internal => "internal",
45+
Binding::Kubernetes => "kubernetes",
46+
}
47+
}
48+
49+
/// Validates if a string is a recognized binding value.
50+
pub(crate) fn validate(s: &str) -> Result<(), String> {
51+
match s.to_lowercase().as_str() {
52+
"public" | "internal" | "kubernetes" => Ok(()),
53+
_ => Err(format!(
54+
"Invalid binding value '{}'. Expected 'public', 'internal', or 'kubernetes'",
55+
s
56+
)),
57+
}
58+
}
59+
}
60+
61+
impl From<Binding> for String {
62+
fn from(binding: Binding) -> String {
63+
binding.as_str().to_string()
64+
}
65+
}
66+
67+
impl std::str::FromStr for Binding {
68+
type Err = String;
69+
70+
fn from_str(s: &str) -> Result<Self, Self::Err> {
71+
match s.to_lowercase().as_str() {
72+
"public" => Ok(Binding::Public),
73+
"internal" => Ok(Binding::Internal),
74+
"kubernetes" => Ok(Binding::Kubernetes),
75+
_ => Err(format!(
76+
"Invalid binding value '{}'. Expected 'public', 'internal', or 'kubernetes'",
77+
s
78+
)),
79+
}
80+
}
81+
}
82+
83+
impl std::fmt::Display for Binding {
84+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85+
write!(f, "{}", self.as_str())
86+
}
87+
}
88+
2689
pub(crate) fn default_forwards_to() -> &'static str {
2790
static FORWARDS_TO: OnceCell<String> = OnceCell::new();
2891

ngrok/src/config/http.rs

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::{
2323
config::{
2424
common::{
2525
default_forwards_to,
26+
Binding,
2627
CommonOpts,
2728
TunnelConfig,
2829
},
@@ -261,9 +262,45 @@ impl HttpTunnelBuilder {
261262
self.options.common_opts.metadata = Some(metadata.into());
262263
self
263264
}
264-
/// Sets the ingress configuration for this endpoint
265+
266+
/// Sets the ingress configuration for this endpoint.
267+
///
268+
/// Valid binding values are:
269+
/// - `"public"` - Publicly accessible endpoint
270+
/// - `"internal"` - Internal-only endpoint
271+
/// - `"kubernetes"` - Kubernetes cluster binding
272+
///
273+
/// If not specified, the ngrok service will use its default binding configuration.
274+
///
275+
/// # Panics
276+
///
277+
/// Panics if called more than once or if an invalid binding value is provided.
278+
///
279+
/// # Examples
280+
///
281+
/// ```no_run
282+
/// # use ngrok::Session;
283+
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
284+
/// let session = Session::builder().authtoken_from_env().connect().await?;
285+
///
286+
/// // Using string
287+
/// let tunnel = session.http_endpoint().binding("internal").listen().await?;
288+
///
289+
/// // Using typed enum
290+
/// use ngrok::config::Binding;
291+
/// let tunnel = session.http_endpoint().binding(Binding::Public).listen().await?;
292+
/// # Ok(())
293+
/// # }
294+
/// ```
265295
pub fn binding(&mut self, binding: impl Into<String>) -> &mut Self {
266-
self.options.bindings.push(binding.into());
296+
if !self.options.bindings.is_empty() {
297+
panic!("binding() can only be called once");
298+
}
299+
let binding_str = binding.into();
300+
if let Err(e) = Binding::validate(&binding_str) {
301+
panic!("{}", e);
302+
}
303+
self.options.bindings.push(binding_str);
267304
self
268305
}
269306
/// Sets the ForwardsTo string for this tunnel. This can be viewed via the
@@ -482,7 +519,6 @@ impl HttpTunnelBuilder {
482519
mod test {
483520
use super::*;
484521
use crate::config::policies::test::POLICY_JSON;
485-
const BINDING: &str = "public";
486522
const METADATA: &str = "testmeta";
487523
const TEST_FORWARD: &str = "testforward";
488524
const TEST_FORWARD_PROTO: &str = "http2";
@@ -509,7 +545,6 @@ mod test {
509545
.deny_cidr(DENY_CIDR)
510546
.proxy_proto(ProxyProto::V2)
511547
.metadata(METADATA)
512-
.binding(BINDING)
513548
.scheme(Scheme::from_str("hTtPs").unwrap())
514549
.domain(DOMAIN)
515550
.mutual_tlsca(CA_CERT.into())
@@ -554,7 +589,7 @@ mod test {
554589
let extra = tunnel_cfg.extra();
555590
assert_eq!(String::default(), *extra.token);
556591
assert_eq!(METADATA, extra.metadata);
557-
assert_eq!(Vec::from([BINDING]), extra.bindings);
592+
assert_eq!(Vec::<String>::new(), extra.bindings);
558593
assert_eq!(String::default(), extra.ip_policy_ref);
559594

560595
assert_eq!("https", tunnel_cfg.proto());
@@ -623,4 +658,61 @@ mod test {
623658

624659
assert_eq!(HashMap::new(), tunnel_cfg.labels());
625660
}
661+
662+
#[test]
663+
fn test_binding_valid_values() {
664+
let mut builder = HttpTunnelBuilder {
665+
session: None,
666+
options: Default::default(),
667+
};
668+
669+
// Test "public"
670+
builder.binding("public");
671+
assert_eq!(vec!["public"], builder.options.bindings);
672+
673+
// Test "internal"
674+
let mut builder = HttpTunnelBuilder {
675+
session: None,
676+
options: Default::default(),
677+
};
678+
builder.binding("internal");
679+
assert_eq!(vec!["internal"], builder.options.bindings);
680+
681+
// Test "kubernetes"
682+
let mut builder = HttpTunnelBuilder {
683+
session: None,
684+
options: Default::default(),
685+
};
686+
builder.binding("kubernetes");
687+
assert_eq!(vec!["kubernetes"], builder.options.bindings);
688+
689+
// Test with Binding enum
690+
let mut builder = HttpTunnelBuilder {
691+
session: None,
692+
options: Default::default(),
693+
};
694+
builder.binding(Binding::Internal);
695+
assert_eq!(vec!["internal"], builder.options.bindings);
696+
}
697+
698+
#[test]
699+
#[should_panic(expected = "Invalid binding value")]
700+
fn test_binding_invalid_value() {
701+
let mut builder = HttpTunnelBuilder {
702+
session: None,
703+
options: Default::default(),
704+
};
705+
builder.binding("invalid");
706+
}
707+
708+
#[test]
709+
#[should_panic(expected = "binding() can only be called once")]
710+
fn test_binding_called_twice() {
711+
let mut builder = HttpTunnelBuilder {
712+
session: None,
713+
options: Default::default(),
714+
};
715+
builder.binding("public");
716+
builder.binding("internal");
717+
}
626718
}

ngrok/src/config/tcp.rs

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::config::{
1818
use crate::{
1919
config::common::{
2020
default_forwards_to,
21+
Binding,
2122
CommonOpts,
2223
TunnelConfig,
2324
},
@@ -127,9 +128,45 @@ impl TcpTunnelBuilder {
127128
self.options.common_opts.metadata = Some(metadata.into());
128129
self
129130
}
130-
/// Sets the ingress configuration for this endpoint
131+
132+
/// Sets the ingress configuration for this endpoint.
133+
///
134+
/// Valid binding values are:
135+
/// - `"public"` - Publicly accessible endpoint
136+
/// - `"internal"` - Internal-only endpoint
137+
/// - `"kubernetes"` - Kubernetes cluster binding
138+
///
139+
/// If not specified, the ngrok service will use its default binding configuration.
140+
///
141+
/// # Panics
142+
///
143+
/// Panics if called more than once or if an invalid binding value is provided.
144+
///
145+
/// # Examples
146+
///
147+
/// ```no_run
148+
/// # use ngrok::Session;
149+
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
150+
/// let session = Session::builder().authtoken_from_env().connect().await?;
151+
///
152+
/// // Using string
153+
/// let tunnel = session.tcp_endpoint().binding("internal").listen().await?;
154+
///
155+
/// // Using typed enum
156+
/// use ngrok::config::Binding;
157+
/// let tunnel = session.tcp_endpoint().binding(Binding::Public).listen().await?;
158+
/// # Ok(())
159+
/// # }
160+
/// ```
131161
pub fn binding(&mut self, binding: impl Into<String>) -> &mut Self {
132-
self.options.bindings.push(binding.into());
162+
if !self.options.bindings.is_empty() {
163+
panic!("binding() can only be called once");
164+
}
165+
let binding_str = binding.into();
166+
if let Err(e) = Binding::validate(&binding_str) {
167+
panic!("{}", e);
168+
}
169+
self.options.bindings.push(binding_str);
133170
self
134171
}
135172
/// Sets the ForwardsTo string for this tunnel. This can be viewed via the
@@ -192,7 +229,6 @@ impl TcpTunnelBuilder {
192229
mod test {
193230
use super::*;
194231
use crate::config::policies::test::POLICY_JSON;
195-
const BINDING: &str = "public";
196232
const METADATA: &str = "testmeta";
197233
const TEST_FORWARD: &str = "testforward";
198234
const REMOTE_ADDR: &str = "4.tcp.ngrok.io:1337";
@@ -212,7 +248,6 @@ mod test {
212248
.deny_cidr(DENY_CIDR)
213249
.proxy_proto(ProxyProto::V2)
214250
.metadata(METADATA)
215-
.binding(BINDING)
216251
.remote_addr(REMOTE_ADDR)
217252
.forwards_to(TEST_FORWARD)
218253
.policy(POLICY_JSON)
@@ -230,7 +265,7 @@ mod test {
230265
let extra = tunnel_cfg.extra();
231266
assert_eq!(String::default(), *extra.token);
232267
assert_eq!(METADATA, extra.metadata);
233-
assert_eq!(Vec::from([BINDING]), extra.bindings);
268+
assert_eq!(Vec::<String>::new(), extra.bindings);
234269
assert_eq!(String::default(), extra.ip_policy_ref);
235270

236271
assert_eq!("tcp", tunnel_cfg.proto());
@@ -248,4 +283,61 @@ mod test {
248283

249284
assert_eq!(HashMap::new(), tunnel_cfg.labels());
250285
}
286+
287+
#[test]
288+
fn test_binding_valid_values() {
289+
let mut builder = TcpTunnelBuilder {
290+
session: None,
291+
options: Default::default(),
292+
};
293+
294+
// Test "public"
295+
builder.binding("public");
296+
assert_eq!(vec!["public"], builder.options.bindings);
297+
298+
// Test "internal"
299+
let mut builder = TcpTunnelBuilder {
300+
session: None,
301+
options: Default::default(),
302+
};
303+
builder.binding("internal");
304+
assert_eq!(vec!["internal"], builder.options.bindings);
305+
306+
// Test "kubernetes"
307+
let mut builder = TcpTunnelBuilder {
308+
session: None,
309+
options: Default::default(),
310+
};
311+
builder.binding("kubernetes");
312+
assert_eq!(vec!["kubernetes"], builder.options.bindings);
313+
314+
// Test with Binding enum
315+
let mut builder = TcpTunnelBuilder {
316+
session: None,
317+
options: Default::default(),
318+
};
319+
builder.binding(Binding::Public);
320+
assert_eq!(vec!["public"], builder.options.bindings);
321+
}
322+
323+
#[test]
324+
#[should_panic(expected = "Invalid binding value")]
325+
fn test_binding_invalid_value() {
326+
let mut builder = TcpTunnelBuilder {
327+
session: None,
328+
options: Default::default(),
329+
};
330+
builder.binding("invalid");
331+
}
332+
333+
#[test]
334+
#[should_panic(expected = "binding() can only be called once")]
335+
fn test_binding_called_twice() {
336+
let mut builder = TcpTunnelBuilder {
337+
session: None,
338+
options: Default::default(),
339+
};
340+
builder.binding("public");
341+
builder.binding("internal");
342+
}
251343
}

0 commit comments

Comments
 (0)