@@ -9,8 +9,9 @@ use std::{
9
9
sync:: Arc ,
10
10
} ;
11
11
12
- use anyhow:: { bail, Context as _, Result } ;
12
+ use anyhow:: { anyhow , bail, Context as _, Result } ;
13
13
use clap:: Parser ;
14
+ use http:: StatusCode ;
14
15
use iroh_base:: NodeId ;
15
16
use iroh_relay:: {
16
17
defaults:: {
@@ -22,11 +23,16 @@ use iroh_relay::{
22
23
use n0_future:: FutureExt ;
23
24
use serde:: { Deserialize , Serialize } ;
24
25
use tokio_rustls_acme:: { caches:: DirCache , AcmeConfig } ;
25
- use tracing:: debug;
26
+ use tracing:: { debug, warn } ;
26
27
use tracing_subscriber:: { prelude:: * , EnvFilter } ;
28
+ use url:: Url ;
27
29
28
30
/// The default `http_bind_port` when using `--dev`.
29
31
const 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" ;
30
36
31
37
/// A relay server for iroh.
32
38
#[ derive( Parser , Debug , Clone ) ]
@@ -181,6 +187,27 @@ enum AccessConfig {
181
187
Allowlist ( Vec < NodeId > ) ,
182
188
/// Allows everyone, except these nodes.
183
189
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 > ,
184
211
}
185
212
186
213
impl From < AccessConfig > for iroh_relay:: server:: AccessConfig {
@@ -215,7 +242,67 @@ impl From<AccessConfig> for iroh_relay::server::AccessConfig {
215
242
. boxed ( )
216
243
} ) )
217
244
}
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" )
218
299
}
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( ) ) ) ,
219
306
}
220
307
}
221
308
@@ -751,6 +838,57 @@ mod tests {
751
838
let config = Config :: from_str ( dbg ! ( & config) ) ?;
752
839
assert_eq ! ( config. access, AccessConfig :: Allowlist ( vec![ node_id] ) ) ;
753
840
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
+ ) ;
754
892
Ok ( ( ) )
755
893
}
756
894
}
0 commit comments