diff --git a/core/node/groups.go b/core/node/groups.go index d9c7f1ffb6f..ba7e5e62a8a 100644 --- a/core/node/groups.go +++ b/core/node/groups.go @@ -201,6 +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.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 3b958e12dfa..b6a5c4ddee4 100644 --- a/core/node/libp2p/addrs.go +++ b/core/node/libp2p/addrs.go @@ -12,9 +12,11 @@ import ( "github.com/ipfs/kubo/config" p2pforge "github.com/ipshipyard/p2p-forge/client" "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/event" "github.com/libp2p/go-libp2p/core/host" p2pbhost "github.com/libp2p/go-libp2p/p2p/host/basic" ma "github.com/multiformats/go-multiaddr" + manet "github.com/multiformats/go-multiaddr/net" mamask "github.com/whyrusleeping/multiaddr-filter" "github.com/caddyserver/certmagic" @@ -36,6 +38,172 @@ func AddrFilters(filters []string) func() (*ma.Filters, Libp2pOpts, error) { } } +// Sources for deadListenerFinding.Source. +const ( + deadListenerSourceAddrFilters = "Swarm.AddrFilters" + deadListenerSourceNoAnnounce = "Addresses.NoAnnounce" +) + +// deadListenerFinding is one resolved listener killed by a CIDR rule: +// `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 +} + +// findDeadListeners returns one finding per (listener, rule, source) +// triple whose IP component falls inside a CIDR in addrFilters or +// 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. +// +// 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. +// +// Listeners without an IP component (`/dns`, `/dnsaddr`) and +// unparseable rules are skipped silently. +func findDeadListeners(listenAddrs []ma.Multiaddr, addrFilters []string, noAnnounce []string) []deadListenerFinding { + 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() + f.AddFilter(*mask, ma.ActionDeny) + for _, l := range listenAddrs { + if !f.AddrBlocked(l) { + continue + } + if source == deadListenerSourceNoAnnounce && isLoopbackMultiaddr(l) { + // Suppressing loopback announcement is operator-intent, + // not a misconfiguration. + continue + } + out = append(out, deadListenerFinding{ + Listener: l.String(), + Source: source, + Rule: r, + }) + } + } + 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 + } + return ip.IsLoopback() +} + +// 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. +func logDeadListenerFinding(f deadListenerFinding) { + switch f.Source { + case deadListenerSourceAddrFilters: + 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, + ) + } +} + +// MonitorDeadListeners runs findDeadListeners at startup and on every +// EvtLocalAddressesUpdated. Listen addresses change at runtime (NAT +// mapping, new interface, AutoTLS cert), so a one-shot check would +// miss listeners that appear later. +// +// Findings are deduplicated against the previous run: a stable +// misconfiguration is logged once. +// +// 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 { + return func(lc fx.Lifecycle, h host.Host) error { + seen := make(map[deadListenerFinding]struct{}) + runCheck := func() { + listenAddrs, err := h.Network().InterfaceListenAddresses() + if err != nil { + log.Warnf("dead-listener check: read InterfaceListenAddresses: %s", err) + return + } + next := make(map[deadListenerFinding]struct{}) + for _, f := range findDeadListeners(listenAddrs, addrFilters, noAnnounce) { + next[f] = struct{}{} + if _, ok := seen[f]; ok { + continue + } + logDeadListenerFinding(f) + } + seen = next + } + + // Startup check, always runs even if the runtime monitor below + // cannot be wired up. + runCheck() + + sub, err := h.EventBus().Subscribe(new(event.EvtLocalAddressesUpdated)) + if err != nil { + log.Errorf("dead-listener check: subscribe to EvtLocalAddressesUpdated failed (%s); runtime monitor disabled, startup check already ran", err) + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + lc.Append(fx.Hook{ + OnStop: func(_ context.Context) error { + cancel() + return nil + }, + }) + + go func() { + defer sub.Close() + for { + select { + case <-ctx.Done(): + return + case _, ok := <-sub.Out(): + if !ok { + return + } + runCheck() + } + } + }() + return nil + } +} + func makeAddrsFactory(announce []string, appendAnnounce []string, noAnnounce []string) (p2pbhost.AddrsFactory, error) { var err error // To assign to the slice in the for loop existing := make(map[string]bool) // To avoid duplicates diff --git a/core/node/libp2p/addrs_test.go b/core/node/libp2p/addrs_test.go index dc31d41e50b..0db847dbbfe 100644 --- a/core/node/libp2p/addrs_test.go +++ b/core/node/libp2p/addrs_test.go @@ -4,8 +4,138 @@ import ( "testing" ma "github.com/multiformats/go-multiaddr" + "github.com/stretchr/testify/require" ) +// mustMultiaddrs parses a list of multiaddr strings or fails the test. +func mustMultiaddrs(t *testing.T, addrs ...string) []ma.Multiaddr { + t.Helper() + out := make([]ma.Multiaddr, 0, len(addrs)) + for _, s := range addrs { + m, err := ma.NewMultiaddr(s) + require.NoError(t, err, "parse %q", s) + out = append(out, m) + } + return out +} + +func TestFindDeadListeners(t *testing.T) { + cases := []struct { + name string + listenAddrs []ma.Multiaddr + addrFilters []string + noAnnounce []string + want []deadListenerFinding + }{ + { + name: "empty config produces no findings", + listenAddrs: mustMultiaddrs(t, "/ip4/192.168.1.5/tcp/4001"), + }, + { + name: "loopback listener with loopback AddrFilters: one finding", + listenAddrs: mustMultiaddrs(t, "/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"}, + }, + }, + { + name: "loopback NoAnnounce match alone is operator-intent: skipped", + listenAddrs: mustMultiaddrs(t, "/ip4/127.0.0.1/tcp/8081/ws"), + noAnnounce: []string{"/ip4/127.0.0.0/ipcidr/8"}, + }, + { + 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"}, + 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"}, + }, + }, + { + 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"}, + want: []deadListenerFinding{ + {Listener: "/ip4/192.168.1.5/tcp/4001", Source: deadListenerSourceNoAnnounce, Rule: "/ip4/192.168.0.0/ipcidr/16"}, + }, + }, + { + name: "IPv6 loopback (resolved from `::`) with `::/3` AddrFilters: flagged", + listenAddrs: mustMultiaddrs(t, "/ip6/::1/tcp/4001"), + addrFilters: []string{"/ip6/::/ipcidr/3"}, + want: []deadListenerFinding{ + {Listener: "/ip6/::1/tcp/4001", Source: deadListenerSourceAddrFilters, Rule: "/ip6/::/ipcidr/3"}, + }, + }, + { + name: "IPv6 loopback NoAnnounce-only is operator-intent: skipped", + listenAddrs: mustMultiaddrs(t, "/ip6/::1/tcp/4001"), + noAnnounce: []string{"/ip6/::/ipcidr/3"}, + }, + { + name: "globally-routable IPv6 (resolved from `::`) is not flagged by `::/3`", + listenAddrs: mustMultiaddrs(t, "/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"), + addrFilters: []string{"/ip4/127.0.0.0/ipcidr/8"}, + }, + { + name: "exact-match NoAnnounce entry is skipped (operator-explicit)", + listenAddrs: mustMultiaddrs(t, "/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", + listenAddrs: mustMultiaddrs(t, "/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"}, + }, + }, + { + name: "bootstrapper-style mix: only AddrFilters loopback fires", + 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", + ), + addrFilters: []string{ + "/ip4/127.0.0.0/ipcidr/8", + "/ip6/::/ipcidr/3", + }, + noAnnounce: []string{ + "/ip4/127.0.0.0/ipcidr/8", + "/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"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := findDeadListeners(tc.listenAddrs, tc.addrFilters, tc.noAnnounce) + require.ElementsMatch(t, tc.want, got) + }) + } +} + // makeAddrsFactory must drop empty multiaddrs from the input list. // A zero-component Multiaddr would otherwise reach the host's signed // peer record and propagate to peers as "/" when they decode the wire diff --git a/docs/changelogs/v0.41.md b/docs/changelogs/v0.41.md index e4ac4213e39..4350133cdec 100644 --- a/docs/changelogs/v0.41.md +++ b/docs/changelogs/v0.41.md @@ -229,6 +229,9 @@ The command is idempotent. See the [`server` profile docs](https://github.com/ip > [!WARNING] > The `server` profile disables local peer discovery ([`Discovery.MDNS`](https://github.com/ipfs/kubo/blob/master/docs/config.md#discoverymdns) off, loopback filtered), so co-located daemons on the same host and peers on the same LAN will no longer find each other automatically. Apply only on public-internet nodes where that is intended. +> [!CAUTION] +> If a manually configured libp2p listener (for example `/ip4/127.0.0.1/tcp/.../ws` fronted by a local nginx or Caddy reverse proxy) terminates inbound on `127.0.0.1`, the new loopback entry in [`Swarm.AddrFilters`](https://github.com/ipfs/kubo/blob/master/docs/config.md#swarmaddrfilters) makes the gater RST every inbound from the proxy before the libp2p handshake. Remove `/ip4/127.0.0.0/ipcidr/8` (and `/ip6/::1/ipcidr/128`, `/ip6/::/ipcidr/3` if the proxy uses IPv6 loopback) from `Swarm.AddrFilters` only; keep them in [`Addresses.NoAnnounce`](https://github.com/ipfs/kubo/blob/master/docs/config.md#addressesnoannounce) so the loopback addresses are still stripped from identify and DHT records. + #### ๐Ÿน Go 1.26, Once More with Feeling Kubo first shipped with [Go 1.26](https://go.dev/doc/go1.26) in v0.40.0, but [v0.40.1](https://github.com/ipfs/kubo/blob/master/docs/changelogs/v0.40.md#v0401) had to downgrade to Go 1.25 because of a Windows crash in Go's overlapped I/O layer ([#11214](https://github.com/ipfs/kubo/issues/11214)). Go 1.26.2 fixes that regression upstream ([golang/go#78041](https://github.com/golang/go/issues/78041)), so Kubo is back on Go 1.26 across all platforms. diff --git a/docs/changelogs/v0.42.md b/docs/changelogs/v0.42.md index 2c05134f19f..67ee0fabf9b 100644 --- a/docs/changelogs/v0.42.md +++ b/docs/changelogs/v0.42.md @@ -11,6 +11,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [Overview](#overview) - [๐Ÿ”ฆ Highlights](#-highlights) - [๐Ÿ› Fixed pin operations hanging under pinned reprovide strategies](#-fixed-pin-operations-hanging-under-pinned-reprovide-strategies) + - [๐Ÿšจ ERROR log for listeners blocked by `Swarm.AddrFilters` or `Addresses.NoAnnounce`](#-error-log-for-listeners-blocked-by-swarmaddrfilters-or-addressesnoannounce) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -24,6 +25,10 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. The pinner now snapshots the index under the read lock and releases it before the reprovider starts, so pin operations are no longer blocked by the reprovide cycle. The default `Provide.Strategy=all` was not affected. +#### ๐Ÿšจ ERROR log for listeners blocked by `Swarm.AddrFilters` or `Addresses.NoAnnounce` + +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. + ### ๐Ÿ“ Changelog ### ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors diff --git a/docs/config.md b/docs/config.md index 00686d4b30f..f3aab7b5a2d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -3225,6 +3225,9 @@ so that a range is neither advertised nor dialed. > [`server` profile](#server-profile) section for the full list and for > optional entries operators may add manually. +> [!CAUTION] +> If an [`Addresses.Swarm`](#addressesswarm) listener (for example a manually configured `/ip4/127.0.0.1/tcp/.../ws` fronted by a local nginx or Caddy reverse proxy) is covered by an entry in this list, Kubo rejects every incoming connection to it, so the proxy cannot reach Kubo. Kubo logs an ERROR at startup naming the offending rule. Remove the rule from `Swarm.AddrFilters` to allow the listener; keep it in [`Addresses.NoAnnounce`](#addressesnoannounce) if you still want to suppress its announcement. + Default: `[]` Type: `array[string]` @@ -4300,6 +4303,7 @@ Or skip the profile and populate those fields manually. | Link-local IPv6 peering | `/ip6/fe80::/ipcidr/10` | | Multiple daemons peering over `127.0.0.1` | `/ip4/127.0.0.0/ipcidr/8` | | Multiple daemons peering over IPv6 loopback `::1` | `/ip6/::1/ipcidr/128` and `/ip6/::/ipcidr/3` | +| Local reverse proxy fronting a `/ws` (or other libp2p) listener on `127.0.0.1` | `/ip4/127.0.0.0/ipcidr/8` from `Swarm.AddrFilters` only (keep it in `Addresses.NoAnnounce`); also drop `/ip6/::1/ipcidr/128` and `/ip6/::/ipcidr/3` from `Swarm.AddrFilters` if the proxy uses IPv6 loopback | | [Yggdrasil] mesh peering (`200::/8`, `300::/8`) | `/ip6/::/ipcidr/3` | | NAT64 (`64:ff9b::/96`) reachability | `/ip6/::/ipcidr/3` |