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 via Endpoint::connect but before any packets are sent. It receives the remote address and ALPN identifier, returning BeforeConnectOutcome::Accept to proceed or BeforeConnectOutcome::Reject to 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 the Connection object. It returns AfterHandshakeOutcome::Accept or AfterHandshakeOutcome::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

  1. Define your filter struct containing state such as whitelists, blacklists, or rate limiters.

  2. Implement EndpointHooks for your struct, overriding before_connect for outbound filtering or after_handshake for inbound filtering.

  3. Return rejection outcomes when connections should be denied. For after_handshake, provide a VarInt error code and reason string.

  4. 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 Endpoint reference inside your hook struct. This creates a reference-count cycle that prevents the endpoint from being dropped, as noted in the documentation for iroh/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::spawn and channels.

  • Prefer after_handshake for inbound filtering because only then is the remote endpoint ID authenticated and available via the Connection object.

  • Return descriptive error codes when rejecting in after_handshake. Use appropriate QUIC error codes (e.g., 0x0b for 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.rs provides before_connect and after_handshake interception 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 calling bind() and listen().
  • Use before_connect to prevent outbound connections before packets are transmitted, and after_handshake to reject inbound connections with specific QUIC error codes.
  • Reference the examples in iroh/examples/incoming-filter.rs and iroh/examples/auth-hook.rs for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →