From 1f247147553549ae67cca163c76839ea669c317c Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 29 May 2026 21:34:43 +0200 Subject: [PATCH 1/3] fix(libp2p): quieter dead-listener check Scope the v0.42 dead-listener ERROR to explicit listens in Addresses.Swarm: a server-profile node with default `/ip4/0.0.0.0` and `/ip6/::` listens otherwise logged ERROR for every loopback, Docker bridge, ULA, or other private interface the wildcard expanded into, drowning the actual gotcha (a `/ip4/127.0.0.1/tcp/.../ws` listener fronted by a local reverse proxy). Log routing: - AddrFilters + explicit listen: ERROR (whole listener unreachable). - AddrFilters + wildcard expansion: DEBUG (other interfaces still serve). - NoAnnounce match: DEBUG (operator intent, useful when tracing identify or DHT contents). --- core/node/groups.go | 2 +- core/node/libp2p/addrs.go | 105 ++++++++++++++++++++------------- core/node/libp2p/addrs_test.go | 104 +++++++++++++++++++++----------- docs/changelogs/v0.42.md | 12 +++- 4 files changed, 144 insertions(+), 79 deletions(-) diff --git a/core/node/groups.go b/core/node/groups.go index 62aa463d5e5..43bd09dcf21 100644 --- a/core/node/groups.go +++ b/core/node/groups.go @@ -201,7 +201,7 @@ func LibP2P(bcfg *BuildCfg, cfg *config.Config, userResourceOverrides rcmgr.Part maybeProvide(libp2p.P2PForgeCertMgr(bcfg.Repo.Path(), cfg.AutoTLS, atlsLog), enableAutoTLS), maybeInvoke(libp2p.StartP2PAutoTLS, enableAutoTLS), fx.Provide(libp2p.AddrFilters(cfg.Swarm.AddrFilters)), - fx.Invoke(libp2p.MonitorDeadListeners(cfg.Swarm.AddrFilters, cfg.Addresses.NoAnnounce)), + fx.Invoke(libp2p.MonitorDeadListeners(cfg.Addresses.Swarm, cfg.Swarm.AddrFilters, cfg.Addresses.NoAnnounce)), fx.Provide(libp2p.AddrsFactory(cfg.Addresses.Announce, cfg.Addresses.AppendAnnounce, cfg.Addresses.NoAnnounce)), fx.Provide(libp2p.SmuxTransport(cfg.Swarm.Transports)), fx.Provide(libp2p.RelayTransport(enableRelayTransport)), diff --git a/core/node/libp2p/addrs.go b/core/node/libp2p/addrs.go index b6a5c4ddee4..cc97577b4c2 100644 --- a/core/node/libp2p/addrs.go +++ b/core/node/libp2p/addrs.go @@ -44,13 +44,14 @@ const ( deadListenerSourceNoAnnounce = "Addresses.NoAnnounce" ) -// deadListenerFinding is one resolved listener killed by a CIDR rule: -// `Swarm.AddrFilters` (gater RSTs inbound) or `Addresses.NoAnnounce` -// (listener never advertised). +// deadListenerFinding is one resolved listener whose IP falls inside a +// CIDR in `Swarm.AddrFilters` (gater RSTs inbound) or +// `Addresses.NoAnnounce` (listener never advertised). type deadListenerFinding struct { Listener string // resolved listen multiaddr (interface-bound) - Source string // deadListenerSourceAddrFilters or deadListenerSourceNoAnnounce Rule string // matching CIDR rule from Source + Source string // deadListenerSourceAddrFilters or deadListenerSourceNoAnnounce + Explicit bool // true if the listener's IP was explicitly listed in `Addresses.Swarm` } // findDeadListeners returns one finding per (listener, rule, source) @@ -58,25 +59,29 @@ type deadListenerFinding struct { // noAnnounce. // // listenAddrs must be already-resolved interface addresses (output of -// `host.Network().InterfaceListenAddresses()`). Without resolution, the -// unspecified address itself can match a broad filter (`::` is in -// `::/3`) even when the listener accepts globally-routable peers. +// `host.Network().InterfaceListenAddresses()`). +// +// swarmListen is the raw `Addresses.Swarm` config. It is used to mark +// each finding as `Explicit` when the resolved listener's IP appears +// literally in `swarmListen`, or non-explicit when the IP came from a +// wildcard listen (`/ip4/0.0.0.0`, `/ip6/::`) expansion. // -// NoAnnounce matches on loopback are skipped: stripping loopback from -// identify and DHT records is normal operator intent, not a bug. -// AddrFilters matches on loopback are always reported, since that is -// the misconfiguration this check exists to catch. +// Callers route findings to log levels based on Source + Explicit: // -// Listeners without an IP component (`/dns`, `/dnsaddr`) and -// unparseable rules are skipped silently. -func findDeadListeners(listenAddrs []ma.Multiaddr, addrFilters []string, noAnnounce []string) []deadListenerFinding { +// - AddrFilters + Explicit: ERROR. The whole listener is unreachable. +// - AddrFilters + wildcard: DEBUG. Other interfaces still serve. +// - NoAnnounce: DEBUG. Operator intent, but useful when tracing why +// an interface address never reaches identify / DHT records. +// +// Unparseable rules (including exact-match multiaddrs in NoAnnounce) +// and listeners without an IP component are skipped silently. +func findDeadListeners(listenAddrs []ma.Multiaddr, swarmListen, addrFilters, noAnnounce []string) []deadListenerFinding { + explicit := explicitListenIPs(swarmListen) check := func(source string, rules []string) []deadListenerFinding { var out []deadListenerFinding for _, r := range rules { mask, err := mamask.NewMask(r) if err != nil { - // Malformed CIDR (caught upstream for AddrFilters) or - // an exact-match multiaddr in NoAnnounce. Skip either way. continue } f := ma.NewFilters() @@ -85,54 +90,72 @@ func findDeadListeners(listenAddrs []ma.Multiaddr, addrFilters []string, noAnnou if !f.AddrBlocked(l) { continue } - if source == deadListenerSourceNoAnnounce && isLoopbackMultiaddr(l) { - // Suppressing loopback announcement is operator-intent, - // not a misconfiguration. + ip, err := manet.ToIP(l) + if err != nil { continue } + _, isExplicit := explicit[ip.String()] out = append(out, deadListenerFinding{ Listener: l.String(), - Source: source, Rule: r, + Source: source, + Explicit: isExplicit, }) } } return out } - findings := check(deadListenerSourceAddrFilters, addrFilters) findings = append(findings, check(deadListenerSourceNoAnnounce, noAnnounce)...) return findings } -// isLoopbackMultiaddr reports whether m's IP component is loopback -// (`127.0.0.0/8` or `::1`). Returns false if m has no IP component. -func isLoopbackMultiaddr(m ma.Multiaddr) bool { - ip, err := manet.ToIP(m) - if err != nil { - return false +// explicitListenIPs returns the set of IPs the operator explicitly bound +// in `Addresses.Swarm`. Unspecified addresses (`0.0.0.0`, `::`) and +// entries without an IP component are skipped. +func explicitListenIPs(swarmListen []string) map[string]struct{} { + set := make(map[string]struct{}, len(swarmListen)) + for _, s := range swarmListen { + m, err := ma.NewMultiaddr(s) + if err != nil { + continue + } + ip, err := manet.ToIP(m) + if err != nil { + continue + } + if ip.IsUnspecified() { + continue + } + set[ip.String()] = struct{}{} } - return ip.IsLoopback() + return set } -// logDeadListenerFinding writes one ERROR line per finding, naming -// the listener, the matching CIDR rule, and where to remove it from. -// Each line stands alone so operators can grep and act on it. +// logDeadListenerFinding writes one log line per finding, naming the +// listener, the matching CIDR rule, and where to remove it from. The +// log level depends on the finding's Source and whether the operator +// explicitly bound the listener IP. See findDeadListeners. func logDeadListenerFinding(f deadListenerFinding) { - switch f.Source { - case deadListenerSourceAddrFilters: + switch { + case f.Source == deadListenerSourceAddrFilters && f.Explicit: log.Errorf( "Addresses.Swarm listener %q matches Swarm.AddrFilters rule %q, "+ "so Kubo rejects every incoming connection to it. Remove %q "+ "from Swarm.AddrFilters to allow connections to this listener.", f.Listener, f.Rule, f.Rule, ) - case deadListenerSourceNoAnnounce: - log.Errorf( - "Addresses.Swarm listener %q matches Addresses.NoAnnounce rule %q, "+ - "so Kubo will not advertise it to other peers. Remove %q from "+ - "Addresses.NoAnnounce to advertise this listener.", - f.Listener, f.Rule, f.Rule, + case f.Source == deadListenerSourceAddrFilters: + log.Debugf( + "Swarm.AddrFilters rule %q blocks resolved listener %q (from a "+ + "wildcard listen). Other interfaces unaffected.", + f.Rule, f.Listener, + ) + case f.Source == deadListenerSourceNoAnnounce: + log.Debugf( + "Addresses.NoAnnounce rule %q strips listener %q from "+ + "announcements (identify, DHT self-record).", + f.Rule, f.Listener, ) } } @@ -148,7 +171,7 @@ func logDeadListenerFinding(f deadListenerFinding) { // If subscribing to the event bus fails, the runtime monitor is // disabled and only the startup check runs. The check is diagnostic // and must never abort node startup. -func MonitorDeadListeners(addrFilters []string, noAnnounce []string) func(fx.Lifecycle, host.Host) error { +func MonitorDeadListeners(swarmListen, addrFilters, noAnnounce []string) func(fx.Lifecycle, host.Host) error { return func(lc fx.Lifecycle, h host.Host) error { seen := make(map[deadListenerFinding]struct{}) runCheck := func() { @@ -158,7 +181,7 @@ func MonitorDeadListeners(addrFilters []string, noAnnounce []string) func(fx.Lif return } next := make(map[deadListenerFinding]struct{}) - for _, f := range findDeadListeners(listenAddrs, addrFilters, noAnnounce) { + for _, f := range findDeadListeners(listenAddrs, swarmListen, addrFilters, noAnnounce) { next[f] = struct{}{} if _, ok := seen[f]; ok { continue diff --git a/core/node/libp2p/addrs_test.go b/core/node/libp2p/addrs_test.go index 0db847dbbfe..429ebec8575 100644 --- a/core/node/libp2p/addrs_test.go +++ b/core/node/libp2p/addrs_test.go @@ -23,6 +23,7 @@ func TestFindDeadListeners(t *testing.T) { cases := []struct { name string listenAddrs []ma.Multiaddr + swarmListen []string addrFilters []string noAnnounce []string want []deadListenerFinding @@ -30,89 +31,122 @@ func TestFindDeadListeners(t *testing.T) { { name: "empty config produces no findings", listenAddrs: mustMultiaddrs(t, "/ip4/192.168.1.5/tcp/4001"), + swarmListen: []string{"/ip4/192.168.1.5/tcp/4001"}, }, { - name: "loopback listener with loopback AddrFilters: one finding", + name: "explicit loopback listen with loopback AddrFilters: explicit AddrFilters finding (reverse-proxy gotcha)", listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/8081/ws"}, addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, want: []deadListenerFinding{ - {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Source: deadListenerSourceAddrFilters, Rule: "/ip4/127.0.0.0/ipcidr/8"}, + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, }, }, { - name: "loopback NoAnnounce match alone is operator-intent: skipped", + name: "wildcard listen resolves to loopback: non-explicit AddrFilters finding", + listenAddrs: mustMultiaddrs(t, + "/ip4/127.0.0.1/tcp/4001", + "/ip4/1.2.3.4/tcp/4001", + ), + swarmListen: []string{"/ip4/0.0.0.0/tcp/4001"}, + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + want: []deadListenerFinding{ + {Listener: "/ip4/127.0.0.1/tcp/4001", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: false}, + }, + }, + { + name: "explicit loopback listen with loopback NoAnnounce: non-explicit NoAnnounce finding (debug trace)", listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/8081/ws"}, noAnnounce: []string{"/ip4/127.0.0.0/ipcidr/8"}, + want: []deadListenerFinding{ + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceNoAnnounce, Explicit: true}, + }, }, { - name: "loopback in both lists: AddrFilters reported, NoAnnounce skipped", - listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), - addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + name: "wildcard listen resolves to loopback with NoAnnounce: non-explicit NoAnnounce finding", + listenAddrs: mustMultiaddrs(t, + "/ip4/127.0.0.1/tcp/4001", + "/ip4/1.2.3.4/tcp/4001", + ), + swarmListen: []string{"/ip4/0.0.0.0/tcp/4001"}, noAnnounce: []string{"/ip4/127.0.0.0/ipcidr/8"}, want: []deadListenerFinding{ - {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Source: deadListenerSourceAddrFilters, Rule: "/ip4/127.0.0.0/ipcidr/8"}, + {Listener: "/ip4/127.0.0.1/tcp/4001", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceNoAnnounce, Explicit: false}, }, }, { - name: "non-loopback NoAnnounce match is reported", - listenAddrs: mustMultiaddrs(t, "/ip4/192.168.1.5/tcp/4001"), - noAnnounce: []string{"/ip4/192.168.0.0/ipcidr/16"}, + name: "loopback in both AddrFilters and NoAnnounce on explicit listen: one finding per source", + listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/8081/ws"}, + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + noAnnounce: []string{"/ip4/127.0.0.0/ipcidr/8"}, want: []deadListenerFinding{ - {Listener: "/ip4/192.168.1.5/tcp/4001", Source: deadListenerSourceNoAnnounce, Rule: "/ip4/192.168.0.0/ipcidr/16"}, + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceNoAnnounce, Explicit: true}, }, }, { - name: "IPv6 loopback (resolved from `::`) with `::/3` AddrFilters: flagged", - listenAddrs: mustMultiaddrs(t, "/ip6/::1/tcp/4001"), - addrFilters: []string{"/ip6/::/ipcidr/3"}, + name: "wildcard IPv6 listen resolves to ULA with `fc00::/7` AddrFilters: non-explicit AddrFilters finding", + listenAddrs: mustMultiaddrs(t, + "/ip6/fd7d:54ce:fe4::1/tcp/4001", + "/ip6/2604:2dc0:200:484::1/tcp/4001", + ), + swarmListen: []string{"/ip6/::/tcp/4001"}, + addrFilters: []string{"/ip6/fc00::/ipcidr/7"}, want: []deadListenerFinding{ - {Listener: "/ip6/::1/tcp/4001", Source: deadListenerSourceAddrFilters, Rule: "/ip6/::/ipcidr/3"}, + {Listener: "/ip6/fd7d:54ce:fe4::1/tcp/4001", Rule: "/ip6/fc00::/ipcidr/7", Source: deadListenerSourceAddrFilters, Explicit: false}, }, }, { - name: "IPv6 loopback NoAnnounce-only is operator-intent: skipped", - listenAddrs: mustMultiaddrs(t, "/ip6/::1/tcp/4001"), - noAnnounce: []string{"/ip6/::/ipcidr/3"}, + name: "explicit Docker bridge listen with matching private CIDR: explicit AddrFilters finding", + listenAddrs: mustMultiaddrs(t, "/ip4/172.17.0.1/tcp/4001"), + swarmListen: []string{"/ip4/172.17.0.1/tcp/4001"}, + addrFilters: []string{"/ip4/172.16.0.0/ipcidr/12"}, + want: []deadListenerFinding{ + {Listener: "/ip4/172.17.0.1/tcp/4001", Rule: "/ip4/172.16.0.0/ipcidr/12", Source: deadListenerSourceAddrFilters, Explicit: true}, + }, }, { - name: "globally-routable IPv6 (resolved from `::`) is not flagged by `::/3`", + name: "globally-routable IPv6 explicit listen is not matched by `::/3`", listenAddrs: mustMultiaddrs(t, "/ip6/2604:2dc0:200:484::1/tcp/4001"), + swarmListen: []string{"/ip6/2604:2dc0:200:484::1/tcp/4001"}, addrFilters: []string{"/ip6/::/ipcidr/3"}, }, - { - name: "private LAN listener with matching private CIDR: flagged on AddrFilters", - listenAddrs: mustMultiaddrs(t, "/ip4/192.168.1.5/tcp/4001"), - addrFilters: []string{"/ip4/192.168.0.0/ipcidr/16"}, - want: []deadListenerFinding{ - {Listener: "/ip4/192.168.1.5/tcp/4001", Source: deadListenerSourceAddrFilters, Rule: "/ip4/192.168.0.0/ipcidr/16"}, - }, - }, { name: "DNS listener has no IP component: no finding", listenAddrs: mustMultiaddrs(t, "/dns/example.com/tcp/443/wss"), + swarmListen: []string{"/dns/example.com/tcp/443/wss"}, addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, }, { - name: "exact-match NoAnnounce entry is skipped (operator-explicit)", + name: "exact-match NoAnnounce multiaddr is not a CIDR: skipped", listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/8081/ws"}, noAnnounce: []string{"/ip4/127.0.0.1/tcp/8081/ws"}, }, { - name: "malformed filter entry: skipped, valid filters still match", + name: "malformed AddrFilters entry: skipped, valid filters still match", listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/8081/ws"}, addrFilters: []string{"garbage", "/ip4/127.0.0.0/ipcidr/8"}, want: []deadListenerFinding{ - {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Source: deadListenerSourceAddrFilters, Rule: "/ip4/127.0.0.0/ipcidr/8"}, + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, }, }, { - name: "bootstrapper-style mix: only AddrFilters loopback fires", + name: "server-profile bootstrapper mix: explicit reverse-proxy listen flagged ERROR, wildcard-resolved interfaces DEBUG", listenAddrs: mustMultiaddrs(t, "/ip4/147.135.44.132/tcp/4001", "/ip4/127.0.0.1/tcp/8081/ws", "/ip6/2604:2dc0:200:484::1/tcp/4001", "/ip6/::1/tcp/4001", ), + swarmListen: []string{ + "/ip4/0.0.0.0/tcp/4001", + "/ip4/127.0.0.1/tcp/8081/ws", + "/ip6/::/tcp/4001", + }, addrFilters: []string{ "/ip4/127.0.0.0/ipcidr/8", "/ip6/::/ipcidr/3", @@ -122,15 +156,17 @@ func TestFindDeadListeners(t *testing.T) { "/ip6/::/ipcidr/3", }, want: []deadListenerFinding{ - {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Source: deadListenerSourceAddrFilters, Rule: "/ip4/127.0.0.0/ipcidr/8"}, - {Listener: "/ip6/::1/tcp/4001", Source: deadListenerSourceAddrFilters, Rule: "/ip6/::/ipcidr/3"}, + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, + {Listener: "/ip6/::1/tcp/4001", Rule: "/ip6/::/ipcidr/3", Source: deadListenerSourceAddrFilters, Explicit: false}, + {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceNoAnnounce, Explicit: true}, + {Listener: "/ip6/::1/tcp/4001", Rule: "/ip6/::/ipcidr/3", Source: deadListenerSourceNoAnnounce, Explicit: false}, }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := findDeadListeners(tc.listenAddrs, tc.addrFilters, tc.noAnnounce) + got := findDeadListeners(tc.listenAddrs, tc.swarmListen, tc.addrFilters, tc.noAnnounce) require.ElementsMatch(t, tc.want, got) }) } diff --git a/docs/changelogs/v0.42.md b/docs/changelogs/v0.42.md index 7b1fff9622e..599fab5eaf5 100644 --- a/docs/changelogs/v0.42.md +++ b/docs/changelogs/v0.42.md @@ -16,7 +16,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [🐛 Fixed pin operations hanging under pinned reprovide strategies](#-fixed-pin-operations-hanging-under-pinned-reprovide-strategies) - [🐛 Smoother first-run upgrades from very old repos](#-smoother-first-run-upgrades-from-very-old-repos) - [🐛 Reliable shutdown and container health checks](#-reliable-shutdown-and-container-health-checks) - - [🚨 ERROR log for listeners blocked by `Swarm.AddrFilters` or `Addresses.NoAnnounce`](#-error-log-for-listeners-blocked-by-swarmaddrfilters-or-addressesnoannounce) + - [🚨 ERROR log for explicit listeners blocked by `Swarm.AddrFilters`](#-error-log-for-explicit-listeners-blocked-by-swarmaddrfilters) - [📊 OpenTelemetry: scope info now exposed as labels](#-opentelemetry-scope-info-now-exposed-as-labels) - [🔧 Cleaner progress bars](#-cleaner-progress-bars) - [📦️ Dependency updates](#-dependency-updates) @@ -94,9 +94,15 @@ What changed: - **DHT provider deadlines.** `ipfs provide stat` now returns promptly when the caller cancels, instead of blocking on a slow keystore lookup (previously seen at over an hour). Each provider record sent to a peer is capped by [`Provide.DHT.SendProviderRecordTimeout`](https://github.com/ipfs/kubo/blob/master/docs/config.md#providedhtsendproviderrecordtimeout), so an unresponsive peer cannot stall a reprovide cycle. -#### 🚨 ERROR log for listeners blocked by `Swarm.AddrFilters` or `Addresses.NoAnnounce` +#### 🚨 ERROR log for explicit listeners blocked by `Swarm.AddrFilters` -Kubo now logs an ERROR when an [`Addresses.Swarm`](https://github.com/ipfs/kubo/blob/master/docs/config.md#addressesswarm) listener is covered by a rule in [`Swarm.AddrFilters`](https://github.com/ipfs/kubo/blob/master/docs/config.md#swarmaddrfilters) (Kubo will reject every incoming connection to it) or [`Addresses.NoAnnounce`](https://github.com/ipfs/kubo/blob/master/docs/config.md#addressesnoannounce) (Kubo will not advertise it to other peers). Each line names the listener, the matching rule, and the field to remove it from. This catches silent misconfigurations like a `/ip4/127.0.0.1/tcp/.../ws` listener behind a local reverse proxy that stops working once `/ip4/127.0.0.0/ipcidr/8` lands in `Swarm.AddrFilters` (for example via the [`server` profile](https://github.com/ipfs/kubo/blob/master/docs/config.md#server-profile)). See the [reverse-proxy override row](https://github.com/ipfs/kubo/blob/master/docs/config.md#overriding-specific-entries) for the fix. +If you list a specific address in [`Addresses.Swarm`](https://github.com/ipfs/kubo/blob/master/docs/config.md#addressesswarm) and a rule in [`Swarm.AddrFilters`](https://github.com/ipfs/kubo/blob/master/docs/config.md#swarmaddrfilters) blocks it, no incoming connection reaches that listener. Kubo now logs one ERROR per such listener, naming the listener, the matching rule, and the field to remove the rule from. + +The common trigger: a `/ip4/127.0.0.1/tcp/.../ws` listener fronted by nginx or Caddy on a [`server`-profile](https://github.com/ipfs/kubo/blob/master/docs/config.md#server-profile) node. The profile adds `/ip4/127.0.0.0/ipcidr/8` to `Swarm.AddrFilters`, which rejects every proxy connection over loopback. See the [reverse-proxy override row](https://github.com/ipfs/kubo/blob/master/docs/config.md#overriding-specific-entries) for the fix. + +Wildcard listens (`/ip4/0.0.0.0`, `/ip6/::`) stay out of the ERROR log. Even if their interface expansion lands inside a filtered CIDR, the listener still accepts traffic on the interfaces outside that CIDR, so the filter is working as intended. These matches log at DEBUG instead, so you can still trace which interfaces an AddrFilters rule strips when you need to. + +[`Addresses.NoAnnounce`](https://github.com/ipfs/kubo/blob/master/docs/config.md#addressesnoannounce) matches also log at DEBUG. Hiding addresses there is the point of the field, but the log line helps when you ask "why isn't this interface in my identify or DHT records?" and the answer is a CIDR rule you forgot you set. #### 📊 OpenTelemetry: scope info now exposed as labels From 5e32d1b8b3128d3e693487159d622c0c198df9e7 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 2 Jun 2026 15:43:23 +0200 Subject: [PATCH 2/3] fix(libp2p): match explicit listens by full addr Explicit-ness keyed on the listener IP alone, so a wildcard listen expanding onto an interface whose IP was also bound explicitly on another port (server profile plus a /ip4/127.0.0.1/.../ws reverse proxy) was logged as a spurious ERROR. Match the full resolved multiaddr instead: InterfaceListenAddresses echoes a specific-IP listen verbatim while a wildcard never resolves to itself. --- core/node/libp2p/addrs.go | 39 +++++++++++++++++----------------- core/node/libp2p/addrs_test.go | 6 ++++++ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/core/node/libp2p/addrs.go b/core/node/libp2p/addrs.go index cc97577b4c2..5da57562aa8 100644 --- a/core/node/libp2p/addrs.go +++ b/core/node/libp2p/addrs.go @@ -51,7 +51,7 @@ type deadListenerFinding struct { Listener string // resolved listen multiaddr (interface-bound) Rule string // matching CIDR rule from Source Source string // deadListenerSourceAddrFilters or deadListenerSourceNoAnnounce - Explicit bool // true if the listener's IP was explicitly listed in `Addresses.Swarm` + Explicit bool // true if the listener is itself a specific-IP entry in `Addresses.Swarm`, not a wildcard expansion } // findDeadListeners returns one finding per (listener, rule, source) @@ -61,10 +61,10 @@ type deadListenerFinding struct { // listenAddrs must be already-resolved interface addresses (output of // `host.Network().InterfaceListenAddresses()`). // -// swarmListen is the raw `Addresses.Swarm` config. It is used to mark -// each finding as `Explicit` when the resolved listener's IP appears -// literally in `swarmListen`, or non-explicit when the IP came from a -// wildcard listen (`/ip4/0.0.0.0`, `/ip6/::`) expansion. +// swarmListen is the raw `Addresses.Swarm` config. A finding is marked +// `Explicit` when the resolved listener appears literally in `swarmListen`, +// and non-explicit when it came from a wildcard listen (`/ip4/0.0.0.0`, +// `/ip6/::`) expanding onto a per-interface address. // // Callers route findings to log levels based on Source + Explicit: // @@ -76,7 +76,7 @@ type deadListenerFinding struct { // Unparseable rules (including exact-match multiaddrs in NoAnnounce) // and listeners without an IP component are skipped silently. func findDeadListeners(listenAddrs []ma.Multiaddr, swarmListen, addrFilters, noAnnounce []string) []deadListenerFinding { - explicit := explicitListenIPs(swarmListen) + explicit := explicitListens(swarmListen) check := func(source string, rules []string) []deadListenerFinding { var out []deadListenerFinding for _, r := range rules { @@ -90,11 +90,7 @@ func findDeadListeners(listenAddrs []ma.Multiaddr, swarmListen, addrFilters, noA if !f.AddrBlocked(l) { continue } - ip, err := manet.ToIP(l) - if err != nil { - continue - } - _, isExplicit := explicit[ip.String()] + _, isExplicit := explicit[l.String()] out = append(out, deadListenerFinding{ Listener: l.String(), Rule: r, @@ -110,10 +106,16 @@ func findDeadListeners(listenAddrs []ma.Multiaddr, swarmListen, addrFilters, noA return findings } -// explicitListenIPs returns the set of IPs the operator explicitly bound -// in `Addresses.Swarm`. Unspecified addresses (`0.0.0.0`, `::`) and -// entries without an IP component are skipped. -func explicitListenIPs(swarmListen []string) map[string]struct{} { +// explicitListens returns the set of specific-interface listen addresses +// from `Addresses.Swarm`, as normalized multiaddr strings. Wildcard listens +// (`/ip4/0.0.0.0`, `/ip6/::`) and entries without an IP component are +// skipped. +// +// `InterfaceListenAddresses` echoes a specific-IP listen verbatim but +// resolves a wildcard listen to per-interface addresses that never appear +// here, so membership in this set tells a deliberately-bound listener apart +// from an incidental wildcard expansion onto a filtered interface. +func explicitListens(swarmListen []string) map[string]struct{} { set := make(map[string]struct{}, len(swarmListen)) for _, s := range swarmListen { m, err := ma.NewMultiaddr(s) @@ -121,13 +123,10 @@ func explicitListenIPs(swarmListen []string) map[string]struct{} { continue } ip, err := manet.ToIP(m) - if err != nil { - continue - } - if ip.IsUnspecified() { + if err != nil || ip.IsUnspecified() { continue } - set[ip.String()] = struct{}{} + set[m.String()] = struct{}{} } return set } diff --git a/core/node/libp2p/addrs_test.go b/core/node/libp2p/addrs_test.go index 429ebec8575..ea6fd8b9035 100644 --- a/core/node/libp2p/addrs_test.go +++ b/core/node/libp2p/addrs_test.go @@ -138,6 +138,7 @@ func TestFindDeadListeners(t *testing.T) { name: "server-profile bootstrapper mix: explicit reverse-proxy listen flagged ERROR, wildcard-resolved interfaces DEBUG", listenAddrs: mustMultiaddrs(t, "/ip4/147.135.44.132/tcp/4001", + "/ip4/127.0.0.1/tcp/4001", // loopback expansion of /ip4/0.0.0.0 "/ip4/127.0.0.1/tcp/8081/ws", "/ip6/2604:2dc0:200:484::1/tcp/4001", "/ip6/::1/tcp/4001", @@ -155,10 +156,15 @@ func TestFindDeadListeners(t *testing.T) { "/ip4/127.0.0.0/ipcidr/8", "/ip6/::/ipcidr/3", }, + // The /ip4/127.0.0.1/tcp/4001 loopback shares its IP with the + // explicit /ws listener but came from the /ip4/0.0.0.0 wildcard, + // so it stays non-explicit (DEBUG); only the /ws listener is ERROR. want: []deadListenerFinding{ {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, + {Listener: "/ip4/127.0.0.1/tcp/4001", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: false}, {Listener: "/ip6/::1/tcp/4001", Rule: "/ip6/::/ipcidr/3", Source: deadListenerSourceAddrFilters, Explicit: false}, {Listener: "/ip4/127.0.0.1/tcp/8081/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceNoAnnounce, Explicit: true}, + {Listener: "/ip4/127.0.0.1/tcp/4001", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceNoAnnounce, Explicit: false}, {Listener: "/ip6/::1/tcp/4001", Rule: "/ip6/::/ipcidr/3", Source: deadListenerSourceNoAnnounce, Explicit: false}, }, }, From daf244fdebef8bd0d934735448a2f9056e06a10b Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 2 Jun 2026 17:02:39 +0200 Subject: [PATCH 3/3] fix(libp2p): match explicit listens by socket Classify a dead listener as explicit by its bound socket (IP, transport, port) instead of the full multiaddr string. A listener is reported under a different multiaddr than its Addresses.Swarm entry once a transport rewrites trailing components: WebTransport appends /certhash, WebSocket turns /wss into /tls/ws. The string compare missed these and silently downgraded the affected explicit listeners from ERROR to DEBUG, hiding the reverse-proxy gotcha the check exists to surface. The transport is part of the key because TCP and QUIC share a port number by default (4001), so a pinned QUIC listener must not promote the same-port TCP wildcard expansion to ERROR. --- core/node/libp2p/addrs.go | 73 ++++++++++++++++++++++------- core/node/libp2p/addrs_test.go | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/core/node/libp2p/addrs.go b/core/node/libp2p/addrs.go index 5da57562aa8..9670f787fcc 100644 --- a/core/node/libp2p/addrs.go +++ b/core/node/libp2p/addrs.go @@ -51,7 +51,7 @@ type deadListenerFinding struct { Listener string // resolved listen multiaddr (interface-bound) Rule string // matching CIDR rule from Source Source string // deadListenerSourceAddrFilters or deadListenerSourceNoAnnounce - Explicit bool // true if the listener is itself a specific-IP entry in `Addresses.Swarm`, not a wildcard expansion + Explicit bool // true when the listener IP+port was bound by a specific-IP entry in `Addresses.Swarm`, not a wildcard expansion } // findDeadListeners returns one finding per (listener, rule, source) @@ -62,9 +62,11 @@ type deadListenerFinding struct { // `host.Network().InterfaceListenAddresses()`). // // swarmListen is the raw `Addresses.Swarm` config. A finding is marked -// `Explicit` when the resolved listener appears literally in `swarmListen`, -// and non-explicit when it came from a wildcard listen (`/ip4/0.0.0.0`, -// `/ip6/::`) expanding onto a per-interface address. +// `Explicit` when the resolved listener shares its IP and port with a +// specific-IP entry in `swarmListen`, and non-explicit when it came from a +// wildcard listen (`/ip4/0.0.0.0`, `/ip6/::`) expanding onto a per-interface +// address. See explicitListens for why the match is on IP+port rather than +// the full multiaddr string. // // Callers route findings to log levels based on Source + Explicit: // @@ -90,7 +92,10 @@ func findDeadListeners(listenAddrs []ma.Multiaddr, swarmListen, addrFilters, noA if !f.AddrBlocked(l) { continue } - _, isExplicit := explicit[l.String()] + isExplicit := false + if ep, ok := listenEndpoint(l); ok { + _, isExplicit = explicit[ep] + } out = append(out, deadListenerFinding{ Listener: l.String(), Rule: r, @@ -106,15 +111,51 @@ func findDeadListeners(listenAddrs []ma.Multiaddr, swarmListen, addrFilters, noA return findings } -// explicitListens returns the set of specific-interface listen addresses -// from `Addresses.Swarm`, as normalized multiaddr strings. Wildcard listens -// (`/ip4/0.0.0.0`, `/ip6/::`) and entries without an IP component are -// skipped. +// listenEndpoint returns a key identifying m's bound socket: its IP value, +// transport (tcp or udp), and port. The bool is false when m has no specific +// IP (a wildcard such as `/ip4/0.0.0.0`, or an IP-less `/dns...` listen) or +// no TCP/UDP port, since such an address cannot name a single socket. +// +// The key intentionally drops everything after the port. The same socket is +// reported under different multiaddrs depending on transport: the WebSocket +// listener canonicalizes `/wss` to `/tls/ws`, and WebTransport appends a +// `/certhash/...` component for its self-signed certificate. Comparing on +// IP+transport+port keeps a specific-IP listen recognizable across those +// rewrites, where a full-string comparison would not. +// +// The transport is part of the key because TCP and QUIC routinely share a +// port number (Kubo defaults to 4001 for both) yet are distinct sockets. The +// IP is matched by value: an `/ip6zone` qualifier is dropped, so a zoneless +// config entry still matches the resolved interface address. +func listenEndpoint(m ma.Multiaddr) (string, bool) { + ip, err := manet.ToIP(m) + if err != nil || ip.IsUnspecified() { + return "", false + } + if port, err := m.ValueForProtocol(ma.P_TCP); err == nil { + return ip.String() + "/tcp/" + port, true + } + if port, err := m.ValueForProtocol(ma.P_UDP); err == nil { + return ip.String() + "/udp/" + port, true + } + return "", false +} + +// explicitListens returns the set of network endpoints (IP+port), keyed by +// listenEndpoint, that `Addresses.Swarm` binds to a specific interface. +// Wildcard listens (`/ip4/0.0.0.0`, `/ip6/::`) and entries without an IP +// component (`/dns...`) are skipped: they do not pin a single interface. // -// `InterfaceListenAddresses` echoes a specific-IP listen verbatim but -// resolves a wildcard listen to per-interface addresses that never appear -// here, so membership in this set tells a deliberately-bound listener apart -// from an incidental wildcard expansion onto a filtered interface. +// A resolved listener counts as explicit when its endpoint is in this set. +// A wildcard listen expands to per-interface addresses whose IPs never +// appear here, so endpoint membership separates a deliberately-bound +// listener from an incidental wildcard expansion onto a filtered interface, +// even when the two share an IP (their transport or port differs). +// +// A `/tcp/0` (OS-assigned port) listen is stored with port "0", which no +// resolved listener reports, so it falls back to non-explicit (DEBUG). The +// reverse-proxy misconfiguration this routing exists to flag always pins a +// fixed port, so the best-effort gap costs nothing in practice. func explicitListens(swarmListen []string) map[string]struct{} { set := make(map[string]struct{}, len(swarmListen)) for _, s := range swarmListen { @@ -122,11 +163,9 @@ func explicitListens(swarmListen []string) map[string]struct{} { if err != nil { continue } - ip, err := manet.ToIP(m) - if err != nil || ip.IsUnspecified() { - continue + if ep, ok := listenEndpoint(m); ok { + set[ep] = struct{}{} } - set[m.String()] = struct{}{} } return set } diff --git a/core/node/libp2p/addrs_test.go b/core/node/libp2p/addrs_test.go index ea6fd8b9035..f18da91b2ca 100644 --- a/core/node/libp2p/addrs_test.go +++ b/core/node/libp2p/addrs_test.go @@ -168,6 +168,92 @@ func TestFindDeadListeners(t *testing.T) { {Listener: "/ip6/::1/tcp/4001", Rule: "/ip6/::/ipcidr/3", Source: deadListenerSourceNoAnnounce, Explicit: false}, }, }, + // A listener is reported under a different multiaddr than its + // Addresses.Swarm entry once a transport rewrites trailing + // components. Matching on IP+port keeps the explicit listener + // recognizable across these rewrites. + { + // A WebTransport listener reports the current and next cert + // hashes, so InterfaceListenAddresses surfaces two /certhash + // components the config entry never had. + name: "explicit WebTransport listen reported with /certhash: explicit AddrFilters finding", + listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/udp/4001/quic-v1/webtransport/certhash/uEiAkH5a4DPGKUuOBjYw0CgwjLa2R_RF71v86aVxlqdKNOQ/certhash/uEiAsGPzpiPGQzSlVHRXrUCT5EkTV7YFrV4VZ3hpEKTd_zg"), + swarmListen: []string{"/ip4/127.0.0.1/udp/4001/quic-v1/webtransport"}, + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + want: []deadListenerFinding{ + {Listener: "/ip4/127.0.0.1/udp/4001/quic-v1/webtransport/certhash/uEiAkH5a4DPGKUuOBjYw0CgwjLa2R_RF71v86aVxlqdKNOQ/certhash/uEiAsGPzpiPGQzSlVHRXrUCT5EkTV7YFrV4VZ3hpEKTd_zg", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, + }, + }, + { + // TCP and QUIC share port 4001 in stock Kubo. A pinned QUIC + // listener must not promote the TCP wildcard's expansion onto + // the same IP to ERROR: they are different sockets. + name: "pinned QUIC listener leaves same-port TCP wildcard expansion non-explicit", + listenAddrs: mustMultiaddrs(t, + "/ip4/172.17.0.1/tcp/4001", // from the /ip4/0.0.0.0/tcp/4001 wildcard + "/ip4/172.17.0.1/udp/4001/quic-v1", // the explicit QUIC listener + ), + swarmListen: []string{ + "/ip4/0.0.0.0/tcp/4001", + "/ip4/172.17.0.1/udp/4001/quic-v1", + }, + addrFilters: []string{"/ip4/172.16.0.0/ipcidr/12"}, + want: []deadListenerFinding{ + {Listener: "/ip4/172.17.0.1/tcp/4001", Rule: "/ip4/172.16.0.0/ipcidr/12", Source: deadListenerSourceAddrFilters, Explicit: false}, + {Listener: "/ip4/172.17.0.1/udp/4001/quic-v1", Rule: "/ip4/172.16.0.0/ipcidr/12", Source: deadListenerSourceAddrFilters, Explicit: true}, + }, + }, + { + name: "explicit wss listen reported as /tls/ws: explicit AddrFilters finding", + listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/tls/ws"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/8081/wss"}, + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + want: []deadListenerFinding{ + {Listener: "/ip4/127.0.0.1/tcp/8081/tls/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, + }, + }, + { + // The wildcard expansion onto loopback (/tcp/4001) and the + // explicit reverse-proxy wss listener (/tcp/8081) share an IP + // but differ in port, so only the explicit port is ERROR. + name: "wildcard expansion shares loopback IP with explicit wss listener on another port", + listenAddrs: mustMultiaddrs(t, + "/ip4/127.0.0.1/tcp/4001", // from the /ip4/0.0.0.0 wildcard + "/ip4/127.0.0.1/tcp/8081/tls/ws", // the explicit wss listener, as reported + ), + swarmListen: []string{ + "/ip4/0.0.0.0/tcp/4001", + "/ip4/127.0.0.1/tcp/8081/wss", + }, + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + want: []deadListenerFinding{ + {Listener: "/ip4/127.0.0.1/tcp/4001", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: false}, + {Listener: "/ip4/127.0.0.1/tcp/8081/tls/ws", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: true}, + }, + }, + { + // Uppercase IPv6 in config plus a wss->/tls/ws rewrite at the + // listener: matching needs both IP canonicalization and the + // transport-independent endpoint key. + name: "explicit IPv6 wss listen configured in uppercase matches resolved lowercase /tls/ws", + listenAddrs: mustMultiaddrs(t, "/ip6/fd7d:54ce:fe4::1/tcp/8081/tls/ws"), + swarmListen: []string{"/ip6/FD7D:54CE:FE4::1/tcp/8081/wss"}, + addrFilters: []string{"/ip6/fc00::/ipcidr/7"}, + want: []deadListenerFinding{ + {Listener: "/ip6/fd7d:54ce:fe4::1/tcp/8081/tls/ws", Rule: "/ip6/fc00::/ipcidr/7", Source: deadListenerSourceAddrFilters, Explicit: true}, + }, + }, + { + // /tcp/0 binds an OS-assigned port that the config entry cannot + // name, so the listener cannot be matched back and stays DEBUG. + name: "explicit /tcp/0 listen resolves to an assigned port: falls back to non-explicit", + listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/54321"), + swarmListen: []string{"/ip4/127.0.0.1/tcp/0"}, + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + want: []deadListenerFinding{ + {Listener: "/ip4/127.0.0.1/tcp/54321", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, Explicit: false}, + }, + }, } for _, tc := range cases {