Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/node/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
143 changes: 102 additions & 41 deletions core/node/libp2p/addrs.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,39 +44,46 @@ 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)
// 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.
// `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()
Expand All @@ -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,
)
}
}
Expand All @@ -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() {
Expand All @@ -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
Expand Down
Loading
Loading