初版功能完成
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
// Package ipam owns authoritative IPv4 overlay allocation.
|
||||
package ipam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAddressPoolExhausted reports that no usable node address remains.
|
||||
ErrAddressPoolExhausted = errors.New("overlay address pool exhausted")
|
||||
// ErrAddressUnavailable reports an invalid, reserved, or occupied manual address.
|
||||
ErrAddressUnavailable = errors.New("overlay address unavailable")
|
||||
)
|
||||
|
||||
// Manager serializes all in-process allocation and modification operations.
|
||||
// The database UNIQUE constraint remains the final consistency boundary.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
store *database.Store
|
||||
prefix netip.Prefix
|
||||
serverIP netip.Addr
|
||||
}
|
||||
|
||||
// PlanNodeAddresses deterministically preserves usable allocations and assigns
|
||||
// the lowest free addresses for Nodes that must move into a new Overlay pool.
|
||||
func PlanNodeAddresses(nodes []model.Node, prefix netip.Prefix, serverIP netip.Addr) (map[string]netip.Addr, error) {
|
||||
prefix = prefix.Masked()
|
||||
if !prefix.Addr().Is4() || prefix.Bits() == 0 || prefix.Bits() > 30 || !serverIP.Is4() || !prefix.Contains(serverIP) ||
|
||||
serverIP == prefix.Addr() || serverIP == lastAddress(prefix) {
|
||||
return nil, errors.New("invalid Overlay migration network")
|
||||
}
|
||||
usable := func(address netip.Addr) bool {
|
||||
return address.Is4() && prefix.Contains(address) && address != prefix.Addr() && address != lastAddress(prefix) && address != serverIP
|
||||
}
|
||||
assignments := make(map[string]netip.Addr, len(nodes))
|
||||
used := make(map[netip.Addr]struct{}, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if node.ID == "" {
|
||||
return nil, errors.New("Node ID is required for Overlay migration")
|
||||
}
|
||||
if usable(node.OverlayIP) {
|
||||
if _, duplicate := used[node.OverlayIP]; !duplicate {
|
||||
assignments[node.ID] = node.OverlayIP
|
||||
used[node.OverlayIP] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if _, assigned := assignments[node.ID]; assigned {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for address := prefix.Addr().Next(); prefix.Contains(address); address = address.Next() {
|
||||
if !usable(address) {
|
||||
continue
|
||||
}
|
||||
if _, occupied := used[address]; occupied {
|
||||
continue
|
||||
}
|
||||
assignments[node.ID] = address
|
||||
used[address] = struct{}{}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrAddressPoolExhausted
|
||||
}
|
||||
}
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// Reconfigure switches subsequent allocation/manual-validation to a migrated pool.
|
||||
func (m *Manager) Reconfigure(prefix netip.Prefix, serverIP netip.Addr) error {
|
||||
prefix = prefix.Masked()
|
||||
if _, err := New(m.store, prefix, serverIP); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.prefix = prefix
|
||||
m.serverIP = serverIP
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// New validates an IPv4 prefix and its reserved Server address.
|
||||
func New(store *database.Store, prefix netip.Prefix, serverIP netip.Addr) (*Manager, error) {
|
||||
prefix = prefix.Masked()
|
||||
if !prefix.Addr().Is4() || prefix.Bits() == 0 || prefix.Bits() > 30 {
|
||||
return nil, errors.New("overlay prefix must be IPv4 with at least two usable addresses")
|
||||
}
|
||||
if !serverIP.Is4() || !prefix.Contains(serverIP) || serverIP == prefix.Addr() || serverIP == lastAddress(prefix) {
|
||||
return nil, errors.New("server address must be a usable address inside overlay prefix")
|
||||
}
|
||||
return &Manager{store: store, prefix: prefix, serverIP: serverIP}, nil
|
||||
}
|
||||
|
||||
// ReserveNode creates a node using the lowest free usable address. Existing
|
||||
// NodeIDs keep their allocation and return created=false.
|
||||
func (m *Manager) ReserveNode(ctx context.Context, node model.Node) (reserved model.Node, created bool, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
existing, err := m.store.GetNode(ctx, node.ID)
|
||||
if err == nil {
|
||||
return existing, false, nil
|
||||
}
|
||||
if !errors.Is(err, database.ErrNodeNotFound) {
|
||||
return model.Node{}, false, err
|
||||
}
|
||||
used, err := m.used(ctx)
|
||||
if err != nil {
|
||||
return model.Node{}, false, err
|
||||
}
|
||||
address, ok := m.firstAvailable(used)
|
||||
if !ok {
|
||||
return model.Node{}, false, ErrAddressPoolExhausted
|
||||
}
|
||||
node.OverlayIP = address
|
||||
if err := m.store.CreateNode(ctx, node); err != nil {
|
||||
return model.Node{}, false, err
|
||||
}
|
||||
createdNode, err := m.store.GetNode(ctx, node.ID)
|
||||
if err != nil {
|
||||
return model.Node{}, false, err
|
||||
}
|
||||
return createdNode, true, nil
|
||||
}
|
||||
|
||||
// ChangeNodeAddress performs the Web UI's authoritative manual reassignment.
|
||||
func (m *Manager) ChangeNodeAddress(ctx context.Context, nodeID string, desired netip.Addr) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.usable(desired) {
|
||||
return fmt.Errorf("%w: %s is reserved or outside %s", ErrAddressUnavailable, desired, m.prefix)
|
||||
}
|
||||
used, err := m.used(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := m.store.GetNode(ctx, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.OverlayIP == desired {
|
||||
return nil
|
||||
}
|
||||
if _, occupied := used[desired]; occupied {
|
||||
return fmt.Errorf("%w: %s is already assigned", ErrAddressUnavailable, desired)
|
||||
}
|
||||
return m.store.UpdateNodeOverlayIP(ctx, nodeID, desired)
|
||||
}
|
||||
|
||||
// ReleaseNode deletes the node record, token, and allocation. Peer revocation
|
||||
// must succeed in the orchestration layer before this method is called.
|
||||
func (m *Manager) ReleaseNode(ctx context.Context, nodeID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.store.DeleteNode(ctx, nodeID)
|
||||
}
|
||||
|
||||
func (m *Manager) used(ctx context.Context) (map[netip.Addr]struct{}, error) {
|
||||
addresses, err := m.store.ListOverlayIPs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used := make(map[netip.Addr]struct{}, len(addresses))
|
||||
for _, address := range addresses {
|
||||
used[address] = struct{}{}
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
func (m *Manager) firstAvailable(used map[netip.Addr]struct{}) (netip.Addr, bool) {
|
||||
for address := m.prefix.Addr().Next(); m.prefix.Contains(address); address = address.Next() {
|
||||
if !m.usable(address) {
|
||||
continue
|
||||
}
|
||||
if _, occupied := used[address]; !occupied {
|
||||
return address, true
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
func (m *Manager) usable(address netip.Addr) bool {
|
||||
return address.Is4() && m.prefix.Contains(address) && address != m.prefix.Addr() &&
|
||||
address != lastAddress(m.prefix) && address != m.serverIP
|
||||
}
|
||||
|
||||
func lastAddress(prefix netip.Prefix) netip.Addr {
|
||||
bits := prefix.Bits()
|
||||
base := prefix.Masked().Addr().As4()
|
||||
hostBits := 32 - bits
|
||||
value := uint32(base[0])<<24 | uint32(base[1])<<16 | uint32(base[2])<<8 | uint32(base[3])
|
||||
value |= uint32(1)<<hostBits - 1
|
||||
return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package ipam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/model"
|
||||
)
|
||||
|
||||
func TestReserveTenNodesUniqueStableAndConcurrent(t *testing.T) {
|
||||
manager, store := testManager(t, "10.88.0.0/16", "10.88.0.1")
|
||||
ctx := context.Background()
|
||||
const count = 10
|
||||
results := make(chan model.Node, count)
|
||||
errorsChannel := make(chan error, count)
|
||||
var group sync.WaitGroup
|
||||
for index := range count {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
node, created, err := manager.ReserveNode(ctx, newNode(index))
|
||||
if err != nil {
|
||||
errorsChannel <- err
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
errorsChannel <- fmt.Errorf("node %d was not newly created", index)
|
||||
return
|
||||
}
|
||||
results <- node
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
close(errorsChannel)
|
||||
for err := range errorsChannel {
|
||||
t.Error(err)
|
||||
}
|
||||
seen := make(map[netip.Addr]struct{}, count)
|
||||
for result := range results {
|
||||
if result.OverlayIP == netip.MustParseAddr("10.88.0.1") {
|
||||
t.Fatal("Server address allocated to node")
|
||||
}
|
||||
if _, duplicate := seen[result.OverlayIP]; duplicate {
|
||||
t.Fatalf("duplicate allocation %s", result.OverlayIP)
|
||||
}
|
||||
seen[result.OverlayIP] = struct{}{}
|
||||
}
|
||||
if len(seen) != count {
|
||||
t.Fatalf("unique allocation count = %d, want %d", len(seen), count)
|
||||
}
|
||||
|
||||
existing, err := store.GetNode(ctx, newNode(3).ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted, created, err := manager.ReserveNode(ctx, newNode(3))
|
||||
if err != nil || created || restarted.OverlayIP != existing.OverlayIP {
|
||||
t.Fatalf("restart allocation = %v, created=%v, err=%v; want %s", restarted.OverlayIP, created, err, existing.OverlayIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualChangeAndRelease(t *testing.T) {
|
||||
manager, store := testManager(t, "10.88.0.0/24", "10.88.0.1")
|
||||
ctx := context.Background()
|
||||
first, _, err := manager.ReserveNode(ctx, newNode(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, err := manager.ReserveNode(ctx, newNode(2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.ChangeNodeAddress(ctx, first.ID, second.OverlayIP); !errors.Is(err, ErrAddressUnavailable) {
|
||||
t.Fatalf("occupied address error = %v", err)
|
||||
}
|
||||
for _, reserved := range []string{"10.88.0.0", "10.88.0.1", "10.88.0.255", "10.89.0.2"} {
|
||||
if err := manager.ChangeNodeAddress(ctx, first.ID, netip.MustParseAddr(reserved)); !errors.Is(err, ErrAddressUnavailable) {
|
||||
t.Errorf("reserved address %s error = %v", reserved, err)
|
||||
}
|
||||
}
|
||||
desired := netip.MustParseAddr("10.88.0.100")
|
||||
if err := manager.ChangeNodeAddress(ctx, first.ID, desired); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := store.GetNode(ctx, first.ID)
|
||||
if err != nil || updated.OverlayIP != desired {
|
||||
t.Fatalf("updated address = %s, err=%v", updated.OverlayIP, err)
|
||||
}
|
||||
if err := manager.ReleaseNode(ctx, first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.GetNode(ctx, first.ID); !errors.Is(err, database.ErrNodeNotFound) {
|
||||
t.Fatalf("released node error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolExhaustion(t *testing.T) {
|
||||
manager, _ := testManager(t, "10.88.0.0/30", "10.88.0.1")
|
||||
ctx := context.Background()
|
||||
if _, _, err := manager.ReserveNode(ctx, newNode(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := manager.ReserveNode(ctx, newNode(2)); !errors.Is(err, ErrAddressPoolExhausted) {
|
||||
t.Fatalf("second /30 allocation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAMRejectsExitNodePool(t *testing.T) {
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := New(database.NewStore(db), netip.MustParsePrefix("0.0.0.0/0"), netip.MustParseAddr("10.88.0.1")); err == nil {
|
||||
t.Fatal("IPAM accepted 0.0.0.0/0 Exit Node pool")
|
||||
}
|
||||
}
|
||||
|
||||
func testManager(t *testing.T, prefix, server string) (*Manager, *database.Store) {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "remlink.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
store := database.NewStore(db)
|
||||
manager, err := New(store, netip.MustParsePrefix(prefix), netip.MustParseAddr(server))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manager, store
|
||||
}
|
||||
|
||||
func newNode(index int) model.Node {
|
||||
return model.Node{
|
||||
ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", index),
|
||||
Type: model.NodeTypeEngineer,
|
||||
Name: fmt.Sprintf("node-%d", index),
|
||||
WGPublicKey: fmt.Sprintf("public-key-%d", index),
|
||||
NodeTokenHash: []byte(fmt.Sprintf("token-hash-%d", index)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user