Skip to content
Merged
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
1 change: 1 addition & 0 deletions core/node/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
168 changes: 168 additions & 0 deletions core/node/libp2p/addrs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
130 changes: 130 additions & 0 deletions core/node/libp2p/addrs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/changelogs/v0.41.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/changelogs/v0.42.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
4 changes: 4 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down Expand Up @@ -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` |

Expand Down
Loading