Funnel ingress denied: upstream 0eb38dc2e strips peer caps from UnsignedPeerAPIOnly relays #16

Open
opened 2026-07-28 22:06:19 +02:00 by codinget · 0 comments
Owner

Carrying a fork-local patch to canIngress() because upstream 0eb38dc2e breaks Funnel. This issue is the reminder to drop the patch once upstream fixes it.

What breaks

Funnel ingress is refused by the receiving node. Externally it looks like a TLS failure, because the ingress relay closes the client connection after the ClientHello:

* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (OUT), TLS alert, decode error (562):
* TLS connect error: error:0A000126:SSL routines::unexpected eof while reading
curl: (35) TLS connect error

The node logs the real reason:

Drop: TCP{[fd7a:115c:a1e0::a801:26ab]:32617 > [...]:37384} 80 no rules matched
peerapi: ingress: denied; no ingress cap from [fd7a:115c:a1e0::a801:26ab]:32617

Cause

Upstream 0eb38dc2e ("ipn,magicsock: deny peer capabilities to unsigned peers", 2026-07-22, tailscale/tailscale#20561) added srcIsUnsignedPeerLocked and made peerCapsLocked return nil for any peer with UnsignedPeerAPIOnly set.

Funnel ingress relays are exactly that class of node. From the doc comment on tailcfg.Node.UnsignedPeerAPIOnly:

UnsignedPeerAPIOnly means that this node is not signed nor subject to TKA restrictions. However, in exchange for that privilege, it does not get network access. It can only access this node's peerapi, which may not let it do anything.

Both halves of that are visible in the log above: the relay's direct traffic is dropped by the packet filter (no rules matched), and its one sanctioned action — the peerapi ingress endpoint — is now refused too, because canIngress() is resolved purely through the capability map:

func (h *peerAPIHandler) canIngress() bool {
	return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
}

Note that canDebug() directly above it has its own explicit UnsignedPeerAPIOnly check. The pre-existing design was granular per handler; the new blanket denial at the capability layer catches Funnel as collateral damage.

This does not require Tailnet Lock to be enabled on the tailnet — the relay is delivered unsigned regardless — so it should affect any node running upstream main, not just this fork.

The patch

  • nodeBackend.peerCapsLocked is split, factoring the lookup into peerCapsIgnoringSignatureLocked. peerCapsLocked keeps the unsigned-peer denial for every existing caller.
  • New nodeBackend.PeerCapsIncludingUnsigned / LocalBackend.PeerCapsIncludingUnsigned skip only that denial.
  • canIngress() is the sole caller.

Every other capability keeps upstream's stricter behaviour; only the ingress cap on the peerapi ingress endpoint is exempted.

To do

  • Verify against upstream tailscaled on main to confirm it reproduces outside this fork
  • Report upstream if it reproduces and isn't already known
  • Revert this patch once upstream lands a fix, and check whether their fix conflicts with the split of peerCapsLocked

🤖 Generated with Claude Code

Carrying a fork-local patch to `canIngress()` because upstream `0eb38dc2e` breaks Funnel. **This issue is the reminder to drop the patch once upstream fixes it.** ## What breaks Funnel ingress is refused by the receiving node. Externally it looks like a TLS failure, because the ingress relay closes the client connection after the ClientHello: ``` * TLSv1.3 (OUT), TLS handshake, Client hello (1): * TLSv1.3 (OUT), TLS alert, decode error (562): * TLS connect error: error:0A000126:SSL routines::unexpected eof while reading curl: (35) TLS connect error ``` The node logs the real reason: ``` Drop: TCP{[fd7a:115c:a1e0::a801:26ab]:32617 > [...]:37384} 80 no rules matched peerapi: ingress: denied; no ingress cap from [fd7a:115c:a1e0::a801:26ab]:32617 ``` ## Cause Upstream `0eb38dc2e` ("ipn,magicsock: deny peer capabilities to unsigned peers", 2026-07-22, tailscale/tailscale#20561) added `srcIsUnsignedPeerLocked` and made `peerCapsLocked` return `nil` for any peer with `UnsignedPeerAPIOnly` set. Funnel ingress relays are exactly that class of node. From the doc comment on `tailcfg.Node.UnsignedPeerAPIOnly`: > UnsignedPeerAPIOnly means that this node is not signed nor subject to TKA restrictions. However, in exchange for that privilege, it does not get network access. It can only access this node's peerapi, which may not let it do anything. Both halves of that are visible in the log above: the relay's direct traffic is dropped by the packet filter (`no rules matched`), and its one sanctioned action — the peerapi ingress endpoint — is now refused too, because `canIngress()` is resolved purely through the capability map: ```go func (h *peerAPIHandler) canIngress() bool { return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf) } ``` Note that `canDebug()` directly above it has its own explicit `UnsignedPeerAPIOnly` check. The pre-existing design was granular per handler; the new blanket denial at the capability layer catches Funnel as collateral damage. This does not require Tailnet Lock to be enabled on the tailnet — the relay is delivered unsigned regardless — so it should affect any node running upstream main, not just this fork. ## The patch - `nodeBackend.peerCapsLocked` is split, factoring the lookup into `peerCapsIgnoringSignatureLocked`. `peerCapsLocked` keeps the unsigned-peer denial for every existing caller. - New `nodeBackend.PeerCapsIncludingUnsigned` / `LocalBackend.PeerCapsIncludingUnsigned` skip only that denial. - `canIngress()` is the sole caller. Every other capability keeps upstream's stricter behaviour; only the ingress cap on the peerapi ingress endpoint is exempted. ## To do - [ ] Verify against upstream `tailscaled` on main to confirm it reproduces outside this fork - [ ] Report upstream if it reproduces and isn't already known - [ ] Revert this patch once upstream lands a fix, and check whether their fix conflicts with the split of `peerCapsLocked` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
codinget added the
Agent
claude-opus-5
Agentic
labels 2026-07-28 22:06:19 +02:00
Sign in to join this conversation.