302 lines
10 KiB
Go
302 lines
10 KiB
Go
// Package admin implements the v1 Server Admin API and network migration.
|
|
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"remlink/internal/bootstrap"
|
|
"remlink/internal/database"
|
|
"remlink/internal/ipam"
|
|
"remlink/internal/overlay/serverwg"
|
|
"remlink/internal/protocol"
|
|
)
|
|
|
|
const networkSettingKey = "admin.network"
|
|
|
|
type Network struct {
|
|
OverlayCIDR string `json:"overlay_cidr"`
|
|
ServerOverlayIP string `json:"server_overlay_ip"`
|
|
WireGuardPort int `json:"wireguard_port"`
|
|
SessionUDPPort int `json:"session_udp_port"`
|
|
MTU int `json:"mtu"`
|
|
ConfigVersion uint64 `json:"config_version"`
|
|
}
|
|
|
|
type NetworkUpdate struct {
|
|
OverlayCIDR string `json:"overlay_cidr"`
|
|
ServerOverlayIP string `json:"server_overlay_ip"`
|
|
WireGuardPort int `json:"wireguard_port"`
|
|
SessionUDPPort int `json:"session_udp_port"`
|
|
MTU int `json:"mtu"`
|
|
RotateJoinToken bool `json:"rotate_join_token,omitempty"`
|
|
}
|
|
|
|
type NetworkView struct {
|
|
Network
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
}
|
|
|
|
type IPAM interface {
|
|
ChangeNodeAddress(context.Context, string, netip.Addr) error
|
|
ReleaseNode(context.Context, string) error
|
|
Reconfigure(netip.Prefix, netip.Addr) error
|
|
}
|
|
|
|
type PeerManager interface {
|
|
EnsurePeer(context.Context, string, netip.Addr) error
|
|
RemovePeer(context.Context, string) error
|
|
Reconfigure(context.Context, netip.Prefix, int, []serverwg.Peer) error
|
|
LastHandshake(context.Context, string) (*time.Time, error)
|
|
}
|
|
|
|
type BootstrapNetwork interface {
|
|
NetworkSnapshot() bootstrap.ServiceConfig
|
|
UpdateNetwork(bootstrap.ServiceConfig) error
|
|
}
|
|
|
|
type ControlNetwork interface {
|
|
Send(context.Context, string, protocol.ControlMessageType, any) error
|
|
SetNetworkConfigVersion(uint64) error
|
|
ResetNodeConnection(string, string)
|
|
ResetConnections(string)
|
|
}
|
|
|
|
type SessionControl interface {
|
|
Disconnect(context.Context, uint64, string) error
|
|
DisconnectAll(context.Context, string) error
|
|
DisconnectNode(context.Context, string, string) error
|
|
BeginNetworkMigration(context.Context, string) error
|
|
EndNetworkMigration()
|
|
ReconfigureNetwork(netip.Prefix, int, int) error
|
|
}
|
|
|
|
type NetworkManager struct {
|
|
mu sync.Mutex
|
|
store *database.Store
|
|
ipam IPAM
|
|
peers PeerManager
|
|
bootstrap BootstrapNetwork
|
|
control ControlNetwork
|
|
sessions SessionControl
|
|
current Network
|
|
startedAt time.Time
|
|
rebindControl func(netip.Addr) error
|
|
}
|
|
|
|
func LoadStoredNetwork(ctx context.Context, store *database.Store, fallback Network) (Network, error) {
|
|
value, found, err := database.GetSetting(ctx, store.DB(), networkSettingKey)
|
|
if err != nil || !found {
|
|
return fallback, err
|
|
}
|
|
var network Network
|
|
if err := json.Unmarshal([]byte(value), &network); err != nil {
|
|
return Network{}, fmt.Errorf("decode stored Admin network: %w", err)
|
|
}
|
|
if _, _, err := validateNetwork(network); err != nil {
|
|
return Network{}, fmt.Errorf("validate stored Admin network: %w", err)
|
|
}
|
|
return network, nil
|
|
}
|
|
|
|
func NewNetworkManager(store *database.Store, ipamManager IPAM, peers PeerManager, bootstrapService BootstrapNetwork,
|
|
control ControlNetwork, sessions SessionControl, initial Network, rebindControl func(netip.Addr) error) (*NetworkManager, error) {
|
|
if store == nil || ipamManager == nil || peers == nil || bootstrapService == nil || control == nil || sessions == nil {
|
|
return nil, errors.New("Admin NetworkManager dependencies are required")
|
|
}
|
|
if _, _, err := validateNetwork(initial); err != nil {
|
|
return nil, err
|
|
}
|
|
if rebindControl == nil {
|
|
rebindControl = func(netip.Addr) error { return nil }
|
|
}
|
|
return &NetworkManager{
|
|
store: store, ipam: ipamManager, peers: peers, bootstrap: bootstrapService,
|
|
control: control, sessions: sessions, current: initial, startedAt: time.Now(), rebindControl: rebindControl,
|
|
}, nil
|
|
}
|
|
|
|
func (m *NetworkManager) View() NetworkView {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return NetworkView{Network: m.current, UptimeSeconds: int64(time.Since(m.startedAt).Seconds())}
|
|
}
|
|
|
|
func (m *NetworkManager) Current() Network {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.current
|
|
}
|
|
|
|
func (m *NetworkManager) Update(ctx context.Context, input NetworkUpdate) (Network, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
desired := Network{
|
|
OverlayCIDR: input.OverlayCIDR, ServerOverlayIP: input.ServerOverlayIP,
|
|
WireGuardPort: input.WireGuardPort, SessionUDPPort: input.SessionUDPPort, MTU: input.MTU,
|
|
ConfigVersion: m.current.ConfigVersion + 1,
|
|
}
|
|
prefix, serverIP, err := validateNetwork(desired)
|
|
if err != nil {
|
|
return Network{}, err
|
|
}
|
|
nodes, err := m.store.ListNodes(ctx)
|
|
if err != nil {
|
|
return Network{}, err
|
|
}
|
|
assignments, err := ipam.PlanNodeAddresses(nodes, prefix, serverIP)
|
|
if err != nil {
|
|
return Network{}, err
|
|
}
|
|
if err := m.sessions.BeginNetworkMigration(ctx, "NETWORK_CONFIG_CHANGED"); err != nil {
|
|
return Network{}, err
|
|
}
|
|
defer m.sessions.EndNetworkMigration()
|
|
old := m.current
|
|
oldPrefix, oldServerIP, _ := validateNetwork(old)
|
|
oldAssignments := make(map[string]netip.Addr, len(nodes))
|
|
oldPeers := make([]serverwg.Peer, 0, len(nodes))
|
|
newPeers := make([]serverwg.Peer, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
oldAssignments[node.ID] = node.OverlayIP
|
|
oldPeers = append(oldPeers, serverwg.Peer{PublicKey: node.WGPublicKey, Address: node.OverlayIP})
|
|
newPeers = append(newPeers, serverwg.Peer{PublicKey: node.WGPublicKey, Address: assignments[node.ID]})
|
|
}
|
|
serviceConfig := m.bootstrap.NetworkSnapshot()
|
|
oldServiceConfig := serviceConfig
|
|
serviceConfig.OverlayCIDR = prefix
|
|
serviceConfig.ServerOverlayIP = serverIP
|
|
serviceConfig.WGEndpoint, err = replaceEndpointPort(serviceConfig.WGEndpoint, desired.WireGuardPort)
|
|
if err != nil {
|
|
return Network{}, err
|
|
}
|
|
serviceConfig.ControlURL, err = replaceControlHost(serviceConfig.ControlURL, serverIP)
|
|
if err != nil {
|
|
return Network{}, err
|
|
}
|
|
serviceConfig.SessionUDPPort = desired.SessionUDPPort
|
|
serviceConfig.MTU = desired.MTU
|
|
serviceConfig.ConfigVersion = desired.ConfigVersion
|
|
oldEncoded, _ := json.Marshal(old)
|
|
desiredEncoded, _ := json.Marshal(desired)
|
|
|
|
if err := m.sessions.ReconfigureNetwork(prefix, desired.MTU, desired.SessionUDPPort); err != nil {
|
|
return Network{}, err
|
|
}
|
|
rollbackSessions := func() { _ = m.sessions.ReconfigureNetwork(oldPrefix, old.MTU, old.SessionUDPPort) }
|
|
if err := m.store.ReplaceNodeOverlayIPsAndSetting(ctx, assignments, networkSettingKey, string(desiredEncoded)); err != nil {
|
|
rollbackSessions()
|
|
return Network{}, err
|
|
}
|
|
rollbackDatabase := func() {
|
|
_ = m.store.ReplaceNodeOverlayIPsAndSetting(context.Background(), oldAssignments, networkSettingKey, string(oldEncoded))
|
|
}
|
|
if err := m.ipam.Reconfigure(prefix, serverIP); err != nil {
|
|
rollbackDatabase()
|
|
rollbackSessions()
|
|
return Network{}, err
|
|
}
|
|
if err := m.bootstrap.UpdateNetwork(serviceConfig); err != nil {
|
|
_ = m.ipam.Reconfigure(oldPrefix, oldServerIP)
|
|
rollbackDatabase()
|
|
rollbackSessions()
|
|
return Network{}, err
|
|
}
|
|
if err := m.control.SetNetworkConfigVersion(desired.ConfigVersion); err != nil {
|
|
_ = m.bootstrap.UpdateNetwork(oldServiceConfig)
|
|
_ = m.ipam.Reconfigure(oldPrefix, oldServerIP)
|
|
rollbackDatabase()
|
|
rollbackSessions()
|
|
return Network{}, err
|
|
}
|
|
rollbackPublishedConfig := func() {
|
|
_ = m.control.SetNetworkConfigVersion(old.ConfigVersion)
|
|
_ = m.bootstrap.UpdateNetwork(oldServiceConfig)
|
|
_ = m.ipam.Reconfigure(oldPrefix, oldServerIP)
|
|
rollbackDatabase()
|
|
rollbackSessions()
|
|
}
|
|
|
|
// Publish the authoritative Bootstrap snapshot before notifying over the
|
|
// still-live old Control path. Switching wg0 peers or the listener first
|
|
// would make REBOOTSTRAP_REQUIRED physically undeliverable.
|
|
for _, node := range nodes {
|
|
_ = m.control.Send(ctx, node.ID, protocol.ControlRebootstrapRequired, protocol.RebootstrapRequiredPayload{
|
|
ConfigVersion: desired.ConfigVersion, Reason: "NETWORK_CONFIG_CHANGED",
|
|
})
|
|
}
|
|
if err := m.peers.Reconfigure(ctx, netip.PrefixFrom(serverIP, prefix.Bits()), desired.WireGuardPort, newPeers); err != nil {
|
|
rollbackPublishedConfig()
|
|
return Network{}, err
|
|
}
|
|
rollbackKernel := func() {
|
|
_ = m.peers.Reconfigure(context.Background(), netip.PrefixFrom(oldServerIP, oldPrefix.Bits()), old.WireGuardPort, oldPeers)
|
|
}
|
|
if serverIP != oldServerIP {
|
|
if err := m.rebindControl(serverIP); err != nil {
|
|
rollbackKernel()
|
|
rollbackPublishedConfig()
|
|
return Network{}, fmt.Errorf("rebind Control listener after migration notification: %w", err)
|
|
}
|
|
}
|
|
m.control.ResetConnections("Network configuration changed")
|
|
m.current = desired
|
|
return desired, nil
|
|
}
|
|
|
|
func validateNetwork(network Network) (netip.Prefix, netip.Addr, error) {
|
|
prefix, err := netip.ParsePrefix(network.OverlayCIDR)
|
|
if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 || prefix.Bits() > 30 {
|
|
return netip.Prefix{}, netip.Addr{}, errors.New("Overlay CIDR must be canonical IPv4 with usable hosts")
|
|
}
|
|
serverIP, err := netip.ParseAddr(network.ServerOverlayIP)
|
|
if err != nil || !serverIP.Is4() || !prefix.Contains(serverIP) || serverIP == prefix.Addr() || serverIP == lastIPv4(prefix) {
|
|
return netip.Prefix{}, netip.Addr{}, errors.New("Server Overlay IP must be a usable address in Overlay CIDR")
|
|
}
|
|
for name, port := range map[string]int{"WireGuard port": network.WireGuardPort, "Session UDP port": network.SessionUDPPort} {
|
|
if port < 1 || port > 65535 {
|
|
return netip.Prefix{}, netip.Addr{}, fmt.Errorf("%s must be between 1 and 65535", name)
|
|
}
|
|
}
|
|
if network.MTU < 576 || network.MTU > 65535 || network.ConfigVersion == 0 {
|
|
return netip.Prefix{}, netip.Addr{}, errors.New("MTU or config version is invalid")
|
|
}
|
|
return prefix, serverIP, nil
|
|
}
|
|
|
|
func replaceEndpointPort(endpoint string, port int) (string, error) {
|
|
host, _, err := net.SplitHostPort(endpoint)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return net.JoinHostPort(host, strconv.Itoa(port)), nil
|
|
}
|
|
|
|
func replaceControlHost(raw string, host netip.Addr) (string, error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, port, err := net.SplitHostPort(parsed.Host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parsed.Host = net.JoinHostPort(host.String(), port)
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func lastIPv4(prefix netip.Prefix) netip.Addr {
|
|
bytes := prefix.Masked().Addr().As4()
|
|
value := uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3])
|
|
value |= ^uint32(0) >> prefix.Bits()
|
|
return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)})
|
|
}
|