-
Notifications
You must be signed in to change notification settings - Fork 142
feat(router): configurable relay circuit limits #498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package router | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "math" | ||
| "time" | ||
|
|
||
| "github.com/dustin/go-humanize" | ||
| "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" | ||
| ) | ||
|
|
||
| // DefaultRelayLimitDuration is how long a relayed connection lives unless the operator sets it. | ||
| const DefaultRelayLimitDuration = time.Hour | ||
|
|
||
| // relayLimit maps operator limits (0 = none) onto go-libp2p, where only a nil | ||
| // limit is unlimited and a zero field cuts every circuit at once. | ||
| func relayLimit(duration time.Duration, data int64) *relay.RelayLimit { | ||
| if duration <= 0 && data <= 0 { | ||
| return nil | ||
| } | ||
| limit := &relay.RelayLimit{Duration: duration, Data: data} | ||
| if duration <= 0 { | ||
| // The relay advertises seconds as uint32; anything larger wraps. | ||
| limit.Duration = math.MaxUint32 * time.Second | ||
| } | ||
| if data <= 0 { | ||
| limit.Data = math.MaxInt64 | ||
| } | ||
| return limit | ||
| } | ||
|
|
||
| // ByteSize is a flag value that accepts human sizes such as 128MiB. | ||
| type ByteSize int64 | ||
|
|
||
| func (b *ByteSize) String() string { return humanize.IBytes(uint64(*b)) } | ||
|
|
||
| func (b *ByteSize) Type() string { return "size" } | ||
|
|
||
| func (b *ByteSize) Set(s string) error { | ||
| n, err := humanize.ParseBytes(s) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if n > math.MaxInt64 { | ||
| return fmt.Errorf("size %q is too large", s) | ||
| } | ||
| *b = ByteSize(n) | ||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,141 @@ | ||||||
| // Copyright 2026 Google LLC | ||||||
| // | ||||||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
| // you may not use this file except in compliance with the License. | ||||||
| // You may obtain a copy of the License at | ||||||
| // | ||||||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||||||
| // | ||||||
| // Unless required by applicable law or agreed to in writing, software | ||||||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| // See the License for the specific language governing permissions and | ||||||
| // limitations under the License. | ||||||
|
|
||||||
| package router | ||||||
|
|
||||||
| import ( | ||||||
| "context" | ||||||
| "io" | ||||||
| "math" | ||||||
| "reflect" | ||||||
| "testing" | ||||||
| "time" | ||||||
|
|
||||||
| "github.com/libp2p/go-libp2p" | ||||||
| "github.com/libp2p/go-libp2p/core/host" | ||||||
| "github.com/libp2p/go-libp2p/core/network" | ||||||
| "github.com/libp2p/go-libp2p/core/peer" | ||||||
| "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/client" | ||||||
| "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" | ||||||
| "github.com/multiformats/go-multiaddr" | ||||||
| ) | ||||||
|
|
||||||
| func TestRelayLimit(t *testing.T) { | ||||||
| unlimitedDuration := math.MaxUint32 * time.Second | ||||||
| for _, tc := range []struct { | ||||||
| name string | ||||||
| duration time.Duration | ||||||
| data int64 | ||||||
| want *relay.RelayLimit | ||||||
| }{ | ||||||
| {"no limits", 0, 0, nil}, | ||||||
| {"duration only", time.Hour, 0, &relay.RelayLimit{Duration: time.Hour, Data: math.MaxInt64}}, | ||||||
| {"data only", 0, 4096, &relay.RelayLimit{Duration: unlimitedDuration, Data: 4096}}, | ||||||
| {"both", time.Minute, 4096, &relay.RelayLimit{Duration: time.Minute, Data: 4096}}, | ||||||
| } { | ||||||
| t.Run(tc.name, func(t *testing.T) { | ||||||
| if got := relayLimit(tc.duration, tc.data); !reflect.DeepEqual(got, tc.want) { | ||||||
| t.Errorf("relayLimit(%v, %d) = %+v, want %+v", tc.duration, tc.data, got, tc.want) | ||||||
| } | ||||||
| }) | ||||||
| } | ||||||
| } | ||||||
|
Comment on lines
+34
to
+53
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the test cases to align with the new capping behavior in relayLimit (where unlimited or overflowing values are capped to math.MaxUint32 instead of math.MaxInt64), and add test cases to verify that overflow is correctly handled. func TestRelayLimit(t *testing.T) {
unlimitedDuration := math.MaxUint32 * time.Second
for _, tc := range []struct {
name string
duration time.Duration
data int64
want *relay.RelayLimit
}{
{"no limits", 0, 0, nil},
{"duration only", time.Hour, 0, &relay.RelayLimit{Duration: time.Hour, Data: math.MaxUint32}},
{"data only", 0, 4096, &relay.RelayLimit{Duration: unlimitedDuration, Data: 4096}},
{"both", time.Minute, 4096, &relay.RelayLimit{Duration: time.Minute, Data: 4096}},
{"duration overflow", 200 * 365 * 24 * time.Hour, 0, &relay.RelayLimit{Duration: unlimitedDuration, Data: math.MaxUint32}},
{"data overflow", 0, 5 << 30, &relay.RelayLimit{Duration: unlimitedDuration, Data: math.MaxUint32}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := relayLimit(tc.duration, tc.data); !reflect.DeepEqual(got, tc.want) {
t.Errorf("relayLimit(%v, %d) = %+v, want %+v", tc.duration, tc.data, got, tc.want)
}
})
}
}
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follows from the thread above: no capping change, so the expectations stay. |
||||||
|
|
||||||
| func TestByteSizeSet(t *testing.T) { | ||||||
| for _, tc := range []struct { | ||||||
| in string | ||||||
| want ByteSize | ||||||
| wantErr bool | ||||||
| }{ | ||||||
| {"128MiB", 128 << 20, false}, | ||||||
| {"1GB", 1_000_000_000, false}, | ||||||
| {"0", 0, false}, | ||||||
| {"abc", 0, true}, | ||||||
| {"20EiB", 0, true}, | ||||||
| } { | ||||||
| var got ByteSize | ||||||
| err := got.Set(tc.in) | ||||||
| if (err != nil) != tc.wantErr || got != tc.want { | ||||||
| t.Errorf("Set(%q) = %d, %v; want %d, error %v", tc.in, got, err, tc.want, tc.wantErr) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // A real circuit: the data cap truncates, and a duration-only limit must not cut. | ||||||
| func TestRelayLimitOnCircuit(t *testing.T) { | ||||||
| const payload = 8192 | ||||||
| for _, tc := range []struct { | ||||||
| name string | ||||||
| limit *relay.RelayLimit | ||||||
| complete bool | ||||||
| }{ | ||||||
| {"data cap cuts the circuit", relayLimit(0, 4096), false}, | ||||||
| {"duration only passes everything", relayLimit(time.Hour, 0), true}, | ||||||
| } { | ||||||
| t.Run(tc.name, func(t *testing.T) { | ||||||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||||||
| defer cancel() | ||||||
|
|
||||||
| relayHost := newLoopbackHost(t, libp2p.DisableRelay()) | ||||||
| if _, err := relay.New(relayHost, relay.WithLimit(tc.limit)); err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
| relayInfo := peer.AddrInfo{ID: relayHost.ID(), Addrs: relayHost.Addrs()} | ||||||
|
|
||||||
| dst := newLoopbackHost(t, libp2p.EnableRelay()) | ||||||
| dst.SetStreamHandler("/test", func(s network.Stream) { | ||||||
| _, _ = s.Write(make([]byte, payload)) | ||||||
| _ = s.Close() | ||||||
| }) | ||||||
| if err := dst.Connect(ctx, relayInfo); err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
| if _, err := client.Reserve(ctx, dst, relayInfo); err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
|
|
||||||
| src := newLoopbackHost(t, libp2p.EnableRelay()) | ||||||
| if err := src.Connect(ctx, relayInfo); err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
| circuit, err := multiaddr.NewMultiaddr("/p2p/" + relayHost.ID().String() + "/p2p-circuit") | ||||||
| if err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
| if err := src.Connect(ctx, peer.AddrInfo{ID: dst.ID(), Addrs: []multiaddr.Multiaddr{relayHost.Addrs()[0].Encapsulate(circuit)}}); err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
|
|
||||||
| s, err := src.NewStream(network.WithAllowLimitedConn(ctx, "test"), dst.ID(), "/test") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The protocol ID registered on the destination host is "/test" (line 97), and the stream is opened with "/test" (line 120). However, WithAllowLimitedConn is called with "test". Since protocol IDs must match exactly, this should be "/test" to correctly allow the protocol on the limited connection.
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The second argument of |
||||||
| if err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
| defer func() { _ = s.Close() }() | ||||||
| got, _ := io.Copy(io.Discard, s) | ||||||
| if (got == payload) != tc.complete { | ||||||
| t.Errorf("read %d of %d bytes through the relay, want complete=%v", got, payload, tc.complete) | ||||||
| } | ||||||
| }) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func newLoopbackHost(t *testing.T, opts ...libp2p.Option) host.Host { | ||||||
| t.Helper() | ||||||
| h, err := libp2p.New(append(opts, libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"))...) | ||||||
| if err != nil { | ||||||
| t.Fatal(err) | ||||||
| } | ||||||
| t.Cleanup(func() { _ = h.Close() }) | ||||||
| return h | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the libp2p circuit relay v2 protocol, both duration (in seconds) and data (in bytes) are represented as uint32 on the wire. If the configured limits exceed math.MaxUint32 (e.g., math.MaxInt64 or a user-configured value like 5GB), they will silently wrap around when serialized by go-libp2p. For example, a 5GB limit wraps around to 1GB, and a 4GB limit wraps to 0 (which cuts the connection immediately). To prevent silent wrap-around, we should explicitly cap both duration and data to math.MaxUint32 (or math.MaxUint32 * time.Second for duration).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only
Durationisuint32on the wire;Dataisuint64(go-libp2p v0.49.0p2p/protocol/circuitv2/pb/circuit.pb.go:454), so byte limits don't wrap and capping them at 4 GiB would add a hidden limit. Also, the relay enforces its localrc.Limit(deadline +io.LimitReader,relay/relay.go:473-478); the wireLimitis only advertised to the client, so a wrapped value couldn't cut a connection. The only case that would advertise a wrong number is a duration above ~136 years. Leaving as is.