Skip to content

Commit c9bdd0f

Browse files
authored
feat(relay): expose circuit-v2 relay-service resource knobs (#1031)
* feat(relay): expose circuit-v2 relay-service resource knobs EdgeVPN previously never enabled libp2p's circuit-v2 relay *service* (only the relay *client* via DefaultEnableRelay). That meant publicly reachable cluster peers refused to carry relayed traffic for NAT-traversed peers that failed to DCUtR hole-punch (QEMU slirp, CGNAT, double-NAT). This change unconditionally enables libp2p.EnableRelayService and exposes the relayv2.Resources tunables as CLI flags / env vars so operators can widen the small libp2p defaults (128KB/2min/16 circuits/2048 buffer) when they need cluster peers to relay bulk transfers (e.g. model files for distributed inference): --relay-service-max-data EDGEVPN_RELAY_MAX_DATA 1 GiB --relay-service-max-duration EDGEVPN_RELAY_MAX_DURATION 30m --relay-service-max-circuits EDGEVPN_RELAY_MAX_CIRCUITS 64 --relay-service-reservation-ttl EDGEVPN_RELAY_RESERVATION_TTL 1h --relay-service-buffer-size EDGEVPN_RELAY_BUFFER_SIZE 64 KiB * feat(relay): add --relay-service flag to disable the relay-service offering When operators don't want a node to act as a circuit-v2 relay for others (resource-constrained edge nodes, untrusted environments, deployments where only a few designated nodes should relay), set --relay-service=false / EDGEVPN_RELAY_SERVICE=false / programmatic Connection.RelayService.Disabled=true. The node still runs as a relay client (can reserve slots on OTHER relays via AutoRelay) — only the incoming-reservation service is skipped. The struct field is named Disabled (not Enabled) so the Go zero value preserves the prior "always offer relay service" behaviour for programmatic callers constructing &config.Config{} directly. Adds TestRelayServiceDisabledSkipsLibp2pOption which asserts that ToOpts produces strictly fewer node options with Disabled=true (the libp2p.EnableRelayService wrapper disappears) and that both variants still produce a constructible Node. A follow-up will add a NetworkOnly mode (relay-service ACL gated on ledger membership) so cluster relays don't service random internet peers that found us via DHT. Assisted-by: Claude:claude-opus-4-7
1 parent c7358e2 commit c9bdd0f

4 files changed

Lines changed: 442 additions & 0 deletions

File tree

cmd/util.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,42 @@ var CommonFlags []cli.Flag = []cli.Flag{
248248
Usage: "List of autorelay static peers to use",
249249
EnvVars: []string{"EDGEVPNAUTORELAYPEERS"},
250250
},
251+
&cli.BoolFlag{
252+
Name: "relay-service",
253+
Usage: "Offer the circuit-v2 relay service to cluster peers (i.e. let other peers reserve a slot on this node and route relayed traffic through us). Disabling does NOT prevent this node from USING other relays as a client via AutoRelay — set this to false on resource-constrained nodes or nodes that should not act as relays.",
254+
EnvVars: []string{"EDGEVPN_RELAY_SERVICE"},
255+
Value: true,
256+
},
257+
&cli.Int64Flag{
258+
Name: "relay-service-max-data",
259+
Usage: "Bytes (per direction) a relayed connection may carry before reset. Higher values let cluster peers carry larger relayed transfers (e.g. model files for distributed inference) at the cost of a larger memory footprint per relay client. Set lower for resource-constrained deployments.",
260+
EnvVars: []string{"EDGEVPN_RELAY_MAX_DATA"},
261+
Value: int64(config.DefaultRelayServiceMaxData),
262+
},
263+
&cli.StringFlag{
264+
Name: "relay-service-max-duration",
265+
Usage: "Maximum lifetime of a single relayed connection (Go duration). Higher values let cluster peers carry longer-running relayed transfers at the cost of holding circuits open. Set lower for resource-constrained deployments.",
266+
EnvVars: []string{"EDGEVPN_RELAY_MAX_DURATION"},
267+
Value: config.DefaultRelayServiceMaxDuration.String(),
268+
},
269+
&cli.IntFlag{
270+
Name: "relay-service-max-circuits",
271+
Usage: "Maximum number of concurrent relay circuits per peer. Higher values let more peers tunnel through this node simultaneously at the cost of a larger memory footprint. Set lower for resource-constrained deployments.",
272+
EnvVars: []string{"EDGEVPN_RELAY_MAX_CIRCUITS"},
273+
Value: config.DefaultRelayServiceMaxCircuits,
274+
},
275+
&cli.StringFlag{
276+
Name: "relay-service-reservation-ttl",
277+
Usage: "Time-to-live of a relay reservation (Go duration). Higher values reduce reservation churn for stable cluster peers; lower values free relay slots faster.",
278+
EnvVars: []string{"EDGEVPN_RELAY_RESERVATION_TTL"},
279+
Value: config.DefaultRelayServiceReservationTTL.String(),
280+
},
281+
&cli.IntFlag{
282+
Name: "relay-service-buffer-size",
283+
Usage: "Per-circuit relayed connection buffer size in bytes. Higher values improve throughput of large relayed transfers at the cost of memory per relay client. Set lower for resource-constrained deployments.",
284+
EnvVars: []string{"EDGEVPN_RELAY_BUFFER_SIZE"},
285+
Value: config.DefaultRelayServiceBufferSize,
286+
},
251287
&cli.StringSliceFlag{
252288
Name: "blacklist",
253289
Usage: "List of peers/cidr to gate",
@@ -409,6 +445,15 @@ func ConfigFromContext(c *cli.Context) *config.Config {
409445
autorelayInterval = 0
410446
}
411447

448+
relayMaxDuration, err := time.ParseDuration(c.String("relay-service-max-duration"))
449+
if err != nil {
450+
relayMaxDuration = 0
451+
}
452+
relayReservationTTL, err := time.ParseDuration(c.String("relay-service-reservation-ttl"))
453+
if err != nil {
454+
relayReservationTTL = 0
455+
}
456+
412457
// Authproviders are supposed to be passed as a json object
413458
pa := c.String("peergate-auth")
414459
d := map[string]map[string]interface{}{}
@@ -461,6 +506,14 @@ func ConfigFromContext(c *cli.Context) *config.Config {
461506
OnlyStaticRelays: c.Bool("autorelay-static-only"),
462507
HighWater: c.Int("connection-high-water"),
463508
LowWater: c.Int("connection-low-water"),
509+
RelayService: config.RelayService{
510+
Disabled: !c.Bool("relay-service"),
511+
MaxData: c.Int64("relay-service-max-data"),
512+
MaxDuration: relayMaxDuration,
513+
MaxCircuits: c.Int("relay-service-max-circuits"),
514+
ReservationTTL: relayReservationTTL,
515+
BufferSize: c.Int("relay-service-buffer-size"),
516+
},
464517
},
465518
Limit: config.ResourceLimit{
466519
Enable: c.Bool("limit-enable"),

pkg/config/config.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/libp2p/go-libp2p/p2p/host/autorelay"
3030
rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager"
3131
connmanager "github.com/libp2p/go-libp2p/p2p/net/connmgr"
32+
relayv2 "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay"
3233
"github.com/mudler/edgevpn/pkg/blockchain"
3334
"github.com/mudler/edgevpn/pkg/crypto"
3435
"github.com/mudler/edgevpn/pkg/discovery"
@@ -122,6 +123,43 @@ type Connection struct {
122123

123124
LowWater int
124125
HighWater int
126+
127+
// RelayService configures circuit-v2 relay-service resource limits
128+
// applied when this node acts as a relay for other peers.
129+
RelayService RelayService
130+
}
131+
132+
// RelayService holds the circuit-v2 relay-service resource limits
133+
// applied to this node when it serves as a relay for other peers.
134+
//
135+
// Higher values let cluster peers carry larger relayed transfers
136+
// (e.g. model files for distributed inference) at the cost of a
137+
// larger memory footprint per relay client. Lower values are safer
138+
// for resource-constrained deployments.
139+
type RelayService struct {
140+
// Disabled, when true, turns off the circuit-v2 relay *service* on
141+
// this node so it no longer accepts reservations from other peers.
142+
// The relay *client* (the ability to reserve slots on OTHER relays
143+
// via AutoRelay) stays on regardless — turning the service off does
144+
// not prevent this node from using third-party relays to traverse
145+
// its own NAT. Default (zero value) is false → service is offered,
146+
// preserving back-compat for programmatic callers.
147+
Disabled bool
148+
// MaxData is the byte limit (per direction) for a single relayed
149+
// connection before it is reset. libp2p default is 128 KiB.
150+
MaxData int64
151+
// MaxDuration is the time limit before a relayed connection is reset.
152+
// libp2p default is 2 minutes.
153+
MaxDuration time.Duration
154+
// MaxCircuits is the maximum number of open relay circuits per peer.
155+
// libp2p default is 16.
156+
MaxCircuits int
157+
// ReservationTTL is the duration of a relay reservation.
158+
// libp2p default is 1 hour.
159+
ReservationTTL time.Duration
160+
// BufferSize is the per-circuit relayed connection buffer size in bytes.
161+
// libp2p default is 2048.
162+
BufferSize int
125163
}
126164

127165
// NAT is the structure relative to NAT configuration settings
@@ -279,6 +317,20 @@ func (c Config) ToOpts(l log.StandardLogger) ([]node.Option, []vpn.Option, error
279317
libp2p.EnableAutoRelay(relayOpts...))
280318
}
281319

320+
// Offer the circuit-v2 relay SERVICE unless explicitly disabled. Any
321+
// publicly-reachable cluster peer can carry relayed traffic for
322+
// NAT-traversed peers that fail to DCUtR hole-punch. Resources are
323+
// tuned via Connection.RelayService. Set RelayService.Disabled=true
324+
// to opt out of serving as a relay (resource-constrained nodes,
325+
// edge devices, untrusted environments). The relay CLIENT (the
326+
// ability to reserve slots on OTHER relays via AutoRelay) stays on
327+
// regardless — disabling the service does not prevent this node
328+
// from using third-party relays to traverse its own NAT.
329+
if !c.Connection.RelayService.Disabled {
330+
libp2pOpts = append(libp2pOpts,
331+
libp2p.EnableRelayService(relayv2.WithResources(RelayServiceResources(c.Connection.RelayService))))
332+
}
333+
282334
if c.NAT.RateLimit {
283335
libp2pOpts = append(libp2pOpts, libp2p.AutoNATServiceRateLimit(
284336
c.NAT.RateLimitGlobal,
@@ -517,3 +569,58 @@ func logScale(val int) int {
517569
bitlen := bits.Len(uint(val))
518570
return 1 << bitlen
519571
}
572+
573+
// Default circuit-v2 relay-service resource limits for edgevpn.
574+
// These are deliberately much wider than libp2p's defaults so cluster
575+
// peers can carry larger / longer relayed transfers (e.g. model files
576+
// for distributed inference) when DCUtR hole-punching fails. Operators
577+
// can override any of these via the Connection.RelayService config.
578+
const (
579+
DefaultRelayServiceMaxData int64 = 1 << 30 // 1 GiB
580+
DefaultRelayServiceMaxDuration time.Duration = 30 * time.Minute
581+
DefaultRelayServiceMaxCircuits int = 64
582+
DefaultRelayServiceReservationTTL time.Duration = time.Hour
583+
DefaultRelayServiceBufferSize int = 64 << 10 // 64 KiB
584+
)
585+
586+
// RelayServiceResources builds a relayv2.Resources struct from the
587+
// configured knobs, falling back to edgevpn defaults (wider than
588+
// libp2p's defaults) for any zero-valued field. libp2p's Resources
589+
// struct is passed by value to relayv2.WithResources; it has no public
590+
// constructor that merges with defaults, so we apply defaults here.
591+
func RelayServiceResources(c RelayService) relayv2.Resources {
592+
res := relayv2.DefaultResources()
593+
594+
if c.MaxCircuits > 0 {
595+
res.MaxCircuits = c.MaxCircuits
596+
} else {
597+
res.MaxCircuits = DefaultRelayServiceMaxCircuits
598+
}
599+
600+
if c.BufferSize > 0 {
601+
res.BufferSize = c.BufferSize
602+
} else {
603+
res.BufferSize = DefaultRelayServiceBufferSize
604+
}
605+
606+
if c.ReservationTTL > 0 {
607+
res.ReservationTTL = c.ReservationTTL
608+
} else {
609+
res.ReservationTTL = DefaultRelayServiceReservationTTL
610+
}
611+
612+
limit := *res.Limit // copy so we don't mutate the DefaultLimit singleton
613+
if c.MaxDuration > 0 {
614+
limit.Duration = c.MaxDuration
615+
} else {
616+
limit.Duration = DefaultRelayServiceMaxDuration
617+
}
618+
if c.MaxData > 0 {
619+
limit.Data = c.MaxData
620+
} else {
621+
limit.Data = DefaultRelayServiceMaxData
622+
}
623+
res.Limit = &limit
624+
625+
return res
626+
}

pkg/config/config_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
Copyright © 2021-2022 Ettore Di Giacinto <mudler@mocaccino.org>
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package config
15+
16+
import (
17+
"testing"
18+
"time"
19+
)
20+
21+
// TestRelayServiceResourcesDefaults verifies that a zero-valued RelayService
22+
// produces relayv2.Resources populated with edgevpn's wider defaults, not
23+
// libp2p's conservative ones.
24+
func TestRelayServiceResourcesDefaults(t *testing.T) {
25+
res := RelayServiceResources(RelayService{})
26+
27+
if res.MaxCircuits != DefaultRelayServiceMaxCircuits {
28+
t.Errorf("MaxCircuits: got %d, want %d", res.MaxCircuits, DefaultRelayServiceMaxCircuits)
29+
}
30+
if res.BufferSize != DefaultRelayServiceBufferSize {
31+
t.Errorf("BufferSize: got %d, want %d", res.BufferSize, DefaultRelayServiceBufferSize)
32+
}
33+
if res.ReservationTTL != DefaultRelayServiceReservationTTL {
34+
t.Errorf("ReservationTTL: got %s, want %s", res.ReservationTTL, DefaultRelayServiceReservationTTL)
35+
}
36+
if res.Limit == nil {
37+
t.Fatal("Limit is nil")
38+
}
39+
if res.Limit.Duration != DefaultRelayServiceMaxDuration {
40+
t.Errorf("Limit.Duration: got %s, want %s", res.Limit.Duration, DefaultRelayServiceMaxDuration)
41+
}
42+
if res.Limit.Data != DefaultRelayServiceMaxData {
43+
t.Errorf("Limit.Data: got %d, want %d", res.Limit.Data, DefaultRelayServiceMaxData)
44+
}
45+
}
46+
47+
// TestRelayServiceResourcesCustom verifies that explicit knob values override
48+
// the defaults end-to-end into the relayv2.Resources struct.
49+
func TestRelayServiceResourcesCustom(t *testing.T) {
50+
custom := RelayService{
51+
MaxData: 2 << 30, // 2 GiB
52+
MaxDuration: 45 * time.Minute,
53+
MaxCircuits: 128,
54+
ReservationTTL: 2 * time.Hour,
55+
BufferSize: 128 << 10, // 128 KiB
56+
}
57+
res := RelayServiceResources(custom)
58+
59+
if res.MaxCircuits != 128 {
60+
t.Errorf("MaxCircuits: got %d, want 128", res.MaxCircuits)
61+
}
62+
if res.BufferSize != 128<<10 {
63+
t.Errorf("BufferSize: got %d, want %d", res.BufferSize, 128<<10)
64+
}
65+
if res.ReservationTTL != 2*time.Hour {
66+
t.Errorf("ReservationTTL: got %s, want %s", res.ReservationTTL, 2*time.Hour)
67+
}
68+
if res.Limit == nil {
69+
t.Fatal("Limit is nil")
70+
}
71+
if res.Limit.Duration != 45*time.Minute {
72+
t.Errorf("Limit.Duration: got %s, want %s", res.Limit.Duration, 45*time.Minute)
73+
}
74+
if res.Limit.Data != 2<<30 {
75+
t.Errorf("Limit.Data: got %d, want %d", res.Limit.Data, int64(2<<30))
76+
}
77+
}
78+
79+
// TestRelayServiceResourcesDoesNotMutateDefaults guards against the
80+
// DefaultLimit() singleton being mutated through res.Limit aliasing.
81+
// Two independent calls with different overrides must not interfere.
82+
func TestRelayServiceResourcesDoesNotMutateDefaults(t *testing.T) {
83+
a := RelayServiceResources(RelayService{MaxData: 1})
84+
b := RelayServiceResources(RelayService{MaxData: 2})
85+
86+
if a.Limit.Data != 1 {
87+
t.Errorf("a.Limit.Data: got %d, want 1 (mutated by b?)", a.Limit.Data)
88+
}
89+
if b.Limit.Data != 2 {
90+
t.Errorf("b.Limit.Data: got %d, want 2", b.Limit.Data)
91+
}
92+
if a.Limit == b.Limit {
93+
t.Error("a.Limit and b.Limit share the same pointer; defaults are being aliased")
94+
}
95+
}

0 commit comments

Comments
 (0)