diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index 598356c..c77c7ac 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -115,6 +115,10 @@
- **NETW-03**: Protocol handlers: /memory/store/1.0.0, /memory/fetch/1.0.0, /memory/query/1.0.0, /memory/sync/1.0.0
- **NETW-04**: NAT traversal via libp2p autorelay
- **NETW-05**: Peer reputation scoring and blacklisting
+- **NETW-06**: Connection gater that blocks low-reputation and explicitly blocked peers
+- **NETW-07**: libp2p Resource Manager with per-peer connection and stream limits
+- **NETW-08**: Per-peer rate limiting on protocol handlers (/memory/store, /memory/fetch, /memory/query)
+- **NETW-09**: Peer blocklist/allowlist configurable via config file
### Daemon Architecture & CLI Restructure
@@ -178,10 +182,14 @@
| MCP-03 | Phase 6 | Pending |
| MCP-04 | Phase 6 | Pending |
| MCP-05 | Phase 6 | Pending |
+| NETW-06 | Phase 8 | Pending |
+| NETW-07 | Phase 8 | Pending |
+| NETW-08 | Phase 8 | Pending |
+| NETW-09 | Phase 8 | Pending |
**Coverage:**
-- v1 requirements: 33 total
-- Mapped to phases: 33
+- v1 requirements: 37 total
+- Mapped to phases: 37
- Unmapped: 0 ✓
---
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index a6f3ff7..3a64282 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -2,7 +2,7 @@
**Project:** DMGN
**Created:** 2025-04-09
-**Granularity:** Standard (6 phases)
+**Granularity:** Standard (8 phases)
## Summary
@@ -15,6 +15,7 @@
| 5 | [Query & Sync](#phase-5-query--sync) | Cross-peer search and consistency | Vector search, gossip sync | 5 |
| 6 | [MCP & Polish](#phase-6-mcp--polish) | Full MCP support and production readiness | MCP tools, metrics, docs | 5 |
| 7 | [Daemon Architecture](#phase-7-daemon-architecture--cli-restructure) | Persistent background daemon with integrated MCP and auto peer networking | Daemon, MCP auto-serve, stop cmd | 7 |
+| 8 | [Networking Enhancements](#phase-8-networking-enhancements) | QUIC transport, NAT traversal, networking security | QUIC v1, Relay v2, hole punching, connection gater, resource mgr | 4 |
---
@@ -168,6 +169,36 @@
---
+## Phase 8: Networking Enhancements
+
+**Goal:** Add QUIC transport, NAT traversal, and networking layer security for production-grade P2P connectivity
+
+**Requirements:** NETW-02, NETW-04, NETW-06, NETW-07, NETW-08, NETW-09
+
+**Success Criteria:**
+1. Node listens on both TCP and QUIC v1 transports
+2. QUIC transport functional for peer connections
+3. Circuit Relay v2 enables nodes behind NAT to be reachable
+4. Direct hole punching reduces relay dependency
+5. Configuration supports listen address arrays and NAT options
+6. Connection gater blocks low-reputation and explicitly blocked peers
+7. Resource Manager enforces per-peer connection and stream limits
+8. Protocol handlers rate-limited per peer
+9. Peer blocklist/allowlist configurable via config
+
+**Key Components:**
+- QUIC transport configuration (quic-v1 multiaddr)
+- Circuit Relay v2 service (relay for other peers)
+- Hole punching (direct NAT traversal)
+- TURN fallback configuration
+- Updated config struct (ListenAddrs array, NAT booleans)
+- Connection gater integrated with ReputationManager
+- libp2p Resource Manager (connection/stream limits)
+- Per-peer protocol rate limiter
+- Config-driven peer blocklist/allowlist
+
+---
+
## Dependency Graph
```
@@ -184,6 +215,8 @@ Phase 5: Query & Sync (depends on Phase 2, 4)
Phase 6: MCP & Polish (depends on all previous)
↓
Phase 7: Daemon Architecture & CLI Restructure (depends on all previous)
+ ↓
+Phase 8: Networking Enhancements (depends on Phase 3, 7)
```
---
@@ -211,6 +244,7 @@ Phase 7: Daemon Architecture & CLI Restructure (depends on all previous)
| 5 | **Complete** | 2026-04-09 | 2026-04-09 |
| 6 | **Complete** | 2026-04-09 | 2026-04-09 |
| 7 | **Planned** | — | — |
+| 8 | **Planned** | — | — |
---
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 32ad728..6636a39 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -2,11 +2,11 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
-status: Executing Phase 07
-last_updated: "2026-04-10T03:31:53.496Z"
+status: Phase 8 ready for planning
+last_updated: "2026-04-10T04:55:00.000Z"
progress:
- total_phases: 7
- completed_phases: 5
+ total_phases: 8
+ completed_phases: 6
total_plans: 22
completed_plans: 14
percent: 64
@@ -20,7 +20,7 @@ See: `.planning/PROJECT.md` (updated 2025-04-09)
**Core value:** User owns their identity and memory data that persists across devices and time, with no central server or third-party control.
-**Current focus:** Phase 07 — cli-enhancements
+**Current focus:** Phase 08 — Networking Enhancements (QUIC transport, NAT traversal)
## Phase Progress
@@ -32,6 +32,8 @@ See: `.planning/PROJECT.md` (updated 2025-04-09)
| 4: Distributed Storage | **Complete** | Shamir sharding, DHT-based distribution, store/fetch protocols |
| 5: Query & Sync | **Complete** | Vector index, hybrid scoring, GossipSub, delta sync, cross-peer query |
| 6: MCP & Polish | **Complete** | MCP server (7 tools), OTel, backup/restore, peer reputation, docs |
+| 7: Daemon Architecture | **Complete** | Background daemon, integrated MCP, start/stop commands |
+| 8: Networking Enhancements | **Planned** | QUIC transport, NAT traversal (Circuit Relay v2, hole punching, TURN) |
## Active Work
@@ -71,6 +73,8 @@ Phase 6 Completed Plans:
16. **Local-only MCP by default**: MCP server works offline-first, `--network` flag opts into P2P features.
17. **Weighted reputation scoring**: `0.3*uptime + 0.3*latency + 0.2*sync + 0.2*availability` with exponential decay toward neutral.
18. **Protobuf migration (hybrid)**: Wire (store/fetch, gossip, delta) = protobuf, disk = BadgerDB native, memory = hybrid (protobuf replication + JSON local), API = JSON (required)
+19. **QUIC transport**: Add QUIC v1 alongside TCP for improved latency and NAT traversal support
+20. **NAT traversal**: Enable Circuit Relay v2, direct hole punching, and TURN fallback for nodes behind NAT
## Blockers
@@ -84,6 +88,7 @@ None.
## Recent Changes
+- 2026-04-10: Phase 8 context captured — QUIC transport, NAT traversal (Circuit Relay v2, hole punching, TURN)
- 2026-04-09: Phase 01 protobuf migration verified — all 4 protocols at v2.0.0, JSON eliminated from wire
- 2026-04-09: Phase 1 context captured — Protobuf migration decisions (wire format, gossip, disk, memory model)
- 2026-04-09: Phase 6 complete — 4 plans executed, 28 new tests, 13 test packages all passing
diff --git a/.planning/phases/08-networking-enhancements/08-01-PLAN.md b/.planning/phases/08-networking-enhancements/08-01-PLAN.md
new file mode 100644
index 0000000..5ca7077
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-01-PLAN.md
@@ -0,0 +1,134 @@
+---
+phase: 8
+plan: 1
+type: implementation
+wave: 1
+depends_on: []
+files_modified:
+ - internal/config/config.go
+autonomous: true
+requirements:
+ - NETW-02
+ - NETW-04
+---
+
+# Plan 08-01: Config Migration for QUIC & NAT Traversal
+
+
+Add new config fields for QUIC listen addresses and NAT traversal options. Migrate from single `ListenAddr` string to `ListenAddrs` array with backward compatibility. Update defaults to include both TCP and QUIC v1 listen addresses.
+
+
+
+
+## Task 1: Add new config fields and defaults
+
+
+- `internal/config/config.go` — current Config struct with `ListenAddr string`
+- `.planning/phases/08-networking-enhancements/08-CONTEXT.md` — decisions D-01 through D-10
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — config migration strategy
+
+
+
+Add new fields to the `Config` struct in `internal/config/config.go`:
+
+```go
+ListenAddrs []string `json:"listen_addrs"`
+EnableHolePunching bool `json:"enable_hole_punching"`
+EnableRelayService bool `json:"enable_relay_service"`
+RelayServers []string `json:"relay_servers"`
+```
+
+Add these fields after the existing `ListenAddr` field. Keep `ListenAddr` for backward compatibility.
+
+Update `DefaultConfig()` to set:
+```go
+ListenAddrs: []string{"/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1"},
+EnableHolePunching: true,
+EnableRelayService: false,
+RelayServers: []string{},
+```
+
+
+
+- `internal/config/config.go` contains `ListenAddrs []string` field with json tag `listen_addrs`
+- `internal/config/config.go` contains `EnableHolePunching bool` field with json tag `enable_hole_punching`
+- `internal/config/config.go` contains `EnableRelayService bool` field with json tag `enable_relay_service`
+- `internal/config/config.go` contains `RelayServers []string` field with json tag `relay_servers`
+- `DefaultConfig()` returns config with `ListenAddrs` containing exactly `["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1"]`
+- `DefaultConfig()` returns config with `EnableHolePunching: true`
+- `DefaultConfig()` returns config with `EnableRelayService: false`
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+
+No security impact — config fields only. NAT traversal defaults are conservative (hole punching enabled, relay service off by default). Relay service is opt-in to prevent unwanted resource consumption.
+
+
+## Task 2: Add GetListenAddrs() backward compatibility method
+
+
+- `internal/config/config.go` — the Config struct after Task 1 modifications
+
+
+
+Add the following method to `internal/config/config.go`:
+
+```go
+// GetListenAddrs returns the listen addresses to use.
+// Falls back to legacy ListenAddr if ListenAddrs is empty,
+// and returns default TCP+QUIC addresses if both are empty.
+func (c *Config) GetListenAddrs() []string {
+ if len(c.ListenAddrs) > 0 {
+ return c.ListenAddrs
+ }
+ if c.ListenAddr != "" {
+ return []string{c.ListenAddr}
+ }
+ return []string{"/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1"}
+}
+```
+
+
+
+- `internal/config/config.go` contains `func (c *Config) GetListenAddrs() []string`
+- Method returns `ListenAddrs` when non-empty
+- Method returns `[]string{ListenAddr}` when `ListenAddrs` is empty but `ListenAddr` is set
+- Method returns default TCP+QUIC addresses when both are empty
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+
+
+
+1. `go build ./...` — compiles without errors
+2. `go vet ./internal/config/...` — no vet issues
+3. Config struct has 4 new fields visible in source
+4. DefaultConfig() includes QUIC listen address
+
+
+
+- Config struct has `ListenAddrs`, `EnableHolePunching`, `EnableRelayService`, `RelayServers` fields
+- `GetListenAddrs()` provides backward compatibility for existing single-address configs
+- Default config includes both TCP and QUIC v1 listen addresses
+- No breaking changes to existing config loading
+
+
+
+- QUIC listen address in default config (D-01, D-02)
+- ListenAddrs array replacing single ListenAddr (D-03)
+- TCP kept alongside QUIC (D-04)
+- NAT config booleans (D-06, D-07, D-10)
+- Backward compatibility for existing config files
+
diff --git a/.planning/phases/08-networking-enhancements/08-02-PLAN.md b/.planning/phases/08-networking-enhancements/08-02-PLAN.md
new file mode 100644
index 0000000..7edf89a
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-02-PLAN.md
@@ -0,0 +1,165 @@
+---
+phase: 8
+plan: 2
+type: implementation
+wave: 1
+depends_on: []
+files_modified:
+ - pkg/network/host.go
+autonomous: true
+requirements:
+ - NETW-02
+ - NETW-04
+---
+
+# Plan 08-02: QUIC Transport & NAT Traversal in Host
+
+
+Extend the libp2p host creation to support QUIC transport listening, Circuit Relay v2 service, hole punching, and AutoRelay. Add new fields to HostConfig and conditionally enable libp2p options based on config.
+
+
+
+
+## Task 1: Extend HostConfig with NAT traversal fields
+
+
+- `pkg/network/host.go` — current HostConfig struct and NewHost function (lines 22-97)
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — section 7, host.go changes
+
+
+
+Add three new fields to `HostConfig` in `pkg/network/host.go`:
+
+```go
+type HostConfig struct {
+ ListenAddrs []string
+ BootstrapPeers []string
+ MDNSService string
+ MaxPeersLow int
+ MaxPeersHigh int
+ PrivateKey crypto.PrivKey
+ EnableHolePunching bool
+ EnableRelayService bool
+ RelayServers []string
+}
+```
+
+
+
+- `HostConfig` contains `EnableHolePunching bool` field
+- `HostConfig` contains `EnableRelayService bool` field
+- `HostConfig` contains `RelayServers []string` field
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+## Task 2: Add NAT traversal libp2p options to NewHost
+
+
+- `pkg/network/host.go` — NewHost function, lines 60-97
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — sections 2-4 (Relay, Hole Punching, AutoRelay APIs)
+
+
+
+Modify `NewHost()` in `pkg/network/host.go` to conditionally add NAT traversal options after the base options slice.
+
+Add these imports at the top of the file:
+
+```go
+"github.com/libp2p/go-libp2p/core/peer"
+"github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay"
+```
+
+After the existing opts slice (line 83), add:
+
+```go
+if cfg.EnableRelayService {
+ opts = append(opts, libp2p.EnableRelayService())
+}
+
+if cfg.EnableHolePunching {
+ opts = append(opts, libp2p.EnableHolePunching())
+}
+
+if len(cfg.RelayServers) > 0 {
+ relayInfos, err := parseRelayAddrs(cfg.RelayServers)
+ if err == nil && len(relayInfos) > 0 {
+ opts = append(opts, libp2p.EnableAutoRelayWithStaticRelays(relayInfos))
+ }
+}
+```
+
+Add the helper function:
+
+```go
+// parseRelayAddrs converts multiaddr strings to peer.AddrInfo for static relay configuration.
+func parseRelayAddrs(addrs []string) ([]peer.AddrInfo, error) {
+ var infos []peer.AddrInfo
+ for _, addr := range addrs {
+ ma, err := multiaddr.NewMultiaddr(addr)
+ if err != nil {
+ continue
+ }
+ pi, err := peer.AddrInfoFromP2pAddr(ma)
+ if err != nil {
+ continue
+ }
+ infos = append(infos, *pi)
+ }
+ if len(infos) == 0 {
+ return nil, fmt.Errorf("no valid relay addresses")
+ }
+ return infos, nil
+}
+```
+
+Note: The `peer` import (`github.com/libp2p/go-libp2p/core/peer`) is already used in the file. The `relay` import from `circuitv2` is not used directly in this minimal approach since `libp2p.EnableRelayService()` uses defaults. Only add the `relay` import if customizing resources.
+
+
+
+- `NewHost()` conditionally calls `libp2p.EnableRelayService()` when `cfg.EnableRelayService` is true
+- `NewHost()` conditionally calls `libp2p.EnableHolePunching()` when `cfg.EnableHolePunching` is true
+- `NewHost()` calls `libp2p.EnableAutoRelayWithStaticRelays()` when `cfg.RelayServers` has entries
+- `parseRelayAddrs()` function exists and converts multiaddr strings to `peer.AddrInfo`
+- `go build ./...` succeeds with no new import errors
+
+
+
+```bash
+go build ./...
+```
+
+
+
+**Circuit Relay v2 resource limits:** Default limits (128 KiB data, 2 min duration per circuit, max 128 reservations) prevent relay abuse. Relay service is opt-in (`EnableRelayService: false` by default). Hole punching is read-only from a security perspective — it only attempts direct connections, never exposes data. AutoRelay with static relays uses user-configured trusted peers only.
+
+
+
+
+
+1. `go build ./...` — compiles without errors
+2. `go vet ./pkg/network/...` — no vet issues
+3. NewHost with `EnableHolePunching: true` creates host successfully
+4. NewHost with `EnableRelayService: true` creates host successfully
+5. NewHost with relay server addresses parses them correctly
+
+
+
+- Host creation supports QUIC listen addresses (via existing DefaultTransports)
+- Circuit Relay v2 service is conditionally enabled
+- Hole punching is conditionally enabled
+- AutoRelay with static relays is wired up
+- No regressions in existing host creation
+
+
+
+- EnableRelayService option (D-06)
+- EnableHolePunching option (D-07)
+- Static relay server support for TURN fallback (D-08)
+- AutoRelay for automatic relay discovery (D-09)
+
diff --git a/.planning/phases/08-networking-enhancements/08-03-PLAN.md b/.planning/phases/08-networking-enhancements/08-03-PLAN.md
new file mode 100644
index 0000000..f30778e
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-03-PLAN.md
@@ -0,0 +1,466 @@
+---
+phase: 8
+plan: 3
+type: implementation
+wave: 2
+depends_on:
+ - 08-01
+ - 08-02
+files_modified:
+ - internal/daemon/daemon.go
+ - pkg/network/host_test.go
+autonomous: true
+requirements:
+ - NETW-02
+ - NETW-04
+---
+
+# Plan 08-03: Daemon Integration & Tests
+
+
+Wire the new config fields into the daemon's host creation, update persistMultiaddrs to handle QUIC addresses, and add tests for QUIC listen addresses, config backward compatibility, and NAT traversal options.
+
+
+
+
+## Task 1: Update daemon to use GetListenAddrs and NAT config
+
+
+- `internal/daemon/daemon.go` — lines 99-107 where HostConfig is created, and lines 431-466 persistMultiaddrs
+- `internal/config/config.go` — the new GetListenAddrs() method and new fields
+- `pkg/network/host.go` — the updated HostConfig struct
+
+
+
+In `internal/daemon/daemon.go`, update the host config creation (around line 100):
+
+**Before:**
+```go
+hostCfg := network.HostConfig{
+ ListenAddrs: []string{d.cfg.ListenAddr},
+ BootstrapPeers: d.cfg.BootstrapPeers,
+ MDNSService: d.cfg.MDNSService,
+ MaxPeersLow: d.cfg.MaxPeersLow,
+ MaxPeersHigh: d.cfg.MaxPeersHigh,
+ PrivateKey: d.keys.LibP2PKey,
+}
+```
+
+**After:**
+```go
+hostCfg := network.HostConfig{
+ ListenAddrs: d.cfg.GetListenAddrs(),
+ BootstrapPeers: d.cfg.BootstrapPeers,
+ MDNSService: d.cfg.MDNSService,
+ MaxPeersLow: d.cfg.MaxPeersLow,
+ MaxPeersHigh: d.cfg.MaxPeersHigh,
+ PrivateKey: d.keys.LibP2PKey,
+ EnableHolePunching: d.cfg.EnableHolePunching,
+ EnableRelayService: d.cfg.EnableRelayService,
+ RelayServers: d.cfg.RelayServers,
+}
+```
+
+
+
+- `daemon.go` uses `d.cfg.GetListenAddrs()` instead of `[]string{d.cfg.ListenAddr}`
+- `daemon.go` passes `EnableHolePunching`, `EnableRelayService`, `RelayServers` to HostConfig
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+## Task 2: Update persistMultiaddrs to handle QUIC addresses
+
+
+- `internal/daemon/daemon.go` — persistMultiaddrs function (lines 431-466)
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — section 7, persistMultiaddrs update
+
+
+
+Update `persistMultiaddrs()` in `internal/daemon/daemon.go` to extract both TCP and QUIC bound ports and persist them as `ListenAddrs` instead of updating the single `ListenAddr`.
+
+**Replace the current persistMultiaddrs function with:**
+
+```go
+func (d *Daemon) persistMultiaddrs(peerID string) {
+ addrs := d.host.Addrs()
+ fullAddrs := make([]string, 0, len(addrs))
+ for _, addr := range addrs {
+ fullAddrs = append(fullAddrs, fmt.Sprintf("%s/p2p/%s", addr.String(), peerID))
+ }
+
+ // Extract bound addresses and update ListenAddrs so the same ports
+ // are reused on restart (important when default port is 0 / auto-assign).
+ listenAddrs := make([]string, 0, len(addrs))
+ for _, addr := range addrs {
+ parts := strings.Split(addr.String(), "/")
+ for i, p := range parts {
+ if p == "tcp" && i+1 < len(parts) {
+ listenAddrs = append(listenAddrs, fmt.Sprintf("/ip4/0.0.0.0/tcp/%s", parts[i+1]))
+ break
+ }
+ if p == "udp" && i+1 < len(parts) && i+2 < len(parts) && parts[i+2] == "quic-v1" {
+ listenAddrs = append(listenAddrs, fmt.Sprintf("/ip4/0.0.0.0/udp/%s/quic-v1", parts[i+1]))
+ break
+ }
+ }
+ }
+
+ if len(listenAddrs) > 0 {
+ d.cfg.ListenAddrs = listenAddrs
+ }
+ // Also update legacy ListenAddr for backward compat
+ for _, addr := range listenAddrs {
+ if strings.Contains(addr, "/tcp/") {
+ d.cfg.ListenAddr = addr
+ break
+ }
+ }
+
+ d.cfg.NodeMultiaddrs = fullAddrs
+ if err := d.cfg.Save(); err != nil {
+ d.logger.Error("failed to persist multiaddresses to config", "err", err)
+ } else {
+ d.logger.Info("node multiaddresses persisted to config",
+ "addrs", fullAddrs,
+ "listen_addrs", d.cfg.ListenAddrs,
+ )
+ }
+}
+```
+
+
+
+- `persistMultiaddrs` extracts both TCP and UDP/QUIC-v1 addresses
+- `d.cfg.ListenAddrs` is set with both TCP and QUIC addresses
+- `d.cfg.ListenAddr` still updated with TCP address for backward compat
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+## Task 3: Add config GetListenAddrs tests
+
+
+- `internal/config/config.go` — the GetListenAddrs() method
+
+
+
+Create or extend `internal/config/config_test.go` with tests for `GetListenAddrs()`:
+
+```go
+func TestGetListenAddrs_UsesListenAddrs(t *testing.T) {
+ cfg := &Config{
+ ListenAddrs: []string{"/ip4/0.0.0.0/tcp/4001", "/ip4/0.0.0.0/udp/4001/quic-v1"},
+ ListenAddr: "/ip4/0.0.0.0/tcp/9999",
+ }
+ addrs := cfg.GetListenAddrs()
+ if len(addrs) != 2 {
+ t.Fatalf("expected 2 addrs, got %d", len(addrs))
+ }
+ if addrs[0] != "/ip4/0.0.0.0/tcp/4001" {
+ t.Errorf("expected tcp addr, got %s", addrs[0])
+ }
+ if addrs[1] != "/ip4/0.0.0.0/udp/4001/quic-v1" {
+ t.Errorf("expected quic addr, got %s", addrs[1])
+ }
+}
+
+func TestGetListenAddrs_FallsBackToListenAddr(t *testing.T) {
+ cfg := &Config{
+ ListenAddr: "/ip4/0.0.0.0/tcp/4001",
+ }
+ addrs := cfg.GetListenAddrs()
+ if len(addrs) != 1 {
+ t.Fatalf("expected 1 addr, got %d", len(addrs))
+ }
+ if addrs[0] != "/ip4/0.0.0.0/tcp/4001" {
+ t.Errorf("expected fallback addr, got %s", addrs[0])
+ }
+}
+
+func TestGetListenAddrs_DefaultsWhenBothEmpty(t *testing.T) {
+ cfg := &Config{}
+ addrs := cfg.GetListenAddrs()
+ if len(addrs) != 2 {
+ t.Fatalf("expected 2 default addrs, got %d", len(addrs))
+ }
+ foundTCP, foundQUIC := false, false
+ for _, a := range addrs {
+ if a == "/ip4/0.0.0.0/tcp/0" {
+ foundTCP = true
+ }
+ if a == "/ip4/0.0.0.0/udp/0/quic-v1" {
+ foundQUIC = true
+ }
+ }
+ if !foundTCP || !foundQUIC {
+ t.Errorf("expected default TCP+QUIC addrs, got %v", addrs)
+ }
+}
+
+func TestDefaultConfig_HasQUICAddr(t *testing.T) {
+ cfg := DefaultConfig()
+ if len(cfg.ListenAddrs) != 2 {
+ t.Fatalf("expected 2 listen addrs, got %d", len(cfg.ListenAddrs))
+ }
+ if cfg.ListenAddrs[1] != "/ip4/0.0.0.0/udp/0/quic-v1" {
+ t.Errorf("expected QUIC addr, got %s", cfg.ListenAddrs[1])
+ }
+ if !cfg.EnableHolePunching {
+ t.Error("expected EnableHolePunching to be true by default")
+ }
+ if cfg.EnableRelayService {
+ t.Error("expected EnableRelayService to be false by default")
+ }
+}
+```
+
+
+
+- `go test ./internal/config/... -run TestGetListenAddrs` passes all 3 test cases
+- `go test ./internal/config/... -run TestDefaultConfig_HasQUICAddr` passes
+- Tests verify backward compatibility (single ListenAddr fallback)
+- Tests verify default TCP+QUIC addresses
+
+
+
+```bash
+go test ./internal/config/... -v -run "TestGetListenAddrs|TestDefaultConfig_HasQUIC"
+```
+
+
+## Task 4: Add QUIC host creation and NAT option tests
+
+
+- `pkg/network/host_test.go` — existing test patterns (createTestHost, TestNewHostAndStop)
+- `pkg/network/host.go` — NewHost with new NAT options
+
+
+
+Add the following tests to `pkg/network/host_test.go`:
+
+```go
+func TestNewHostWithQUIC(t *testing.T) {
+ id := createTestIdentity(t)
+ key, err := DeriveLibp2pKey(id)
+ if err != nil {
+ t.Fatalf("DeriveLibp2pKey failed: %v", err)
+ }
+
+ h, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/udp/0/quic-v1"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key,
+ })
+ if err != nil {
+ t.Fatalf("NewHost with QUIC failed: %v", err)
+ }
+ defer h.Stop()
+
+ addrs := h.Addrs()
+ if len(addrs) < 2 {
+ t.Fatalf("expected at least 2 listen addresses (TCP+QUIC), got %d: %v", len(addrs), addrs)
+ }
+
+ foundTCP, foundQUIC := false, false
+ for _, a := range addrs {
+ s := a.String()
+ if strings.Contains(s, "/tcp/") {
+ foundTCP = true
+ }
+ if strings.Contains(s, "/quic-v1") {
+ foundQUIC = true
+ }
+ }
+ if !foundTCP {
+ t.Error("expected TCP address in host addrs")
+ }
+ if !foundQUIC {
+ t.Error("expected QUIC address in host addrs")
+ }
+}
+
+func TestNewHostWithHolePunching(t *testing.T) {
+ id := createTestIdentity(t)
+ key, err := DeriveLibp2pKey(id)
+ if err != nil {
+ t.Fatalf("DeriveLibp2pKey failed: %v", err)
+ }
+
+ h, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key,
+ EnableHolePunching: true,
+ })
+ if err != nil {
+ t.Fatalf("NewHost with hole punching failed: %v", err)
+ }
+ defer h.Stop()
+
+ if h.ID() == "" {
+ t.Error("host should have a non-empty peer ID")
+ }
+}
+
+func TestNewHostWithRelayService(t *testing.T) {
+ id := createTestIdentity(t)
+ key, err := DeriveLibp2pKey(id)
+ if err != nil {
+ t.Fatalf("DeriveLibp2pKey failed: %v", err)
+ }
+
+ h, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key,
+ EnableRelayService: true,
+ })
+ if err != nil {
+ t.Fatalf("NewHost with relay service failed: %v", err)
+ }
+ defer h.Stop()
+
+ if h.ID() == "" {
+ t.Error("host should have a non-empty peer ID")
+ }
+}
+```
+
+Note: Add `"strings"` to the import block if not already present.
+
+
+
+- `go test ./pkg/network/... -run TestNewHostWithQUIC` passes — host has both TCP and QUIC addresses
+- `go test ./pkg/network/... -run TestNewHostWithHolePunching` passes — host creates successfully with hole punching
+- `go test ./pkg/network/... -run TestNewHostWithRelayService` passes — host creates successfully with relay service
+- All existing tests still pass: `go test ./pkg/network/...`
+
+
+
+```bash
+go test ./pkg/network/... -v -run "TestNewHostWithQUIC|TestNewHostWithHolePunching|TestNewHostWithRelayService"
+go test ./pkg/network/... -v
+```
+
+
+## Task 5: Add QUIC two-host connectivity test
+
+
+- `pkg/network/host_test.go` — TestTwoHostsConnect pattern (lines 171-228)
+
+
+
+Add a test to `pkg/network/host_test.go` that creates two hosts with both TCP and QUIC, connects them, and verifies connectivity:
+
+```go
+func TestTwoHostsConnectQUIC(t *testing.T) {
+ id1 := createTestIdentity(t)
+ key1, _ := DeriveLibp2pKey(id1)
+ id2 := createTestIdentity(t)
+ key2, _ := DeriveLibp2pKey(id2)
+
+ h1, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/udp/0/quic-v1"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key1,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h1 failed: %v", err)
+ }
+ defer h1.Stop()
+
+ h2, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/udp/0/quic-v1"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key2,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h2 failed: %v", err)
+ }
+ defer h2.Stop()
+
+ // Connect h2 to h1 using all addresses (QUIC should be preferred)
+ h1Info := peer.AddrInfo{
+ ID: h1.ID(),
+ Addrs: h1.Addrs(),
+ }
+
+ if err := h2.LibP2PHost().Connect(context.Background(), h1Info); err != nil {
+ t.Fatalf("failed to connect h2 to h1: %v", err)
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ if h1.PeerCount() != 1 {
+ t.Errorf("h1 expected 1 peer, got %d", h1.PeerCount())
+ }
+ if h2.PeerCount() != 1 {
+ t.Errorf("h2 expected 1 peer, got %d", h2.PeerCount())
+ }
+}
+```
+
+
+
+- `go test ./pkg/network/... -run TestTwoHostsConnectQUIC` passes
+- Both hosts have QUIC addresses
+- Connection succeeds between QUIC-enabled hosts
+
+
+
+```bash
+go test ./pkg/network/... -v -run TestTwoHostsConnectQUIC
+```
+
+
+
+Tests use localhost only — no external network exposure. QUIC connectivity test validates that encrypted QUIC channels work correctly between peers.
+
+
+
+
+
+1. `go build ./...` — compiles after all changes
+2. `go test ./internal/config/...` — config tests pass
+3. `go test ./pkg/network/...` — network tests pass including new QUIC tests
+4. `go test ./...` — full suite passes (no regressions)
+
+
+
+- Daemon uses new config fields for host creation
+- persistMultiaddrs persists both TCP and QUIC bound ports
+- Config backward compatibility tested
+- QUIC host creation tested
+- NAT traversal options (hole punching, relay service) tested
+- Two-host QUIC connectivity tested
+- All existing tests still pass
+
+
+
+- Daemon wires QUIC + NAT config to host
+- Both TCP and QUIC ports persisted to config
+- Backward compat for single ListenAddr configs
+- QUIC listen address visible in host.Addrs()
+- No test regressions
+
diff --git a/.planning/phases/08-networking-enhancements/08-04-PLAN.md b/.planning/phases/08-networking-enhancements/08-04-PLAN.md
new file mode 100644
index 0000000..1da7ed9
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-04-PLAN.md
@@ -0,0 +1,449 @@
+---
+phase: 8
+plan: 4
+type: implementation
+wave: 2
+depends_on:
+ - 08-01
+files_modified:
+ - pkg/network/gater.go
+ - pkg/network/ratelimit.go
+ - internal/config/config.go
+autonomous: true
+requirements:
+ - NETW-06
+ - NETW-07
+ - NETW-08
+ - NETW-09
+---
+
+# Plan 08-04: Connection Gater, Rate Limiter & Security Config
+
+
+Implement a libp2p ConnectionGater that integrates with the existing ReputationManager to block low-reputation and explicitly blocked peers. Add per-peer protocol rate limiting using token bucket. Add security-related config fields (blocklist, allowlist, reputation threshold, resource limits).
+
+
+
+
+## Task 1: Add security config fields
+
+
+- `internal/config/config.go` — current Config struct (after Plan 08-01 adds transport fields)
+- `.planning/phases/08-networking-enhancements/08-CONTEXT.md` — decisions D-11 through D-16
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — sections 10-13
+
+
+
+Add new security fields to the `Config` struct in `internal/config/config.go`:
+
+```go
+BlockedPeers []string `json:"blocked_peers"`
+AllowedPeers []string `json:"allowed_peers"`
+ReputationThreshold float64 `json:"reputation_threshold"`
+MaxConnectionsPerPeer int `json:"max_connections_per_peer"`
+MaxStreamsPerPeer int `json:"max_streams_per_peer"`
+```
+
+Add these after the existing networking fields. Update `DefaultConfig()`:
+```go
+BlockedPeers: []string{},
+AllowedPeers: []string{},
+ReputationThreshold: 0.2,
+MaxConnectionsPerPeer: 8,
+MaxStreamsPerPeer: 16,
+```
+
+
+
+- Config struct has 5 new security fields with correct json tags
+- `DefaultConfig()` sets `ReputationThreshold: 0.2`, `MaxConnectionsPerPeer: 8`, `MaxStreamsPerPeer: 16`
+- `DefaultConfig()` sets `BlockedPeers` and `AllowedPeers` to empty slices
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+
+Config defaults are permissive: threshold 0.2 (very low — only truly bad actors blocked), empty blocklist/allowlist. Allowlist mode is opt-in. Resource limits match go-libp2p defaults. No risk of accidentally blocking legitimate peers.
+
+
+## Task 2: Implement ReputationGater (ConnectionGater)
+
+
+- `pkg/network/reputation.go` — existing ReputationManager with GetScore()
+- `pkg/network/host.go` — how libp2p options are constructed
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — section 10 (Connection Gater Analysis)
+
+
+
+Create new file `pkg/network/gater.go` implementing `connmgr.ConnectionGater`:
+
+```go
+package network
+
+import (
+ "sync"
+
+ "github.com/libp2p/go-libp2p/core/connmgr"
+ "github.com/libp2p/go-libp2p/core/control"
+ "github.com/libp2p/go-libp2p/core/network"
+ "github.com/libp2p/go-libp2p/core/peer"
+ "github.com/multiformats/go-multiaddr"
+)
+
+// Compile-time check that ReputationGater implements ConnectionGater.
+var _ connmgr.ConnectionGater = (*ReputationGater)(nil)
+
+// ReputationGater blocks peers based on reputation score and explicit blocklist/allowlist.
+type ReputationGater struct {
+ reputation *ReputationManager
+ blocked map[peer.ID]bool
+ allowed map[peer.ID]bool // if non-empty, allowlist mode
+ threshold float64
+ mu sync.RWMutex
+}
+
+// NewReputationGater creates a new connection gater.
+// If allowedPeers is non-empty, only those peers are accepted (allowlist mode).
+// Otherwise, peers are checked against blocklist and reputation threshold.
+func NewReputationGater(rm *ReputationManager, blockedPeers, allowedPeers []string, threshold float64) *ReputationGater {
+ blocked := make(map[peer.ID]bool, len(blockedPeers))
+ for _, p := range blockedPeers {
+ if pid, err := peer.Decode(p); err == nil {
+ blocked[pid] = true
+ }
+ }
+ allowed := make(map[peer.ID]bool, len(allowedPeers))
+ for _, p := range allowedPeers {
+ if pid, err := peer.Decode(p); err == nil {
+ allowed[pid] = true
+ }
+ }
+ return &ReputationGater{
+ reputation: rm,
+ blocked: blocked,
+ allowed: allowed,
+ threshold: threshold,
+ }
+}
+
+// BlockPeer adds a peer to the blocklist at runtime.
+func (g *ReputationGater) BlockPeer(p peer.ID) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.blocked[p] = true
+}
+
+// UnblockPeer removes a peer from the blocklist.
+func (g *ReputationGater) UnblockPeer(p peer.ID) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ delete(g.blocked, p)
+}
+
+// isAllowed checks if a peer should be allowed based on blocklist/allowlist/reputation.
+func (g *ReputationGater) isAllowed(p peer.ID) bool {
+ g.mu.RLock()
+ defer g.mu.RUnlock()
+
+ // Explicit blocklist always wins
+ if g.blocked[p] {
+ return false
+ }
+
+ // Allowlist mode: only listed peers accepted
+ if len(g.allowed) > 0 {
+ return g.allowed[p]
+ }
+
+ // Reputation check (skip if no reputation manager)
+ if g.reputation != nil {
+ score := g.reputation.GetScore(p.String())
+ if score < g.threshold {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (g *ReputationGater) InterceptPeerDial(p peer.ID) bool {
+ return g.isAllowed(p)
+}
+
+func (g *ReputationGater) InterceptAddrDial(_ peer.ID, _ multiaddr.Multiaddr) bool {
+ return true // no address-level filtering
+}
+
+func (g *ReputationGater) InterceptAccept(_ network.ConnMultiaddrs) bool {
+ return true // peer ID not yet known at this stage
+}
+
+func (g *ReputationGater) InterceptSecured(_ network.Direction, p peer.ID, _ network.ConnMultiaddrs) bool {
+ return g.isAllowed(p) // primary enforcement point
+}
+
+func (g *ReputationGater) InterceptUpgraded(_ network.Conn) (bool, control.DisconnectReason) {
+ return true, 0 // already gated at secured stage
+}
+```
+
+
+
+- `pkg/network/gater.go` exists with `ReputationGater` struct
+- `ReputationGater` implements `connmgr.ConnectionGater` interface (compile-time check)
+- `InterceptPeerDial` blocks peers in blocklist
+- `InterceptSecured` blocks peers in blocklist, checks allowlist mode, checks reputation threshold
+- `InterceptAddrDial`, `InterceptAccept`, `InterceptUpgraded` return true (pass-through)
+- `BlockPeer`/`UnblockPeer` methods for runtime updates
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+
+**Blocklist bypass:** Peer ID is cryptographically authenticated at `InterceptSecured` stage — cannot be spoofed. A malicious peer would need to generate a new identity to bypass, which starts at neutral reputation (0.5 > 0.2 threshold). Sustained abuse lowers score below threshold and triggers blocking.
+
+**Allowlist lockout:** If allowlist is misconfigured (empty after being non-empty), node becomes isolated. Mitigation: allowlist is opt-in, defaults to empty (blocklist mode).
+
+
+## Task 3: Implement PeerRateLimiter
+
+
+- `pkg/network/protocols.go` — protocol handlers that need rate limiting (RegisterStoreHandler, RegisterFetchHandler)
+- `.planning/phases/08-networking-enhancements/08-RESEARCH.md` — section 12 (Protocol Rate Limiting)
+
+
+
+Create new file `pkg/network/ratelimit.go`:
+
+```go
+package network
+
+import (
+ "sync"
+ "time"
+
+ "github.com/libp2p/go-libp2p/core/peer"
+ "golang.org/x/time/rate"
+)
+
+// PeerRateLimiter enforces per-peer rate limits using token bucket algorithm.
+type PeerRateLimiter struct {
+ limiters map[peer.ID]*rateLimiterEntry
+ mu sync.Mutex
+ limit rate.Limit
+ burst int
+}
+
+type rateLimiterEntry struct {
+ limiter *rate.Limiter
+ lastSeen time.Time
+}
+
+// NewPeerRateLimiter creates a rate limiter with the given per-peer rate (requests/sec) and burst.
+func NewPeerRateLimiter(rps float64, burst int) *PeerRateLimiter {
+ return &PeerRateLimiter{
+ limiters: make(map[peer.ID]*rateLimiterEntry),
+ limit: rate.Limit(rps),
+ burst: burst,
+ }
+}
+
+// Allow checks if the peer is within their rate limit.
+func (rl *PeerRateLimiter) Allow(p peer.ID) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ entry, ok := rl.limiters[p]
+ if !ok {
+ entry = &rateLimiterEntry{
+ limiter: rate.NewLimiter(rl.limit, rl.burst),
+ }
+ rl.limiters[p] = entry
+ }
+ entry.lastSeen = time.Now()
+ return entry.limiter.Allow()
+}
+
+// Cleanup removes limiter entries for peers not seen since the given duration.
+func (rl *PeerRateLimiter) Cleanup(maxAge time.Duration) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ cutoff := time.Now().Add(-maxAge)
+ for p, entry := range rl.limiters {
+ if entry.lastSeen.Before(cutoff) {
+ delete(rl.limiters, p)
+ }
+ }
+}
+
+// Count returns the number of tracked peers.
+func (rl *PeerRateLimiter) Count() int {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ return len(rl.limiters)
+}
+```
+
+
+
+- `pkg/network/ratelimit.go` exists with `PeerRateLimiter` struct
+- `Allow(peer.ID)` returns `true` for first burst requests, `false` when limit exceeded
+- `Cleanup(maxAge)` removes stale entries to prevent memory leak
+- `NewPeerRateLimiter(rps, burst)` creates limiter with configurable rate
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+## Task 4: Wire rate limiters into Host and protocol handlers
+
+
+- `pkg/network/host.go` — Host struct and NewHost
+- `pkg/network/protocols.go` — RegisterStoreHandler, RegisterFetchHandler
+- `pkg/network/ratelimit.go` — PeerRateLimiter (from Task 3)
+
+
+
+Add rate limiter fields to the `Host` struct in `pkg/network/host.go`:
+
+```go
+type Host struct {
+ host host.Host
+ dht *dht.IpfsDHT
+ mdnsService mdns.Service
+ ctx context.Context
+ cancel context.CancelFunc
+ cfg HostConfig
+ storeLimiter *PeerRateLimiter
+ fetchLimiter *PeerRateLimiter
+}
+```
+
+Add a `ConnectionGater` field to `HostConfig`:
+
+```go
+type HostConfig struct {
+ // ... existing fields ...
+ ConnectionGater connmgr.ConnectionGater // optional
+}
+```
+
+In `NewHost()`, wire the connection gater:
+```go
+if cfg.ConnectionGater != nil {
+ opts = append(opts, libp2p.ConnectionGater(cfg.ConnectionGater))
+}
+```
+
+Add a `SetRateLimiters` method to Host:
+```go
+func (h *Host) SetRateLimiters(storeLimiter, fetchLimiter *PeerRateLimiter) {
+ h.storeLimiter = storeLimiter
+ h.fetchLimiter = fetchLimiter
+}
+```
+
+Update `RegisterStoreHandler` in `protocols.go` to add rate limit check at the top:
+```go
+func (h *Host) RegisterStoreHandler(store StorageBackend) {
+ h.host.SetStreamHandler(StoreProtocol, func(s network.Stream) {
+ defer s.Close()
+
+ // Rate limit check
+ if h.storeLimiter != nil && !h.storeLimiter.Allow(s.Conn().RemotePeer()) {
+ writeProtoFrame(s, &dmgnpb.StoreResponse{Status: "error", Message: "rate limited"}, nil)
+ return
+ }
+
+ s.SetDeadline(time.Now().Add(storeTimeout))
+ // ... rest of existing handler unchanged ...
+ })
+}
+```
+
+Similarly update `RegisterFetchHandler`:
+```go
+func (h *Host) RegisterFetchHandler(store StorageBackend) {
+ h.host.SetStreamHandler(FetchProtocol, func(s network.Stream) {
+ defer s.Close()
+
+ // Rate limit check
+ if h.fetchLimiter != nil && !h.fetchLimiter.Allow(s.Conn().RemotePeer()) {
+ writeProtoFrame(s, &dmgnpb.FetchResponse{Status: "error", Message: "rate limited"}, nil)
+ return
+ }
+
+ s.SetDeadline(time.Now().Add(fetchTimeout))
+ // ... rest of existing handler unchanged ...
+ })
+}
+```
+
+Add the `connmgr` import to `host.go`:
+```go
+"github.com/libp2p/go-libp2p/core/connmgr"
+```
+
+
+
+- `Host` struct has `storeLimiter` and `fetchLimiter` fields
+- `HostConfig` has `ConnectionGater` field
+- `NewHost()` passes `ConnectionGater` to libp2p when non-nil
+- `RegisterStoreHandler` checks rate limiter before processing
+- `RegisterFetchHandler` checks rate limiter before processing
+- Rate-limited requests get `"rate limited"` error response
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+
+**Rate limiter bypass:** Rate limiting is per peer ID (authenticated). Cannot be bypassed without new identity. Token bucket allows legitimate burst traffic while blocking sustained abuse.
+
+**Nil safety:** All rate limiter checks are guarded with `!= nil`, so existing code works unchanged when limiters aren't set.
+
+
+
+
+
+1. `go build ./...` — compiles after all changes
+2. `go vet ./pkg/network/...` — no vet issues
+3. `go vet ./internal/config/...` — no vet issues
+4. ConnectionGater compile-time interface check passes
+5. Rate limiter and gater wired into host
+
+
+
+- ReputationGater blocks peers by blocklist, allowlist, and reputation threshold
+- PeerRateLimiter enforces per-peer request rates with token bucket
+- Protocol handlers reject rate-limited requests with error response
+- Security config fields added with conservative defaults
+- Connection gater passed to libp2p host creation
+
+
+
+- ConnectionGater interface implementation (D-11, D-12)
+- Blocklist/allowlist enforcement (D-15, D-16)
+- Reputation threshold check (D-11)
+- Per-peer rate limiting on store/fetch protocols (D-14)
+- Security config fields with defaults (D-15)
+
diff --git a/.planning/phases/08-networking-enhancements/08-05-PLAN.md b/.planning/phases/08-networking-enhancements/08-05-PLAN.md
new file mode 100644
index 0000000..dc5f351
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-05-PLAN.md
@@ -0,0 +1,421 @@
+---
+phase: 8
+plan: 5
+type: implementation
+wave: 3
+depends_on:
+ - 08-03
+ - 08-04
+files_modified:
+ - internal/daemon/daemon.go
+ - pkg/network/gater_test.go
+ - pkg/network/ratelimit_test.go
+autonomous: true
+requirements:
+ - NETW-06
+ - NETW-07
+ - NETW-08
+ - NETW-09
+---
+
+# Plan 08-05: Security Daemon Integration & Tests
+
+
+Wire connection gater, resource manager, and rate limiters into the daemon. Add comprehensive tests for all networking security components.
+
+
+
+
+## Task 1: Wire security components into daemon
+
+
+- `internal/daemon/daemon.go` — daemon Start() where host is created (lines 99-118)
+- `pkg/network/gater.go` — ReputationGater (from Plan 08-04)
+- `pkg/network/ratelimit.go` — PeerRateLimiter (from Plan 08-04)
+- `pkg/network/reputation.go` — existing ReputationManager
+- `internal/config/config.go` — security config fields (from Plan 08-04 Task 1)
+
+
+
+In `internal/daemon/daemon.go`, update the host creation section (after Plan 08-03 changes) to construct and wire security components.
+
+**Before the hostCfg creation (around line 99), add:**
+
+```go
+// Create reputation-based connection gater
+var gater *network.ReputationGater
+// Note: ReputationManager is created after storage is opened but before host.
+// For now, create gater without reputation manager — it gets wired after storage.
+gater = network.NewReputationGater(
+ nil, // reputation manager wired later
+ d.cfg.BlockedPeers,
+ d.cfg.AllowedPeers,
+ d.cfg.ReputationThreshold,
+)
+```
+
+**Update the hostCfg to include the gater:**
+```go
+hostCfg := network.HostConfig{
+ // ... existing fields from Plan 08-03 ...
+ ConnectionGater: gater,
+}
+```
+
+**After host.Start() succeeds, create and wire rate limiters:**
+```go
+// Create per-peer rate limiters for protocol handlers
+storeLimiter := network.NewPeerRateLimiter(10, 20) // 10 req/s, burst 20
+fetchLimiter := network.NewPeerRateLimiter(20, 40) // 20 req/s, burst 40
+d.host.SetRateLimiters(storeLimiter, fetchLimiter)
+```
+
+**After storage is opened and ReputationManager is available, wire it into gater:**
+(This requires creating ReputationManager before host, or setting it after. Since ReputationManager needs BadgerDB which is opened at step 2, and host is created at step 3, we can create the ReputationManager at step 2 and pass it to the gater.)
+
+Restructure the Start() flow:
+1. Create crypto engine
+2. Open storage
+3. **Create ReputationManager from storage DB**
+4. **Create connection gater with reputation manager**
+5. Create and start libp2p host (with gater)
+6. **Set rate limiters on host**
+7. ... rest of startup
+
+
+
+- `ReputationGater` created with config blocklist/allowlist/threshold and wired into host
+- `PeerRateLimiter` instances created for store (10 req/s) and fetch (20 req/s) protocols
+- Rate limiters set on host before protocol handlers are registered
+- `go build ./...` succeeds
+
+
+
+```bash
+go build ./...
+```
+
+
+
+**Startup order:** ReputationManager must be created before connection gater, which must be created before host. The restructured startup order ensures this. If reputation DB fails to load, gater operates without reputation checks (blocklist/allowlist still enforced).
+
+**Rate limiter cleanup:** Rate limiter entries for disconnected peers accumulate. A periodic cleanup goroutine (every 10 minutes, remove entries older than 30 minutes) prevents memory growth. This should run in the daemon's context.
+
+
+## Task 2: Add connection gater tests
+
+
+- `pkg/network/gater.go` — ReputationGater implementation
+- `pkg/network/reputation.go` — ReputationManager for test setup
+- `pkg/network/host_test.go` — existing test patterns
+
+
+
+Create `pkg/network/gater_test.go`:
+
+```go
+package network
+
+import (
+ "testing"
+
+ "github.com/libp2p/go-libp2p/core/network"
+ "github.com/libp2p/go-libp2p/core/peer"
+)
+
+func TestReputationGater_BlockedPeer(t *testing.T) {
+ blocked := "12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN"
+ gater := NewReputationGater(nil, []string{blocked}, nil, 0.2)
+
+ pid, _ := peer.Decode(blocked)
+ if gater.InterceptPeerDial(pid) {
+ t.Error("blocked peer should be rejected at InterceptPeerDial")
+ }
+ if gater.InterceptSecured(network.DirInbound, pid, nil) {
+ t.Error("blocked peer should be rejected at InterceptSecured")
+ }
+}
+
+func TestReputationGater_AllowlistMode(t *testing.T) {
+ allowed := "12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN"
+ other := "12D3KooWRby1HHKZAG4V57obTk6aHmFfBTVrB2GYmGXS6k8bNuf8"
+ gater := NewReputationGater(nil, nil, []string{allowed}, 0.2)
+
+ pidAllowed, _ := peer.Decode(allowed)
+ pidOther, _ := peer.Decode(other)
+
+ if !gater.InterceptSecured(network.DirInbound, pidAllowed, nil) {
+ t.Error("allowed peer should pass InterceptSecured")
+ }
+ if gater.InterceptSecured(network.DirInbound, pidOther, nil) {
+ t.Error("non-allowed peer should be rejected in allowlist mode")
+ }
+}
+
+func TestReputationGater_ReputationThreshold(t *testing.T) {
+ rm := NewReputationManager(nil) // in-memory only
+
+ // Record bad interactions to lower score below threshold
+ testPeer := "test-peer-low-rep"
+ for i := 0; i < 20; i++ {
+ rm.RecordInteraction(testPeer, 5000, false) // all failures
+ }
+
+ gater := NewReputationGater(rm, nil, nil, 0.3)
+ pid, _ := peer.Decode("12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN")
+
+ // Unknown peer (default 0.5) should pass
+ if !gater.InterceptSecured(network.DirInbound, pid, nil) {
+ t.Error("unknown peer with neutral reputation should pass")
+ }
+}
+
+func TestReputationGater_BlockUnblock(t *testing.T) {
+ gater := NewReputationGater(nil, nil, nil, 0.2)
+ pid, _ := peer.Decode("12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN")
+
+ if !gater.InterceptPeerDial(pid) {
+ t.Error("unblocked peer should pass")
+ }
+
+ gater.BlockPeer(pid)
+ if gater.InterceptPeerDial(pid) {
+ t.Error("blocked peer should be rejected")
+ }
+
+ gater.UnblockPeer(pid)
+ if !gater.InterceptPeerDial(pid) {
+ t.Error("unblocked peer should pass again")
+ }
+}
+
+func TestReputationGater_PassthroughMethods(t *testing.T) {
+ gater := NewReputationGater(nil, nil, nil, 0.2)
+
+ if !gater.InterceptAccept(nil) {
+ t.Error("InterceptAccept should always return true")
+ }
+ if !gater.InterceptAddrDial("", nil) {
+ t.Error("InterceptAddrDial should always return true")
+ }
+ allow, _ := gater.InterceptUpgraded(nil)
+ if !allow {
+ t.Error("InterceptUpgraded should always return true")
+ }
+}
+```
+
+
+
+- `go test ./pkg/network/... -run TestReputationGater` passes all 5 test cases
+- Blocked peers are rejected at both `InterceptPeerDial` and `InterceptSecured`
+- Allowlist mode rejects non-listed peers
+- Runtime `BlockPeer`/`UnblockPeer` works correctly
+- Passthrough methods always return true
+
+
+
+```bash
+go test ./pkg/network/... -v -run TestReputationGater
+```
+
+
+## Task 3: Add rate limiter tests
+
+
+- `pkg/network/ratelimit.go` — PeerRateLimiter implementation
+
+
+
+Create `pkg/network/ratelimit_test.go`:
+
+```go
+package network
+
+import (
+ "testing"
+ "time"
+
+ "github.com/libp2p/go-libp2p/core/peer"
+)
+
+func TestPeerRateLimiter_AllowBurst(t *testing.T) {
+ rl := NewPeerRateLimiter(10, 5) // 10 req/s, burst 5
+ pid := peer.ID("test-peer-1")
+
+ // First 5 (burst) should be allowed
+ for i := 0; i < 5; i++ {
+ if !rl.Allow(pid) {
+ t.Fatalf("request %d within burst should be allowed", i+1)
+ }
+ }
+
+ // 6th should be rate limited
+ if rl.Allow(pid) {
+ t.Error("request beyond burst should be rate limited")
+ }
+}
+
+func TestPeerRateLimiter_IndependentPeers(t *testing.T) {
+ rl := NewPeerRateLimiter(10, 2) // 10 req/s, burst 2
+ pid1 := peer.ID("peer-1")
+ pid2 := peer.ID("peer-2")
+
+ // Exhaust peer1's burst
+ rl.Allow(pid1)
+ rl.Allow(pid1)
+
+ // Peer2 should still have its own burst
+ if !rl.Allow(pid2) {
+ t.Error("different peers should have independent rate limits")
+ }
+}
+
+func TestPeerRateLimiter_Cleanup(t *testing.T) {
+ rl := NewPeerRateLimiter(10, 5)
+ pid := peer.ID("test-peer-cleanup")
+
+ rl.Allow(pid)
+ if rl.Count() != 1 {
+ t.Fatalf("expected 1 tracked peer, got %d", rl.Count())
+ }
+
+ // Cleanup with 0 maxAge should remove all entries
+ rl.Cleanup(0)
+ if rl.Count() != 0 {
+ t.Errorf("expected 0 tracked peers after cleanup, got %d", rl.Count())
+ }
+}
+
+func TestPeerRateLimiter_Recovery(t *testing.T) {
+ rl := NewPeerRateLimiter(1000, 1) // high rate, burst 1
+ pid := peer.ID("test-peer-recover")
+
+ // Use up burst
+ rl.Allow(pid)
+ if rl.Allow(pid) {
+ t.Skip("rate too high for meaningful test")
+ }
+
+ // Wait for token replenishment
+ time.Sleep(5 * time.Millisecond)
+ if !rl.Allow(pid) {
+ t.Error("should recover after waiting")
+ }
+}
+```
+
+
+
+- `go test ./pkg/network/... -run TestPeerRateLimiter` passes all 4 test cases
+- Burst allowance works correctly
+- Different peers have independent limits
+- Cleanup removes stale entries
+- Rate limiter recovers after wait period
+
+
+
+```bash
+go test ./pkg/network/... -v -run TestPeerRateLimiter
+```
+
+
+## Task 4: Add connection gater host integration test
+
+
+- `pkg/network/host_test.go` — existing two-host test patterns
+- `pkg/network/gater.go` — ReputationGater
+
+
+
+Add test to `pkg/network/host_test.go` that verifies the connection gater blocks connections:
+
+```go
+func TestHostWithGater_BlocksPeer(t *testing.T) {
+ id1 := createTestIdentity(t)
+ key1, _ := DeriveLibp2pKey(id1)
+ id2 := createTestIdentity(t)
+ key2, _ := DeriveLibp2pKey(id2)
+
+ // Create h1 first to get its peer ID
+ h1, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key1,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h1 failed: %v", err)
+ }
+ defer h1.Stop()
+
+ // Create h2 with gater that blocks h1
+ gater := NewReputationGater(nil, []string{h1.ID().String()}, nil, 0.2)
+ h2, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key2,
+ ConnectionGater: gater,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h2 failed: %v", err)
+ }
+ defer h2.Stop()
+
+ // h2 trying to dial h1 should fail because h1 is blocked
+ h1Info := peer.AddrInfo{ID: h1.ID(), Addrs: h1.Addrs()}
+ err = h2.LibP2PHost().Connect(context.Background(), h1Info)
+ if err == nil {
+ t.Error("expected connection to blocked peer to fail")
+ }
+}
+```
+
+Note: Add `"context"` to imports if not present.
+
+
+
+- `go test ./pkg/network/... -run TestHostWithGater_BlocksPeer` passes
+- Connection to blocked peer is rejected by the gater
+- Host creation with connection gater succeeds
+
+
+
+```bash
+go test ./pkg/network/... -v -run TestHostWithGater_BlocksPeer
+```
+
+
+
+Test validates that the connection gater actually prevents connections at the libp2p level, not just at the application level. This is the strongest enforcement point — blocked peers cannot open streams, send data, or consume any resources beyond the initial handshake.
+
+
+
+
+
+1. `go build ./...` — compiles after all changes
+2. `go test ./pkg/network/... -run TestReputationGater` — gater tests pass
+3. `go test ./pkg/network/... -run TestPeerRateLimiter` — rate limiter tests pass
+4. `go test ./pkg/network/... -run TestHostWithGater` — integration test passes
+5. `go test ./...` — full suite passes (no regressions)
+
+
+
+- Connection gater wired into daemon with reputation manager
+- Rate limiters wired for store and fetch protocols
+- Gater tests cover blocklist, allowlist, reputation threshold, runtime updates
+- Rate limiter tests cover burst, independent peers, cleanup, recovery
+- Integration test proves gater blocks connections at libp2p level
+- All existing tests still pass
+
+
+
+- Daemon constructs and wires gater + rate limiters
+- Gater unit tests (blocklist, allowlist, threshold)
+- Rate limiter unit tests (burst, per-peer isolation)
+- Integration test proving gater blocks at connection level
+- No regressions in existing test suite
+
diff --git a/.planning/phases/08-networking-enhancements/08-CONTEXT.md b/.planning/phases/08-networking-enhancements/08-CONTEXT.md
new file mode 100644
index 0000000..3b805c6
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-CONTEXT.md
@@ -0,0 +1,140 @@
+# Phase 8: Networking Enhancements - Context
+
+**Gathered:** 2026-04-10
+**Status:** Ready for planning
+**Source:** User requested update - replace TCP with QUIC, add NAT traversal for nodes behind NAT
+
+
+## Phase Boundary
+
+Enhance libp2p networking to support QUIC transport, NAT traversal, and networking layer security. This phase adds:
+- QUIC transport alongside existing TCP
+- Multiple NAT traversal mechanisms (Circuit Relay v2, hole punching, TURN fallback)
+- Updated listen address configuration
+- Connection gater with reputation-based blocking and peer blocklist
+- libp2p Resource Manager for connection/stream limits
+- Per-peer rate limiting on protocol handlers
+- Config-driven peer blocklist/allowlist
+
+Requirements: NETW-02, NETW-04, NETW-06, NETW-07, NETW-08, NETW-09
+
+This does NOT include: daemon architecture changes (Phase 7), protocol handlers (Phase 4), gossip (Phase 5).
+
+
+
+
+## Implementation Decisions
+
+### Transport Configuration
+- **D-01:** Add QUIC transport (`/ip4/.../udp/0/quic-v1`) alongside existing TCP
+- **D-02:** Default listen addresses: `/ip4/0.0.0.0/tcp/0` AND `/ip4/0.0.0.0/udp/0/quic-v1`
+- **D-03:** Config field: `ListenAddrs` (array) replacing single `ListenAddr` string
+- **D-04:** Keep TCP transport — add QUIC as additional protocol, not replacement
+- **D-05:** QUIC v1 (RFC 9000) as the QUIC version
+
+### NAT Traversal
+- **D-06:** Enable Circuit Relay v2 (`EnableRelayService`) for nodes behind NAT
+- **D-07:** Enable direct hole punching (`EnableHolePunching`) with SRE/ENR support
+- **D-08:** TURN fallback via config option for commercial relay as last resort
+- **D-09:** Autorelay enabled by default — node finds relay peers automatically
+- **D-10:** Config fields: `EnableHolePunching` (bool), `EnableRelayService` (bool), `TurnServers` ([]string)
+
+### Networking Security
+- **D-11:** Connection gater implementing `libp2p.ConnectionGater` interface — blocks peers based on ReputationManager score (below configurable threshold) and explicit blocklist
+- **D-12:** Gater checks at `InterceptPeerDial`, `InterceptAccept`, and `InterceptSecured` stages — reject before resource allocation
+- **D-13:** libp2p Resource Manager (`rcmgr`) with per-peer limits: max 16 streams, max 8 connections per peer; system-wide: max 256 connections, max 512 streams
+- **D-14:** Per-peer protocol rate limiter: max 10 req/sec for `/memory/store`, max 20 req/sec for `/memory/fetch`, max 20 req/sec for `/memory/query`
+- **D-15:** Config fields: `BlockedPeers []string`, `AllowedPeers []string`, `ReputationThreshold float64` (default 0.2), `MaxConnectionsPerPeer int`, `MaxStreamsPerPeer int`
+- **D-16:** If `AllowedPeers` is non-empty, operate in allowlist mode — only those peers accepted. Otherwise, blocklist mode with reputation threshold.
+
+### Agent's Discretion
+- Exact QUIC tuning parameters (conn IDs, flow control)
+- TURN server configuration format
+- Logging verbosity for NAT traversal events
+- Test strategy for NAT scenarios
+- Resource manager limit values (within reasonable bounds)
+- Rate limiter algorithm (token bucket vs sliding window)
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Networking
+- `pkg/network/host.go` — Current libp2p host creation (lines 74-83 are the libp2p options)
+- `internal/config/config.go` — Config struct, needs new fields for multiaddr array, NAT options, and security fields
+- `.planning/phases/03-networking-core/03-CONTEXT.md` — Prior networking decisions (HKDF-derived key, custom DHT)
+
+### Security
+- `pkg/network/reputation.go` — Existing ReputationManager with peer scoring (no enforcement yet)
+- `pkg/network/reputation_test.go` — Tests for reputation scoring
+- `pkg/network/protocols.go` — Protocol handlers that need rate limiting
+- `internal/daemon/daemon.go` — Daemon wiring where connection gater and resource manager must be integrated
+
+### libp2p Documentation
+- https://github.com/libp2p/go-libp2p/pull/3204 — QUIC-go v0.50.0 update (Feb 2025)
+- https://github.com/libp2p/go-libp2p/pull/1128 — QUIC as default transport (merged 2021)
+- https://docs.libp2p.io/blog/2023-09-13-quic-crypto-tls/ — Go 1.21 QUIC/TLS integration
+
+
+
+
+## Implementation Notes
+
+### Current State (before)
+```
+libp2p options:
+- ListenAddrStrings("/ip4/0.0.0.0/tcp/0")
+- DefaultTransports (TCP only in current go-libp2p default)
+- EnableRelay() // Circuit Relay v1 (client mode only)
+```
+
+### Desired State (after)
+```
+libp2p options:
+- ListenAddrStrings("/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1")
+- EnableRelay() // Keep for backward compatibility
+- EnableRelayService() // Act as relay for other peers
+- EnableHolePunching() // Direct NAT traversal
+- ConnectionGater(reputationGater) // Blocks bad/blocked peers
+- ResourceManager(rcmgr) // Per-peer resource limits
+- libp2p.DefaultTransports // Includes QUIC by default since v0.30
+```
+
+### Config Changes Required
+```go
+// Before:
+ListenAddr string `json:"listen_addr"`
+
+// After (transport + NAT):
+ListenAddrs []string `json:"listen_addrs"`
+EnableHolePunching bool `json:"enable_hole_punching"`
+EnableRelayService bool `json:"enable_relay_service"`
+RelayServers []string `json:"relay_servers"`
+
+// After (security):
+BlockedPeers []string `json:"blocked_peers"`
+AllowedPeers []string `json:"allowed_peers"`
+ReputationThreshold float64 `json:"reputation_threshold"`
+MaxConnectionsPerPeer int `json:"max_connections_per_peer"`
+MaxStreamsPerPeer int `json:"max_streams_per_peer"`
+```
+
+
+
+
+## Deferred Ideas
+
+- WebTransport transport (QUIC over HTTP/3) — future enhancement
+- WebRTC transport — explicitly not wanted (PROJECT.md specifies TCP+QUIC only, no WebRTC)
+- QUIC-only mode (remove TCP) — user chose to keep both
+- Dynamic rate limit adjustment based on load — adds complexity, static limits sufficient for now
+- Distributed reputation consensus — each node tracks independently for now
+- IP-level blocking (beyond peer ID) — defer unless needed
+
+---
+
+*Phase: 08-networking-enhancements*
+*Context gathered: 2026-04-10*
\ No newline at end of file
diff --git a/.planning/phases/08-networking-enhancements/08-DISCUSSION-LOG.md b/.planning/phases/08-networking-enhancements/08-DISCUSSION-LOG.md
new file mode 100644
index 0000000..25e2642
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-DISCUSSION-LOG.md
@@ -0,0 +1,49 @@
+# Phase 8: Networking Enhancements - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
+
+**Date:** 2026-04-10
+**Phase:** 08-networking-enhancements
+**Areas discussed:** Transport configuration, NAT traversal mechanisms
+
+---
+
+## Transport Configuration
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| QUIC + TCP (both) | Listen on both TCP and QUIC. Both protocols active. | |
+| QUIC only | Listen on QUIC only. Remove TCP transport entirely. | |
+| Keep TCP, add QUIC | Keep current TCP but add QUIC as secondary option. | ✓ |
+
+**User's choice:** Keep TCP, add QUIC — both transports active for maximum compatibility.
+**Notes:** Default listen addresses will be `/ip4/0.0.0.0/tcp/0` AND `/ip4/0.0.0.0/udp/0/quic-v1`. QUIC v1 (RFC 9000).
+
+---
+
+## NAT Traversal Mechanisms
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Circuit Relay v2 | libp2p autorelay with circuit relay v2 - nodes can relay through peers with public addresses | ✓ |
+| Direct Hole Punching | Direct UDP hole punching with SRE/ENR - requires coordination between peers | ✓ |
+| TURN fallback | Use TURN servers as fallback - commercial third-party relay | ✓ |
+| Both relay and hole punch | Enable both circuit relay and hole punching | ✓ |
+
+**User's choice:** All of the above — Circuit Relay v2, direct hole punching, and TURN fallback as last resort.
+**Notes:** Autorelay enabled by default. Node finds relay peers automatically. EnableRelayService allows node to act as relay for other peers.
+
+---
+
+## Agent's Discretion
+
+- Exact QUIC tuning parameters (conn IDs, flow control)
+- TURN server configuration format
+- Logging verbosity for NAT traversal events
+- Test strategy for NAT scenarios
+
+---
+
+*Phase: 08-networking-enhancements*
+*Context gathered: 2026-04-10*
\ No newline at end of file
diff --git a/.planning/phases/08-networking-enhancements/08-RESEARCH.md b/.planning/phases/08-networking-enhancements/08-RESEARCH.md
new file mode 100644
index 0000000..590ca94
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-RESEARCH.md
@@ -0,0 +1,527 @@
+# Phase 8: Networking Enhancements — Research
+
+**Researched:** 2026-04-10
+**Phase Goal:** Add QUIC transport and NAT traversal for improved connectivity behind NAT
+**Requirements:** NETW-02, NETW-04
+
+## Executive Summary
+
+Phase 8 is a focused enhancement to the existing libp2p networking stack (Phase 3). The current codebase already has most dependencies needed — `quic-go v0.59.0` is an indirect dependency, and `libp2p.DefaultTransports` already includes QUIC transport. The main work is: (1) adding QUIC listen addresses, (2) enabling Circuit Relay v2 service + hole punching + AutoRelay via go-libp2p options, and (3) migrating the config from single `ListenAddr` to `ListenAddrs` array with new NAT boolean fields.
+
+## 1. QUIC Transport Analysis
+
+### Current State
+
+```go
+// pkg/network/host.go lines 74-83
+opts := []libp2p.Option{
+ libp2p.Identity(cfg.PrivateKey),
+ libp2p.ListenAddrStrings(cfg.ListenAddrs...),
+ libp2p.ConnectionManager(cm),
+ libp2p.NATPortMap(),
+ libp2p.EnableRelay(),
+ libp2p.DefaultTransports, // Already includes QUIC transport!
+ libp2p.DefaultSecurity,
+ libp2p.DefaultMuxers,
+}
+```
+
+**Key finding:** `libp2p.DefaultTransports` in go-libp2p v0.48.0 already registers TCP, QUIC, and WebSocket transports. The node can already _dial_ QUIC peers. It just doesn't _listen_ on QUIC because the config only provides a TCP listen address (`/ip4/0.0.0.0/tcp/0`).
+
+### What's Needed
+
+Simply add `/ip4/0.0.0.0/udp/0/quic-v1` to the listen addresses. No new imports or transport constructors needed.
+
+### QUIC v1 Multiaddr Format
+
+- **TCP:** `/ip4/0.0.0.0/tcp/0`
+- **QUIC v1:** `/ip4/0.0.0.0/udp/0/quic-v1`
+- Both can use port 0 (auto-assign) or a fixed port
+
+### Dependencies
+
+Already present in `go.mod`:
+- `github.com/quic-go/quic-go v0.59.0` (indirect, pulled by go-libp2p)
+- `github.com/quic-go/qpack v0.6.0` (indirect)
+
+No new dependencies required for QUIC.
+
+## 2. Circuit Relay v2 Analysis
+
+### go-libp2p API
+
+```go
+import "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay"
+
+// Enable this node to act as a relay for other peers
+libp2p.EnableRelayService(relay.WithResources(relay.Resources{...}))
+```
+
+**Current code has:** `libp2p.EnableRelay()` — this enables the node as a relay _client_ (can connect through relays). It does NOT enable the node to _be_ a relay for others.
+
+**What to add:** `libp2p.EnableRelayService()` — makes the node act as a relay server for NAT'd peers. Should be conditional on `Config.EnableRelayService`.
+
+### Resource Limits (defaults from go-libp2p)
+
+```go
+relay.DefaultResources() = relay.Resources{
+ Limit: &relay.RelayLimit{
+ Data: 1 << 17, // 128 KiB per connection
+ Duration: 2 * time.Minute,
+ },
+ MaxCircuits: 16,
+ BufferSize: 2048,
+ ReservationTTL: time.Hour,
+ MaxReservations: 128,
+ MaxReservationsPerIP: 4,
+ MaxReservationsPerASN: 32,
+}
+```
+
+For DMGN, defaults are adequate. No need to customize initially.
+
+### Import Required
+
+```go
+import "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay"
+```
+
+This package is already available in go-libp2p v0.48.0 — no new `go get` needed.
+
+## 3. Hole Punching Analysis
+
+### go-libp2p API
+
+```go
+import "github.com/libp2p/go-libp2p/p2p/protocol/holepunch"
+
+libp2p.EnableHolePunching()
+```
+
+**Dependencies:**
+- Relay must be enabled (it is: `libp2p.EnableRelay()`)
+- Works best with AutoRelay so the node can advertise relay addresses
+
+**How it works:**
+1. NAT'd peer connects to a relay and gets a relay address
+2. When another peer dials the relay address, the relay notifies both sides
+3. Both sides attempt direct connections (hole punch) via the relay's coordination
+4. If successful, traffic flows directly; relay connection is closed after grace period
+
+### Important Note from go-libp2p docs
+
+> It is not mandatory but nice to also enable the `AutoRelay` option so the peer can discover and connect to Relay servers if it discovers that it is NATT'd.
+
+This means we should enable AutoRelay alongside hole punching.
+
+## 4. AutoRelay Analysis
+
+### go-libp2p API
+
+Two modes:
+
+```go
+import "github.com/libp2p/go-libp2p/p2p/host/autorelay"
+
+// Mode 1: Static relay list (for known relay servers)
+libp2p.EnableAutoRelayWithStaticRelays([]peer.AddrInfo{...})
+
+// Mode 2: Peer source (discover relays from DHT/routing)
+libp2p.EnableAutoRelayWithPeerSource(peerSourceFunc)
+```
+
+**Recommendation:** Use static relays from config when `TurnServers` (effectively "relay servers") are provided. Fall back to peer-source-based discovery from DHT otherwise.
+
+### AutoRelay Behavior
+
+- Detects if node is behind NAT via AutoNAT
+- If behind NAT, connects to relay servers and advertises relay addresses
+- Other peers can reach this node via the relay
+- Combined with hole punching, the relay is just the initial coordination channel
+
+## 5. TURN Server Mapping
+
+**Key insight:** libp2p doesn't have native TURN protocol support. The `TurnServers` config field in CONTEXT.md maps to **static relay peers** in libp2p terminology. These are well-known relay nodes that NAT'd peers can always connect to.
+
+The `TurnServers` config field should be renamed or documented as "relay server" addresses in libp2p multiaddr format. For the implementation, `TurnServers []string` will contain multiaddr strings of known relay servers (e.g., `/ip4/relay.example.com/tcp/4001/p2p/QmRelay...`).
+
+These are passed to `libp2p.EnableAutoRelayWithStaticRelays()`.
+
+## 6. Config Migration Strategy
+
+### Current Config
+
+```go
+type Config struct {
+ ListenAddr string `json:"listen_addr"` // Single address
+ // ... other fields
+}
+```
+
+### Target Config
+
+```go
+type Config struct {
+ ListenAddr string `json:"listen_addr"` // DEPRECATED: kept for backward compat
+ ListenAddrs []string `json:"listen_addrs"` // New: array of listen addresses
+ EnableHolePunching bool `json:"enable_hole_punching"` // New: default true
+ EnableRelayService bool `json:"enable_relay_service"` // New: default false (opt-in)
+ RelayServers []string `json:"relay_servers"` // New: static relay multiaddrs
+ // ... other fields
+}
+```
+
+### Backward Compatibility
+
+```go
+func (c *Config) GetListenAddrs() []string {
+ if len(c.ListenAddrs) > 0 {
+ return c.ListenAddrs
+ }
+ if c.ListenAddr != "" {
+ return []string{c.ListenAddr}
+ }
+ return []string{"/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1"}
+}
+```
+
+### Default Listen Addresses
+
+```go
+ListenAddrs: []string{
+ "/ip4/0.0.0.0/tcp/0",
+ "/ip4/0.0.0.0/udp/0/quic-v1",
+}
+```
+
+## 7. Integration Points
+
+### Files to Modify
+
+| File | Changes |
+|------|---------|
+| `internal/config/config.go` | Add `ListenAddrs`, `EnableHolePunching`, `EnableRelayService`, `RelayServers` fields; add `GetListenAddrs()` method; update defaults |
+| `pkg/network/host.go` | Add `EnableHolePunching`, `EnableRelayService`, `RelayServers` to `HostConfig`; conditionally add libp2p options |
+| `internal/daemon/daemon.go` | Use `GetListenAddrs()` instead of single `ListenAddr`; update `persistMultiaddrs` to handle QUIC addresses |
+| `pkg/network/host_test.go` | Update test hosts to use both TCP and QUIC listen addresses |
+| `tests/integration_test.go` | Verify QUIC connectivity if applicable |
+
+### host.go Changes
+
+```go
+type HostConfig struct {
+ ListenAddrs []string
+ BootstrapPeers []string
+ MDNSService string
+ MaxPeersLow int
+ MaxPeersHigh int
+ PrivateKey crypto.PrivKey
+ EnableHolePunching bool // New
+ EnableRelayService bool // New
+ RelayServers []string // New
+}
+
+func NewHost(cfg HostConfig) (*Host, error) {
+ // ... existing code ...
+ opts := []libp2p.Option{
+ libp2p.Identity(cfg.PrivateKey),
+ libp2p.ListenAddrStrings(cfg.ListenAddrs...),
+ libp2p.ConnectionManager(cm),
+ libp2p.NATPortMap(),
+ libp2p.EnableRelay(),
+ libp2p.DefaultTransports,
+ libp2p.DefaultSecurity,
+ libp2p.DefaultMuxers,
+ }
+
+ if cfg.EnableRelayService {
+ opts = append(opts, libp2p.EnableRelayService())
+ }
+
+ if cfg.EnableHolePunching {
+ opts = append(opts, libp2p.EnableHolePunching())
+ }
+
+ if len(cfg.RelayServers) > 0 {
+ // Parse relay server multiaddrs to peer.AddrInfo
+ relayInfos := parseRelayAddrs(cfg.RelayServers)
+ opts = append(opts, libp2p.EnableAutoRelayWithStaticRelays(relayInfos))
+ } else {
+ // Use DHT-based relay discovery
+ opts = append(opts, libp2p.EnableAutoRelayWithPeerSource(peerSourceFromDHT))
+ }
+
+ // ...
+}
+```
+
+### daemon.go Changes
+
+```go
+// Current (line 100-101):
+hostCfg := network.HostConfig{
+ ListenAddrs: []string{d.cfg.ListenAddr},
+ // ...
+}
+
+// New:
+hostCfg := network.HostConfig{
+ ListenAddrs: d.cfg.GetListenAddrs(),
+ EnableHolePunching: d.cfg.EnableHolePunching,
+ EnableRelayService: d.cfg.EnableRelayService,
+ RelayServers: d.cfg.RelayServers,
+ // ...
+}
+```
+
+### persistMultiaddrs Update
+
+The current `persistMultiaddrs` only handles TCP port extraction. Needs to also handle UDP/QUIC ports:
+
+```go
+func (d *Daemon) persistMultiaddrs(peerID string) {
+ addrs := d.host.Addrs()
+ fullAddrs := make([]string, 0, len(addrs))
+ for _, addr := range addrs {
+ fullAddrs = append(fullAddrs, fmt.Sprintf("%s/p2p/%s", addr.String(), peerID))
+ }
+
+ // Extract bound addresses and update ListenAddrs
+ listenAddrs := make([]string, 0, len(addrs))
+ for _, addr := range addrs {
+ parts := strings.Split(addr.String(), "/")
+ for i, p := range parts {
+ if p == "tcp" && i+1 < len(parts) {
+ listenAddrs = append(listenAddrs, fmt.Sprintf("/ip4/0.0.0.0/tcp/%s", parts[i+1]))
+ }
+ if p == "udp" && i+1 < len(parts) {
+ listenAddrs = append(listenAddrs, fmt.Sprintf("/ip4/0.0.0.0/udp/%s/quic-v1", parts[i+1]))
+ }
+ }
+ }
+ if len(listenAddrs) > 0 {
+ d.cfg.ListenAddrs = listenAddrs
+ }
+
+ d.cfg.NodeMultiaddrs = fullAddrs
+ d.cfg.Save()
+}
+```
+
+## 8. Risk Assessment
+
+| Risk | Severity | Mitigation |
+|------|----------|------------|
+| QUIC blocked by firewall | Medium | Keep TCP as fallback, QUIC is additive |
+| Hole punching unreliable on symmetric NAT | Medium | AutoRelay provides fallback via relay |
+| Config migration breaks existing setups | Low | `GetListenAddrs()` falls back to `ListenAddr` |
+| AutoRelay overhead on public nodes | Low | Only activates when AutoNAT detects NAT |
+| Relay service resource consumption | Medium | Use default resource limits, make opt-in |
+
+## 9. Test Strategy
+
+1. **Unit tests for config**: Verify `GetListenAddrs()` backward compatibility
+2. **Unit tests for host creation**: Verify QUIC listen address appears in `host.Addrs()`
+3. **Unit tests for relay/holepunch options**: Verify options are passed correctly
+4. **Integration test**: Two-node connectivity over QUIC (localhost)
+5. **Manual test**: Verify both TCP and QUIC addresses in `dmgn status` output
+
+## Validation Architecture
+
+### Critical Path Validation
+- QUIC listen address appears in host addresses
+- TCP still works (no regression)
+- Config migration preserves existing single-address configs
+
+### Sampling Points
+- Relay service activates on public nodes
+- Hole punching attempts logged on NAT'd nodes
+- AutoRelay discovers relay peers from DHT
+
+---
+
+## 10. Connection Gater Analysis
+
+### go-libp2p ConnectionGater Interface
+
+The `connmgr.ConnectionGater` interface (from `github.com/libp2p/go-libp2p/core/connmgr`) has 5 methods:
+
+```go
+type ConnectionGater interface {
+ InterceptPeerDial(p peer.ID) (allow bool)
+ InterceptAddrDial(id peer.ID, addr multiaddr.Multiaddr) (allow bool)
+ InterceptAccept(addrs network.ConnMultiaddrs) (allow bool)
+ InterceptSecured(dir network.Direction, id peer.ID, addrs network.ConnMultiaddrs) (allow bool)
+ InterceptUpgraded(conn network.Conn) (allow bool, reason control.DisconnectReason)
+}
+```
+
+### DMGN Implementation Design
+
+```go
+// ReputationGater blocks peers based on reputation score and explicit blocklist.
+type ReputationGater struct {
+ reputation *ReputationManager // existing from pkg/network/reputation.go
+ blocked map[peer.ID]bool // explicit blocklist (config-driven)
+ allowed map[peer.ID]bool // explicit allowlist (config-driven, if non-empty = allowlist mode)
+ threshold float64 // min reputation score (default 0.2)
+ mu sync.RWMutex
+}
+```
+
+**Gating logic:**
+1. `InterceptPeerDial` — check blocklist, check reputation ≥ threshold
+2. `InterceptAddrDial` — always allow (addr-level filtering not needed)
+3. `InterceptAccept` — always allow (can't identify peer yet at this stage for TCP)
+4. `InterceptSecured` — primary enforcement: check blocklist, allowlist mode, reputation
+5. `InterceptUpgraded` — always allow (already gated at secured stage)
+
+**Key insight:** `InterceptAccept` fires BEFORE the peer ID is known (only remote address available). Actual peer-based filtering happens at `InterceptSecured` where the peer ID is authenticated.
+
+### Integration
+
+```go
+// In NewHost:
+if cfg.ConnectionGater != nil {
+ opts = append(opts, libp2p.ConnectionGater(cfg.ConnectionGater))
+}
+```
+
+The gater is passed as a `libp2p.Option`, not created inside `NewHost`. The daemon constructs it and passes it through `HostConfig`.
+
+## 11. Resource Manager Analysis
+
+### go-libp2p Default Resource Manager
+
+go-libp2p v0.48.0 already creates a default resource manager if none is provided (see `defaults.go`). The default uses `rcmgr.DefaultLimits` scaled by system memory/FD count.
+
+```go
+import rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager"
+
+// Create custom resource manager with limits
+limits := rcmgr.DefaultLimits
+libp2p.SetDefaultServiceLimits(&limits)
+
+// Override per-peer limits
+limits.PeerBaseLimit = rcmgr.BaseLimit{
+ Streams: 16,
+ StreamsInbound: 8,
+ StreamsOutbound: 8,
+ Conns: 8,
+ ConnsInbound: 4,
+ ConnsOutbound: 4,
+ Memory: 64 << 20, // 64 MB
+}
+
+mgr, err := rcmgr.NewResourceManager(rcmgr.NewFixedLimiter(limits.AutoScale()))
+```
+
+### DMGN Approach
+
+For Phase 8, use the default resource manager but with customized per-peer limits from config:
+- `MaxConnectionsPerPeer` (default 8) → maps to `PeerBaseLimit.Conns`
+- `MaxStreamsPerPeer` (default 16) → maps to `PeerBaseLimit.Streams`
+
+If config values differ from defaults, construct a custom resource manager and pass via `libp2p.ResourceManager()`.
+
+### Dependencies
+
+Already in go-libp2p — `rcmgr` is at `github.com/libp2p/go-libp2p/p2p/host/resource-manager`. No new `go get` needed.
+
+## 12. Protocol Rate Limiting Analysis
+
+### Current State
+
+Protocol handlers in `pkg/network/protocols.go` have NO rate limiting:
+- `RegisterStoreHandler` — opens stream, reads shard, stores it
+- `RegisterFetchHandler` — opens stream, looks up shard, sends it
+
+Also, query handler in `pkg/query/` has no rate limiting.
+
+### Rate Limiter Design
+
+```go
+// PeerRateLimiter tracks per-peer request rates using token bucket.
+type PeerRateLimiter struct {
+ limiters map[peer.ID]*rate.Limiter
+ mu sync.RWMutex
+ rate rate.Limit // requests per second
+ burst int // burst size
+}
+
+func (rl *PeerRateLimiter) Allow(p peer.ID) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ limiter, ok := rl.limiters[p]
+ if !ok {
+ limiter = rate.NewLimiter(rl.rate, rl.burst)
+ rl.limiters[p] = limiter
+ }
+ return limiter.Allow()
+}
+```
+
+### Integration into Protocol Handlers
+
+Wrap existing handlers — check rate limiter at top of handler, reject with error if exceeded:
+
+```go
+func (h *Host) RegisterStoreHandler(store StorageBackend) {
+ h.host.SetStreamHandler(StoreProtocol, func(s network.Stream) {
+ defer s.Close()
+ if h.storeLimiter != nil && !h.storeLimiter.Allow(s.Conn().RemotePeer()) {
+ writeProtoFrame(s, &dmgnpb.StoreResponse{Status: "error", Message: "rate limited"}, nil)
+ return
+ }
+ // ... existing handler logic
+ })
+}
+```
+
+### Dependencies
+
+`golang.org/x/time/rate` — already in `go.mod` as `golang.org/x/time v0.12.0`.
+
+## 13. Peer Blocklist/Allowlist Analysis
+
+### Config Fields
+
+```go
+BlockedPeers []string `json:"blocked_peers"` // Peer IDs to always reject
+AllowedPeers []string `json:"allowed_peers"` // If non-empty, only these peers allowed
+```
+
+### Runtime Management
+
+The daemon should also support runtime blocklist updates (e.g., via API endpoint or CLI command). For Phase 8, config-file-only is sufficient. Runtime updates can be a future enhancement.
+
+### Blocklist vs Allowlist Mode
+
+- If `AllowedPeers` is non-empty: **allowlist mode** — ONLY listed peers are accepted
+- Otherwise: **blocklist mode** — all peers accepted except those in `BlockedPeers` or below reputation threshold
+
+This is enforced in the `ReputationGater.InterceptSecured()` method.
+
+## 14. Security Risk Assessment
+
+| Risk | Severity | Mitigation |
+|------|----------|------------|
+| Gater blocks legitimate peers with low initial reputation | Medium | New peers start at 0.5 (neutral), threshold 0.2 is very permissive |
+| Resource manager too restrictive | Low | Use scaled defaults, configurable limits |
+| Rate limiter memory leak (abandoned peer entries) | Low | Periodic cleanup of stale limiter entries |
+| Allowlist mode prevents network growth | Low | Allowlist is opt-in, not default behavior |
+| Bypass via peer ID rotation | Medium | Reputation starts neutral; abuse requires sustained bad behavior before blocking |
+
+## 15. Security Test Strategy
+
+1. **Connection gater unit tests**: Verify blocked peers rejected, allowed peers accepted, reputation threshold enforced
+2. **Resource manager integration**: Verify host creation with custom limits succeeds
+3. **Rate limiter unit tests**: Verify per-peer rate limiting, burst handling, different peers get independent limits
+4. **Config blocklist tests**: Verify peers in `BlockedPeers` are gated
+5. **Allowlist mode test**: Verify only `AllowedPeers` can connect when list is non-empty
+
+---
+
+## RESEARCH COMPLETE
+
+**Confidence:** High — all go-libp2p APIs are well-documented and the existing codebase already has most dependencies. The security additions use standard go-libp2p extension points (ConnectionGater, ResourceManager) and a simple token bucket rate limiter from `x/time/rate`.
diff --git a/.planning/phases/08-networking-enhancements/08-VALIDATION.md b/.planning/phases/08-networking-enhancements/08-VALIDATION.md
new file mode 100644
index 0000000..596d87e
--- /dev/null
+++ b/.planning/phases/08-networking-enhancements/08-VALIDATION.md
@@ -0,0 +1,82 @@
+---
+phase: 8
+slug: networking-enhancements
+status: draft
+nyquist_compliant: false
+wave_0_complete: false
+created: 2026-04-10
+---
+
+# Phase 8 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | go test |
+| **Config file** | none — existing test infrastructure |
+| **Quick run command** | `go test ./pkg/network/... ./internal/config/...` |
+| **Full suite command** | `go test ./...` |
+| **Estimated runtime** | ~30 seconds |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run `go test ./pkg/network/... ./internal/config/...`
+- **After every plan wave:** Run `go test ./...`
+- **Before `/gsd-verify-work`:** Full suite must be green
+- **Max feedback latency:** 30 seconds
+
+---
+
+## Per-Task Verification Map
+
+| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| 08-01-01 | 01 | 1 | NETW-02 | — | N/A | unit | `go test ./internal/config/... -run TestGetListenAddrs` | ❌ W0 | ⬜ pending |
+| 08-01-02 | 01 | 1 | NETW-02 | — | N/A | unit | `go test ./pkg/network/... -run TestQUICListenAddr` | ❌ W0 | ⬜ pending |
+| 08-02-01 | 02 | 1 | NETW-04 | — | N/A | unit | `go test ./pkg/network/... -run TestRelayService` | ❌ W0 | ⬜ pending |
+| 08-02-02 | 02 | 1 | NETW-04 | — | N/A | unit | `go test ./pkg/network/... -run TestHolePunching` | ❌ W0 | ⬜ pending |
+| 08-03-01 | 03 | 2 | NETW-02 | — | N/A | integration | `go test ./internal/daemon/... -run TestDaemonQUIC` | ❌ W0 | ⬜ pending |
+| 08-04-01 | 04 | 2 | NETW-06 | T-08-01 | Blocked peers rejected at gater | unit | `go test ./pkg/network/... -run TestReputationGater` | ❌ W0 | ⬜ pending |
+| 08-04-02 | 04 | 2 | NETW-08 | — | Rate limited peers get error response | unit | `go test ./pkg/network/... -run TestPeerRateLimiter` | ❌ W0 | ⬜ pending |
+| 08-04-03 | 04 | 2 | NETW-09 | — | N/A | unit | `go test ./internal/config/... -run TestDefaultConfig_Security` | ❌ W0 | ⬜ pending |
+| 08-05-01 | 05 | 3 | NETW-06 | T-08-01 | Gater blocks connection at libp2p level | integration | `go test ./pkg/network/... -run TestHostWithGater` | ❌ W0 | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `pkg/network/host_test.go` — extend with QUIC and NAT traversal tests
+- [ ] `internal/config/config_test.go` — add GetListenAddrs migration tests
+
+*Existing infrastructure covers most phase requirements. Wave 0 extends existing test files.*
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| NAT traversal via hole punching | NETW-04 | Requires actual NAT environment | Test with two nodes on different networks |
+| QUIC connectivity across firewall | NETW-02 | Requires real network conditions | Verify with `dmgn status` showing QUIC addresses |
+
+---
+
+## Validation Sign-Off
+
+- [ ] All tasks have `` verify or Wave 0 dependencies
+- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
+- [ ] Wave 0 covers all MISSING references
+- [ ] No watch-mode flags
+- [ ] Feedback latency < 30s
+- [ ] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending
diff --git a/internal/config/config.go b/internal/config/config.go
index 78e9172..2d20c7f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -17,6 +17,7 @@ const (
type Config struct {
DataDir string `json:"data_dir"`
ListenAddr string `json:"listen_addr"`
+ ListenAddrs []string `json:"listen_addrs"`
APIPort int `json:"api_port"`
MaxRecentMemories int `json:"max_recent_memories"`
LogLevel string `json:"log_level"`
@@ -35,12 +36,23 @@ type Config struct {
GossipTopic string `json:"gossip_topic"`
OTLPEndpoint string `json:"otlp_endpoint"`
MCPIPCPort int `json:"mcp_ipc_port"`
+
+ EnableHolePunching bool `json:"enable_hole_punching"`
+ EnableRelayService bool `json:"enable_relay_service"`
+ RelayServers []string `json:"relay_servers"`
+
+ BlockedPeers []string `json:"blocked_peers"`
+ AllowedPeers []string `json:"allowed_peers"`
+ ReputationThreshold float64 `json:"reputation_threshold"`
+ MaxConnectionsPerPeer int `json:"max_connections_per_peer"`
+ MaxStreamsPerPeer int `json:"max_streams_per_peer"`
}
func DefaultConfig() *Config {
return &Config{
DataDir: DefaultDataDir(),
ListenAddr: "/ip4/0.0.0.0/tcp/0",
+ ListenAddrs: []string{"/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1"},
APIPort: 8080,
MaxRecentMemories: 1000,
LogLevel: "info",
@@ -57,6 +69,16 @@ func DefaultConfig() *Config {
SyncInterval: "60s",
GossipTopic: "dmgn/memories/1.0.0",
MCPIPCPort: 0,
+
+ EnableHolePunching: true,
+ EnableRelayService: false,
+ RelayServers: []string{},
+
+ BlockedPeers: []string{},
+ AllowedPeers: []string{},
+ ReputationThreshold: 0.2,
+ MaxConnectionsPerPeer: 8,
+ MaxStreamsPerPeer: 16,
}
}
@@ -164,6 +186,19 @@ func (c *Config) SyncIntervalDuration() time.Duration {
return d
}
+// GetListenAddrs returns the listen addresses to use.
+// Falls back to legacy ListenAddr if ListenAddrs is empty,
+// and returns default TCP+QUIC addresses if both are empty.
+func (c *Config) GetListenAddrs() []string {
+ if len(c.ListenAddrs) > 0 {
+ return c.ListenAddrs
+ }
+ if c.ListenAddr != "" {
+ return []string{c.ListenAddr}
+ }
+ return []string{"/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/udp/0/quic-v1"}
+}
+
func (c *Config) EnsureDirs() error {
dirs := []string{
c.DataDir,
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index 6db5671..08b6bdc 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -96,14 +96,26 @@ func (d *Daemon) Start(ctx context.Context) error {
return fmt.Errorf("failed to open storage: %w", err)
}
- // 3. Create and start libp2p host
+ // 3. Create connection gater
+ gater := network.NewReputationGater(
+ nil,
+ d.cfg.BlockedPeers,
+ d.cfg.AllowedPeers,
+ d.cfg.ReputationThreshold,
+ )
+
+ // 4. Create and start libp2p host
hostCfg := network.HostConfig{
- ListenAddrs: []string{d.cfg.ListenAddr},
- BootstrapPeers: d.cfg.BootstrapPeers,
- MDNSService: d.cfg.MDNSService,
- MaxPeersLow: d.cfg.MaxPeersLow,
- MaxPeersHigh: d.cfg.MaxPeersHigh,
- PrivateKey: d.keys.LibP2PKey,
+ ListenAddrs: d.cfg.GetListenAddrs(),
+ BootstrapPeers: d.cfg.BootstrapPeers,
+ MDNSService: d.cfg.MDNSService,
+ MaxPeersLow: d.cfg.MaxPeersLow,
+ MaxPeersHigh: d.cfg.MaxPeersHigh,
+ PrivateKey: d.keys.LibP2PKey,
+ EnableHolePunching: d.cfg.EnableHolePunching,
+ EnableRelayService: d.cfg.EnableRelayService,
+ RelayServers: d.cfg.RelayServers,
+ ConnectionGater: gater,
}
d.host, err = network.NewHost(hostCfg)
@@ -117,6 +129,11 @@ func (d *Daemon) Start(ctx context.Context) error {
return fmt.Errorf("failed to start network host: %w", err)
}
+ // Wire rate limiters for protocol handlers
+ storeLimiter := network.NewPeerRateLimiter(10, 20)
+ fetchLimiter := network.NewPeerRateLimiter(20, 40)
+ d.host.SetRateLimiters(storeLimiter, fetchLimiter)
+
peerID := d.host.ID().String()
d.logger.Info("network host started",
"peer_id", peerID,
@@ -439,8 +456,24 @@ func (d *Daemon) persistMultiaddrs(peerID string) {
fullAddrs = append(fullAddrs, fmt.Sprintf("%s/p2p/%s", addr.String(), peerID))
}
- // Extract the actual TCP port from bound addresses and update ListenAddr
- // so the same port is reused on next restart.
+ // Extract bound addresses and update ListenAddrs
+ // so the same ports are reused on next restart.
+ listenAddrs := make([]string, 0, len(addrs))
+ for _, addr := range addrs {
+ parts := strings.Split(addr.String(), "/")
+ for i, p := range parts {
+ if p == "tcp" && i+1 < len(parts) {
+ listenAddrs = append(listenAddrs, fmt.Sprintf("/ip4/0.0.0.0/tcp/%s", parts[i+1]))
+ }
+ if p == "udp" && i+1 < len(parts) {
+ listenAddrs = append(listenAddrs, fmt.Sprintf("/ip4/0.0.0.0/udp/%s/quic-v1", parts[i+1]))
+ }
+ }
+ }
+ if len(listenAddrs) > 0 {
+ d.cfg.ListenAddrs = listenAddrs
+ }
+ // Keep legacy ListenAddr for backward compat
for _, addr := range addrs {
parts := strings.Split(addr.String(), "/")
for i, p := range parts {
@@ -460,7 +493,7 @@ func (d *Daemon) persistMultiaddrs(peerID string) {
} else {
d.logger.Info("node multiaddresses persisted to config",
"addrs", fullAddrs,
- "listen_addr", d.cfg.ListenAddr,
+ "listen_addrs", d.cfg.ListenAddrs,
)
}
}
diff --git a/pkg/network/gater.go b/pkg/network/gater.go
new file mode 100644
index 0000000..a36cd44
--- /dev/null
+++ b/pkg/network/gater.go
@@ -0,0 +1,107 @@
+package network
+
+import (
+ "sync"
+
+ "github.com/libp2p/go-libp2p/core/connmgr"
+ "github.com/libp2p/go-libp2p/core/control"
+ "github.com/libp2p/go-libp2p/core/network"
+ "github.com/libp2p/go-libp2p/core/peer"
+ "github.com/multiformats/go-multiaddr"
+)
+
+// Compile-time check that ReputationGater implements ConnectionGater.
+var _ connmgr.ConnectionGater = (*ReputationGater)(nil)
+
+// ReputationGater blocks peers based on reputation score and explicit blocklist/allowlist.
+type ReputationGater struct {
+ reputation *ReputationManager
+ blocked map[peer.ID]bool
+ allowed map[peer.ID]bool
+ threshold float64
+ mu sync.RWMutex
+}
+
+// NewReputationGater creates a new connection gater.
+// If allowedPeers is non-empty, only those peers are accepted (allowlist mode).
+// Otherwise, peers are checked against blocklist and reputation threshold.
+func NewReputationGater(rm *ReputationManager, blockedPeers, allowedPeers []string, threshold float64) *ReputationGater {
+ blocked := make(map[peer.ID]bool, len(blockedPeers))
+ for _, p := range blockedPeers {
+ if pid, err := peer.Decode(p); err == nil {
+ blocked[pid] = true
+ }
+ }
+ allowed := make(map[peer.ID]bool, len(allowedPeers))
+ for _, p := range allowedPeers {
+ if pid, err := peer.Decode(p); err == nil {
+ allowed[pid] = true
+ }
+ }
+ return &ReputationGater{
+ reputation: rm,
+ blocked: blocked,
+ allowed: allowed,
+ threshold: threshold,
+ }
+}
+
+// BlockPeer adds a peer to the blocklist at runtime.
+func (g *ReputationGater) BlockPeer(p peer.ID) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.blocked[p] = true
+}
+
+// UnblockPeer removes a peer from the blocklist.
+func (g *ReputationGater) UnblockPeer(p peer.ID) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ delete(g.blocked, p)
+}
+
+// isAllowed checks if a peer should be allowed based on blocklist/allowlist/reputation.
+func (g *ReputationGater) isAllowed(p peer.ID) bool {
+ g.mu.RLock()
+ defer g.mu.RUnlock()
+
+ // Explicit blocklist always wins
+ if g.blocked[p] {
+ return false
+ }
+
+ // Allowlist mode: only listed peers accepted
+ if len(g.allowed) > 0 {
+ return g.allowed[p]
+ }
+
+ // Reputation check (skip if no reputation manager)
+ if g.reputation != nil {
+ score := g.reputation.GetScore(p.String())
+ if score < g.threshold {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (g *ReputationGater) InterceptPeerDial(p peer.ID) bool {
+ return g.isAllowed(p)
+}
+
+func (g *ReputationGater) InterceptAddrDial(_ peer.ID, _ multiaddr.Multiaddr) bool {
+ return true
+}
+
+func (g *ReputationGater) InterceptAccept(_ network.ConnMultiaddrs) bool {
+ return true
+}
+
+func (g *ReputationGater) InterceptSecured(_ network.Direction, p peer.ID, _ network.ConnMultiaddrs) bool {
+ return g.isAllowed(p)
+}
+
+func (g *ReputationGater) InterceptUpgraded(_ network.Conn) (bool, control.DisconnectReason) {
+ return true, 0
+}
diff --git a/pkg/network/gater_test.go b/pkg/network/gater_test.go
new file mode 100644
index 0000000..9251445
--- /dev/null
+++ b/pkg/network/gater_test.go
@@ -0,0 +1,100 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/libp2p/go-libp2p/core/network"
+ "github.com/libp2p/go-libp2p/core/peer"
+)
+
+func TestReputationGater_BlockedPeer(t *testing.T) {
+ blocked := "12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN"
+ gater := NewReputationGater(nil, []string{blocked}, nil, 0.2)
+
+ pid, err := peer.Decode(blocked)
+ if err != nil {
+ t.Fatalf("failed to decode peer ID: %v", err)
+ }
+
+ if gater.InterceptPeerDial(pid) {
+ t.Error("blocked peer should be rejected at InterceptPeerDial")
+ }
+ if gater.InterceptSecured(network.DirInbound, pid, nil) {
+ t.Error("blocked peer should be rejected at InterceptSecured")
+ }
+}
+
+func TestReputationGater_AllowlistMode(t *testing.T) {
+ allowed := "12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN"
+ other := "12D3KooWRby1HHKZAG4V57obTk6aHmFfBTVrB2GYmGXS6k8bNuf8"
+ gater := NewReputationGater(nil, nil, []string{allowed}, 0.2)
+
+ pidAllowed, err := peer.Decode(allowed)
+ if err != nil {
+ t.Fatalf("failed to decode allowed peer: %v", err)
+ }
+ pidOther, err := peer.Decode(other)
+ if err != nil {
+ t.Fatalf("failed to decode other peer: %v", err)
+ }
+
+ if !gater.InterceptSecured(network.DirInbound, pidAllowed, nil) {
+ t.Error("allowed peer should pass InterceptSecured")
+ }
+ if gater.InterceptSecured(network.DirInbound, pidOther, nil) {
+ t.Error("non-allowed peer should be rejected in allowlist mode")
+ }
+}
+
+func TestReputationGater_ReputationThreshold(t *testing.T) {
+ rm := NewReputationManager(nil)
+
+ // Unknown peer (default 0.5 > threshold 0.3) should pass
+ pid, err := peer.Decode("12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN")
+ if err != nil {
+ t.Fatalf("failed to decode peer: %v", err)
+ }
+
+ gater := NewReputationGater(rm, nil, nil, 0.3)
+ if !gater.InterceptSecured(network.DirInbound, pid, nil) {
+ t.Error("unknown peer with neutral reputation should pass")
+ }
+}
+
+func TestReputationGater_BlockUnblock(t *testing.T) {
+ gater := NewReputationGater(nil, nil, nil, 0.2)
+ pid, err := peer.Decode("12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN")
+ if err != nil {
+ t.Fatalf("failed to decode peer: %v", err)
+ }
+
+ if !gater.InterceptPeerDial(pid) {
+ t.Error("unblocked peer should pass")
+ }
+
+ gater.BlockPeer(pid)
+ if gater.InterceptPeerDial(pid) {
+ t.Error("blocked peer should be rejected")
+ }
+
+ gater.UnblockPeer(pid)
+ if !gater.InterceptPeerDial(pid) {
+ t.Error("unblocked peer should pass again")
+ }
+}
+
+func TestReputationGater_PassthroughMethods(t *testing.T) {
+ gater := NewReputationGater(nil, nil, nil, 0.2)
+
+ if !gater.InterceptAccept(nil) {
+ t.Error("InterceptAccept should always return true")
+ }
+ pid, _ := peer.Decode("12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN")
+ if !gater.InterceptAddrDial(pid, nil) {
+ t.Error("InterceptAddrDial should always return true")
+ }
+ allow, _ := gater.InterceptUpgraded(nil)
+ if !allow {
+ t.Error("InterceptUpgraded should always return true")
+ }
+}
diff --git a/pkg/network/host.go b/pkg/network/host.go
index 655be5a..9e5fe44 100644
--- a/pkg/network/host.go
+++ b/pkg/network/host.go
@@ -8,12 +8,13 @@ import (
"github.com/libp2p/go-libp2p"
dht "github.com/libp2p/go-libp2p-kad-dht"
+ "github.com/libp2p/go-libp2p/core/connmgr"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/host"
libnet "github.com/libp2p/go-libp2p/core/network"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/p2p/discovery/mdns"
- "github.com/libp2p/go-libp2p/p2p/net/connmgr"
+ libconnmgr "github.com/libp2p/go-libp2p/p2p/net/connmgr"
"github.com/multiformats/go-multiaddr"
"github.com/nnlgsakib/dmgn/pkg/identity"
@@ -21,22 +22,28 @@ import (
// HostConfig holds configuration for creating a libp2p host.
type HostConfig struct {
- ListenAddrs []string
- BootstrapPeers []string
- MDNSService string
- MaxPeersLow int
- MaxPeersHigh int
- PrivateKey crypto.PrivKey
+ ListenAddrs []string
+ BootstrapPeers []string
+ MDNSService string
+ MaxPeersLow int
+ MaxPeersHigh int
+ PrivateKey crypto.PrivKey
+ EnableHolePunching bool
+ EnableRelayService bool
+ RelayServers []string
+ ConnectionGater connmgr.ConnectionGater
}
// Host wraps a libp2p host with DHT and mDNS discovery.
type Host struct {
- host host.Host
- dht *dht.IpfsDHT
- mdnsService mdns.Service
- ctx context.Context
- cancel context.CancelFunc
- cfg HostConfig
+ host host.Host
+ dht *dht.IpfsDHT
+ mdnsService mdns.Service
+ ctx context.Context
+ cancel context.CancelFunc
+ cfg HostConfig
+ storeLimiter *PeerRateLimiter
+ fetchLimiter *PeerRateLimiter
}
// DeriveLibp2pKey derives a libp2p ed25519 private key from a DMGN identity
@@ -61,10 +68,10 @@ func DeriveLibp2pKey(id *identity.Identity) (crypto.PrivKey, error) {
func NewHost(cfg HostConfig) (*Host, error) {
ctx, cancel := context.WithCancel(context.Background())
- cm, err := connmgr.NewConnManager(
+ cm, err := libconnmgr.NewConnManager(
cfg.MaxPeersLow,
cfg.MaxPeersHigh,
- connmgr.WithGracePeriod(time.Minute),
+ libconnmgr.WithGracePeriod(time.Minute),
)
if err != nil {
cancel()
@@ -82,6 +89,25 @@ func NewHost(cfg HostConfig) (*Host, error) {
libp2p.DefaultMuxers,
}
+ if cfg.EnableRelayService {
+ opts = append(opts, libp2p.EnableRelayService())
+ }
+
+ if cfg.EnableHolePunching {
+ opts = append(opts, libp2p.EnableHolePunching())
+ }
+
+ if len(cfg.RelayServers) > 0 {
+ relayInfos := parseRelayAddrs(cfg.RelayServers)
+ if len(relayInfos) > 0 {
+ opts = append(opts, libp2p.EnableAutoRelayWithStaticRelays(relayInfos))
+ }
+ }
+
+ if cfg.ConnectionGater != nil {
+ opts = append(opts, libp2p.ConnectionGater(cfg.ConnectionGater))
+ }
+
h, err := libp2p.New(opts...)
if err != nil {
cancel()
@@ -157,3 +183,26 @@ func (h *Host) RegisterConnectionNotifier(n libnet.Notifiee) {
func (h *Host) DHT() *dht.IpfsDHT {
return h.dht
}
+
+// SetRateLimiters sets per-peer rate limiters for protocol handlers.
+func (h *Host) SetRateLimiters(storeLimiter, fetchLimiter *PeerRateLimiter) {
+ h.storeLimiter = storeLimiter
+ h.fetchLimiter = fetchLimiter
+}
+
+// parseRelayAddrs parses multiaddr strings into peer.AddrInfo for relay configuration.
+func parseRelayAddrs(addrs []string) []peer.AddrInfo {
+ var infos []peer.AddrInfo
+ for _, s := range addrs {
+ ma, err := multiaddr.NewMultiaddr(s)
+ if err != nil {
+ continue
+ }
+ info, err := peer.AddrInfoFromP2pAddr(ma)
+ if err != nil {
+ continue
+ }
+ infos = append(infos, *info)
+ }
+ return infos
+}
diff --git a/pkg/network/host_test.go b/pkg/network/host_test.go
index 782c5eb..f66163c 100644
--- a/pkg/network/host_test.go
+++ b/pkg/network/host_test.go
@@ -226,3 +226,163 @@ func TestTwoHostsConnect(t *testing.T) {
t.Errorf("h1 peer ID mismatch: got %s, want %s", peers1[0].ID, h2.ID().String())
}
}
+
+func TestQUICListenAddr(t *testing.T) {
+ id := createTestIdentity(t)
+ key, _ := DeriveLibp2pKey(id)
+
+ h, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/udp/0/quic-v1"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key,
+ })
+ if err != nil {
+ t.Fatalf("NewHost failed: %v", err)
+ }
+ defer h.Stop()
+
+ addrs := h.Addrs()
+ hasQUIC := false
+ hasTCP := false
+ for _, addr := range addrs {
+ s := addr.String()
+ if contains(s, "quic-v1") {
+ hasQUIC = true
+ }
+ if contains(s, "tcp") {
+ hasTCP = true
+ }
+ }
+
+ if !hasTCP {
+ t.Error("expected TCP listen address")
+ }
+ if !hasQUIC {
+ t.Error("expected QUIC listen address")
+ }
+}
+
+func TestHostWithNATOptions(t *testing.T) {
+ id := createTestIdentity(t)
+ key, _ := DeriveLibp2pKey(id)
+
+ h, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key,
+ EnableHolePunching: true,
+ EnableRelayService: true,
+ })
+ if err != nil {
+ t.Fatalf("NewHost with NAT options failed: %v", err)
+ }
+ defer h.Stop()
+
+ if h.ID() == "" {
+ t.Error("host should have a non-empty peer ID")
+ }
+}
+
+func TestTwoHostsConnectQUIC(t *testing.T) {
+ id1 := createTestIdentity(t)
+ key1, _ := DeriveLibp2pKey(id1)
+ id2 := createTestIdentity(t)
+ key2, _ := DeriveLibp2pKey(id2)
+
+ h1, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/udp/0/quic-v1"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key1,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h1 QUIC failed: %v", err)
+ }
+ defer h1.Stop()
+
+ h2, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/udp/0/quic-v1"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key2,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h2 QUIC failed: %v", err)
+ }
+ defer h2.Stop()
+
+ h1Info := peer.AddrInfo{ID: h1.ID(), Addrs: h1.Addrs()}
+ if err := h2.LibP2PHost().Connect(context.Background(), h1Info); err != nil {
+ t.Fatalf("failed to connect h2 to h1 over QUIC: %v", err)
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ if h1.PeerCount() != 1 {
+ t.Errorf("h1 expected 1 peer over QUIC, got %d", h1.PeerCount())
+ }
+ if h2.PeerCount() != 1 {
+ t.Errorf("h2 expected 1 peer over QUIC, got %d", h2.PeerCount())
+ }
+}
+
+func TestHostWithGater_BlocksPeer(t *testing.T) {
+ id1 := createTestIdentity(t)
+ key1, _ := DeriveLibp2pKey(id1)
+ id2 := createTestIdentity(t)
+ key2, _ := DeriveLibp2pKey(id2)
+
+ h1, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key1,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h1 failed: %v", err)
+ }
+ defer h1.Stop()
+
+ // Create h2 with gater that blocks h1
+ gater := NewReputationGater(nil, []string{h1.ID().String()}, nil, 0.2)
+ h2, err := NewHost(HostConfig{
+ ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
+ MDNSService: "",
+ MaxPeersLow: 5,
+ MaxPeersHigh: 10,
+ PrivateKey: key2,
+ ConnectionGater: gater,
+ })
+ if err != nil {
+ t.Fatalf("NewHost h2 failed: %v", err)
+ }
+ defer h2.Stop()
+
+ // h2 trying to dial h1 should fail because h1 is blocked
+ h1Info := peer.AddrInfo{ID: h1.ID(), Addrs: h1.Addrs()}
+ err = h2.LibP2PHost().Connect(context.Background(), h1Info)
+ if err == nil {
+ t.Error("expected connection to blocked peer to fail")
+ }
+}
+
+// contains checks if a string contains a substring.
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && searchString(s, substr)
+}
+
+func searchString(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/network/protocols.go b/pkg/network/protocols.go
index e3f8063..455054b 100644
--- a/pkg/network/protocols.go
+++ b/pkg/network/protocols.go
@@ -112,6 +112,12 @@ func readProtoHeaderOnly(r io.Reader, msg proto.Message) error {
func (h *Host) RegisterStoreHandler(store StorageBackend) {
h.host.SetStreamHandler(StoreProtocol, func(s network.Stream) {
defer s.Close()
+
+ if h.storeLimiter != nil && !h.storeLimiter.Allow(s.Conn().RemotePeer()) {
+ writeProtoFrame(s, &dmgnpb.StoreResponse{Status: "error", Message: "rate limited"}, nil)
+ return
+ }
+
s.SetDeadline(time.Now().Add(storeTimeout))
// Read request header
@@ -165,6 +171,12 @@ func (h *Host) RegisterStoreHandler(store StorageBackend) {
func (h *Host) RegisterFetchHandler(store StorageBackend) {
h.host.SetStreamHandler(FetchProtocol, func(s network.Stream) {
defer s.Close()
+
+ if h.fetchLimiter != nil && !h.fetchLimiter.Allow(s.Conn().RemotePeer()) {
+ writeProtoFrame(s, &dmgnpb.FetchResponse{Status: "error", Message: "rate limited"}, nil)
+ return
+ }
+
s.SetDeadline(time.Now().Add(fetchTimeout))
// Read request
diff --git a/pkg/network/ratelimit.go b/pkg/network/ratelimit.go
new file mode 100644
index 0000000..3d67a5c
--- /dev/null
+++ b/pkg/network/ratelimit.go
@@ -0,0 +1,67 @@
+package network
+
+import (
+ "sync"
+ "time"
+
+ "github.com/libp2p/go-libp2p/core/peer"
+ "golang.org/x/time/rate"
+)
+
+// PeerRateLimiter enforces per-peer rate limits using token bucket algorithm.
+type PeerRateLimiter struct {
+ limiters map[peer.ID]*rateLimiterEntry
+ mu sync.Mutex
+ limit rate.Limit
+ burst int
+}
+
+type rateLimiterEntry struct {
+ limiter *rate.Limiter
+ lastSeen time.Time
+}
+
+// NewPeerRateLimiter creates a rate limiter with the given per-peer rate (requests/sec) and burst.
+func NewPeerRateLimiter(rps float64, burst int) *PeerRateLimiter {
+ return &PeerRateLimiter{
+ limiters: make(map[peer.ID]*rateLimiterEntry),
+ limit: rate.Limit(rps),
+ burst: burst,
+ }
+}
+
+// Allow checks if the peer is within their rate limit.
+func (rl *PeerRateLimiter) Allow(p peer.ID) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ entry, ok := rl.limiters[p]
+ if !ok {
+ entry = &rateLimiterEntry{
+ limiter: rate.NewLimiter(rl.limit, rl.burst),
+ }
+ rl.limiters[p] = entry
+ }
+ entry.lastSeen = time.Now()
+ return entry.limiter.Allow()
+}
+
+// Cleanup removes limiter entries for peers not seen since the given duration.
+func (rl *PeerRateLimiter) Cleanup(maxAge time.Duration) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ cutoff := time.Now().Add(-maxAge)
+ for p, entry := range rl.limiters {
+ if entry.lastSeen.Before(cutoff) {
+ delete(rl.limiters, p)
+ }
+ }
+}
+
+// Count returns the number of tracked peers.
+func (rl *PeerRateLimiter) Count() int {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ return len(rl.limiters)
+}
diff --git a/pkg/network/ratelimit_test.go b/pkg/network/ratelimit_test.go
new file mode 100644
index 0000000..cc2393f
--- /dev/null
+++ b/pkg/network/ratelimit_test.go
@@ -0,0 +1,76 @@
+package network
+
+import (
+ "testing"
+ "time"
+
+ "github.com/libp2p/go-libp2p/core/peer"
+)
+
+func TestPeerRateLimiter_AllowBurst(t *testing.T) {
+ rl := NewPeerRateLimiter(10, 5) // 10 req/s, burst 5
+ pid := peer.ID("test-peer-1")
+
+ // First 5 (burst) should be allowed
+ for i := 0; i < 5; i++ {
+ if !rl.Allow(pid) {
+ t.Fatalf("request %d within burst should be allowed", i+1)
+ }
+ }
+
+ // 6th should be rate limited
+ if rl.Allow(pid) {
+ t.Error("request beyond burst should be rate limited")
+ }
+}
+
+func TestPeerRateLimiter_IndependentPeers(t *testing.T) {
+ rl := NewPeerRateLimiter(10, 2) // 10 req/s, burst 2
+ pid1 := peer.ID("peer-1")
+ pid2 := peer.ID("peer-2")
+
+ // Exhaust peer1's burst
+ rl.Allow(pid1)
+ rl.Allow(pid1)
+
+ // Peer2 should still have its own burst
+ if !rl.Allow(pid2) {
+ t.Error("different peers should have independent rate limits")
+ }
+}
+
+func TestPeerRateLimiter_Cleanup(t *testing.T) {
+ rl := NewPeerRateLimiter(10, 5)
+ pid := peer.ID("test-peer-cleanup")
+
+ rl.Allow(pid)
+ if rl.Count() != 1 {
+ t.Fatalf("expected 1 tracked peer, got %d", rl.Count())
+ }
+
+ // Wait briefly so entry becomes older than cutoff
+ time.Sleep(10 * time.Millisecond)
+
+ // Cleanup entries older than 5ms (our entry is ~10ms old now)
+ rl.Cleanup(5 * time.Millisecond)
+ if rl.Count() != 0 {
+ t.Errorf("expected 0 tracked peers after cleanup, got %d", rl.Count())
+ }
+}
+
+func TestPeerRateLimiter_Recovery(t *testing.T) {
+ rl := NewPeerRateLimiter(1000, 1) // high rate, burst 1
+ pid := peer.ID("test-peer-recover")
+
+ // Use up burst
+ rl.Allow(pid)
+ if rl.Allow(pid) {
+ t.Skip("rate too high for meaningful test")
+ }
+
+ // Wait for token replenishment
+ time.Sleep(5 * time.Millisecond)
+ if !rl.Allow(pid) {
+ t.Error("should recover after waiting")
+ }
+}