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..9670f787fcc 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 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) @@ -58,25 +59,31 @@ 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. A finding is marked +// `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. // -// 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 := explicitListens(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 +92,108 @@ 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. - continue + isExplicit := false + if ep, ok := listenEndpoint(l); ok { + _, isExplicit = explicit[ep] } 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 { +// 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 { - return false + 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 ip.IsLoopback() + return "", false } -// 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. +// 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. +// +// 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 { + m, err := ma.NewMultiaddr(s) + if err != nil { + continue + } + if ep, ok := listenEndpoint(m); ok { + set[ep] = struct{}{} + } + } + return set +} + +// 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 +209,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 +219,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..f18da91b2ca 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,123 @@ 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/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", ), + 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", @@ -121,16 +156,109 @@ 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}, + }, + }, + // 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/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/54321", Rule: "/ip4/127.0.0.0/ipcidr/8", Source: deadListenerSourceAddrFilters, 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