@@ -9,8 +9,9 @@ use std::{
99 sync:: Arc ,
1010} ;
1111
12- use anyhow:: { bail, Context as _, Result } ;
12+ use anyhow:: { anyhow , bail, Context as _, Result } ;
1313use clap:: Parser ;
14+ use http:: StatusCode ;
1415use iroh_base:: NodeId ;
1516use iroh_relay:: {
1617 defaults:: {
@@ -22,11 +23,16 @@ use iroh_relay::{
2223use n0_future:: FutureExt ;
2324use serde:: { Deserialize , Serialize } ;
2425use tokio_rustls_acme:: { caches:: DirCache , AcmeConfig } ;
25- use tracing:: debug;
26+ use tracing:: { debug, warn } ;
2627use tracing_subscriber:: { prelude:: * , EnvFilter } ;
28+ use url:: Url ;
2729
2830/// The default `http_bind_port` when using `--dev`.
2931const DEV_MODE_HTTP_PORT : u16 = 3340 ;
32+ /// The header name for setting the node id in HTTP auth requests.
33+ const X_IROH_NODE_ID : & str = "X-Iroh-NodeId" ;
34+ /// Environment variable to read a bearer token for HTTP auth requests from.
35+ const ENV_HTTP_BEARER_TOKEN : & str = "IROH_RELAY_HTTP_BEARER_TOKEN" ;
3036
3137/// A relay server for iroh.
3238#[ derive( Parser , Debug , Clone ) ]
@@ -181,6 +187,27 @@ enum AccessConfig {
181187 Allowlist ( Vec < NodeId > ) ,
182188 /// Allows everyone, except these nodes.
183189 Denylist ( Vec < NodeId > ) ,
190+ /// Performs a HTTP POST request to determine access for each node that connects to the relay.
191+ ///
192+ /// The request will have a header `X-Iroh-Node-Id` set to the hex-encoded node id attempting
193+ /// to connect to the relay.
194+ ///
195+ /// To grant access, the HTTP endpoint must return a `200` response with `true` as the response text.
196+ /// In all other cases, the node will be denied access.
197+ Http ( HttpAccessConfig ) ,
198+ }
199+
200+ #[ derive( Debug , Clone , Serialize , Deserialize , PartialEq , Eq ) ]
201+ struct HttpAccessConfig {
202+ /// The URL to send the `POST` request to.
203+ url : Url ,
204+ /// Optional bearer token for authorizing to the HTTP endpoint.
205+ ///
206+ /// If set, an `Authorization: Bearer {token}` header will be set on the HTTP request.
207+ /// The bearer token can also be set via the `IROH_RELAY_HTTP_BEARER_TOKEN` environment variable.
208+ /// If both the config and the environment variable are set, the value from the environment variable
209+ /// is used.
210+ bearer_token : Option < String > ,
184211}
185212
186213impl From < AccessConfig > for iroh_relay:: server:: AccessConfig {
@@ -215,7 +242,67 @@ impl From<AccessConfig> for iroh_relay::server::AccessConfig {
215242 . boxed ( )
216243 } ) )
217244 }
245+ AccessConfig :: Http ( mut config) => {
246+ let client = reqwest:: Client :: default ( ) ;
247+ // Allow to set bearer token via environment variable as well.
248+ if let Ok ( token) = std:: env:: var ( ENV_HTTP_BEARER_TOKEN ) {
249+ config. bearer_token = Some ( token) ;
250+ }
251+ let config = Arc :: new ( config) ;
252+ iroh_relay:: server:: AccessConfig :: Restricted ( Box :: new ( move |node_id| {
253+ let client = client. clone ( ) ;
254+ let config = config. clone ( ) ;
255+ async move { http_access_check ( & client, & config, node_id) . await } . boxed ( )
256+ } ) )
257+ }
258+ }
259+ }
260+ }
261+
262+ #[ tracing:: instrument( "http-access-check" , skip_all, fields( node_id=%node_id. fmt_short( ) ) ) ]
263+ async fn http_access_check (
264+ client : & reqwest:: Client ,
265+ config : & HttpAccessConfig ,
266+ node_id : NodeId ,
267+ ) -> iroh_relay:: server:: Access {
268+ use iroh_relay:: server:: Access ;
269+ debug ! ( url=%config. url, "Check relay access via HTTP POST" ) ;
270+
271+ match http_access_check_inner ( client, config, node_id) . await {
272+ Ok ( ( ) ) => {
273+ debug ! ( "HTTP access check OK: Allow access" ) ;
274+ Access :: Allow
275+ }
276+ Err ( err) => {
277+ debug ! ( "HTTP access check failed: Deny access (reason: {err:#})" ) ;
278+ Access :: Deny
279+ }
280+ }
281+ }
282+
283+ async fn http_access_check_inner (
284+ client : & reqwest:: Client ,
285+ config : & HttpAccessConfig ,
286+ node_id : NodeId ,
287+ ) -> Result < ( ) > {
288+ let mut request = client
289+ . post ( config. url . clone ( ) )
290+ . header ( X_IROH_NODE_ID , node_id. to_string ( ) ) ;
291+ if let Some ( token) = config. bearer_token . as_ref ( ) {
292+ request = request. header ( http:: header:: AUTHORIZATION , format ! ( "Bearer {token}" ) ) ;
293+ }
294+
295+ match request. send ( ) . await {
296+ Err ( err) => {
297+ warn ! ( "Failed to retrieve response for HTTP access check: {err:#}" ) ;
298+ Err ( err) . context ( "Failed to fetch response" )
218299 }
300+ Ok ( res) if res. status ( ) == StatusCode :: OK => match res. text ( ) . await {
301+ Ok ( text) if text == "true" => Ok ( ( ) ) ,
302+ Ok ( _) => Err ( anyhow ! ( "Invalid response text (must be 'true')" ) ) ,
303+ Err ( err) => Err ( err) . context ( "Failed to read response" ) ,
304+ } ,
305+ Ok ( res) => Err ( anyhow ! ( "Received invalid status code ({})" , res. status( ) ) ) ,
219306 }
220307}
221308
@@ -751,6 +838,57 @@ mod tests {
751838 let config = Config :: from_str ( dbg ! ( & config) ) ?;
752839 assert_eq ! ( config. access, AccessConfig :: Allowlist ( vec![ node_id] ) ) ;
753840
841+ let config = r#"
842+ access.http.url = "https://example.com/foo/bar?boo=baz"
843+ "#
844+ . to_string ( ) ;
845+ let config = Config :: from_str ( dbg ! ( & config) ) ?;
846+ assert_eq ! (
847+ config. access,
848+ AccessConfig :: Http ( HttpAccessConfig {
849+ url: "https://example.com/foo/bar?boo=baz" . parse( ) . unwrap( ) ,
850+ bearer_token: None
851+ } )
852+ ) ;
853+ let config = r#"
854+ access.http.url = "https://example.com/foo/bar?boo=baz"
855+ access.http.bearer_token = "foo"
856+ "#
857+ . to_string ( ) ;
858+ let config = Config :: from_str ( dbg ! ( & config) ) ?;
859+ assert_eq ! (
860+ config. access,
861+ AccessConfig :: Http ( HttpAccessConfig {
862+ url: "https://example.com/foo/bar?boo=baz" . parse( ) . unwrap( ) ,
863+ bearer_token: Some ( "foo" . to_string( ) )
864+ } )
865+ ) ;
866+
867+ let config = r#"
868+ access.http = { url = "https://example.com/foo" }
869+ "#
870+ . to_string ( ) ;
871+ let config = Config :: from_str ( dbg ! ( & config) ) ?;
872+ assert_eq ! (
873+ config. access,
874+ AccessConfig :: Http ( HttpAccessConfig {
875+ url: "https://example.com/foo" . parse( ) . unwrap( ) ,
876+ bearer_token: None
877+ } )
878+ ) ;
879+
880+ let config = r#"
881+ access.http = { url = "https://example.com/foo", bearer_token = "foo" }
882+ "#
883+ . to_string ( ) ;
884+ let config = Config :: from_str ( dbg ! ( & config) ) ?;
885+ assert_eq ! (
886+ config. access,
887+ AccessConfig :: Http ( HttpAccessConfig {
888+ url: "https://example.com/foo" . parse( ) . unwrap( ) ,
889+ bearer_token: Some ( "foo" . to_string( ) )
890+ } )
891+ ) ;
754892 Ok ( ( ) )
755893 }
756894}
0 commit comments