Skip to content

Commit 3447758

Browse files
authored
feat(relay): NetworkOnly ACL — gate reservations on cluster membership (#1032)
* feat(relay): NetworkOnly ACL — gate reservations on cluster membership Follow-up to #1031. With the relay service offered by every edgevpn node, anyone on the public DHT who finds us can reserve a slot and get us to carry their traffic. In a private cluster only network members should be able to use us as a relay. Adds a relayv2.ACLFilter (NetworkOnlyACL) that consults the local ledger's alive bucket. A NetworkService periodically snapshots the bucket into the ACL via an atomic.Pointer swap; AllowReserve checks reduce to a constant-time map lookup. Bootstrap window: A peer joining for the first time is not yet in our alive bucket (it needs to join gossipsub to write to it). If we strict-gated from t=0 a new peer could deadlock trying to reserve its way in. The ACL therefore allows ALL reservations until the first successful alive-bucket snapshot, then switches to strict mode. AllowConnect is left permissive (return true): we gate the reservation step, not in-flight relayed sessions, so a peer's existing tunnel doesn't get yanked if the alive bucket briefly flickers. Knobs: --relay-service-network-only / EDGEVPN_RELAY_SERVICE_NETWORK_ONLY bool, default TRUE — secure by default. Pass =false to open the relay to all peers. --relay-service-acl-refresh / EDGEVPN_RELAY_SERVICE_ACL_REFRESH duration, default 30s — should be <= the alive-service announce interval so churn is reflected within a couple of ticks. Tests (Ginkgo, package config_test): - bootstrap window admits any peer until the first Members call - strict mode admits members listed in the set - strict mode rejects non-members - AllowConnect stays permissive regardless of membership - Members defensively copies the caller's map The ACL lives in pkg/config alongside the existing relay-service plumbing so pkg/node doesn't grow a relayv2 dependency. Wired via the existing node.WithNetworkService pattern. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(relay-acl): e2e reservation handshake against a real libp2p relay The state-machine specs cover the ACL in isolation. These put it inside a real circuit-v2 relay and exercise the actual reservation handshake — the contract the NetworkService relies on in production. Four scenarios: - bootstrap window: fresh ACL accepts any peer (client.Reserve returns a valid voucher) - strict + member: voucher binds to the right peer ID - strict + stranger: libp2p surfaces the relay's refusal as "reservation error: status: PERMISSION_DENIED reason: reservation failed" — proves the ACL ran on the real handshake - membership flip: pre-membership denied, then Members(set) including the joiner is called, then the very next reservation attempt succeeds (mimics the alive-bucket watcher's behaviour) Uses libp2p.ForceReachabilityPublic() in the relay host so the relay service actually advertises itself — without it AutoNAT may refuse to register /hop and the e2e test can't reach the ACL code path. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent c9bdd0f commit 3447758

6 files changed

Lines changed: 516 additions & 8 deletions

File tree

cmd/util.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,18 @@ var CommonFlags []cli.Flag = []cli.Flag{
254254
EnvVars: []string{"EDGEVPN_RELAY_SERVICE"},
255255
Value: true,
256256
},
257+
&cli.BoolFlag{
258+
Name: "relay-service-network-only",
259+
Usage: "Restrict incoming relay reservations to peers observed in the local ledger's alive bucket (cluster members). Strangers that found us via the public DHT or another relay discovery path are rejected. Requires the alive service to be running. During a short bootstrap window — before the alive bucket is first observed — every reservation is allowed so the node itself can finish joining the cluster. Default ON: secure by default; pass --relay-service-network-only=false to open the relay to all peers.",
260+
EnvVars: []string{"EDGEVPN_RELAY_SERVICE_NETWORK_ONLY"},
261+
Value: true,
262+
},
263+
&cli.StringFlag{
264+
Name: "relay-service-acl-refresh",
265+
Usage: "Cadence at which the NetworkOnly relay-service ACL re-snapshots the alive bucket (Go duration). Should be <= the alive-service announce interval so peer churn is reflected within a couple of ticks.",
266+
EnvVars: []string{"EDGEVPN_RELAY_SERVICE_ACL_REFRESH"},
267+
Value: config.DefaultRelayServiceACLRefresh.String(),
268+
},
257269
&cli.Int64Flag{
258270
Name: "relay-service-max-data",
259271
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.",
@@ -453,6 +465,10 @@ func ConfigFromContext(c *cli.Context) *config.Config {
453465
if err != nil {
454466
relayReservationTTL = 0
455467
}
468+
relayACLRefresh, err := time.ParseDuration(c.String("relay-service-acl-refresh"))
469+
if err != nil {
470+
relayACLRefresh = 0 // zero → ToOpts falls back to DefaultRelayServiceACLRefresh
471+
}
456472

457473
// Authproviders are supposed to be passed as a json object
458474
pa := c.String("peergate-auth")
@@ -507,12 +523,14 @@ func ConfigFromContext(c *cli.Context) *config.Config {
507523
HighWater: c.Int("connection-high-water"),
508524
LowWater: c.Int("connection-low-water"),
509525
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"),
526+
Disabled: !c.Bool("relay-service"),
527+
NetworkOnly: c.Bool("relay-service-network-only"),
528+
NetworkOnlyRefresh: relayACLRefresh,
529+
MaxData: c.Int64("relay-service-max-data"),
530+
MaxDuration: relayMaxDuration,
531+
MaxCircuits: c.Int("relay-service-max-circuits"),
532+
ReservationTTL: relayReservationTTL,
533+
BufferSize: c.Int("relay-service-buffer-size"),
516534
},
517535
},
518536
Limit: config.ResourceLimit{

pkg/config/config.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,27 @@ type RelayService struct {
145145
// its own NAT. Default (zero value) is false → service is offered,
146146
// preserving back-compat for programmatic callers.
147147
Disabled bool
148+
// NetworkOnly, when true, gates incoming relay reservations on
149+
// cluster membership: only peers whose libp2p peer ID appears in
150+
// our local ledger's alive bucket (peers that completed a
151+
// gossipsub handshake against our network token) may reserve a
152+
// slot on this node. Strangers that found us via the public DHT
153+
// or another relay discovery path are rejected. During a short
154+
// bootstrap window — before the alive bucket has been observed —
155+
// every reservation is allowed so the node itself can finish
156+
// joining its cluster. Requires the alive service to be running.
157+
//
158+
// Note on the default: the CLI flag --relay-service-network-only
159+
// defaults to TRUE (secure by default). The Go zero value of this
160+
// field is false purely because Go doesn't let us pick a different
161+
// zero. Programmatic callers constructing &config.Config{} who
162+
// want to match the CLI default should set NetworkOnly: true
163+
// explicitly.
164+
NetworkOnly bool
165+
// NetworkOnlyRefresh is the cadence at which the NetworkOnly ACL
166+
// re-snapshots the alive bucket. Zero means the package default
167+
// (30s).
168+
NetworkOnlyRefresh time.Duration
148169
// MaxData is the byte limit (per direction) for a single relayed
149170
// connection before it is reset. libp2p default is 128 KiB.
150171
MaxData int64
@@ -326,9 +347,25 @@ func (c Config) ToOpts(l log.StandardLogger) ([]node.Option, []vpn.Option, error
326347
// ability to reserve slots on OTHER relays via AutoRelay) stays on
327348
// regardless — disabling the service does not prevent this node
328349
// from using third-party relays to traverse its own NAT.
350+
//
351+
// When NetworkOnly is set, an ACL gates incoming reservations on
352+
// cluster membership (peers observed in our alive bucket). The
353+
// ACL is constructed here and shared with a NetworkService that
354+
// keeps it up to date from the ledger; see relay_acl.go.
329355
if !c.Connection.RelayService.Disabled {
330-
libp2pOpts = append(libp2pOpts,
331-
libp2p.EnableRelayService(relayv2.WithResources(RelayServiceResources(c.Connection.RelayService))))
356+
relayOpts := []relayv2.Option{
357+
relayv2.WithResources(RelayServiceResources(c.Connection.RelayService)),
358+
}
359+
if c.Connection.RelayService.NetworkOnly {
360+
acl := &NetworkOnlyACL{}
361+
relayOpts = append(relayOpts, relayv2.WithACL(acl))
362+
refresh := c.Connection.RelayService.NetworkOnlyRefresh
363+
if refresh <= 0 {
364+
refresh = DefaultRelayServiceACLRefresh
365+
}
366+
opts = append(opts, node.WithNetworkService(NetworkOnlyACLService(acl, refresh)))
367+
}
368+
libp2pOpts = append(libp2pOpts, libp2p.EnableRelayService(relayOpts...))
332369
}
333370

334371
if c.NAT.RateLimit {
@@ -581,6 +618,11 @@ const (
581618
DefaultRelayServiceMaxCircuits int = 64
582619
DefaultRelayServiceReservationTTL time.Duration = time.Hour
583620
DefaultRelayServiceBufferSize int = 64 << 10 // 64 KiB
621+
// DefaultRelayServiceACLRefresh is how often the NetworkOnly ACL
622+
// re-snapshots the alive bucket. Should be ≤ the alive-service
623+
// announce interval (default 120s) so peers leaving/joining are
624+
// reflected within a couple of ticks.
625+
DefaultRelayServiceACLRefresh time.Duration = 30 * time.Second
584626
)
585627

586628
// RelayServiceResources builds a relayv2.Resources struct from the

pkg/config/config_suite_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
Copyright © 2021-2026 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_test
15+
16+
import (
17+
"testing"
18+
19+
. "github.com/onsi/ginkgo/v2"
20+
. "github.com/onsi/gomega"
21+
)
22+
23+
func TestConfig(t *testing.T) {
24+
RegisterFailHandler(Fail)
25+
RunSpecs(t, "Config Suite")
26+
}

pkg/config/relay_acl.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
Copyright © 2021-2026 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+
"context"
18+
"sync/atomic"
19+
"time"
20+
21+
"github.com/ipfs/go-log"
22+
"github.com/libp2p/go-libp2p/core/peer"
23+
ma "github.com/multiformats/go-multiaddr"
24+
25+
"github.com/mudler/edgevpn/pkg/blockchain"
26+
"github.com/mudler/edgevpn/pkg/node"
27+
"github.com/mudler/edgevpn/pkg/protocol"
28+
)
29+
30+
// NetworkOnlyACL is a relayv2.ACLFilter that gates incoming circuit-v2
31+
// relay reservations on cluster membership. A peer is a "cluster
32+
// member" when its libp2p peer ID is present in the local ledger's
33+
// alive bucket (i.e. it has successfully gossiped a healthcheck
34+
// timestamp against our network token).
35+
//
36+
// During a short bootstrap window — before the ACL has been told
37+
// "you can start strict-mode now" — every reservation is allowed.
38+
// Without this window a new peer joining a network where we are
39+
// reachable would be unable to reserve a slot on us before having
40+
// proved itself, even though it can't prove itself until it has
41+
// joined gossipsub, which on many NAT'd setups requires going
42+
// through a relay first.
43+
//
44+
// The zero value is usable: AllowReserve returns true (open) until
45+
// the first call to Members(...) flips the gate via Start.
46+
type NetworkOnlyACL struct {
47+
members atomic.Pointer[map[peer.ID]struct{}]
48+
started atomic.Bool
49+
}
50+
51+
// AllowReserve implements relayv2.ACLFilter. Returns true if the
52+
// peer is a recognised cluster member, or if strict-mode hasn't been
53+
// entered yet (bootstrap window).
54+
func (a *NetworkOnlyACL) AllowReserve(p peer.ID, _ ma.Multiaddr) bool {
55+
if !a.started.Load() {
56+
return true
57+
}
58+
m := a.members.Load()
59+
if m == nil {
60+
return true
61+
}
62+
_, ok := (*m)[p]
63+
return ok
64+
}
65+
66+
// AllowConnect implements relayv2.ACLFilter. We only gate the
67+
// reservation step — once a peer has a reservation it is by
68+
// definition a cluster member, so any connect through it stays
69+
// permitted. This keeps in-flight relayed sessions stable even if
70+
// the alive bucket flickers.
71+
func (a *NetworkOnlyACL) AllowConnect(_ peer.ID, _ ma.Multiaddr, _ peer.ID) bool {
72+
return true
73+
}
74+
75+
// Members replaces the authorised peer set. Callers can drop or add
76+
// peers atomically; readers see a consistent snapshot. Calling
77+
// Members for the first time also flips the gate out of bootstrap
78+
// mode: subsequent AllowReserve calls enforce the set strictly.
79+
func (a *NetworkOnlyACL) Members(set map[peer.ID]struct{}) {
80+
// Defensive copy so callers can keep mutating their map without
81+
// racing with the goroutines reading via AllowReserve.
82+
cp := make(map[peer.ID]struct{}, len(set))
83+
for k, v := range set {
84+
cp[k] = v
85+
}
86+
a.members.Store(&cp)
87+
a.started.Store(true)
88+
}
89+
90+
// NetworkOnlyACLService is a node.NetworkService that periodically
91+
// snapshots the ledger's alive bucket into the supplied ACL.
92+
//
93+
// The first non-empty snapshot ends the bootstrap window. If the
94+
// bucket is empty (e.g. the alive service is disabled), the ACL
95+
// stays in open-mode and a debug log line is emitted on each tick
96+
// so operators can spot the misconfiguration.
97+
//
98+
// The refresh cadence should be ≤ the alive-service announce
99+
// interval; once per 30 s is a reasonable default.
100+
func NetworkOnlyACLService(acl *NetworkOnlyACL, refresh time.Duration) node.NetworkService {
101+
return func(ctx context.Context, c node.Config, n *node.Node, b *blockchain.Ledger) error {
102+
if acl == nil {
103+
return nil
104+
}
105+
// Refresh once immediately so the very first reservation after
106+
// the ledger is up does not have to wait a full tick.
107+
refreshACL(c.Logger, acl, b)
108+
109+
go func() {
110+
t := time.NewTicker(refresh)
111+
defer t.Stop()
112+
for {
113+
select {
114+
case <-ctx.Done():
115+
return
116+
case <-t.C:
117+
refreshACL(c.Logger, acl, b)
118+
}
119+
}
120+
}()
121+
return nil
122+
}
123+
}
124+
125+
func refreshACL(logger log.StandardLogger, acl *NetworkOnlyACL, b *blockchain.Ledger) {
126+
bucket := b.LastBlock().Storage[protocol.HealthCheckKey]
127+
if len(bucket) == 0 {
128+
// Ledger has no alive entries yet — could mean alive-service is
129+
// disabled, or we are very early in startup. Stay in open-mode
130+
// until something appears.
131+
if logger != nil {
132+
logger.Debugf("relay-service NetworkOnly ACL: alive bucket empty, keeping ACL open until next refresh")
133+
}
134+
return
135+
}
136+
set := make(map[peer.ID]struct{}, len(bucket))
137+
for uuid := range bucket {
138+
pid, err := peer.Decode(uuid)
139+
if err != nil {
140+
continue
141+
}
142+
set[pid] = struct{}{}
143+
}
144+
acl.Members(set)
145+
}

0 commit comments

Comments
 (0)