Skip to content

Commit 592c3b5

Browse files
authored
feat(iroh-relay): allow to authenticate nodes via a HTTP POST request (#3246)
## Description This adds a new `access` config option to the `iroh-relay` binary: If set to `http`, a `POST` request will be performed for each node that attempts to connect to the relay. The URL to request is set in the config, and a `X-Iroh-NodeId` header will be set to the hex-encoded node id to check access for. If and only if the response has a 200 status code, and a response text of `true`, the connecting node will be granted access. In all other cases, the node will be denied access. Config example to use this: ```toml access.http.url = "http://localhost:8000/api/relays/check-auth/frando" ``` ## Breaking Changes <!-- Optional, if there are any breaking changes document them, including how to migrate older code. --> ## Notes & open questions <!-- Any notes, remarks or open questions you have to make about the PR. --> ## Change checklist <!-- Remove any that are not relevant. --> - [x] Self-review. - [x] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [x] Tests if relevant.
1 parent bc6e98c commit 592c3b5

File tree

1 file changed

+140
-2
lines changed

1 file changed

+140
-2
lines changed

iroh-relay/src/main.rs

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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};
1313
use clap::Parser;
14+
use http::StatusCode;
1415
use iroh_base::NodeId;
1516
use iroh_relay::{
1617
defaults::{
@@ -22,11 +23,16 @@ use iroh_relay::{
2223
use n0_future::FutureExt;
2324
use serde::{Deserialize, Serialize};
2425
use tokio_rustls_acme::{caches::DirCache, AcmeConfig};
25-
use tracing::debug;
26+
use tracing::{debug, warn};
2627
use tracing_subscriber::{prelude::*, EnvFilter};
28+
use url::Url;
2729

2830
/// The default `http_bind_port` when using `--dev`.
2931
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";
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

186213
impl 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

Comments
 (0)