[management, client] Add IPv6 overlay support (#5631)

This commit is contained in:
Viktor Liu
2026-05-07 18:33:37 +09:00
committed by GitHub
parent f23aaa9ae7
commit 205ebcfda2
229 changed files with 10155 additions and 2816 deletions

View File

@@ -937,8 +937,22 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
DisableFirewall: info.DisableFirewall,
BlockLANAccess: info.BlockLANAccess,
BlockInbound: info.BlockInbound,
DisableIPv6: info.DisableIPv6,
LazyConnectionEnabled: info.LazyConnectionEnabled,
},
Capabilities: peerCapabilities(*info),
}
}
// peerCapabilities returns the capabilities this client supports.
func peerCapabilities(info system.Info) []proto.PeerCapability {
caps := []proto.PeerCapability{
proto.PeerCapability_PeerCapabilitySourcePrefixes,
}
if !info.DisableIPv6 {
caps = append(caps, proto.PeerCapability_PeerCapabilityIPv6Overlay)
}
return caps
}

View File

@@ -341,7 +341,11 @@ components:
description: Allows to define a custom network range for the account in CIDR format
type: string
format: cidr
example: 100.64.0.0/16
network_range_v6:
description: Allows to define a custom IPv6 network range for the account in CIDR format.
type: string
format: cidr
example: fd00:1234:5678::/64
peer_expose_enabled:
description: Enables or disables peer expose. If enabled, peers can expose local services through the reverse proxy using the CLI.
type: boolean
@@ -377,6 +381,12 @@ components:
type: boolean
readOnly: true
example: false
ipv6_enabled_groups:
description: List of group IDs whose peers receive IPv6 overlay addresses. Peers not in any of these groups will not be allocated an IPv6 address. New accounts default to the All group.
type: array
items:
type: string
example: ["ch8i4ug6lnn4g9hqv7m0"]
required:
- peer_login_expiration_enabled
- peer_login_expiration
@@ -776,6 +786,11 @@ components:
type: string
format: ipv4
example: 100.64.0.15
ipv6:
description: Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account.
type: string
format: ipv6
example: "fd00:4e42:ab12::1"
required:
- name
- ssh_enabled
@@ -795,6 +810,11 @@ components:
description: Peer's IP address
type: string
example: 10.64.0.1
ipv6:
description: Peer's IPv6 overlay address
type: string
format: ipv6
example: "fd00:4e42:ab12::1"
connection_ip:
description: Peer's public connection IP address
type: string
@@ -1013,6 +1033,10 @@ components:
description: Peer's IP address
type: string
example: 10.64.0.1
ipv6:
description: Peer's IPv6 overlay address
type: string
example: "fd00:4e42:ab12::1"
dns_label:
description: Peer's DNS label is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's domain to the peer label. e.g. peer-dns-label.netbird.cloud
type: string

View File

@@ -1381,6 +1381,9 @@ type AccessiblePeer struct {
// Ip Peer's IP address
Ip string `json:"ip"`
// Ipv6 Peer's IPv6 overlay address
Ipv6 *string `json:"ipv6,omitempty"`
// LastSeen Last time peer connected to Netbird's management service
LastSeen time.Time `json:"last_seen"`
@@ -1465,6 +1468,9 @@ type AccountSettings struct {
// GroupsPropagationEnabled Allows propagate the new user auto groups to peers that belongs to the user
GroupsPropagationEnabled *bool `json:"groups_propagation_enabled,omitempty"`
// Ipv6EnabledGroups List of group IDs whose peers receive IPv6 overlay addresses. Peers not in any of these groups will not be allocated an IPv6 address. New accounts default to the All group.
Ipv6EnabledGroups *[]string `json:"ipv6_enabled_groups,omitempty"`
// JwtAllowGroups List of groups to which users are allowed access
JwtAllowGroups *[]string `json:"jwt_allow_groups,omitempty"`
@@ -1483,6 +1489,9 @@ type AccountSettings struct {
// NetworkRange Allows to define a custom network range for the account in CIDR format
NetworkRange *string `json:"network_range,omitempty"`
// NetworkRangeV6 Allows to define a custom IPv6 network range for the account in CIDR format.
NetworkRangeV6 *string `json:"network_range_v6,omitempty"`
// PeerExposeEnabled Enables or disables peer expose. If enabled, peers can expose local services through the reverse proxy using the CLI.
PeerExposeEnabled bool `json:"peer_expose_enabled"`
@@ -3141,6 +3150,9 @@ type Peer struct {
// Ip Peer's IP address
Ip string `json:"ip"`
// Ipv6 Peer's IPv6 overlay address
Ipv6 *string `json:"ipv6,omitempty"`
// KernelVersion Peer's operating system kernel version
KernelVersion string `json:"kernel_version"`
@@ -3232,6 +3244,9 @@ type PeerBatch struct {
// Ip Peer's IP address
Ip string `json:"ip"`
// Ipv6 Peer's IPv6 overlay address
Ipv6 *string `json:"ipv6,omitempty"`
// KernelVersion Peer's operating system kernel version
KernelVersion string `json:"kernel_version"`
@@ -3331,7 +3346,10 @@ type PeerRequest struct {
InactivityExpirationEnabled bool `json:"inactivity_expiration_enabled"`
// Ip Peer's IP address
Ip *string `json:"ip,omitempty"`
Ip *string `json:"ip,omitempty"`
// Ipv6 Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account.
Ipv6 *string `json:"ipv6,omitempty"`
LoginExpirationEnabled bool `json:"login_expiration_enabled"`
Name string `json:"name"`
SshEnabled bool `json:"ssh_enabled"`

File diff suppressed because it is too large Load Diff

View File

@@ -200,6 +200,18 @@ message Flags {
bool enableSSHLocalPortForwarding = 13;
bool enableSSHRemotePortForwarding = 14;
bool disableSSHAuth = 15;
bool disableIPv6 = 16;
}
// PeerCapability represents a feature the client binary supports.
// Reported in PeerSystemMeta.capabilities on every login/sync.
enum PeerCapability {
PeerCapabilityUnknown = 0;
// Client reads SourcePrefixes instead of the deprecated PeerIP string.
PeerCapabilitySourcePrefixes = 1;
// Client handles IPv6 overlay addresses and firewall rules.
PeerCapabilityIPv6Overlay = 2;
}
// PeerSystemMeta is machine meta data like OS and version.
@@ -221,6 +233,8 @@ message PeerSystemMeta {
Environment environment = 15;
repeated File files = 16;
Flags flags = 17;
repeated PeerCapability capabilities = 18;
}
message LoginResponse {
@@ -335,6 +349,9 @@ message PeerConfig {
// Auto-update config
AutoUpdateSettings autoUpdate = 8;
// IPv6 overlay address as compact bytes: 16 bytes IP + 1 byte prefix length.
bytes address_v6 = 9;
}
message AutoUpdateSettings {
@@ -567,7 +584,8 @@ enum RuleAction {
// FirewallRule represents a firewall rule
message FirewallRule {
string PeerIP = 1;
// Use sourcePrefixes instead.
string PeerIP = 1 [deprecated = true];
RuleDirection Direction = 2;
RuleAction Action = 3;
RuleProtocol Protocol = 4;
@@ -576,6 +594,13 @@ message FirewallRule {
// PolicyID is the ID of the policy that this rule belongs to
bytes PolicyID = 7;
// CustomProtocol is a custom protocol ID when Protocol is CUSTOM.
uint32 customProtocol = 8;
// Compact source IP prefixes for this rule, supersedes PeerIP.
// Each entry is 5 bytes (v4) or 17 bytes (v6): [IP bytes][1 byte prefix_len].
repeated bytes sourcePrefixes = 9;
}
message NetworkAddress {

View File

@@ -0,0 +1,78 @@
// Package netiputil provides compact binary encoding for IP prefixes used in
// the management proto wire format.
//
// Format: [IP bytes][1 byte prefix_len]
// - IPv4: 5 bytes total (4 IP + 1 prefix_len, 0-32)
// - IPv6: 17 bytes total (16 IP + 1 prefix_len, 0-128)
//
// Address family is determined by length: 5 = v4, 17 = v6.
package netiputil
import (
"fmt"
"net/netip"
)
// EncodePrefix encodes a netip.Prefix into compact bytes.
// The address is always unmapped before encoding.
func EncodePrefix(p netip.Prefix) ([]byte, error) {
addr := p.Addr().Unmap()
bits := p.Bits()
if addr.Is4() && bits > 32 {
return nil, fmt.Errorf("invalid prefix length %d for IPv4 address %s (max 32)", bits, addr)
}
return append(addr.AsSlice(), byte(bits)), nil
}
// DecodePrefix decodes compact bytes into a netip.Prefix.
func DecodePrefix(b []byte) (netip.Prefix, error) {
switch len(b) {
case 5:
var ip4 [4]byte
copy(ip4[:], b)
bits := int(b[len(b)-1])
if bits > 32 {
return netip.Prefix{}, fmt.Errorf("invalid IPv4 prefix length %d (max 32)", bits)
}
return netip.PrefixFrom(netip.AddrFrom4(ip4), bits), nil
case 17:
var ip6 [16]byte
copy(ip6[:], b)
addr := netip.AddrFrom16(ip6).Unmap()
bits := int(b[len(b)-1])
if addr.Is4() {
if bits > 32 {
return netip.Prefix{}, fmt.Errorf("invalid prefix length %d for v4-mapped address (max 32)", bits)
}
} else if bits > 128 {
return netip.Prefix{}, fmt.Errorf("invalid IPv6 prefix length %d (max 128)", bits)
}
return netip.PrefixFrom(addr, bits), nil
default:
return netip.Prefix{}, fmt.Errorf("invalid compact prefix length %d (expected 5 or 17)", len(b))
}
}
// EncodeAddr encodes a netip.Addr into compact prefix bytes with a host prefix
// length (/32 for v4, /128 for v6). The address is always unmapped before encoding.
func EncodeAddr(a netip.Addr) []byte {
a = a.Unmap()
bits := 128
if a.Is4() {
bits = 32
}
// Host prefix lengths are always valid for the address family, so error is impossible.
b, _ := EncodePrefix(netip.PrefixFrom(a, bits))
return b
}
// DecodeAddr decodes compact prefix bytes and returns only the address,
// discarding the prefix length. Useful when the prefix length is implied
// (e.g. peer overlay IPs are always /32 or /128).
func DecodeAddr(b []byte) (netip.Addr, error) {
p, err := DecodePrefix(b)
if err != nil {
return netip.Addr{}, err
}
return p.Addr(), nil
}

View File

@@ -0,0 +1,175 @@
package netiputil
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeDecodePrefix(t *testing.T) {
tests := []struct {
name string
prefix string
size int
}{
{
name: "v4 host",
prefix: "100.64.0.1/32",
size: 5,
},
{
name: "v4 network",
prefix: "10.0.0.0/8",
size: 5,
},
{
name: "v4 default",
prefix: "0.0.0.0/0",
size: 5,
},
{
name: "v6 host",
prefix: "fd00::1/128",
size: 17,
},
{
name: "v6 network",
prefix: "fd00:1234:5678::/48",
size: 17,
},
{
name: "v6 default",
prefix: "::/0",
size: 17,
},
{
name: "v4 /16 overlay",
prefix: "100.64.0.1/16",
size: 5,
},
{
name: "v6 /64 overlay",
prefix: "fd00::abcd:1/64",
size: 17,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := netip.MustParsePrefix(tt.prefix)
b, err := EncodePrefix(p)
require.NoError(t, err)
assert.Equal(t, tt.size, len(b), "encoded size")
decoded, err := DecodePrefix(b)
require.NoError(t, err)
assert.Equal(t, p, decoded)
})
}
}
func TestEncodePrefixUnmaps(t *testing.T) {
// v4-mapped v6 address should encode as v4
mapped := netip.MustParsePrefix("::ffff:10.1.2.3/32")
b, err := EncodePrefix(mapped)
require.NoError(t, err)
assert.Equal(t, 5, len(b), "v4-mapped should encode as 5 bytes")
decoded, err := DecodePrefix(b)
require.NoError(t, err)
assert.Equal(t, netip.MustParsePrefix("10.1.2.3/32"), decoded)
}
func TestEncodePrefixUnmapsRejectsInvalidBits(t *testing.T) {
// v4-mapped v6 with bits > 32 should return an error
mapped128 := netip.MustParsePrefix("::ffff:10.1.2.3/128")
_, err := EncodePrefix(mapped128)
require.Error(t, err)
// v4-mapped v6 with bits=96 should also return an error
mapped96 := netip.MustParsePrefix("::ffff:10.0.0.0/96")
_, err = EncodePrefix(mapped96)
require.Error(t, err)
// v4-mapped v6 with bits=32 should succeed
mapped32 := netip.MustParsePrefix("::ffff:10.1.2.3/32")
b, err := EncodePrefix(mapped32)
require.NoError(t, err)
assert.Equal(t, 5, len(b), "v4-mapped should encode as 5 bytes")
decoded, err := DecodePrefix(b)
require.NoError(t, err)
assert.Equal(t, netip.MustParsePrefix("10.1.2.3/32"), decoded)
}
func TestDecodeAddr(t *testing.T) {
v4 := netip.MustParseAddr("100.64.0.5")
b := EncodeAddr(v4)
assert.Equal(t, 5, len(b))
got, err := DecodeAddr(b)
require.NoError(t, err)
assert.Equal(t, v4, got)
v6 := netip.MustParseAddr("fd00::1")
b = EncodeAddr(v6)
assert.Equal(t, 17, len(b))
got, err = DecodeAddr(b)
require.NoError(t, err)
assert.Equal(t, v6, got)
}
func TestDecodePrefixInvalidLength(t *testing.T) {
_, err := DecodePrefix([]byte{1, 2, 3})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid compact prefix length 3")
_, err = DecodePrefix(nil)
assert.Error(t, err)
_, err = DecodePrefix([]byte{})
assert.Error(t, err)
}
func TestDecodePrefixInvalidBits(t *testing.T) {
// v4 with bits > 32
b := []byte{10, 0, 0, 1, 33}
_, err := DecodePrefix(b)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid IPv4 prefix length 33")
// v6 with bits > 128
b = make([]byte, 17)
b[0] = 0xfd
b[16] = 129
_, err = DecodePrefix(b)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid IPv6 prefix length 129")
}
func TestDecodePrefixUnmapsV6Input(t *testing.T) {
addr := netip.MustParseAddr("::ffff:192.168.1.1")
// v4-mapped v6 with bits > 32 should return an error
raw := addr.As16()
bInvalid := make([]byte, 17)
copy(bInvalid, raw[:])
bInvalid[16] = 128
_, err := DecodePrefix(bInvalid)
require.Error(t, err, "v4-mapped address with /128 prefix should be rejected")
assert.Contains(t, err.Error(), "invalid prefix length")
// v4-mapped v6 with valid /32 should decode and unmap correctly
bValid := make([]byte, 17)
copy(bValid, raw[:])
bValid[16] = 32
decoded, err := DecodePrefix(bValid)
require.NoError(t, err)
assert.True(t, decoded.Addr().Is4(), "should be unmapped to v4")
assert.Equal(t, netip.MustParsePrefix("192.168.1.1/32"), decoded)
}

View File

@@ -49,7 +49,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
}
udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0})
if err != nil {
log.Errorf("failed to listen on UDP: %s", err)
return nil, err