Table of Contents
Observing and rejecting connections
Endpoint hooks run before an outgoing dial and after every handshake — the supported way to add authentication or an allow list.
Endpoint hooks intercept connection establishment. They are how you add authentication, an allow list, or connection logging without any cooperation from the protocols running inside those connections.
Upstream documents the same feature for Rust in Observing & Rejecting Connections; this page covers the Go API and the behavior verified against it.
The interface
type EndpointHooks interface {
BeforeConnect(ctx context.Context, addr netaddr.EndpointAddr, alpn string) error
AfterHandshake(ctx context.Context, conn *Conn) error
}
Register one or more with iroh.WithHooks at Bind time. They run in
registration order, and the first error short-circuits the rest.
The two hook points are:
| Hook | When | Direction |
|---|---|---|
BeforeConnect |
before a dial sends any packet | outbound only |
AfterHandshake |
after the QUIC/TLS handshake, before application data | inbound and outbound |
By AfterHandshake the peer is authenticated, so conn.RemoteID() and
conn.ALPN() are trustworthy. That is what makes an allow list possible.
Hooks observe and reject. They must not use the connection — opening streams from a hook would race the protocol handler that is about to own it.
Rejecting
The two hooks reject differently, and the difference matters:
BeforeConnect— any error you return is returned verbatim fromConnect. Nothing wraps it. Returniroh.ErrConnectRejectedyourself if you want callers to match witherrors.Is; it is a convention sentinel the endpoint never produces on its own.AfterHandshake— returningiroh.RejectHandshake(code, reason)closes the connection with that QUIC application close code and reason, and the local caller gets an error wrappingiroh.ErrHandshakeRejected. Any other error also closes the connection, but with code 0 and reason"rejected by hook", and is returned unwrapped.
So use RejectHandshake when you want the peer to learn why, and match with
errors.Is(err, iroh.ErrHandshakeRejected) locally.
An allow list
type allowList struct{ allowed map[key.EndpointID]bool }
func (a allowList) BeforeConnect(ctx context.Context, addr netaddr.EndpointAddr, alpn string) error {
return nil
}
func (a allowList) AfterHandshake(ctx context.Context, conn *iroh.Conn) error {
if !a.allowed[conn.RemoteID()] {
return iroh.RejectHandshake(1, "not on the allow list")
}
return nil
}
Install it on the accepting endpoint:
server, err := iroh.Bind(ctx,
iroh.WithALPNs(alpn),
iroh.WithHooks(allowList{allowed: map[key.EndpointID]bool{}}),
)
With an empty allow list, the server’s Accept returns:
iroh: handshake rejected by hook: reject handshake: code 1: not on the allow list
and errors.Is(err, iroh.ErrHandshakeRejected) is true.
The caveat worth knowing
A server-side AfterHandshake rejection does not make the client’s
Connect fail. Running the program above, the client’s Connect returns a
usable *iroh.Conn — its handshake completed — while the server’s Accept
returns the rejection. The client only learns of the rejection when it uses
the connection and sees it closed with your code and reason.
If your protocol needs the dialer to fail fast, the rejection has to be part of the protocol, not only the hook. Hooks are an admission-control mechanism for the accepting side; they are not a synchronous handshake-level “no” that the dialer observes immediately.
Earlier still: the router’s incoming filter
Hooks run after the handshake. That is too late to be a defense against an
unauthenticated flood, because you have already paid for a handshake. For that,
Router has a separate mechanism:
router, err := iroh.NewRouter(ep, handlers, &iroh.RouterConfig{
IncomingFilter: func(in *iroh.Incoming) iroh.IncomingFilterOutcome {
return iroh.FilterRetry
},
})
The four outcomes are not equivalent, and the ordering is the point:
| Outcome | Effect |
|---|---|
FilterAccept |
accept and dispatch to the handler for the ALPN |
FilterRetry |
emit a real QUIC Retry packet, forcing address validation |
FilterReject |
refuse the connection |
FilterIgnore |
close it without dispatching |
FilterRetry is evaluated at QUIC Initial admission time, before ALPN
negotiation and before a connection is constructed, so it produces a genuine
Retry packet — the standard QUIC defense against address-spoofed flooding. The
other outcomes are evaluated in the accept loop once an early connection
exists. So FilterRetry is a cheap-per-packet defense; hooks are an expensive,
fully-informed one. Use them at different layers.
Graceful shutdown
Related, and also easy to miss: a protocol handler that implements
iroh.ShutdownHandler gets its Shutdown(ctx) called once when the router
shuts down, before the endpoint closes, which is your chance to close
connections cleanly rather than having them dropped.
Next steps
- Security and privacy — what hooks do and do not protect against.
- Endpoints and connections — the rest of the connection API.
- Troubleshooting — the sentinel errors in context.