Skip to content

net/transport/iroh: iroh transport with relay fallback - #768

Open
cheggaaa wants to merge 10 commits into
mainfrom
cheggaaa/syn-190-global-p2p
Open

net/transport/iroh: iroh transport with relay fallback#768
cheggaaa wants to merge 10 commits into
mainfrom
cheggaaa/syn-190-global-p2p

Conversation

@cheggaaa

@cheggaaa cheggaaa commented Aug 25, 2026

Copy link
Copy Markdown
Member

New transport over tmc/go-iroh v0.1.0 (QUIC + relay fallback + hole punching) for direct client-to-client sync beyond the LAN. Nodes are unaffected and never link it; the SDK wires it (separate PR) and discovers peers through per-space key-value records.

What

  • net/transport/iroh: transport.Transport + app.ComponentRunnable. Endpoint identity is the device peer key, so any-sync peer id ↔ iroh endpoint id convert both ways (ident.go, PeerIdFromTicket); a dial verifies the ticket id against the expected peer id and re-checks the TLS-verified remote id after the handshake completes (a resumed session returns early with 0-RTT; the any-sync handshake never goes out as early data).
  • The any-sync handshake runs on the first stream exactly like QUIC — identity, proto version, credential checks unchanged.
  • Addresses are endpoint tickets: iroh://<ticket>. Ticket() is the relay-only ticket of this endpoint, published only once the home-relay session is up (direct interface addresses when no relay is configured); TicketUpdates() wakes on change; RelayConnected() for status.
  • Inbound: SetIncomingFilter is mandatory (Run fails without it) — the relay makes the endpoint reachable from the whole internet. Attempts are taken pre-handshake and finished on their own goroutines, bounded by MaxInflightAccepts (64) globally and 8 per source; excess is refused before any handshake work.
  • Relay URLs must be https:// with a host; InsecureRelay admits http:// for dev/test relays (go-iroh's own cmd/iroh-relay has no TLS). Net reports are enabled with relays so hole punching has reflexive candidates.
  • peerservice: iroh ranks after every LAN/node scheme, never runs the local-address probe (a ticket is not a host), and is dialed only when the ctx opts in via CtxWithGlobalDial — LAN and node dial paths can never wait on a relay dial. Tickets are shortened in logs. The component is looked up by transport.IrohCName, so binaries that never register it do not link go-iroh.
  • go 1.26 — required by go-iroh's own go directive; every downstream module (node, coordinator, filenode, heart, mobile) moves with it. go-iroh is pure Go with one indirect dep (coder/websocket, ISC — needs a license decision in anyproto/open); it vendors a quic-go fork, so the binary carries a second QUIC stack.

Also

  • peer.CtxWithTTL / CtxTTL: a transport can set the pool TTL of its peers through the connection context; NewPeer applies it on both sides. The iroh transport sets PeerTTLSec (30 min): the pool's 1-min idle GC was closing accepted relay connections between sync ticks, at a full handshake (≈13 KB) each time.
  • peer.TryClose: a peer without sub conns now lives for max(ttl, 1 min) instead of a hardcoded minute — SetTTL never applied to a connection held for reachability alone, so relay-backed peers were closed and re-dialed every GC pass (seen in the cross-machine hand-test: ~70 s up / ~50 s down, 13 KB per re-handshake).
  • keyvalue: KeyValueFromProto now binds the row id to the signed key + peer (KeyPeerId(key, peerId)). The id came from the proto unchecked, so a member could plant a row at another peer's id with a far-future timestamp and block that peer's genuine rows on every replica — the global-p2p discovery record relies on one row per device.

Known upstream limitation

go-iroh's magic socket grows its per-source and relay-mapped address tables without eviction for untracked sources (internal/socket/transport.go, socket.go). Relay-only tickets keep direct exposure low; the version is pinned so an upgrade past a fix is deliberate.

Tests

  • transport (-race): loopback round trip through the accepter, relay round trip through an embedded relayserver, handshake ctx (peer id / identity / proto), expected-peer-id mismatch, bad ticket, incoming filter, filter required, close → transport.ErrConnClosed, dial timeout on a dead ticket, close during dial, ticket coalescing, direct tickets for 0.0.0.0 / [::] binds, ticket address caps, in-flight accounting, io.Copy counted, byte counters, identity round trip.
  • peerservice: iroh dropped without the ctx flag, dialed with it, ranks last, skips the resolver, log shortening.

SYN-190

🤖 Generated with Claude Code

Adds a transport over github.com/tmc/go-iroh (QUIC, relay fallback,
hole punching) for direct client-to-client sync across the internet.

- endpoint identity is the device peer key: any-sync peer id and iroh
  endpoint id convert both ways (ident.go), so a dial verifies the
  ticket id against the expected peer id and inbound connections map
  RemoteID to a peer id before the any-sync handshake
- the any-sync handshake runs on the first stream, as for QUIC, so
  identity, proto version and credentials are unchanged
- addresses are endpoint tickets: "iroh://<ticket>"; Ticket() exposes
  the relay-only ticket of this endpoint (direct IPs when no relay is
  configured), TicketUpdates wakes on change
- SetIncomingFilter gates inbound peers before the handshake
- peerservice: iroh ranks after every LAN/node scheme, skips the local
  address probe, and is dialed only when the ctx opts in with
  CtxWithGlobalDial, so LAN and node dial paths never wait on a relay
- go 1.26 (required by go-iroh)

SYN-190
@socket-security

socket-security Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgithub.com/​tmc/​go-iroh@​v0.1.074100100100100

View full report

…ckets

The relay drops frames for endpoints it has not registered, so a relay
ticket is published only once Online succeeds and a relay dial waits
for it (no-op once up). Adds a round trip through an embedded relay.
- accept takes incoming attempts before their QUIC handshake and finishes
  each on its own goroutine, bounded globally (MaxInflightAccepts) and per
  source IP; excess attempts are refused before any handshake work
- Run requires an incoming filter: the relay makes the endpoint reachable
  from the whole internet
- a dial waits for HandshakeComplete before the any-sync handshake (a
  resumed session returns early with 0-RTT) and checks the verified
  remote id; ticket addresses are capped and filtered before Connect
- relay URLs must be https with a host; http only with InsecureRelay
- net reports are enabled with relays so hole punching has candidates;
  keep-alive defaults to 25s like quic; InitialPacketSize is configurable
- a direct ticket for an unspecified bind carries the interface addresses
- peerservice looks the transport up by transport.IrohCName so binaries
  that never register it do not link go-iroh; tickets are shortened in
  logs; PeerIdFromTicket and RelayConnected exported for consumers
- CloseWithError blocks on connection teardown, so Close waits at most
  CloseTimeoutSec for it, as the quic transport does
- an unspecified [::] bind expands to both address families, 0.0.0.0 to
  IPv4 only, so a default bind stays dialable over IPv4
- irohNetConn routes ReadFrom through Write so io.Copy honours the write
  deadline and the byte counter
- the accept loop exits on a dead transport; a home relay that never
  connects is logged
A row is addressed by key+"-"+peerId, but the id came from the proto
unchecked: a member could plant a row at another peer's id with a
far-future timestamp and block that peer's genuine rows on every
replica. KeyValueFromProto now rejects an id that differs from the
signed key and peer.
A relay-backed connection costs a full handshake to re-establish, and
the pool's one-minute idle TTL was closing accepted iroh peers between
sync ticks. The transport now puts a TTL into the connection context
(PeerTTLSec, default 30 min) and NewPeer applies it on both sides.
TryClose closed a peer with no sub conns one hardcoded minute after
creation, so SetTTL never applied to a connection held for reachability
alone — relay-backed global peers were dropped and re-dialed every GC
pass. The idle bound is now max(ttl, minute).
…orPeer

- DeriveDiscoveryKeys derives from the identity seed the ed25519 key that
  addresses and signs the account's device-discovery record (slip-10
  m/99999'/2', frozen) and the symmetric key that encrypts it, so every
  device and a fresh restore agree while the identity key never signs
  the record
- the iroh transport gains a handshake-stage filter that sees the
  remote's proven identity, and TicketForPeer builds the relay-only
  ticket of a peer from its peer id and home relay
- HandshakeInbound verifies the remote's signed identity when the ctx
  carries CtxAllowAccountCheck, independent of the service default; the
  iroh transport sets it on both sides, so a client accepting internet
  connections always learns a proven account and a filter can admit by
  it; a connection without one is refused when a filter is set
- the handshake filter receives the identity as crypto.PubKey
- TicketForPeer validates the relay URL like a configured relay
- DeriveDiscoveryKeys checks the key type; known-answer test pins the
  frozen derivation; the root seed is documented as the identity's
  32-byte ed25519 seed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant