mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[management, client] Add API to change the network range (#4177)
This commit is contained in:
@@ -133,6 +133,11 @@ components:
|
||||
description: Allows to define a custom dns domain for the account
|
||||
type: string
|
||||
example: my-organization.org
|
||||
network_range:
|
||||
description: Allows to define a custom network range for the account in CIDR format
|
||||
type: string
|
||||
format: cidr
|
||||
example: 100.64.0.0/16
|
||||
extra:
|
||||
$ref: '#/components/schemas/AccountExtraSettings'
|
||||
lazy_connection_enabled:
|
||||
@@ -342,6 +347,11 @@ components:
|
||||
description: (Cloud only) Indicates whether peer needs approval
|
||||
type: boolean
|
||||
example: true
|
||||
ip:
|
||||
description: Peer's IP address
|
||||
type: string
|
||||
format: ipv4
|
||||
example: 100.64.0.15
|
||||
required:
|
||||
- name
|
||||
- ssh_enabled
|
||||
|
||||
@@ -303,6 +303,9 @@ type AccountSettings struct {
|
||||
// LazyConnectionEnabled Enables or disables experimental lazy connection
|
||||
LazyConnectionEnabled *bool `json:"lazy_connection_enabled,omitempty"`
|
||||
|
||||
// NetworkRange Allows to define a custom network range for the account in CIDR format
|
||||
NetworkRange *string `json:"network_range,omitempty"`
|
||||
|
||||
// PeerInactivityExpiration Period of time of inactivity after which peer session expires (seconds).
|
||||
PeerInactivityExpiration int `json:"peer_inactivity_expiration"`
|
||||
|
||||
@@ -1196,11 +1199,14 @@ type PeerNetworkRangeCheckAction string
|
||||
// PeerRequest defines model for PeerRequest.
|
||||
type PeerRequest struct {
|
||||
// ApprovalRequired (Cloud only) Indicates whether peer needs approval
|
||||
ApprovalRequired *bool `json:"approval_required,omitempty"`
|
||||
InactivityExpirationEnabled bool `json:"inactivity_expiration_enabled"`
|
||||
LoginExpirationEnabled bool `json:"login_expiration_enabled"`
|
||||
Name string `json:"name"`
|
||||
SshEnabled bool `json:"ssh_enabled"`
|
||||
ApprovalRequired *bool `json:"approval_required,omitempty"`
|
||||
InactivityExpirationEnabled bool `json:"inactivity_expiration_enabled"`
|
||||
|
||||
// Ip Peer's IP address
|
||||
Ip *string `json:"ip,omitempty"`
|
||||
LoginExpirationEnabled bool `json:"login_expiration_enabled"`
|
||||
Name string `json:"name"`
|
||||
SshEnabled bool `json:"ssh_enabled"`
|
||||
}
|
||||
|
||||
// PersonalAccessToken defines model for PersonalAccessToken.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -16,6 +18,17 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// PeerBufferPercentage is the percentage of peers to add as buffer for network range calculations
|
||||
PeerBufferPercentage = 0.5
|
||||
// MinRequiredAddresses is the minimum number of addresses required in a network range
|
||||
MinRequiredAddresses = 10
|
||||
// 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
|
||||
)
|
||||
|
||||
// handler is a handler that handles the server.Account HTTP endpoints
|
||||
type handler struct {
|
||||
accountManager account.Manager
|
||||
@@ -37,6 +50,86 @@ func newHandler(accountManager account.Manager, settingsManager settings.Manager
|
||||
}
|
||||
}
|
||||
|
||||
func validateIPAddress(addr netip.Addr) error {
|
||||
if addr.IsLoopback() {
|
||||
return status.Errorf(status.InvalidArgument, "loopback address range not allowed")
|
||||
}
|
||||
|
||||
if addr.IsMulticast() {
|
||||
return status.Errorf(status.InvalidArgument, "multicast address range not allowed")
|
||||
}
|
||||
|
||||
if addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() {
|
||||
return status.Errorf(status.InvalidArgument, "link-local address range not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMinimumSize(prefix netip.Prefix) error {
|
||||
addr := prefix.Addr()
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID string, networkRange netip.Prefix) error {
|
||||
if !networkRange.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateIPAddress(networkRange.Addr()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateMinimumSize(networkRange); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.validateCapacity(ctx, accountID, userID, networkRange)
|
||||
}
|
||||
|
||||
func (h *handler) validateCapacity(ctx context.Context, accountID, userID string, prefix netip.Prefix) error {
|
||||
peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "")
|
||||
if err != nil {
|
||||
return status.Errorf(status.Internal, "get peer count: %v", err)
|
||||
}
|
||||
|
||||
maxHosts := calculateMaxHosts(prefix)
|
||||
requiredAddresses := calculateRequiredAddresses(len(peers))
|
||||
|
||||
if maxHosts < requiredAddresses {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"network range too small: need at least %d addresses for %d peers + buffer, but range provides %d",
|
||||
requiredAddresses, len(peers), maxHosts)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func calculateMaxHosts(prefix netip.Prefix) int64 {
|
||||
availableAddresses := prefix.Addr().BitLen() - prefix.Bits()
|
||||
maxHosts := int64(1) << availableAddresses
|
||||
|
||||
if prefix.Addr().Is4() {
|
||||
maxHosts -= 2 // network and broadcast addresses
|
||||
}
|
||||
|
||||
return maxHosts
|
||||
}
|
||||
|
||||
func calculateRequiredAddresses(peerCount int) int64 {
|
||||
requiredAddresses := int64(peerCount) + int64(float64(peerCount)*PeerBufferPercentage)
|
||||
if requiredAddresses < MinRequiredAddresses {
|
||||
requiredAddresses = MinRequiredAddresses
|
||||
}
|
||||
return requiredAddresses
|
||||
}
|
||||
|
||||
// getAllAccounts is HTTP GET handler that returns a list of accounts. Effectively returns just a single account.
|
||||
func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
@@ -131,6 +224,18 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Settings.LazyConnectionEnabled != nil {
|
||||
settings.LazyConnectionEnabled = *req.Settings.LazyConnectionEnabled
|
||||
}
|
||||
if req.Settings.NetworkRange != nil && *req.Settings.NetworkRange != "" {
|
||||
prefix, err := netip.ParsePrefix(*req.Settings.NetworkRange)
|
||||
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
|
||||
}
|
||||
|
||||
var onboarding *types.AccountOnboarding
|
||||
if req.Onboarding != nil {
|
||||
@@ -208,6 +313,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
|
||||
DnsDomain: &settings.DNSDomain,
|
||||
}
|
||||
|
||||
if settings.NetworkRange.IsValid() {
|
||||
networkRangeStr := settings.NetworkRange.String()
|
||||
apiSettings.NetworkRange = &networkRangeStr
|
||||
}
|
||||
|
||||
apiOnboarding := api.AccountOnboarding{
|
||||
OnboardingFlowPending: onboarding.OnboardingFlowPending,
|
||||
SignupFormPending: onboarding.SignupFormPending,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -111,6 +112,19 @@ func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID stri
|
||||
}
|
||||
}
|
||||
|
||||
if req.Ip != nil {
|
||||
addr, err := netip.ParseAddr(*req.Ip)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, status.Errorf(status.InvalidArgument, "invalid IP address %s: %v", *req.Ip, err), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.accountManager.UpdatePeerIP(ctx, accountID, userID, peerID, addr); 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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
)
|
||||
@@ -112,6 +114,15 @@ func initTestMetaData(peers ...*nbpeer.Peer) *Handler {
|
||||
p.Name = update.Name
|
||||
return p, nil
|
||||
},
|
||||
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())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("peer not found")
|
||||
},
|
||||
GetPeerFunc: func(_ context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) {
|
||||
var p *nbpeer.Peer
|
||||
for _, peer := range peers {
|
||||
@@ -450,3 +461,73 @@ func TestGetAccessiblePeers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeersHandlerUpdatePeerIP(t *testing.T) {
|
||||
testPeer := &nbpeer.Peer{
|
||||
ID: testPeerID,
|
||||
Key: "key",
|
||||
IP: net.ParseIP("100.64.0.1"),
|
||||
Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()},
|
||||
Name: "test-host@netbird.io",
|
||||
LoginExpirationEnabled: false,
|
||||
UserID: regularUser,
|
||||
Meta: nbpeer.PeerSystemMeta{
|
||||
Hostname: "test-host@netbird.io",
|
||||
Core: "22.04",
|
||||
},
|
||||
}
|
||||
|
||||
p := initTestMetaData(testPeer)
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
peerID string
|
||||
requestBody string
|
||||
callerUserID string
|
||||
expectedStatus int
|
||||
expectedIP string
|
||||
}{
|
||||
{
|
||||
name: "update peer IP successfully",
|
||||
peerID: testPeerID,
|
||||
requestBody: `{"ip": "100.64.0.100"}`,
|
||||
callerUserID: adminUser,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedIP: "100.64.0.100",
|
||||
},
|
||||
{
|
||||
name: "update peer IP with invalid IP",
|
||||
peerID: testPeerID,
|
||||
requestBody: `{"ip": "invalid-ip"}`,
|
||||
callerUserID: adminUser,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/peers/%s", tc.peerID), bytes.NewBuffer([]byte(tc.requestBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{
|
||||
UserId: tc.callerUserID,
|
||||
Domain: "hotmail.com",
|
||||
AccountId: "test_id",
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/peers/{peerId}", p.HandlePeer).Methods("PUT")
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK && tc.expectedIP != "" {
|
||||
var updatedPeer api.Peer
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &updatedPeer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIP, updatedPeer.Ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user