How to Implement Custom Connection Filtering Using EndpointHooks in iroh
You can implement custom connection filtering in iroh by implementing the EndpointHooks trait and attaching it to your Endpoint builder, allowing you to intercept connections before they are established (before_connect) or after the TLS handshake completes (after_handshake).
The iroh library from n0-computer provides a robust QUIC-based networking stack with a flexible Endpoint Hooks mechanism for custom connection filtering. By implementing the EndpointHooks trait defined in iroh/src/endpoint/hooks.rs, you can inspect, log, or reject connections based on IP addresses, endpoint IDs, or application-specific logic without modifying the core networking code.
Understanding Endpoint Hooks in iroh
The Endpoint Hooks system provides two distinct interception points in the connection lifecycle. When you attach hooks to an endpoint using Builder::hooks, they are stored in an EndpointHooksList and executed sequentially in the order they were added.
The Two Hook Points
Hooks can intercept connections at two critical stages:
-
before_connect: Runs immediately after an outgoing connection is requested viaEndpoint::connectbut before any packets are sent. It receives the remote address and ALPN identifier, returningBeforeConnectOutcome::Acceptto proceed orBeforeConnectOutcome::Rejectto abort locally without network traffic. -
after_handshake: Executes after the TLS (QUIC) handshake completes for both incoming and outgoing connections. At this stage, the remote's endpoint ID and ALPN are known via theConnectionobject. It returnsAfterHandshakeOutcome::AcceptorAfterHandshakeOutcome::Reject { error_code, reason }to close the connection with a specific QUIC error code.
The EndpointHooks Trait Definition
The core trait in iroh/src/endpoint/hooks.rs defines the hook interface:
pub trait EndpointHooks: std::fmt::Debug + Send + Sync {
fn before_connect<'a>(
&'a self,
_remote_addr: &'a EndpointAddr,
_alpn: &'a [u8],
) -> impl Future<Output = BeforeConnectOutcome> + Send + 'a {
async { BeforeConnectOutcome::Accept }
}
fn after_handshake<'a>(
&'a self,
_conn: &'a Connection,
) -> impl Future<Output = AfterHandshakeOutcome> + Send + 'a {
async { AfterHandshakeOutcome::Accept }
}
}
When building an endpoint, the Builder::hooks method stores each hook in a Vec<Box<dyn DynEndpointHooks>>, allowing heterogeneous hook types to be chained together.
Implementing Custom Connection Filtering
To create a custom filter, you define a struct holding your filtering criteria and implement EndpointHooks with your specific logic.
Step-by-Step Implementation
-
Define your filter struct containing state such as whitelists, blacklists, or rate limiters.
-
Implement
EndpointHooksfor your struct, overridingbefore_connectfor outbound filtering orafter_handshakefor inbound filtering. -
Return rejection outcomes when connections should be denied. For
after_handshake, provide aVarInterror code and reason string. -
Attach the hook when constructing your endpoint using the builder pattern.
use iroh::endpoint::{Endpoint, EndpointHooks, Connection, BeforeConnectOutcome, AfterHandshakeOutcome};
use iroh::EndpointAddr;
use std::collections::HashSet;
use std::net::IpAddr;
#[derive(Debug)]
struct IpFilter {
blocked_ips: HashSet<IpAddr>,
}
impl EndpointHooks for IpFilter {
async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
let remote_ip = conn.remote_addr().ip();
if self.blocked_ips.contains(&remote_ip) {
// QUIC error code 0x0b represents "Connection Refused"
AfterHandshakeOutcome::reject(
iroh::endpoint::VarInt::from_u32(0x0b),
b"IP address blocked"
)
} else {
AfterHandshakeOutcome::Accept
}
}
}
// Usage
let filter = IpFilter { blocked_ips: HashSet::new() };
let endpoint = Endpoint::builder()
.hooks(filter)
.bind("127.0.0.1:0".parse()?)?
.listen()
.await?;
Practical Filtering Examples
The iroh repository provides concrete examples demonstrating different filtering strategies.
Inbound IP Filtering
The file iroh/examples/incoming-filter.rs demonstrates a hook that rejects incoming connections from disallowed IP ranges. The implementation checks the remote IP address during after_handshake:
use iroh::endpoint::{EndpointHooks, Connection, AfterHandshakeOutcome};
use ipnet::Ipv4Net;
use std::net::IpAddr;
#[derive(Debug)]
struct IncomingFilter {
allowed_subnet: Ipv4Net,
}
impl EndpointHooks for IncomingFilter {
async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
let remote_ip = conn.remote_addr().ip();
if self.allowed_subnet.contains(&remote_ip) {
AfterHandshakeOutcome::Accept
} else {
AfterHandshakeOutcome::reject(
iroh::endpoint::VarInt::from_u32(0x0b),
b"IP not in allowed subnet"
)
}
}
}
This approach is effective for network-level access control because the TLS handshake completes before rejection, allowing you to log the attempt with full identity information.
Outbound Endpoint ID Whitelisting
For strict trust boundaries on outgoing connections, implement before_connect to filter by the remote's endpoint ID (a 256-bit public key). This aborts the connection before any packets leave the local machine:
use iroh::endpoint::{EndpointHooks, EndpointAddr, BeforeConnectOutcome};
use iroh::endpoint::EndpointId;
use std::collections::HashSet;
#[derive(Debug)]
struct OutboundWhitelist {
allowed_ids: HashSet<EndpointId>,
}
impl EndpointHooks for OutboundWhitelist {
async fn before_connect(
&self,
remote_addr: &EndpointAddr,
_alpn: &[u8],
) -> BeforeConnectOutcome {
if self.allowed_ids.contains(&remote_addr.id) {
BeforeConnectOutcome::Accept
} else {
BeforeConnectOutcome::Reject
}
}
}
The auth-hook.rs example in the repository demonstrates advanced authentication patterns using this approach.
Best Practices for Hook Implementation
When implementing custom connection filtering using EndpointHooks, follow these guidelines to ensure safety and performance:
-
Avoid storing an
Endpointreference inside your hook struct. This creates a reference-count cycle that prevents the endpoint from being dropped, as noted in the documentation foriroh/src/endpoint/hooks.rs. -
Keep hook execution lightweight since they run on the connection task's executor. Offload heavy computations to background tasks using
tokio::spawnand channels. -
Prefer
after_handshakefor inbound filtering because only then is the remote endpoint ID authenticated and available via theConnectionobject. -
Return descriptive error codes when rejecting in
after_handshake. Use appropriate QUIC error codes (e.g.,0x0bfor connection refused) and clear reason strings to aid remote diagnostics. -
Chain multiple hooks by calling
.hooks(filter1).hooks(filter2)on the builder. They execute in registration order, so place the most restrictive filters first.
Summary
- EndpointHooks in
iroh/src/endpoint/hooks.rsprovidesbefore_connectandafter_handshakeinterception points for custom connection filtering. - Implement the trait on your custom struct to define accept/reject logic based on IP addresses, endpoint IDs, or ALPN identifiers.
- Attach hooks via
Endpoint::builder().hooks(your_filter)before callingbind()andlisten(). - Use
before_connectto prevent outbound connections before packets are transmitted, andafter_handshaketo reject inbound connections with specific QUIC error codes. - Reference the examples in
iroh/examples/incoming-filter.rsandiroh/examples/auth-hook.rsfor production-ready implementations.
Frequently Asked Questions
What is the difference between before_connect and after_handshake hooks?
The before_connect hook runs when you call Endpoint::connect() but before any network packets are transmitted, making it ideal for preventing outbound connections based on unverified criteria like endpoint IDs. The after_handshake hook runs after the TLS handshake completes and cryptographic identity is established, allowing you to inspect the authenticated remote endpoint ID and ALPN before accepting or rejecting the connection.
Can I use multiple hooks for layered filtering?
Yes, you can chain multiple hooks by calling .hooks(filter1).hooks(filter2) on the endpoint builder. The hooks are stored in an EndpointHooksList and executed sequentially in the order they were registered. If any hook returns Reject, the connection attempt aborts immediately without executing subsequent hooks.
What error codes should I use when rejecting connections?
For after_handshake rejections, use QUIC error codes as VarInt values. Common choices include 0x00 for no error (graceful drop), 0x0b (connection refused) for policy violations, or 0x01 (protocol violation) for ALPN mismatches. The error code and reason string are sent to the remote peer in a QUIC close frame, so choose codes that aid debugging without revealing sensitive internal state.
Is it safe to perform async operations inside hook methods?
Yes, hook methods are async and return futures, but you should keep them lightweight since they execute on the connection task. For expensive operations like database lookups or external API calls, spawn a background task and use a channel to communicate the result. Never store an Endpoint inside your hook struct, as this creates a reference cycle that prevents proper cleanup.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →