mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-20 09:16:40 +00:00
[management] Add IPv6 overlay addressing and capability gating (#5698)
This commit is contained in:
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
goversion "github.com/hashicorp/go-version"
|
||||
@@ -29,7 +32,9 @@ const (
|
||||
// MinNetworkBits is the minimum prefix length for IPv4 network ranges (e.g., /29 gives 8 addresses, /28 gives 16)
|
||||
MinNetworkBitsIPv4 = 28
|
||||
// MinNetworkBitsIPv6 is the minimum prefix length for IPv6 network ranges
|
||||
MinNetworkBitsIPv6 = 120
|
||||
MinNetworkBitsIPv6 = 120
|
||||
// MaxNetworkSizeIPv6 is the largest allowed IPv6 prefix (smallest number)
|
||||
MaxNetworkSizeIPv6 = 48
|
||||
disableAutoUpdate = "disabled"
|
||||
autoUpdateLatestVersion = "latest"
|
||||
)
|
||||
@@ -76,12 +81,35 @@ func validateMinimumSize(prefix netip.Prefix) error {
|
||||
if addr.Is4() && prefix.Bits() > MinNetworkBitsIPv4 {
|
||||
return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv4", MinNetworkBitsIPv4)
|
||||
}
|
||||
if addr.Is6() && prefix.Bits() > MinNetworkBitsIPv6 {
|
||||
return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6)
|
||||
if addr.Is6() {
|
||||
if prefix.Bits() > MinNetworkBitsIPv6 {
|
||||
return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6)
|
||||
}
|
||||
if prefix.Bits() < MaxNetworkSizeIPv6 {
|
||||
return status.Errorf(status.InvalidArgument, "network range too large: maximum size is /%d for IPv6", MaxNetworkSizeIPv6)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) parseAndValidateNetworkRange(ctx context.Context, accountID, userID, rangeStr string, requireV6 bool) (netip.Prefix, error) {
|
||||
prefix, err := netip.ParsePrefix(rangeStr)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err)
|
||||
}
|
||||
prefix = prefix.Masked()
|
||||
if requireV6 && !prefix.Addr().Is6() {
|
||||
return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv6 address")
|
||||
}
|
||||
if !requireV6 && prefix.Addr().Is6() {
|
||||
return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv4 address")
|
||||
}
|
||||
if err := h.validateNetworkRange(ctx, accountID, userID, prefix); err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID string, networkRange netip.Prefix) error {
|
||||
if !networkRange.IsValid() {
|
||||
return nil
|
||||
@@ -117,9 +145,12 @@ func (h *handler) validateCapacity(ctx context.Context, accountID, userID string
|
||||
}
|
||||
|
||||
func calculateMaxHosts(prefix netip.Prefix) int64 {
|
||||
availableAddresses := prefix.Addr().BitLen() - prefix.Bits()
|
||||
maxHosts := int64(1) << availableAddresses
|
||||
hostBits := prefix.Addr().BitLen() - prefix.Bits()
|
||||
if hostBits >= 63 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
|
||||
maxHosts := int64(1) << hostBits
|
||||
if prefix.Addr().Is4() {
|
||||
maxHosts -= 2 // network and broadcast addresses
|
||||
}
|
||||
@@ -164,6 +195,24 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
resp := toAccountResponse(accountID, settings, meta, onboarding)
|
||||
|
||||
// Populate effective network ranges when settings don't have explicit overrides.
|
||||
if resp.Settings.NetworkRange == nil || resp.Settings.NetworkRangeV6 == nil {
|
||||
v4, v6, err := h.settingsManager.GetEffectiveNetworkRanges(r.Context(), accountID)
|
||||
if err != nil {
|
||||
log.WithContext(r.Context()).Warnf("get effective network ranges: %v", err)
|
||||
} else {
|
||||
if resp.Settings.NetworkRange == nil && v4.IsValid() {
|
||||
s := v4.String()
|
||||
resp.Settings.NetworkRange = &s
|
||||
}
|
||||
if resp.Settings.NetworkRangeV6 == nil && v6.IsValid() {
|
||||
s := v6.String()
|
||||
resp.Settings.NetworkRangeV6 = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, []*api.Account{resp})
|
||||
}
|
||||
|
||||
@@ -228,6 +277,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
|
||||
if req.Settings.AutoUpdateAlways != nil {
|
||||
returnSettings.AutoUpdateAlways = *req.Settings.AutoUpdateAlways
|
||||
}
|
||||
if req.Settings.Ipv6EnabledGroups != nil {
|
||||
returnSettings.IPv6EnabledGroups = *req.Settings.Ipv6EnabledGroups
|
||||
}
|
||||
|
||||
return returnSettings, nil
|
||||
}
|
||||
@@ -262,18 +314,23 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.Settings.NetworkRange != nil && *req.Settings.NetworkRange != "" {
|
||||
prefix, err := netip.ParsePrefix(*req.Settings.NetworkRange)
|
||||
prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRange, false)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err), w)
|
||||
return
|
||||
}
|
||||
if err := h.validateNetworkRange(r.Context(), accountID, userID, prefix); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
settings.NetworkRange = prefix
|
||||
}
|
||||
|
||||
if req.Settings.NetworkRangeV6 != nil && *req.Settings.NetworkRangeV6 != "" {
|
||||
prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRangeV6, true)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
settings.NetworkRangeV6 = prefix
|
||||
}
|
||||
|
||||
var onboarding *types.AccountOnboarding
|
||||
if req.Onboarding != nil {
|
||||
onboarding = &types.AccountOnboarding{
|
||||
@@ -352,6 +409,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
|
||||
DnsDomain: &settings.DNSDomain,
|
||||
AutoUpdateVersion: &settings.AutoUpdateVersion,
|
||||
AutoUpdateAlways: &settings.AutoUpdateAlways,
|
||||
Ipv6EnabledGroups: &settings.IPv6EnabledGroups,
|
||||
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
|
||||
LocalAuthDisabled: &settings.LocalAuthDisabled,
|
||||
}
|
||||
@@ -360,6 +418,10 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
|
||||
networkRangeStr := settings.NetworkRange.String()
|
||||
apiSettings.NetworkRange = &networkRangeStr
|
||||
}
|
||||
if settings.NetworkRangeV6.IsValid() {
|
||||
networkRangeV6Str := settings.NetworkRangeV6.String()
|
||||
apiSettings.NetworkRangeV6 = &networkRangeV6Str
|
||||
}
|
||||
|
||||
apiOnboarding := api.AccountOnboarding{
|
||||
OnboardingFlowPending: onboarding.OnboardingFlowPending,
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,10 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler {
|
||||
GetSettings(gomock.Any(), account.Id, "test_user").
|
||||
Return(account.Settings, nil).
|
||||
AnyTimes()
|
||||
settingsMockManager.EXPECT().
|
||||
GetEffectiveNetworkRanges(gomock.Any(), account.Id).
|
||||
Return(netip.Prefix{}, netip.Prefix{}, nil).
|
||||
AnyTimes()
|
||||
|
||||
return &handler{
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
@@ -336,3 +342,27 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateMaxHosts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
min int64
|
||||
}{
|
||||
{"v4 /24", "100.64.0.0/24", 254},
|
||||
{"v4 /16", "100.64.0.0/16", 65534},
|
||||
{"v4 /28", "100.64.0.0/28", 14},
|
||||
{"v6 /64", "fd00::/64", math.MaxInt64},
|
||||
{"v6 /120", "fd00::/120", 256},
|
||||
{"v6 /112", "fd00::/112", 65536},
|
||||
{"v6 /48", "fd00::/48", math.MaxInt64},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prefix := netip.MustParsePrefix(tt.prefix)
|
||||
got := calculateMaxHosts(prefix)
|
||||
assert.Equal(t, tt.min, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -29,8 +29,8 @@ import (
|
||||
)
|
||||
|
||||
var TestPeers = map[string]*nbpeer.Peer{
|
||||
"A": {Key: "A", ID: "peer-A-ID", IP: net.ParseIP("100.100.100.100")},
|
||||
"B": {Key: "B", ID: "peer-B-ID", IP: net.ParseIP("200.200.200.200")},
|
||||
"A": {Key: "A", ID: "peer-A-ID", IP: netip.MustParseAddr("100.100.100.100")},
|
||||
"B": {Key: "B", ID: "peer-B-ID", IP: netip.MustParseAddr("200.200.200.200")},
|
||||
}
|
||||
|
||||
func initGroupTestData(initGroups ...*types.Group) *handler {
|
||||
|
||||
@@ -220,6 +220,18 @@ func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID stri
|
||||
}
|
||||
}
|
||||
|
||||
if req.Ipv6 != nil {
|
||||
v6Addr, err := parseIPv6(req.Ipv6)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, status.Errorf(status.InvalidArgument, "%v", err), w)
|
||||
return
|
||||
}
|
||||
if err = h.accountManager.UpdatePeerIPv6(ctx, accountID, userID, peerID, v6Addr); err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
peer, err := h.accountManager.UpdatePeer(ctx, accountID, userID, update)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
@@ -355,6 +367,21 @@ func (h *Handler) setApprovalRequiredFlag(respBody []*api.PeerBatch, validPeersM
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPv6(s *string) (netip.Addr, error) {
|
||||
if s == nil {
|
||||
return netip.Addr{}, fmt.Errorf("IPv6 address is nil")
|
||||
}
|
||||
addr, err := netip.ParseAddr(*s)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("invalid IPv6 address %s: %w", *s, err)
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !addr.Is6() {
|
||||
return netip.Addr{}, fmt.Errorf("address %s is not IPv6", *s)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// GetAccessiblePeers returns a list of all peers that the specified peer can connect to within the network.
|
||||
func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
@@ -529,6 +556,7 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee
|
||||
GeonameId: int(peer.Location.GeoNameID),
|
||||
Id: peer.ID,
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
Name: peer.Name,
|
||||
Os: peer.Meta.OS,
|
||||
@@ -547,6 +575,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
|
||||
Id: peer.ID,
|
||||
Name: peer.Name,
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
||||
Connected: peer.Status.Connected,
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
@@ -601,6 +630,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
|
||||
Id: peer.ID,
|
||||
Name: peer.Name,
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
||||
Connected: peer.Status.Connected,
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
@@ -677,3 +707,11 @@ func fqdnList(extraLabels []string, dnsDomain string) []string {
|
||||
}
|
||||
return fqdnList
|
||||
}
|
||||
|
||||
func peerIPv6String(peer *nbpeer.Peer) *string {
|
||||
if !peer.IPv6.IsValid() {
|
||||
return nil
|
||||
}
|
||||
s := peer.IPv6.String()
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
UpdatePeerIPFunc: func(_ context.Context, accountID, userID, peerID string, newIP netip.Addr) error {
|
||||
for _, peer := range peers {
|
||||
if peer.ID == peerID {
|
||||
peer.IP = net.IP(newIP.AsSlice())
|
||||
peer.IP = newIP
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,8 @@ func TestGetPeers(t *testing.T) {
|
||||
peer := &nbpeer.Peer{
|
||||
ID: testPeerID,
|
||||
Key: "key",
|
||||
IP: net.ParseIP("100.64.0.1"),
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
Status: &nbpeer.PeerStatus{Connected: true},
|
||||
Name: "PeerName",
|
||||
LoginExpirationEnabled: false,
|
||||
@@ -368,7 +369,8 @@ func TestGetAccessiblePeers(t *testing.T) {
|
||||
peer1 := &nbpeer.Peer{
|
||||
ID: "peer1",
|
||||
Key: "key1",
|
||||
IP: net.ParseIP("100.64.0.1"),
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00:1234::1"),
|
||||
Status: &nbpeer.PeerStatus{Connected: true},
|
||||
Name: "peer1",
|
||||
LoginExpirationEnabled: false,
|
||||
@@ -378,7 +380,8 @@ func TestGetAccessiblePeers(t *testing.T) {
|
||||
peer2 := &nbpeer.Peer{
|
||||
ID: "peer2",
|
||||
Key: "key2",
|
||||
IP: net.ParseIP("100.64.0.2"),
|
||||
IP: netip.MustParseAddr("100.64.0.2"),
|
||||
IPv6: netip.MustParseAddr("fd00:1234::2"),
|
||||
Status: &nbpeer.PeerStatus{Connected: true},
|
||||
Name: "peer2",
|
||||
LoginExpirationEnabled: false,
|
||||
@@ -388,7 +391,8 @@ func TestGetAccessiblePeers(t *testing.T) {
|
||||
peer3 := &nbpeer.Peer{
|
||||
ID: "peer3",
|
||||
Key: "key3",
|
||||
IP: net.ParseIP("100.64.0.3"),
|
||||
IP: netip.MustParseAddr("100.64.0.3"),
|
||||
IPv6: netip.MustParseAddr("fd00:1234::3"),
|
||||
Status: &nbpeer.PeerStatus{Connected: true},
|
||||
Name: "peer3",
|
||||
LoginExpirationEnabled: false,
|
||||
@@ -532,7 +536,8 @@ func TestPeersHandlerUpdatePeerIP(t *testing.T) {
|
||||
testPeer := &nbpeer.Peer{
|
||||
ID: testPeerID,
|
||||
Key: "key",
|
||||
IP: net.ParseIP("100.64.0.1"),
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()},
|
||||
Name: "test-host@netbird.io",
|
||||
LoginExpirationEnabled: false,
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -133,7 +133,7 @@ func PopulateTestData(b *testing.B, am account.Manager, peers, groups, users, se
|
||||
ID: fmt.Sprintf("oldpeer-%d", i),
|
||||
DNSLabel: fmt.Sprintf("oldpeer-%d", i),
|
||||
Key: peerKey.PublicKey().String(),
|
||||
IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)),
|
||||
IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)),
|
||||
Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true},
|
||||
UserID: TestUserId,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user