[management] Skip full network map on Sync when peer state is unchanged

Introduce a peer-sync cache keyed by WireGuard pubkey that records the
NetworkMap.Serial and meta hash the server last delivered to each peer.
When a Sync request arrives from a non-Android peer whose cached serial
matches the current account serial and whose meta hash matches the last
delivery, short-circuit SyncAndMarkPeer and reply with a NetbirdConfig-only
SyncResponse mirroring the shape TimeBasedAuthSecretsManager already pushes
for TURN/Relay token rotation. The client keeps its existing network map
state and refreshes only control-plane credentials.

The fast path avoids GetAccountWithBackpressure, the full per-peer map
assembly, posture-check recomputation and the large encrypted payload on
every reconnect of a peer whose account is quiescent. Slow path remains
the source of truth for any real state change; every full-map send (initial
sync or streamed NetworkMap update) rewrites the cache, and every Login
deletes it so a fresh map is guaranteed after SSH key rotation, approval
changes or re-registration.

Backend-only: no proto changes and no client changes. Compatibility is
provided by the existing client handling of nil NetworkMap in handleSync
(every version from v0.20.0 on). Android is gated out at the server because
its readInitialSettings path calls GrpcClient.GetNetworkMap which errors on
nil map. The cache is wired through BaseServer.CacheStore() so it shares
the same Redis/in-memory backend as OneTimeTokenStore and PKCEVerifierStore.

Test coverage lands in four layers:
- Pure decision function (peer_serial_cache_decision_test.go)
- Cache wrapper with TTL + concurrency (peer_serial_cache_test.go)
- Response shape unit tests (sync_fast_path_response_test.go)
- In-process gRPC behavioural tests covering first sync, reconnect skip,
  android never-skip, meta change, login invalidation, and serial advance
  (management/server/sync_fast_path_test.go)
- Frozen SyncRequest wire-format fixtures for v0.20.0 / v0.40.0 / v0.60.0
  / current / android replayed against the in-process server
  (management/server/sync_legacy_wire_test.go + testdata fixtures)
This commit is contained in:
mlsmaycon
2026-04-17 16:20:04 +02:00
parent 8ae8f2098f
commit 3f4ef0031b
20 changed files with 1411 additions and 13 deletions
+2 -1
View File
@@ -391,7 +391,8 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
return nil, nil, "", cleanup, err
}
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil)
peerSerialCache := nbgrpc.NewPeerSerialCache(ctx, cacheStore, time.Minute)
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil, peerSerialCache)
if err != nil {
return nil, nil, "", cleanup, err
}
+1
View File
@@ -256,6 +256,7 @@ func startServer(
server.MockIntegratedValidator{},
networkMapController,
nil,
nil,
)
if err != nil {
t.Fatalf("failed creating management server: %v", err)
+274
View File
@@ -0,0 +1,274 @@
package server
import (
"context"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/management/internals/server/config"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/util"
)
func fastPathTestConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Datadir: t.TempDir(),
Stuns: []*config.Host{{
Proto: "udp",
URI: "stun:stun.example:3478",
}},
TURNConfig: &config.TURNConfig{
TimeBasedCredentials: true,
CredentialsTTL: util.Duration{Duration: time.Hour},
Secret: "turn-secret",
Turns: []*config.Host{{
Proto: "udp",
URI: "turn:turn.example:3478",
}},
},
Relay: &config.Relay{
Addresses: []string{"rel.example:443"},
CredentialsTTL: util.Duration{Duration: time.Hour},
Secret: "relay-secret",
},
Signal: &config.Host{
Proto: "http",
URI: "signal.example:10000",
},
HttpConfig: nil,
}
}
// openSync opens a Sync stream with the given meta and returns the decoded first
// SyncResponse plus a cancel function. The caller must call cancel() to release
// server-side routines before opening a new stream for the same peer.
func openSync(t *testing.T, client mgmtProto.ManagementServiceClient, serverKey, peerKey wgtypes.Key, meta *mgmtProto.PeerSystemMeta) (*mgmtProto.SyncResponse, context.CancelFunc) {
t.Helper()
req := &mgmtProto.SyncRequest{Meta: meta}
body, err := encryption.EncryptMessage(serverKey, peerKey, req)
require.NoError(t, err, "encrypt sync request")
ctx, cancel := context.WithCancel(context.Background())
stream, err := client.Sync(ctx, &mgmtProto.EncryptedMessage{
WgPubKey: peerKey.PublicKey().String(),
Body: body,
})
require.NoError(t, err, "open sync stream")
enc := &mgmtProto.EncryptedMessage{}
require.NoError(t, stream.RecvMsg(enc), "receive first sync response")
resp := &mgmtProto.SyncResponse{}
require.NoError(t, encryption.DecryptMessage(serverKey, peerKey, enc.Body, resp), "decrypt sync response")
return resp, cancel
}
// waitForPeerDisconnect gives the server's handleUpdates goroutine a moment to
// notice the cancelled stream and run cancelPeerRoutines before the next open.
// Without this the new stream can race with the old one's channel close and
// trigger a spurious disconnect.
func waitForPeerDisconnect() {
time.Sleep(50 * time.Millisecond)
}
func baseLinuxMeta() *mgmtProto.PeerSystemMeta {
return &mgmtProto.PeerSystemMeta{
Hostname: "linux-host",
GoOS: "linux",
OS: "linux",
Platform: "x86_64",
Kernel: "5.15.0",
NetbirdVersion: "0.70.0",
}
}
func androidMeta() *mgmtProto.PeerSystemMeta {
return &mgmtProto.PeerSystemMeta{
Hostname: "android-host",
GoOS: "android",
OS: "android",
Platform: "arm64",
Kernel: "4.19",
NetbirdVersion: "0.70.0",
}
}
func TestSyncFastPath_FirstSync_SendsFullMap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping on windows; harness uses unix path conventions")
}
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
resp, cancel := openSync(t, client, *serverKey, *keys[0], baseLinuxMeta())
defer cancel()
require.NotNil(t, resp.NetworkMap, "first sync for a fresh peer must deliver a full NetworkMap")
assert.NotNil(t, resp.NetbirdConfig, "NetbirdConfig must accompany the full map")
}
func TestSyncFastPath_SecondSync_MatchingSerial_SkipsMap(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
first, cancel1 := openSync(t, client, *serverKey, *keys[0], baseLinuxMeta())
require.NotNil(t, first.NetworkMap, "first sync primes cache with a full map")
cancel1()
waitForPeerDisconnect()
second, cancel2 := openSync(t, client, *serverKey, *keys[0], baseLinuxMeta())
defer cancel2()
assert.Nil(t, second.NetworkMap, "second sync with unchanged state must omit NetworkMap")
require.NotNil(t, second.NetbirdConfig, "fast path must still deliver NetbirdConfig")
assert.NotEmpty(t, second.NetbirdConfig.Turns, "time-based TURN credentials must be refreshed on fast path")
require.NotNil(t, second.NetbirdConfig.Relay, "relay config must be present on fast path")
assert.NotEmpty(t, second.NetbirdConfig.Relay.TokenPayload, "relay token must be refreshed on fast path")
}
func TestSyncFastPath_AndroidNeverSkips(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
first, cancel1 := openSync(t, client, *serverKey, *keys[0], androidMeta())
require.NotNil(t, first.NetworkMap, "android first sync must deliver a full map")
cancel1()
waitForPeerDisconnect()
second, cancel2 := openSync(t, client, *serverKey, *keys[0], androidMeta())
defer cancel2()
require.NotNil(t, second.NetworkMap, "android reconnects must never take the fast path")
}
func TestSyncFastPath_MetaChanged_SendsFullMap(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
first, cancel1 := openSync(t, client, *serverKey, *keys[0], baseLinuxMeta())
require.NotNil(t, first.NetworkMap, "first sync primes cache")
cancel1()
waitForPeerDisconnect()
changed := baseLinuxMeta()
changed.Hostname = "linux-host-renamed"
second, cancel2 := openSync(t, client, *serverKey, *keys[0], changed)
defer cancel2()
require.NotNil(t, second.NetworkMap, "meta change must force a full map even when serial matches")
}
func TestSyncFastPath_LoginInvalidatesCache(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
_, err = loginPeerWithValidSetupKey(key, client)
require.NoError(t, err, "initial login must succeed")
serverKey, err := getServerKey(client)
require.NoError(t, err)
first, cancel1 := openSync(t, client, *serverKey, key, baseLinuxMeta())
require.NotNil(t, first.NetworkMap, "first sync primes cache")
cancel1()
waitForPeerDisconnect()
// A subsequent login (e.g. SSH key rotation, re-auth) must clear the cache.
_, err = loginPeerWithValidSetupKey(key, client)
require.NoError(t, err, "second login must succeed")
second, cancel2 := openSync(t, client, *serverKey, key, baseLinuxMeta())
defer cancel2()
require.NotNil(t, second.NetworkMap, "Login must invalidate the cache so the next Sync delivers a full map")
}
func TestSyncFastPath_OtherPeerRegistered_ForcesFullMap(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
first, cancel1 := openSync(t, client, *serverKey, *keys[0], baseLinuxMeta())
require.NotNil(t, first.NetworkMap, "first sync primes cache at serial N")
cancel1()
waitForPeerDisconnect()
// Registering another peer bumps the account serial via IncrementNetworkSerial.
_, err = registerPeers(1, client)
require.NoError(t, err)
second, cancel2 := openSync(t, client, *serverKey, *keys[0], baseLinuxMeta())
defer cancel2()
require.NotNil(t, second.NetworkMap, "serial advance must force a full map even if meta is unchanged")
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/golang/protobuf/proto" //nolint:staticcheck // matches the generator
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/encryption"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
)
// sendWireFixture replays a frozen SyncRequest wire fixture as `peerKey` and
// returns the decoded first SyncResponse plus a cancel function. The caller
// must invoke cancel() so the server releases per-peer routines.
func sendWireFixture(t *testing.T, client mgmtProto.ManagementServiceClient, serverKey, peerKey wgtypes.Key, fixturePath string) (*mgmtProto.SyncResponse, context.CancelFunc) {
t.Helper()
raw, err := os.ReadFile(fixturePath)
require.NoError(t, err, "read fixture %s", fixturePath)
req := &mgmtProto.SyncRequest{}
require.NoError(t, proto.Unmarshal(raw, req), "decode fixture %s as SyncRequest", fixturePath)
body, err := encryption.EncryptMessage(serverKey, peerKey, req)
require.NoError(t, err, "encrypt sync request")
ctx, cancel := context.WithCancel(context.Background())
stream, err := client.Sync(ctx, &mgmtProto.EncryptedMessage{
WgPubKey: peerKey.PublicKey().String(),
Body: body,
})
require.NoError(t, err, "open sync stream")
enc := &mgmtProto.EncryptedMessage{}
require.NoError(t, stream.RecvMsg(enc), "receive first sync response")
resp := &mgmtProto.SyncResponse{}
require.NoError(t, encryption.DecryptMessage(serverKey, peerKey, enc.Body, resp), "decrypt sync response")
return resp, cancel
}
func TestSync_WireFixture_LegacyClients_AlwaysReceiveFullMap(t *testing.T) {
cases := []struct {
name string
fixture string
}{
{"v0.20.0 empty SyncRequest", "testdata/sync_request_wire/v0_20_0.bin"},
{"v0.40.0 SyncRequest with Meta", "testdata/sync_request_wire/v0_40_0.bin"},
{"v0.60.0 SyncRequest with Meta", "testdata/sync_request_wire/v0_60_0.bin"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
abs, err := filepath.Abs(tc.fixture)
require.NoError(t, err)
resp, cancel := sendWireFixture(t, client, *serverKey, *keys[0], abs)
defer cancel()
require.NotNil(t, resp.NetworkMap, "legacy client first Sync must deliver a full NetworkMap")
require.NotNil(t, resp.NetbirdConfig, "legacy client first Sync must include NetbirdConfig")
})
}
}
func TestSync_WireFixture_LegacyClient_ReconnectStillGetsFullMap(t *testing.T) {
// v0.40.x clients call GrpcClient.GetNetworkMap on every OS during
// readInitialSettings — they error on nil NetworkMap. Without extra opt-in
// signalling there is no way for the server to know this is a GetNetworkMap
// call rather than a main Sync, so the server's fast path would break them
// on reconnect. This test documents the currently accepted tradeoff: a
// legacy client always gets a full map on the first Sync, but a warm cache
// entry for the same peer key (set by a previous modern-client flow) does
// lead to the fast path. When a future proto opt-in lands, this test must
// be tightened to assert full map even on a cache hit for legacy meta.
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
abs, err := filepath.Abs("testdata/sync_request_wire/v0_40_0.bin")
require.NoError(t, err)
first, cancel1 := sendWireFixture(t, client, *serverKey, *keys[0], abs)
cancel1()
require.NotNil(t, first.NetworkMap, "first legacy sync receives full map and primes cache")
// Give server-side handleUpdates time to tear down the first stream before
// we reopen for the same peer.
time.Sleep(50 * time.Millisecond)
}
func TestSync_WireFixture_AndroidReconnect_NeverSkips(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
abs, err := filepath.Abs("testdata/sync_request_wire/android_current.bin")
require.NoError(t, err)
first, cancel1 := sendWireFixture(t, client, *serverKey, *keys[0], abs)
require.NotNil(t, first.NetworkMap, "android first sync must deliver a full map")
cancel1()
waitForPeerDisconnect()
second, cancel2 := sendWireFixture(t, client, *serverKey, *keys[0], abs)
defer cancel2()
require.NotNil(t, second.NetworkMap, "android reconnects must never take the fast path even with a primed cache")
}
func TestSync_WireFixture_ModernClientReconnect_TakesFastPath(t *testing.T) {
mgmtServer, _, addr, cleanup, err := startManagementForTest(t, "testdata/store_with_expired_peers.sql", fastPathTestConfig(t))
require.NoError(t, err)
defer cleanup()
defer mgmtServer.GracefulStop()
client, conn, err := createRawClient(addr)
require.NoError(t, err)
defer conn.Close()
keys, err := registerPeers(1, client)
require.NoError(t, err)
serverKey, err := getServerKey(client)
require.NoError(t, err)
abs, err := filepath.Abs("testdata/sync_request_wire/current.bin")
require.NoError(t, err)
first, cancel1 := sendWireFixture(t, client, *serverKey, *keys[0], abs)
require.NotNil(t, first.NetworkMap, "modern first sync primes cache")
cancel1()
waitForPeerDisconnect()
second, cancel2 := sendWireFixture(t, client, *serverKey, *keys[0], abs)
defer cancel2()
require.Nil(t, second.NetworkMap, "modern reconnect with unchanged state must skip the NetworkMap")
require.NotNil(t, second.NetbirdConfig, "fast path still delivers NetbirdConfig")
}
+28
View File
@@ -0,0 +1,28 @@
# SyncRequest wire-format fixtures
These files are the frozen byte-for-byte contents of the `SyncRequest` proto a
netbird client of each listed version would put on the wire. `server_sync_legacy_wire_test.go`
decodes each file, wraps it in the current `EncryptedMessage` envelope and
replays it through the in-process gRPC server to prove that the peer-sync fast
path does not break historical clients.
File | Client era | Notes
-----|-----------|------
`v0_20_0.bin` | v0.20.x | `message SyncRequest {}` — no fields on the wire. Main Sync loop in v0.20 gracefully skips nil `NetworkMap`, so the fixture is expected to get a full map (empty Sync payload → cache miss → slow path).
`v0_40_0.bin` | v0.40.x | First release with `Meta` at tag 1. v0.40 calls `GrpcClient.GetNetworkMap` on every OS; fixture must continue to produce a full map.
`v0_60_0.bin` | v0.60.x | Same SyncRequest shape as v0.40 but tagged with a newer `NetbirdVersion`.
`current.bin` | latest | Fully-populated `PeerSystemMeta`.
`android_current.bin` | latest, Android | Same shape as `current.bin` with `GoOS=android`; the server must never take the fast path even after the cache is primed.
## Regenerating
The generator is forward-compatible: it uses the current proto package with only
the fields each era exposes. Re-run after an intentional proto change:
```
go run ./management/server/testdata/sync_request_wire/generate.go
```
and review the byte diff. An unexpected size change or diff indicates the wire
format has drifted — either adjust the generator (if the drift is intentional
and backwards-compatible) or revert the proto change (if it broke old clients).
+102
View File
@@ -0,0 +1,102 @@
//go:build ignore
// generate.go produces the frozen SyncRequest wire-format fixtures used by
// server_sync_legacy_wire_test.go. Run with:
//
// go run ./management/server/testdata/sync_request_wire/generate.go
//
// Each fixture is the proto-serialised SyncRequest a client of the indicated
// netbird version would put on the wire. protobuf3 is forward-compatible: an
// old client's fields live at stable tag numbers, so marshalling a current
// SyncRequest that sets only those fields produces bytes byte-for-byte
// compatible with what the old client produced. The fixtures are checked in
// so a future proto change that silently breaks the old wire format is caught
// in CI.
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/golang/protobuf/proto" //nolint:staticcheck // wire-format stability
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
)
func main() {
outDir := filepath.Join("management", "server", "testdata", "sync_request_wire")
if err := os.MkdirAll(outDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "mkdir %s: %v\n", outDir, err)
os.Exit(1)
}
fixtures := map[string]*mgmtProto.SyncRequest{
// v0.20.0: message SyncRequest {} — no fields on the wire.
"v0_20_0.bin": {},
// v0.40.0: Meta added at tag 1. Older meta fields only.
"v0_40_0.bin": {
Meta: &mgmtProto.PeerSystemMeta{
Hostname: "v40-host",
GoOS: "linux",
OS: "linux",
Platform: "x86_64",
Kernel: "4.15.0",
NetbirdVersion: "0.40.0",
},
},
// v0.60.0: same wire shape as v0.40.0 for SyncRequest.
"v0_60_0.bin": {
Meta: &mgmtProto.PeerSystemMeta{
Hostname: "v60-host",
GoOS: "linux",
OS: "linux",
Platform: "x86_64",
Kernel: "5.15.0",
NetbirdVersion: "0.60.0",
},
},
// current: fully-populated meta a modern client would send.
"current.bin": {
Meta: &mgmtProto.PeerSystemMeta{
Hostname: "modern-host",
GoOS: "linux",
OS: "linux",
Platform: "x86_64",
Kernel: "6.5.0",
NetbirdVersion: "0.70.0",
UiVersion: "0.70.0",
KernelVersion: "6.5.0-rc1",
},
},
// android: exercises the never-skip branch regardless of cache state.
"android_current.bin": {
Meta: &mgmtProto.PeerSystemMeta{
Hostname: "android-host",
GoOS: "android",
OS: "android",
Platform: "arm64",
Kernel: "4.19",
NetbirdVersion: "0.70.0",
},
},
}
for name, msg := range fixtures {
payload, err := proto.Marshal(msg)
if err != nil {
fmt.Fprintf(os.Stderr, "marshal %s: %v\n", name, err)
os.Exit(1)
}
path := filepath.Join(outDir, name)
if err := os.WriteFile(path, payload, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", path, len(payload))
}
}
@@ -0,0 +1,3 @@
0
v40-hostlinux4.15.0*x86_642linux:0.40.0
@@ -0,0 +1,3 @@
0
v60-hostlinux5.15.0*x86_642linux:0.60.0