#![cfg(feature = "server")]
use std::{
collections::HashMap,
net::Ipv4Addr,
sync::{Arc, Mutex},
time::Duration,
};
use iroh_base::{EndpointId, RelayUrl, SecretKey};
use iroh_dns::dns::DnsResolver;
use iroh_relay::{
client::{Client, ClientBuilder, ConnectError},
protos::{
handshake,
relay::{ClientToRelayMsg, RelayToClientMsg},
},
server::{
Access, AccessControl, ClientRequest, ConnectionId, RelayConfig, Server, ServerConfig,
},
tls::{CaTlsConfig, default_provider},
};
use n0_error::{Result, StackResultExt, StdResultExt};
use n0_future::{SinkExt, StreamExt};
use n0_tracing_test::traced_test;
use rand::RngExt;
#[derive(Debug, Default)]
struct AccessState {
next_token_id: u64,
tokens: HashMap<String, u64>,
connections: HashMap<(EndpointId, ConnectionId), u64>,
}
#[derive(Debug, Default)]
struct TokenAccess(Mutex<AccessState>);
impl TokenAccess {
fn new(allowed: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
let this = TokenAccess::default();
for token in allowed {
this.allow(token);
}
Arc::new(this)
}
fn allow(&self, token: &str) {
let mut state = self.0.lock().expect("poisoned");
if !state.tokens.contains_key(token) {
let id = state.next_token_id;
state.next_token_id += 1;
state.tokens.insert(token.to_string(), id);
}
}
fn revoke(&self, revoked_token: &str) -> Vec<(EndpointId, ConnectionId)> {
let mut state = self.0.lock().expect("poisoned");
let mut removed = vec![];
if let Some(revoked_id) = state.tokens.remove(revoked_token) {
state.connections.retain(|conn_id, token_id| {
if *token_id == revoked_id {
removed.push(*conn_id);
false
} else {
true
}
});
}
removed
}
}
impl AccessControl for TokenAccess {
async fn on_connect(&self, request: &ClientRequest) -> Access {
let mut state = self.0.lock().expect("poisoned");
match request.auth_token() {
Some(token) => {
if let Some(token_id) = state.tokens.get(&token).copied() {
let conn_id = (request.endpoint_id(), request.connection_id());
state.connections.insert(conn_id, token_id);
Access::Allow
} else {
Access::Deny { reason: None }
}
}
None => Access::Deny { reason: None },
}
}
fn on_disconnect(&self, endpoint_id: EndpointId, connection_id: ConnectionId) {
let mut state = self.0.lock().expect("poisoned");
state.connections.remove(&(endpoint_id, connection_id));
}
}
struct RestrictedServer {
server: Server,
access: Arc<TokenAccess>,
}
impl RestrictedServer {
async fn spawn(allowed: impl IntoIterator<Item = &'static str>) -> Result<Self> {
let access = TokenAccess::new(allowed);
let mut relay = RelayConfig::new((Ipv4Addr::LOCALHOST, 0));
relay.access = access.clone();
let mut config = ServerConfig::default();
config.relay = Some(relay);
let server = Server::spawn(config).await?;
Ok(Self { server, access })
}
fn relay_url(&self) -> RelayUrl {
format!("http://{}", self.server.http_addr().expect("http addr"))
.parse()
.expect("valid relay url")
}
fn add_token(&self, token: &str) {
self.access.allow(token);
}
fn remove_token(&self, token: &str) {
let clients = self
.server
.relay_service()
.expect("relay configured")
.clients();
for (endpoint_id, connection_id) in self.access.revoke(token) {
clients.disconnect(endpoint_id, Some(connection_id));
}
}
fn connection_count(&self) -> usize {
self.access.0.lock().expect("poisoned").connections.len()
}
}
#[tokio::test]
#[traced_test]
async fn unknown_token_is_denied() -> Result<()> {
let server = RestrictedServer::spawn(["token-a"]).await?;
assert_denied(connect(&server.relay_url(), "token-b").await);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn token_added_at_runtime_is_admitted() -> Result<()> {
let server = RestrictedServer::spawn(["token-b"]).await?;
let url = server.relay_url();
assert_denied(connect(&url, "token-a").await);
server.add_token("token-a");
let mut client = connect(&url, "token-a").await?;
ping_round_trip(&mut client, [1u8; 8]).await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn revoked_token_disconnects_its_connection() -> Result<()> {
let server = RestrictedServer::spawn(["token-a", "token-b"]).await?;
let url = server.relay_url();
let mut revoked = connect(&url, "token-a").await?;
let mut kept = connect(&url, "token-b").await?;
ping_round_trip(&mut revoked, [1u8; 8]).await?;
ping_round_trip(&mut kept, [2u8; 8]).await?;
server.remove_token("token-a");
assert_disconnected(&mut revoked).await;
ping_round_trip(&mut kept, [3u8; 8]).await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn revoked_token_disconnects_every_endpoint_connection() -> Result<()> {
let server = RestrictedServer::spawn(["token-a", "token-b"]).await?;
let url = server.relay_url();
let shared = SecretKey::from_bytes(&rand::rng().random());
let mut conn1 = connect_as(&url, &shared, "token-a").await?;
ping_round_trip(&mut conn1, [1u8; 8]).await?;
let mut conn2 = connect_as(&url, &shared, "token-a").await?;
ping_round_trip(&mut conn2, [2u8; 8]).await?;
let mut other = connect_as(&url, &shared, "token-b").await?;
ping_round_trip(&mut other, [3u8; 8]).await?;
assert_eq!(server.connection_count(), 3);
server.remove_token("token-a");
assert_disconnected(&mut conn1).await;
assert_disconnected(&mut conn2).await;
ping_round_trip(&mut other, [4u8; 8]).await?;
wait_for(Duration::from_secs(5), || server.connection_count() == 1).await;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn revoked_token_cannot_reconnect() -> Result<()> {
let server = RestrictedServer::spawn(["token-a"]).await?;
let url = server.relay_url();
let mut client = connect(&url, "token-a").await?;
ping_round_trip(&mut client, [1u8; 8]).await?;
server.remove_token("token-a");
assert_denied(connect(&url, "token-a").await);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn disconnected_connections_are_pruned() -> Result<()> {
let server = RestrictedServer::spawn(["token-a"]).await?;
let url = server.relay_url();
let mut client = connect(&url, "token-a").await?;
ping_round_trip(&mut client, [1u8; 8]).await?;
assert_eq!(server.connection_count(), 1);
drop(client);
wait_for(Duration::from_secs(5), || server.connection_count() == 0).await;
Ok(())
}
async fn connect(relay_url: &RelayUrl, token: &str) -> Result<Client, ConnectError> {
let secret = SecretKey::from_bytes(&rand::rng().random());
connect_as(relay_url, &secret, token).await
}
async fn connect_as(
relay_url: &RelayUrl,
secret: &SecretKey,
token: &str,
) -> Result<Client, ConnectError> {
let tls = CaTlsConfig::default()
.client_config(default_provider())
.expect("valid client config");
ClientBuilder::new(relay_url.clone(), secret.clone(), DnsResolver::new())
.tls_client_config(tls)
.auth_token(token)
.connect()
.await
}
async fn ping_round_trip(client: &mut Client, data: [u8; 8]) -> Result<()> {
client.send(ClientToRelayMsg::Ping(data)).await?;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let msg = client.next().await.context("stream ended")??;
match msg {
RelayToClientMsg::Ping(payload) => {
client.send(ClientToRelayMsg::Pong(payload)).await?;
}
RelayToClientMsg::Pong(echo) => {
n0_error::ensure_any!(echo == data, "pong mismatch");
break;
}
other => n0_error::bail_any!("expected pong, got {other:?}"),
}
}
n0_error::Ok(())
})
.await
.std_context("ping roundtrip timeout")?
}
async fn assert_disconnected(client: &mut Client) {
tokio::time::timeout(Duration::from_secs(5), async {
while let Some(msg) = client.next().await {
let _ = msg.expect("expected message, got error");
}
})
.await
.expect("timeout while waiting for disconnect");
}
fn assert_denied(result: Result<Client, ConnectError>) {
let result = result.map(|_| ());
assert!(
matches!(
result,
Err(ConnectError::Handshake { source: handshake::Error::ServerDeniedAuth { ref reason, .. }, .. })
if reason == "not authorized"
),
"expected handshake denial, got {result:?}",
);
}
async fn wait_for(timeout: Duration, cond: impl Fn() -> bool) {
tokio::time::timeout(timeout, async {
while !cond() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("condition not met within timeout");
}