初版功能完成
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/overlay/clientwg"
|
||||
"remlink/internal/platform/windows/route"
|
||||
"remlink/internal/protocol"
|
||||
"remlink/internal/subnet"
|
||||
)
|
||||
|
||||
const DefaultStatsInterval = 5 * time.Second
|
||||
|
||||
type ControlSender interface {
|
||||
Send(context.Context, protocol.ControlMessageType, string, any) error
|
||||
}
|
||||
|
||||
type PacketDevice interface {
|
||||
SetPacketMux(*clientwg.PacketMux)
|
||||
InjectInbound([]byte) error
|
||||
}
|
||||
|
||||
type EngineerRoutes interface {
|
||||
AddRemote(netip.Prefix) error
|
||||
RemoveRemote(netip.Prefix) error
|
||||
Conflicts(netip.Prefix) ([]route.Entry, error)
|
||||
Reconcile() error
|
||||
}
|
||||
|
||||
type EngineerConfig struct {
|
||||
LocalOverlayIP netip.Addr
|
||||
OverlayCIDR netip.Prefix
|
||||
UDPPort int
|
||||
StatsInterval time.Duration
|
||||
Routes EngineerRoutes
|
||||
Device PacketDevice
|
||||
Control ControlSender
|
||||
OnNodeList func(protocol.NodeListPayload)
|
||||
OnSession func(model.SessionStatus, uint64, string)
|
||||
OnPacketReject subnet.RejectHandler
|
||||
OnPacketDrop func(clientwg.DropEvent)
|
||||
}
|
||||
|
||||
type EngineerRuntime struct {
|
||||
mu sync.Mutex
|
||||
config EngineerConfig
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
registry *subnet.Registry
|
||||
listener *subnet.Listener
|
||||
current *engineerSession
|
||||
pending bool
|
||||
pendingRequestID string
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type engineerSession struct {
|
||||
id uint64
|
||||
peer netip.Addr
|
||||
prefixes []netip.Prefix
|
||||
routes []netip.Prefix
|
||||
sender *subnet.Sender
|
||||
router *clientwg.PacketMux
|
||||
status model.SessionStatus
|
||||
}
|
||||
|
||||
type EngineerSnapshot struct {
|
||||
ID uint64
|
||||
Peer netip.Addr
|
||||
CIDRs []netip.Prefix
|
||||
Status model.SessionStatus
|
||||
Counters model.SessionCounters
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) Snapshot() EngineerSnapshot {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.current == nil {
|
||||
return EngineerSnapshot{}
|
||||
}
|
||||
uploadBytes, uploadPackets := e.current.router.RemoteCounters()
|
||||
downloadBytes, downloadPackets := e.listener.SessionCounters(e.current.id)
|
||||
return EngineerSnapshot{
|
||||
ID: e.current.id, Peer: e.current.peer, CIDRs: append([]netip.Prefix(nil), e.current.prefixes...), Status: e.current.status,
|
||||
Counters: model.SessionCounters{UploadBytes: uploadBytes, UploadPackets: uploadPackets, DownloadBytes: downloadBytes, DownloadPackets: downloadPackets},
|
||||
}
|
||||
}
|
||||
|
||||
func NewEngineerRuntime(parent context.Context, config EngineerConfig) (*EngineerRuntime, error) {
|
||||
if !config.LocalOverlayIP.Is4() || !config.OverlayCIDR.IsValid() || !config.OverlayCIDR.Addr().Is4() ||
|
||||
config.UDPPort < 1 || config.UDPPort > 65535 || config.Routes == nil || config.Device == nil || config.Control == nil {
|
||||
return nil, errors.New("Engineer runtime requires Overlay addressing, UDP port, routes, packet device, and Control")
|
||||
}
|
||||
if config.StatsInterval <= 0 {
|
||||
config.StatsInterval = DefaultStatsInterval
|
||||
}
|
||||
if err := config.Routes.Reconcile(); err != nil {
|
||||
return nil, fmt.Errorf("reconcile Engineer Remote routes: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
runtime := &EngineerRuntime{config: config, ctx: ctx, cancel: cancel, registry: subnet.NewRegistry(), done: make(chan struct{})}
|
||||
listener, err := subnet.NewListener(config.LocalOverlayIP, config.UDPPort, runtime.registry, runtime.inject, config.OnPacketReject)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
runtime.listener = listener
|
||||
go runtime.run()
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// CreateSession performs the mandatory local prefix-overlap preflight before
|
||||
// asking the Server to create any state.
|
||||
func (e *EngineerRuntime) CreateSession(ctx context.Context, siteNodeID string, cidrs []string) (string, error) {
|
||||
prefixes, code := validateCIDRs(cidrs, e.config.OverlayCIDR)
|
||||
if code != "" {
|
||||
return "", fmt.Errorf("%s", code)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.pending || e.current != nil {
|
||||
return "", fmt.Errorf("%s", protocol.ErrorEngineerSessionExists)
|
||||
}
|
||||
if err := e.checkConflictsLocked(prefixes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
e.pending = true
|
||||
e.pendingRequestID = requestID
|
||||
if err := e.config.Control.Send(ctx, protocol.ControlCreateSession, requestID,
|
||||
protocol.CreateSessionPayload{SiteNodeID: siteNodeID, TargetCIDRs: cidrs}); err != nil {
|
||||
e.clearPendingLocked()
|
||||
return "", err
|
||||
}
|
||||
e.notify(model.SessionCreating, 0, "")
|
||||
return requestID, nil
|
||||
}
|
||||
|
||||
// PreflightCIDRs runs the same authoritative local route-overlap check used by
|
||||
// CreateSession without creating Server or Route state.
|
||||
func (e *EngineerRuntime) PreflightCIDRs(cidrs []string) error {
|
||||
prefixes, code := validateCIDRs(cidrs, e.config.OverlayCIDR)
|
||||
if code != "" {
|
||||
return fmt.Errorf("%s", code)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.checkConflictsLocked(prefixes)
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) checkConflictsLocked(prefixes []netip.Prefix) error {
|
||||
for _, prefix := range prefixes {
|
||||
conflicts, err := e.config.Routes.Conflicts(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
return fmt.Errorf("%s: %s overlaps %s", protocol.ErrorCIDRLocalConflict, prefix, conflicts[0].Destination)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) HandleControl(ctx context.Context, envelope protocol.ControlEnvelope) error {
|
||||
switch envelope.Type {
|
||||
case protocol.ControlNodeList:
|
||||
var payload protocol.NodeListPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.config.OnNodeList != nil {
|
||||
e.config.OnNodeList(payload)
|
||||
}
|
||||
return nil
|
||||
case protocol.ControlSessionConfig:
|
||||
var payload protocol.SessionConfigPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.configure(ctx, envelope.RequestID, payload)
|
||||
case protocol.ControlSessionActive:
|
||||
var payload protocol.SessionActivePayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.activate(payload.SessionID)
|
||||
case protocol.ControlStopSession:
|
||||
var payload protocol.StopSessionPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.stop(envelope.RequestID, payload)
|
||||
case protocol.ControlRebootstrapRequired:
|
||||
return protocol.ErrRebootstrapRequired
|
||||
default:
|
||||
return fmt.Errorf("unexpected Engineer Control message %s", envelope.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) configure(ctx context.Context, requestID string, payload protocol.SessionConfigPayload) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.pending || requestID == "" || requestID != e.pendingRequestID || e.current != nil || payload.SessionID == 0 || payload.UDPPort != e.config.UDPPort {
|
||||
return errors.New("SESSION_CONFIG does not match a pending Engineer request")
|
||||
}
|
||||
peer, err := netip.ParseAddr(payload.PeerOverlayIP)
|
||||
if err != nil || !peer.Is4() || !e.config.OverlayCIDR.Contains(peer) {
|
||||
return errors.New("SESSION_CONFIG peer is outside Overlay")
|
||||
}
|
||||
prefixes, code := validateCIDRs(payload.CIDRs, e.config.OverlayCIDR)
|
||||
if code != "" {
|
||||
return fmt.Errorf("%s", code)
|
||||
}
|
||||
current := &engineerSession{id: payload.SessionID, peer: peer, prefixes: prefixes, status: model.SessionReady}
|
||||
for _, prefix := range prefixes {
|
||||
if err := e.config.Routes.AddRemote(prefix); err != nil {
|
||||
e.rollbackRoutes(current.routes)
|
||||
e.clearPendingLocked()
|
||||
_ = e.config.Control.Send(ctx, protocol.ControlStopSession, "", protocol.StopSessionPayload{SessionID: payload.SessionID, Reason: string(protocol.ErrorCIDRLocalConflict)})
|
||||
return err
|
||||
}
|
||||
current.routes = append(current.routes, prefix)
|
||||
}
|
||||
sender, err := subnet.NewSender(e.ctx, subnet.SenderConfig{
|
||||
SessionID: payload.SessionID, LocalIP: e.config.LocalOverlayIP, PeerIP: peer, PeerPort: payload.UDPPort,
|
||||
})
|
||||
if err != nil {
|
||||
e.rollbackRoutes(current.routes)
|
||||
e.clearPendingLocked()
|
||||
return err
|
||||
}
|
||||
current.sender = sender
|
||||
current.router = clientwg.NewPacketMux(e.config.OverlayCIDR, prefixes, sender)
|
||||
current.router.SetDropHandler(e.config.OnPacketDrop)
|
||||
if err := e.registry.Upsert(subnet.SessionBinding{
|
||||
SessionID: payload.SessionID, PeerOverlayIP: peer, EngineerOverlayIP: e.config.LocalOverlayIP,
|
||||
RemoteCIDRs: prefixes, Direction: subnet.SiteToEngineer, Active: false,
|
||||
}); err != nil {
|
||||
_ = sender.Close()
|
||||
e.rollbackRoutes(current.routes)
|
||||
e.clearPendingLocked()
|
||||
return err
|
||||
}
|
||||
e.current = current
|
||||
e.clearPendingLocked()
|
||||
e.config.Device.SetPacketMux(current.router)
|
||||
if err := e.config.Control.Send(ctx, protocol.ControlRoutesReady, "", protocol.RoutesReadyPayload{SessionID: payload.SessionID}); err != nil {
|
||||
e.cleanupLocked()
|
||||
return err
|
||||
}
|
||||
e.notify(model.SessionReady, payload.SessionID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) activate(sessionID uint64) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.current == nil || e.current.id != sessionID || e.current.status != model.SessionReady {
|
||||
return errors.New("SESSION_ACTIVE does not match Engineer READY Session")
|
||||
}
|
||||
binding, _ := e.registry.Lookup(sessionID)
|
||||
binding.Active = true
|
||||
if err := e.registry.Upsert(binding); err != nil {
|
||||
return err
|
||||
}
|
||||
e.current.status = model.SessionActive
|
||||
e.notify(model.SessionActive, sessionID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) stop(requestID string, payload protocol.StopSessionPayload) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.current == nil && e.pending {
|
||||
// A PREPARE failure arrives before SESSION_CONFIG, so SessionID alone
|
||||
// cannot identify Engineer-local state. Only the matching CREATE request
|
||||
// may release the pending guard; stale replies are ignored.
|
||||
if requestID != e.pendingRequestID {
|
||||
return nil
|
||||
}
|
||||
e.clearPendingLocked()
|
||||
e.notify(model.SessionFailed, payload.SessionID, payload.Reason)
|
||||
return nil
|
||||
}
|
||||
if e.current == nil || e.current.id != payload.SessionID {
|
||||
return nil
|
||||
}
|
||||
e.cleanupLocked()
|
||||
e.notify(model.SessionClosed, payload.SessionID, payload.Reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) clearPendingLocked() {
|
||||
e.pending = false
|
||||
e.pendingRequestID = ""
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) Disconnect(ctx context.Context, reason string) error {
|
||||
e.mu.Lock()
|
||||
if e.current == nil {
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
id := e.current.id
|
||||
e.mu.Unlock()
|
||||
return e.config.Control.Send(ctx, protocol.ControlStopSession, "", protocol.StopSessionPayload{SessionID: id, Reason: reason})
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) inject(_ context.Context, sessionID uint64, packet []byte) error {
|
||||
if err := e.config.Device.InjectInbound(packet); err != nil {
|
||||
_ = e.config.Control.Send(context.Background(), protocol.ControlStopSession, "", protocol.StopSessionPayload{
|
||||
SessionID: sessionID, Reason: string(protocol.ErrorSessionInjectFailed),
|
||||
})
|
||||
e.mu.Lock()
|
||||
if e.current != nil && e.current.id == sessionID {
|
||||
e.cleanupLocked()
|
||||
}
|
||||
e.mu.Unlock()
|
||||
e.notify(model.SessionFailed, sessionID, string(protocol.ErrorSessionInjectFailed))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) run() {
|
||||
defer close(e.done)
|
||||
listenerErrors := make(chan error, 1)
|
||||
go func() { listenerErrors <- e.listener.Run(e.ctx) }()
|
||||
ticker := time.NewTicker(e.config.StatsInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
_ = e.listener.Close()
|
||||
return
|
||||
case <-listenerErrors:
|
||||
e.cancel()
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.reportStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) reportStats() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.current == nil || e.current.status != model.SessionActive {
|
||||
return
|
||||
}
|
||||
uploadBytes, uploadPackets := e.current.router.RemoteCounters()
|
||||
rxBytes, rxPackets := e.listener.SessionCounters(e.current.id)
|
||||
counters := model.SessionCounters{
|
||||
UploadBytes: uploadBytes, UploadPackets: uploadPackets,
|
||||
DownloadBytes: rxBytes, DownloadPackets: rxPackets,
|
||||
}
|
||||
_ = e.config.Control.Send(e.ctx, protocol.ControlSessionStats, "", protocol.SessionStatsPayload{SessionID: e.current.id, Counters: counters})
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) cleanupLocked() {
|
||||
if e.current == nil {
|
||||
return
|
||||
}
|
||||
e.config.Device.SetPacketMux(nil)
|
||||
e.registry.Remove(e.current.id)
|
||||
if e.current.sender != nil {
|
||||
_ = e.current.sender.Close()
|
||||
}
|
||||
e.rollbackRoutes(e.current.routes)
|
||||
e.current = nil
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) rollbackRoutes(prefixes []netip.Prefix) {
|
||||
for index := len(prefixes) - 1; index >= 0; index-- {
|
||||
_ = e.config.Routes.RemoveRemote(prefixes[index])
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) notify(status model.SessionStatus, id uint64, reason string) {
|
||||
if e.config.OnSession != nil {
|
||||
e.config.OnSession(status, id, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineerRuntime) Close() error {
|
||||
e.closeOnce.Do(func() {
|
||||
e.cancel()
|
||||
e.mu.Lock()
|
||||
e.cleanupLocked()
|
||||
e.mu.Unlock()
|
||||
_ = e.listener.Close()
|
||||
<-e.done
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// Package session implements the Server-authoritative Remote Subnet state machine.
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/localization"
|
||||
"remlink/internal/logging"
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/protocol"
|
||||
)
|
||||
|
||||
const DefaultPrepareTimeout = 30 * time.Second
|
||||
|
||||
type Store interface {
|
||||
GetNode(context.Context, string) (model.Node, error)
|
||||
CreateSession(context.Context, model.Session) error
|
||||
GetSession(context.Context, uint64) (model.Session, error)
|
||||
UpdateSessionStatus(context.Context, uint64, model.SessionStatus, string) error
|
||||
UpdateSessionStats(context.Context, uint64, model.SessionCounters) error
|
||||
}
|
||||
|
||||
type Sender interface {
|
||||
Send(context.Context, string, protocol.ControlMessageType, any) error
|
||||
SendRequest(context.Context, string, protocol.ControlMessageType, string, any) error
|
||||
}
|
||||
|
||||
type eventAppender interface {
|
||||
AppendEvent(context.Context, model.EventLog) error
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
OverlayCIDR netip.Prefix
|
||||
MTU int
|
||||
UDPPort int
|
||||
PrepareTimeout time.Duration
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
store Store
|
||||
sender Sender
|
||||
config Config
|
||||
sessions map[uint64]*runtimeSession
|
||||
migrating bool
|
||||
}
|
||||
|
||||
type runtimeSession struct {
|
||||
session model.Session
|
||||
requestID string
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
func NewManager(store Store, sender Sender, config Config) (*Manager, error) {
|
||||
if store == nil || sender == nil {
|
||||
return nil, errors.New("Session Manager requires Store, Sender, and IPv4 Overlay CIDR")
|
||||
}
|
||||
if err := validateManagerConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.PrepareTimeout <= 0 {
|
||||
config.PrepareTimeout = DefaultPrepareTimeout
|
||||
}
|
||||
return &Manager{store: store, sender: sender, config: config, sessions: make(map[uint64]*runtimeSession)}, nil
|
||||
}
|
||||
|
||||
// ReconfigureNetwork switches the Server-authoritative values used by all
|
||||
// subsequently created Sessions after an Admin network migration. Callers must
|
||||
// close every nonterminal Session first; partially migrating live Sessions is
|
||||
// deliberately unsupported by v1.
|
||||
func (m *Manager) ReconfigureNetwork(overlayCIDR netip.Prefix, mtu, udpPort int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
next := Config{OverlayCIDR: overlayCIDR, MTU: mtu, UDPPort: udpPort, PrepareTimeout: m.config.PrepareTimeout}
|
||||
if err := validateManagerConfig(next); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m.sessions) != 0 {
|
||||
return errors.New("Session Manager network cannot change while Sessions are open")
|
||||
}
|
||||
m.config.OverlayCIDR = overlayCIDR.Masked()
|
||||
m.config.MTU = mtu
|
||||
m.config.UDPPort = udpPort
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginNetworkMigration atomically quiesces CREATE_SESSION and closes every
|
||||
// existing runtime Session. EndNetworkMigration must always be called by the
|
||||
// orchestrator, including on rollback paths.
|
||||
func (m *Manager) BeginNetworkMigration(ctx context.Context, reason string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.migrating {
|
||||
return errors.New("Session Manager network migration is already active")
|
||||
}
|
||||
m.migrating = true
|
||||
for _, runtime := range m.sessions {
|
||||
if err := m.closeLocked(ctx, runtime, reason); err != nil {
|
||||
m.migrating = false
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndNetworkMigration re-enables CREATE_SESSION after commit or rollback.
|
||||
func (m *Manager) EndNetworkMigration() {
|
||||
m.mu.Lock()
|
||||
m.migrating = false
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func validateManagerConfig(config Config) error {
|
||||
if !config.OverlayCIDR.IsValid() || !config.OverlayCIDR.Addr().Is4() || config.OverlayCIDR != config.OverlayCIDR.Masked() || config.OverlayCIDR.Bits() == 0 {
|
||||
return errors.New("Session Manager requires a canonical IPv4 Overlay CIDR")
|
||||
}
|
||||
if config.MTU < 576 || config.MTU > 65535 || config.UDPPort < 1 || config.UDPPort > 65535 {
|
||||
return errors.New("Session Manager requires valid MTU and UDP port")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleControl validates the authenticated sender role before changing state.
|
||||
func (m *Manager) HandleControl(ctx context.Context, node model.Node, envelope protocol.ControlEnvelope) error {
|
||||
switch envelope.Type {
|
||||
case protocol.ControlCreateSession:
|
||||
if node.Type != model.NodeTypeEngineer {
|
||||
return errors.New("CREATE_SESSION is Engineer-only")
|
||||
}
|
||||
var payload protocol.CreateSessionPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.create(ctx, node, envelope.RequestID, payload)
|
||||
case protocol.ControlPrepareResult:
|
||||
if node.Type != model.NodeTypeSite {
|
||||
return errors.New("PREPARE_RESULT is Site-only")
|
||||
}
|
||||
var payload protocol.PrepareResultPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.prepareResult(ctx, node, payload)
|
||||
case protocol.ControlRoutesReady:
|
||||
if node.Type != model.NodeTypeEngineer {
|
||||
return errors.New("ROUTES_READY is Engineer-only")
|
||||
}
|
||||
var payload protocol.RoutesReadyPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.routesReady(ctx, node, payload)
|
||||
case protocol.ControlStopSession:
|
||||
var payload protocol.StopSessionPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.stop(ctx, node, payload)
|
||||
case protocol.ControlSessionStats:
|
||||
var payload protocol.SessionStatsPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.stats(ctx, node, payload)
|
||||
default:
|
||||
return fmt.Errorf("%s is not a Node-to-Server Session message", envelope.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) create(ctx context.Context, engineer model.Node, requestID string, payload protocol.CreateSessionPayload) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.migrating {
|
||||
return m.rejectLocked(ctx, engineer.ID, requestID, protocol.ErrorServerUnreachable)
|
||||
}
|
||||
if m.hasOpenEngineerLocked(engineer.ID) {
|
||||
return m.rejectLocked(ctx, engineer.ID, requestID, protocol.ErrorEngineerSessionExists)
|
||||
}
|
||||
site, err := m.store.GetNode(ctx, payload.SiteNodeID)
|
||||
if err != nil || site.Type != model.NodeTypeSite || site.Status != model.NodeOnline {
|
||||
return m.rejectLocked(ctx, engineer.ID, requestID, protocol.ErrorSiteOffline)
|
||||
}
|
||||
prefixes, code := validateCIDRs(payload.TargetCIDRs, m.config.OverlayCIDR)
|
||||
if code != "" {
|
||||
return m.rejectLocked(ctx, engineer.ID, requestID, code)
|
||||
}
|
||||
sessionID, err := m.createPersistentLocked(ctx, engineer.ID, site.ID, prefixes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &runtimeSession{session: model.Session{
|
||||
ID: sessionID, EngineerNodeID: engineer.ID, SiteNodeID: site.ID,
|
||||
Status: model.SessionPreparingSite, CIDRs: prefixes, CreatedAt: time.Now().UTC(),
|
||||
}, requestID: requestID}
|
||||
m.sessions[sessionID] = runtime
|
||||
m.recordEvent(ctx, runtime, "INFO", "会话准备已开始", "")
|
||||
runtime.timer = time.AfterFunc(m.config.PrepareTimeout, func() { m.timeout(sessionID) })
|
||||
prepare := protocol.PrepareSessionPayload{
|
||||
SessionID: sessionID, EngineerOverlayIP: engineer.OverlayIP.String(), TargetCIDRs: prefixStrings(prefixes),
|
||||
}
|
||||
if err := m.sender.Send(ctx, site.ID, protocol.ControlPrepareSession, prepare); err != nil {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorSiteOffline)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) createPersistentLocked(ctx context.Context, engineerID, siteID string, prefixes []netip.Prefix) (uint64, error) {
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, fmt.Errorf("generate SessionID: %w", err)
|
||||
}
|
||||
id := binary.BigEndian.Uint64(raw[:])
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := m.store.GetSession(ctx, id); err == nil {
|
||||
continue
|
||||
} else if !errors.Is(err, database.ErrSessionNotFound) {
|
||||
return 0, err
|
||||
}
|
||||
session := model.Session{ID: id, EngineerNodeID: engineerID, SiteNodeID: siteID, Status: model.SessionCreating, CIDRs: prefixes}
|
||||
if err := m.store.CreateSession(ctx, session); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.store.UpdateSessionStatus(ctx, id, model.SessionPreparingSite, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
return 0, errors.New("could not allocate a unique random SessionID")
|
||||
}
|
||||
|
||||
func (m *Manager) prepareResult(ctx context.Context, site model.Node, payload protocol.PrepareResultPayload) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.sessions[payload.SessionID]
|
||||
if runtime == nil || runtime.session.SiteNodeID != site.ID || runtime.session.Status != model.SessionPreparingSite {
|
||||
return errors.New("PREPARE_RESULT does not match a preparing Session")
|
||||
}
|
||||
if !payload.OK {
|
||||
code := payload.ErrorCode
|
||||
if !code.Valid() {
|
||||
code = protocol.ErrorNetstackUnavailable
|
||||
}
|
||||
return m.failLocked(ctx, runtime, code)
|
||||
}
|
||||
if payload.SubnetGatewayStatus != "netstack" || payload.TCPCapacity < 1 || payload.UDPCapacity < 1 {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorNetstackUnavailable)
|
||||
}
|
||||
if !routeResultsMatch(runtime.session.CIDRs, payload.RouteResults) {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorSiteNoRoute)
|
||||
}
|
||||
engineer, err := m.store.GetNode(ctx, runtime.session.EngineerNodeID)
|
||||
if err != nil {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorServerUnreachable)
|
||||
}
|
||||
runtime.stopTimer()
|
||||
if err := m.setStatusLocked(ctx, runtime, model.SessionReady, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
config := protocol.SessionConfigPayload{
|
||||
SessionID: runtime.session.ID, PeerOverlayIP: site.OverlayIP.String(),
|
||||
CIDRs: prefixStrings(runtime.session.CIDRs), MTU: m.config.MTU, UDPPort: m.config.UDPPort,
|
||||
}
|
||||
if err := m.sender.SendRequest(ctx, engineer.ID, protocol.ControlSessionConfig, runtime.requestID, config); err != nil {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorServerUnreachable)
|
||||
}
|
||||
runtime.timer = time.AfterFunc(m.config.PrepareTimeout, func() { m.timeout(runtime.session.ID) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) routesReady(ctx context.Context, engineer model.Node, payload protocol.RoutesReadyPayload) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.sessions[payload.SessionID]
|
||||
if runtime == nil || runtime.session.EngineerNodeID != engineer.ID || runtime.session.Status != model.SessionReady {
|
||||
return errors.New("ROUTES_READY does not match a ready Session")
|
||||
}
|
||||
runtime.stopTimer()
|
||||
if err := m.setStatusLocked(ctx, runtime, model.SessionActive, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
active := protocol.SessionActivePayload{SessionID: runtime.session.ID}
|
||||
if err := m.sender.Send(ctx, runtime.session.SiteNodeID, protocol.ControlSessionActive, active); err != nil {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorSiteOffline)
|
||||
}
|
||||
if err := m.sender.Send(ctx, engineer.ID, protocol.ControlSessionActive, active); err != nil {
|
||||
return m.failLocked(ctx, runtime, protocol.ErrorServerUnreachable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) stop(ctx context.Context, node model.Node, payload protocol.StopSessionPayload) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.sessions[payload.SessionID]
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
if node.ID != runtime.session.EngineerNodeID && node.ID != runtime.session.SiteNodeID {
|
||||
return errors.New("Node is not a Session participant")
|
||||
}
|
||||
return m.closeLocked(ctx, runtime, payload.Reason)
|
||||
}
|
||||
|
||||
// Disconnect is used by the Admin API and follows the same bilateral cleanup path.
|
||||
func (m *Manager) Disconnect(ctx context.Context, sessionID uint64, reason string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.sessions[sessionID]
|
||||
if runtime == nil {
|
||||
return fmt.Errorf("%w: %d", database.ErrSessionNotFound, sessionID)
|
||||
}
|
||||
return m.closeLocked(ctx, runtime, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) DisconnectAll(ctx context.Context, reason string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, runtime := range m.sessions {
|
||||
if err := m.closeLocked(ctx, runtime, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) DisconnectNode(ctx context.Context, nodeID, reason string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, runtime := range m.sessions {
|
||||
if runtime.session.EngineerNodeID == nodeID || runtime.session.SiteNodeID == nodeID {
|
||||
if err := m.closeLocked(ctx, runtime, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleNodeStatusChange closes every Session involving a Node once the
|
||||
// heartbeat authority classifies it as OFFLINE. A Site outage is surfaced to
|
||||
// Engineer with the stable SITE_OFFLINE reason code.
|
||||
func (m *Manager) HandleNodeStatusChange(ctx context.Context, node model.Node, status model.NodeStatus) error {
|
||||
if status != model.NodeOffline {
|
||||
return nil
|
||||
}
|
||||
reason := string(protocol.ErrorServerUnreachable)
|
||||
if node.Type == model.NodeTypeSite {
|
||||
reason = string(protocol.ErrorSiteOffline)
|
||||
}
|
||||
return m.DisconnectNode(ctx, node.ID, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) closeLocked(ctx context.Context, runtime *runtimeSession, reason string) error {
|
||||
runtime.stopTimer()
|
||||
if err := m.setStatusLocked(ctx, runtime, model.SessionStopping, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := protocol.StopSessionPayload{SessionID: runtime.session.ID, Reason: reason}
|
||||
_ = m.sender.Send(ctx, runtime.session.EngineerNodeID, protocol.ControlStopSession, payload)
|
||||
_ = m.sender.Send(ctx, runtime.session.SiteNodeID, protocol.ControlStopSession, payload)
|
||||
if err := m.setStatusLocked(ctx, runtime, model.SessionClosed, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(m.sessions, runtime.session.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) stats(ctx context.Context, node model.Node, payload protocol.SessionStatsPayload) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.sessions[payload.SessionID]
|
||||
if runtime == nil || runtime.session.Status != model.SessionActive {
|
||||
return errors.New("SESSION_STATS requires an Active Session")
|
||||
}
|
||||
if node.ID != runtime.session.EngineerNodeID && node.ID != runtime.session.SiteNodeID {
|
||||
return errors.New("Node is not a Session participant")
|
||||
}
|
||||
// Engineer and Site report the same Engineer-view counters independently;
|
||||
// merge component-wise maxima so a delayed report can never move storage backward.
|
||||
merged := maxCounters(payload.Counters, runtime.session.Counters)
|
||||
if err := m.store.UpdateSessionStats(ctx, payload.SessionID, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.session.Counters = merged
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) timeout(sessionID uint64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.sessions[sessionID]
|
||||
if runtime == nil || (runtime.session.Status != model.SessionPreparingSite && runtime.session.Status != model.SessionReady) {
|
||||
return
|
||||
}
|
||||
_ = m.failLocked(context.Background(), runtime, protocol.ErrorSessionTimeout)
|
||||
}
|
||||
|
||||
func (m *Manager) failLocked(ctx context.Context, runtime *runtimeSession, code protocol.ErrorCode) error {
|
||||
runtime.stopTimer()
|
||||
payload := protocol.StopSessionPayload{SessionID: runtime.session.ID, Reason: string(code)}
|
||||
// Preserve the CREATE_SESSION correlation until the Engineer has received
|
||||
// SESSION_CONFIG. In particular, PREPARE rejection/timeout happens while the
|
||||
// Engineer only has a pending request and does not yet know the SessionID.
|
||||
_ = m.sender.SendRequest(ctx, runtime.session.EngineerNodeID, protocol.ControlStopSession, runtime.requestID, payload)
|
||||
_ = m.sender.Send(ctx, runtime.session.SiteNodeID, protocol.ControlStopSession, payload)
|
||||
err := m.setStatusLocked(ctx, runtime, model.SessionFailed, string(code))
|
||||
delete(m.sessions, runtime.session.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) rejectLocked(ctx context.Context, engineerID, requestID string, code protocol.ErrorCode) error {
|
||||
return m.sender.SendRequest(ctx, engineerID, protocol.ControlStopSession, requestID,
|
||||
protocol.StopSessionPayload{Reason: string(code)})
|
||||
}
|
||||
|
||||
func (m *Manager) setStatusLocked(ctx context.Context, runtime *runtimeSession, status model.SessionStatus, code string) error {
|
||||
if err := m.store.UpdateSessionStatus(ctx, runtime.session.ID, status, code); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.session.Status = status
|
||||
runtime.session.ErrorCode = code
|
||||
level := "INFO"
|
||||
if status == model.SessionFailed {
|
||||
level = "ERROR"
|
||||
}
|
||||
message := "会话状态变更为 " + localization.SessionStatus(string(status))
|
||||
if code != "" {
|
||||
message += ";原因:" + localization.Reason(code)
|
||||
}
|
||||
m.recordEvent(ctx, runtime, level, message, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) recordEvent(ctx context.Context, runtime *runtimeSession, level, message, code string) {
|
||||
appender, ok := m.store.(eventAppender)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fields, _ := json.Marshal(map[string]string{
|
||||
"engineer_node_id": runtime.session.EngineerNodeID,
|
||||
"site_node_id": runtime.session.SiteNodeID,
|
||||
"error_code": code,
|
||||
})
|
||||
_ = appender.AppendEvent(ctx, model.EventLog{
|
||||
Level: level, Module: string(logging.ModuleSession), SessionID: runtime.session.ID,
|
||||
Message: message, FieldsJSON: fields,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) hasOpenEngineerLocked(engineerID string) bool {
|
||||
for _, runtime := range m.sessions {
|
||||
if runtime.session.EngineerNodeID == engineerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *runtimeSession) stopTimer() {
|
||||
if r.timer != nil {
|
||||
r.timer.Stop()
|
||||
r.timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateCIDRs(raw []string, overlay netip.Prefix) ([]netip.Prefix, protocol.ErrorCode) {
|
||||
if len(raw) == 0 {
|
||||
return nil, protocol.ErrorCIDRInvalid
|
||||
}
|
||||
prefixes := make([]netip.Prefix, 0, len(raw))
|
||||
seen := make(map[netip.Prefix]struct{}, len(raw))
|
||||
for _, value := range raw {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil || !prefix.Addr().Is4() || prefix.Bits() == 0 || prefix != prefix.Masked() {
|
||||
return nil, protocol.ErrorCIDRInvalid
|
||||
}
|
||||
if prefixesOverlap(prefix, overlay) {
|
||||
return nil, protocol.ErrorCIDROverlayConflict
|
||||
}
|
||||
if _, duplicate := seen[prefix]; duplicate {
|
||||
return nil, protocol.ErrorCIDRInvalid
|
||||
}
|
||||
seen[prefix] = struct{}{}
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
return prefixes, ""
|
||||
}
|
||||
|
||||
func routeResultsMatch(prefixes []netip.Prefix, results []protocol.RouteResult) bool {
|
||||
if len(prefixes) != len(results) {
|
||||
return false
|
||||
}
|
||||
wanted := make(map[string]struct{}, len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
wanted[prefix.String()] = struct{}{}
|
||||
}
|
||||
for _, result := range results {
|
||||
if _, ok := wanted[result.CIDR]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(wanted, result.CIDR)
|
||||
switch result.Result {
|
||||
case "DIRECT", "ROUTED":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(wanted) == 0
|
||||
}
|
||||
|
||||
func prefixStrings(prefixes []netip.Prefix) []string {
|
||||
values := make([]string, len(prefixes))
|
||||
for index, prefix := range prefixes {
|
||||
values[index] = prefix.String()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func prefixesOverlap(left, right netip.Prefix) bool {
|
||||
return left.Contains(right.Addr()) || right.Contains(left.Addr())
|
||||
}
|
||||
|
||||
func maxCounters(next, previous model.SessionCounters) model.SessionCounters {
|
||||
if next.UploadBytes < previous.UploadBytes {
|
||||
next.UploadBytes = previous.UploadBytes
|
||||
}
|
||||
if next.DownloadBytes < previous.DownloadBytes {
|
||||
next.DownloadBytes = previous.DownloadBytes
|
||||
}
|
||||
if next.UploadPackets < previous.UploadPackets {
|
||||
next.UploadPackets = previous.UploadPackets
|
||||
}
|
||||
if next.DownloadPackets < previous.DownloadPackets {
|
||||
next.DownloadPackets = previous.DownloadPackets
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/protocol"
|
||||
)
|
||||
|
||||
func TestManagerFullLifecycleAndCounters(t *testing.T) {
|
||||
manager, store, sender, engineer, site := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
create := envelope(t, protocol.ControlCreateSession, "request-1", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.13.0/24", "172.20.0.0/16"},
|
||||
})
|
||||
if err := manager.HandleControl(ctx, engineer, create); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
stored, err := store.GetSession(ctx, prepare.SessionID)
|
||||
if err != nil || stored.Status != model.SessionPreparingSite {
|
||||
t.Fatalf("persisted preparing Session = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
if err := manager.HandleControl(ctx, engineer, create); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rejection := sender.lastForNode(t, protocol.ControlStopSession, engineer.ID)
|
||||
if rejection.requestID != "request-1" || rejection.payload.(protocol.StopSessionPayload).Reason != string(protocol.ErrorEngineerSessionExists) {
|
||||
t.Fatalf("second Session rejection = %+v", rejection)
|
||||
}
|
||||
|
||||
result := protocol.PrepareResultPayload{
|
||||
SessionID: prepare.SessionID, OK: true, SubnetGatewayStatus: "netstack", TCPCapacity: 2048, UDPCapacity: 4096,
|
||||
RouteResults: []protocol.RouteResult{{CIDR: "192.168.13.0/24", Result: "DIRECT"}, {CIDR: "172.20.0.0/16", Result: "ROUTED"}},
|
||||
}
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlPrepareResult, "", result)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configuration := sender.last(t, protocol.ControlSessionConfig)
|
||||
if configuration.nodeID != engineer.ID || configuration.requestID != "request-1" {
|
||||
t.Fatalf("SESSION_CONFIG routing = %+v", configuration)
|
||||
}
|
||||
if got := configuration.payload.(protocol.SessionConfigPayload); got.PeerOverlayIP != site.OverlayIP.String() || got.UDPPort != 51821 || len(got.CIDRs) != 2 {
|
||||
t.Fatalf("SESSION_CONFIG = %+v", got)
|
||||
}
|
||||
stored, _ = store.GetSession(ctx, prepare.SessionID)
|
||||
if stored.Status != model.SessionReady {
|
||||
t.Fatalf("status = %s, want READY", stored.Status)
|
||||
}
|
||||
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlRoutesReady, "", protocol.RoutesReadyPayload{SessionID: prepare.SessionID})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ = store.GetSession(ctx, prepare.SessionID)
|
||||
if stored.Status != model.SessionActive || stored.ActiveAt == nil || sender.count(protocol.ControlSessionActive) != 2 {
|
||||
t.Fatalf("Active Session = %+v, active notifications=%d", stored, sender.count(protocol.ControlSessionActive))
|
||||
}
|
||||
|
||||
counters := model.SessionCounters{UploadBytes: 1234, DownloadBytes: 5678, UploadPackets: 12, DownloadPackets: 34}
|
||||
stats := envelope(t, protocol.ControlSessionStats, "", protocol.SessionStatsPayload{SessionID: prepare.SessionID, Counters: counters})
|
||||
if err := manager.HandleControl(ctx, engineer, stats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ = store.GetSession(ctx, prepare.SessionID)
|
||||
if stored.Counters != counters {
|
||||
t.Fatalf("persisted counters = %+v", stored.Counters)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlSessionStats, "", protocol.SessionStatsPayload{
|
||||
SessionID: prepare.SessionID, Counters: model.SessionCounters{UploadBytes: 1},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ = store.GetSession(ctx, prepare.SessionID)
|
||||
if stored.Counters != counters {
|
||||
t.Fatalf("stale report moved counters backward: %+v", stored.Counters)
|
||||
}
|
||||
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlStopSession, "", protocol.StopSessionPayload{
|
||||
SessionID: prepare.SessionID, Reason: "operator",
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ = store.GetSession(ctx, prepare.SessionID)
|
||||
if stored.Status != model.SessionClosed || stored.ClosedAt == nil {
|
||||
t.Fatalf("closed Session = %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDisconnectNodeClosesStaleRuntimeAndAllowsReconnect(t *testing.T) {
|
||||
manager, store, sender, engineer, site := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "before-restart", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.13.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlPrepareResult, "", protocol.PrepareResultPayload{
|
||||
SessionID: prepare.SessionID, OK: true, SubnetGatewayStatus: "netstack", TCPCapacity: 1, UDPCapacity: 1,
|
||||
RouteResults: []protocol.RouteResult{{CIDR: "192.168.13.0/24", Result: "DIRECT"}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlRoutesReady, "", protocol.RoutesReadyPayload{SessionID: prepare.SessionID})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.DisconnectNode(ctx, engineer.ID, "NODE_RUNTIME_REBUILT"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := store.GetSession(ctx, prepare.SessionID)
|
||||
if err != nil || stored.Status != model.SessionClosed || sender.count(protocol.ControlStopSession) != 2 {
|
||||
t.Fatalf("reconciled Session=%+v err=%v STOP count=%d", stored, err, sender.count(protocol.ControlStopSession))
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "after-restart", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.21.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatalf("Node remained locked by stale Session after Bootstrap reconciliation: %v", err)
|
||||
}
|
||||
if next := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload); next.SessionID == prepare.SessionID || sender.count(protocol.ControlPrepareSession) != 2 {
|
||||
t.Fatalf("new preparation was not accepted after reconciliation: old=%d new=%d count=%d", prepare.SessionID, next.SessionID, sender.count(protocol.ControlPrepareSession))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerClosesActiveSessionWhenSiteBecomesOffline(t *testing.T) {
|
||||
manager, store, sender, engineer, site := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "site-offline", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.17.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlPrepareResult, "", protocol.PrepareResultPayload{
|
||||
SessionID: prepare.SessionID, OK: true, SubnetGatewayStatus: "netstack", TCPCapacity: 1, UDPCapacity: 1,
|
||||
RouteResults: []protocol.RouteResult{{CIDR: "192.168.17.0/24", Result: "DIRECT"}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlRoutesReady, "", protocol.RoutesReadyPayload{SessionID: prepare.SessionID})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateNodeStatus(ctx, site.ID, model.NodeOffline); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
site.Status = model.NodeOffline
|
||||
if err := manager.HandleNodeStatusChange(ctx, site, model.NodeOffline); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := store.GetSession(ctx, prepare.SessionID)
|
||||
if err != nil || stored.Status != model.SessionClosed || stored.ClosedAt == nil {
|
||||
t.Fatalf("offline Site Session = %+v, %v", stored, err)
|
||||
}
|
||||
stop := sender.lastForNode(t, protocol.ControlStopSession, engineer.ID).payload.(protocol.StopSessionPayload)
|
||||
if stop.SessionID != prepare.SessionID || stop.Reason != string(protocol.ErrorSiteOffline) {
|
||||
t.Fatalf("Engineer STOP_SESSION = %+v", stop)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "after-offline", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.107.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sender.lastForNode(t, protocol.ControlStopSession, engineer.ID).payload.(protocol.StopSessionPayload).Reason; got != string(protocol.ErrorSiteOffline) {
|
||||
t.Fatalf("offline Site reconnect reason = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsInvalidCIDRAndTimesOut(t *testing.T) {
|
||||
manager, store, sender, engineer, site := newManagerTest(t, 20*time.Millisecond)
|
||||
ctx := context.Background()
|
||||
invalid := envelope(t, protocol.ControlCreateSession, "bad", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"10.88.4.0/24"},
|
||||
})
|
||||
if err := manager.HandleControl(ctx, engineer, invalid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sender.last(t, protocol.ControlStopSession).payload.(protocol.StopSessionPayload).Reason; got != string(protocol.ErrorCIDROverlayConflict) {
|
||||
t.Fatalf("invalid CIDR reason = %s", got)
|
||||
}
|
||||
|
||||
valid := envelope(t, protocol.ControlCreateSession, "timeout", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.13.0/24"},
|
||||
})
|
||||
if err := manager.HandleControl(ctx, engineer, valid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
stored, err := store.GetSession(ctx, prepare.SessionID)
|
||||
if err == nil && stored.Status == model.SessionFailed {
|
||||
if stored.ErrorCode != string(protocol.ErrorSessionTimeout) {
|
||||
t.Fatalf("timeout error code = %s", stored.ErrorCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("Session did not reach FAILED after prepare timeout")
|
||||
}
|
||||
|
||||
func TestManagerRejectsDefaultOnlyPrepareResult(t *testing.T) {
|
||||
manager, store, sender, engineer, site := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "default-only", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.13.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlPrepareResult, "", protocol.PrepareResultPayload{
|
||||
SessionID: prepare.SessionID, OK: true, SubnetGatewayStatus: "netstack", TCPCapacity: 2048, UDPCapacity: 4096,
|
||||
RouteResults: []protocol.RouteResult{{CIDR: "192.168.13.0/24", Result: "DEFAULT_ONLY"}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := store.GetSession(ctx, prepare.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Status != model.SessionFailed || stored.ErrorCode != string(protocol.ErrorSiteNoRoute) {
|
||||
t.Fatalf("Session = %+v, want FAILED/%s", stored, protocol.ErrorSiteNoRoute)
|
||||
}
|
||||
rejection := sender.lastForNode(t, protocol.ControlStopSession, engineer.ID)
|
||||
if rejection.nodeID != engineer.ID || rejection.requestID != "default-only" || rejection.payload.(protocol.StopSessionPayload).SessionID != prepare.SessionID {
|
||||
t.Fatalf("PREPARE rejection lost CREATE correlation: %+v", rejection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRunsConcurrentDuplicateCIDRSessions(t *testing.T) {
|
||||
manager, store, sender, engineerA, siteA := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
createOnlineNode := func(id string, nodeType model.NodeType, address string) model.Node {
|
||||
node := model.Node{ID: id, Type: nodeType, Name: id, OverlayIP: netip.MustParseAddr(address), WGPublicKey: id + "-key", NodeTokenHash: []byte(id)}
|
||||
if err := store.CreateNode(ctx, node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateNodeHeartbeat(ctx, id, model.NodeOnline, time.Now().UTC(), "1.0", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node, _ = store.GetNode(ctx, id)
|
||||
return node
|
||||
}
|
||||
engineerB := createOnlineNode("engineer-b", model.NodeTypeEngineer, "10.88.0.4")
|
||||
engineerC := createOnlineNode("engineer-c", model.NodeTypeEngineer, "10.88.0.5")
|
||||
siteB := createOnlineNode("site-b", model.NodeTypeSite, "10.88.0.6")
|
||||
|
||||
activate := func(engineer, site model.Node, requestID string) uint64 {
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, requestID, protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.13.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlPrepareResult, "", protocol.PrepareResultPayload{
|
||||
SessionID: prepare.SessionID, OK: true, SubnetGatewayStatus: "netstack", TCPCapacity: 2048, UDPCapacity: 4096,
|
||||
RouteResults: []protocol.RouteResult{{CIDR: "192.168.13.0/24", Result: "DIRECT"}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlRoutesReady, "", protocol.RoutesReadyPayload{SessionID: prepare.SessionID})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return prepare.SessionID
|
||||
}
|
||||
idA := activate(engineerA, siteA, "a")
|
||||
idB := activate(engineerB, siteB, "b")
|
||||
idC := activate(engineerC, siteA, "c")
|
||||
if idA == idB || idA == idC || idB == idC {
|
||||
t.Fatalf("SessionIDs are not unique: %d %d %d", idA, idB, idC)
|
||||
}
|
||||
sessions, err := store.ListSessions(ctx)
|
||||
if err != nil || len(sessions) != 3 {
|
||||
t.Fatalf("Sessions = %+v, %v", sessions, err)
|
||||
}
|
||||
for _, current := range sessions {
|
||||
if current.Status != model.SessionActive || len(current.CIDRs) != 1 || current.CIDRs[0].String() != "192.168.13.0/24" {
|
||||
t.Fatalf("unexpected concurrent Session: %+v", current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerUsesMigratedNetworkForNewSessions(t *testing.T) {
|
||||
manager, store, sender, engineer, site := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
newOverlay := netip.MustParsePrefix("10.99.0.0/24")
|
||||
if err := manager.ReconfigureNetwork(newOverlay, 1400, 6300); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateNodeOverlayIP(ctx, engineer.ID, netip.MustParseAddr("10.99.0.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateNodeOverlayIP(ctx, site.ID, netip.MustParseAddr("10.99.0.3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engineer, _ = store.GetNode(ctx, engineer.ID)
|
||||
site, _ = store.GetNode(ctx, site.ID)
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "new-overlay-conflict", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"10.99.0.0/28"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sender.lastForNode(t, protocol.ControlStopSession, engineer.ID).payload.(protocol.StopSessionPayload).Reason; got != string(protocol.ErrorCIDROverlayConflict) {
|
||||
t.Fatalf("migrated Overlay conflict reason = %s", got)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "after-migration", protocol.CreateSessionPayload{
|
||||
SiteNodeID: site.ID, TargetCIDRs: []string{"10.88.0.0/24"},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload)
|
||||
if prepare.EngineerOverlayIP != "10.99.0.2" {
|
||||
t.Fatalf("PREPARE Engineer IP = %s", prepare.EngineerOverlayIP)
|
||||
}
|
||||
if err := manager.HandleControl(ctx, site, envelope(t, protocol.ControlPrepareResult, "", protocol.PrepareResultPayload{
|
||||
SessionID: prepare.SessionID, OK: true, SubnetGatewayStatus: "netstack", TCPCapacity: 2048, UDPCapacity: 4096,
|
||||
RouteResults: []protocol.RouteResult{{CIDR: "10.88.0.0/24", Result: "DIRECT"}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configured := sender.last(t, protocol.ControlSessionConfig).payload.(protocol.SessionConfigPayload)
|
||||
if configured.MTU != 1400 || configured.UDPPort != 6300 {
|
||||
t.Fatalf("SESSION_CONFIG retained stale network values: %+v", configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerQuiescesSessionCreationDuringNetworkMigration(t *testing.T) {
|
||||
manager, _, sender, engineer, site := newManagerTest(t, time.Second)
|
||||
ctx := context.Background()
|
||||
if err := manager.BeginNetworkMigration(ctx, "NETWORK_CONFIG_CHANGED"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
create := protocol.CreateSessionPayload{SiteNodeID: site.ID, TargetCIDRs: []string{"192.168.13.0/24"}}
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "during-migration", create)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rejection := sender.lastForNode(t, protocol.ControlStopSession, engineer.ID)
|
||||
if rejection.requestID != "during-migration" || rejection.payload.(protocol.StopSessionPayload).Reason != string(protocol.ErrorServerUnreachable) {
|
||||
t.Fatalf("migration rejection = %+v", rejection)
|
||||
}
|
||||
manager.EndNetworkMigration()
|
||||
if err := manager.HandleControl(ctx, engineer, envelope(t, protocol.ControlCreateSession, "after-migration", create)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sender.last(t, protocol.ControlPrepareSession).payload.(protocol.PrepareSessionPayload).SessionID == 0 {
|
||||
t.Fatal("Session creation remained quiesced after migration")
|
||||
}
|
||||
}
|
||||
|
||||
type sentMessage struct {
|
||||
nodeID, requestID string
|
||||
messageType protocol.ControlMessageType
|
||||
payload any
|
||||
}
|
||||
|
||||
type fakeSender struct {
|
||||
mu sync.Mutex
|
||||
messages []sentMessage
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(_ context.Context, nodeID string, messageType protocol.ControlMessageType, payload any) error {
|
||||
return f.SendRequest(context.Background(), nodeID, messageType, "", payload)
|
||||
}
|
||||
|
||||
func (f *fakeSender) SendRequest(_ context.Context, nodeID string, messageType protocol.ControlMessageType, requestID string, payload any) error {
|
||||
f.mu.Lock()
|
||||
f.messages = append(f.messages, sentMessage{nodeID: nodeID, requestID: requestID, messageType: messageType, payload: payload})
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSender) last(t *testing.T, messageType protocol.ControlMessageType) sentMessage {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for index := len(f.messages) - 1; index >= 0; index-- {
|
||||
if f.messages[index].messageType == messageType {
|
||||
return f.messages[index]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s message", messageType)
|
||||
return sentMessage{}
|
||||
}
|
||||
|
||||
func (f *fakeSender) lastForNode(t *testing.T, messageType protocol.ControlMessageType, nodeID string) sentMessage {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for index := len(f.messages) - 1; index >= 0; index-- {
|
||||
if f.messages[index].messageType == messageType && f.messages[index].nodeID == nodeID {
|
||||
return f.messages[index]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s message for Node %s", messageType, nodeID)
|
||||
return sentMessage{}
|
||||
}
|
||||
|
||||
func (f *fakeSender) count(messageType protocol.ControlMessageType) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
count := 0
|
||||
for _, message := range f.messages {
|
||||
if message.messageType == messageType {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func newManagerTest(t *testing.T, timeout time.Duration) (*Manager, *database.Store, *fakeSender, model.Node, model.Node) {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
store := database.NewStore(db)
|
||||
engineer := model.Node{ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer", OverlayIP: netip.MustParseAddr("10.88.0.2"), WGPublicKey: "engineer-key", NodeTokenHash: []byte("a")}
|
||||
site := model.Node{ID: "site", Type: model.NodeTypeSite, Name: "Site", OverlayIP: netip.MustParseAddr("10.88.0.3"), WGPublicKey: "site-key", NodeTokenHash: []byte("b")}
|
||||
for _, node := range []model.Node{engineer, site} {
|
||||
if err := store.CreateNode(context.Background(), node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateNodeHeartbeat(context.Background(), node.ID, model.NodeOnline, time.Now().UTC(), "1.0", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engineer, _ = store.GetNode(context.Background(), engineer.ID)
|
||||
site, _ = store.GetNode(context.Background(), site.ID)
|
||||
sender := &fakeSender{}
|
||||
manager, err := NewManager(store, sender, Config{
|
||||
OverlayCIDR: netip.MustParsePrefix("10.88.0.0/16"), MTU: 1280, UDPPort: 51821, PrepareTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manager, store, sender, engineer, site
|
||||
}
|
||||
|
||||
func envelope(t *testing.T, messageType protocol.ControlMessageType, requestID string, payload any) protocol.ControlEnvelope {
|
||||
t.Helper()
|
||||
envelope, err := protocol.NewControlEnvelope(messageType, requestID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"remlink/internal/overlay/clientwg"
|
||||
"remlink/internal/platform/windows/route"
|
||||
"remlink/internal/protocol"
|
||||
"remlink/internal/subnetgateway"
|
||||
)
|
||||
|
||||
func TestEngineerRuntimePreflightConfigureActivateStop(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.1")
|
||||
routes := &fakeRoutes{}
|
||||
device := &fakePacketDevice{}
|
||||
control := &fakeNodeControl{}
|
||||
runtime, err := NewEngineerRuntime(context.Background(), EngineerConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.1"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
UDPPort: port, StatsInterval: time.Hour, Routes: routes, Device: device, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
requestID, err := runtime.CreateSession(context.Background(), "site", []string{"192.168.13.0/24"})
|
||||
if err != nil || requestID == "" || control.last(t, protocol.ControlCreateSession).requestID != requestID {
|
||||
t.Fatalf("CreateSession = %q, %v", requestID, err)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlSessionConfig, requestID, protocol.SessionConfigPayload{
|
||||
SessionID: 42, PeerOverlayIP: "127.0.0.2", CIDRs: []string{"192.168.13.0/24"}, MTU: 1280, UDPPort: port,
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(routes.added) != 1 || device.router == nil || control.last(t, protocol.ControlRoutesReady).payload.(protocol.RoutesReadyPayload).SessionID != 42 {
|
||||
t.Fatalf("Engineer READY wiring routes=%v router=%v", routes.added, device.router)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlSessionActive, "", protocol.SessionActivePayload{SessionID: 42})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.Disconnect(context.Background(), "operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := control.last(t, protocol.ControlStopSession).payload.(protocol.StopSessionPayload); got.SessionID != 42 {
|
||||
t.Fatalf("Disconnect payload = %+v", got)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlStopSession, "", protocol.StopSessionPayload{SessionID: 42, Reason: "operator"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if device.router != nil || len(routes.removed) != 1 {
|
||||
t.Fatalf("Engineer cleanup router=%v removed=%v", device.router, routes.removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineerRuntimeRejectsLocalConflictBeforeControl(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.1")
|
||||
routes := &fakeRoutes{conflict: true}
|
||||
control := &fakeNodeControl{}
|
||||
runtime, err := NewEngineerRuntime(context.Background(), EngineerConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.1"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
UDPPort: port, Routes: routes, Device: &fakePacketDevice{}, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if err := runtime.PreflightCIDRs([]string{"192.168.13.0/24"}); err == nil {
|
||||
t.Fatal("preflight accepted local route conflict")
|
||||
}
|
||||
if control.count(protocol.ControlCreateSession) != 0 {
|
||||
t.Fatal("preflight changed Control state")
|
||||
}
|
||||
if _, err := runtime.CreateSession(context.Background(), "site", []string{"192.168.13.0/24"}); err == nil {
|
||||
t.Fatal("local route conflict was accepted")
|
||||
}
|
||||
if control.count(protocol.ControlCreateSession) != 0 {
|
||||
t.Fatal("CREATE_SESSION was sent despite local conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineerRuntimePrepareFailureReleasesOnlyMatchingPendingRequest(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.1")
|
||||
control := &fakeNodeControl{}
|
||||
runtime, err := NewEngineerRuntime(context.Background(), EngineerConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.1"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
UDPPort: port, Routes: &fakeRoutes{}, Device: &fakePacketDevice{}, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
requestID, err := runtime.CreateSession(context.Background(), "site", []string{"192.168.13.0/24"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rejection := protocol.StopSessionPayload{SessionID: 81, Reason: string(protocol.ErrorSiteNoRoute)}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlStopSession, "stale-request", rejection)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runtime.CreateSession(context.Background(), "site", []string{"192.168.21.0/24"}); err == nil {
|
||||
t.Fatal("stale rejection released the active pending request")
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlStopSession, requestID, rejection)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runtime.CreateSession(context.Background(), "site", []string{"192.168.21.0/24"}); err != nil {
|
||||
t.Fatalf("matching PREPARE rejection did not release pending state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRuntimePrepareActivateAndCleanup(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.2")
|
||||
control := &fakeNodeControl{}
|
||||
gateway := &fakeGateway{}
|
||||
runtime, err := NewSiteRuntime(context.Background(), SiteConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.2"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
UDPPort: port, StatsInterval: time.Hour, Routes: &fakeRoutes{lookup: route.LookupDirect},
|
||||
Gateway: gateway, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
prepare := protocol.PrepareSessionPayload{SessionID: 77, EngineerOverlayIP: "127.0.0.1", TargetCIDRs: []string{"192.168.13.0/24"}}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlPrepareSession, "", prepare)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := control.last(t, protocol.ControlPrepareResult).payload.(protocol.PrepareResultPayload)
|
||||
if !result.OK || result.SubnetGatewayStatus != "netstack" || gateway.prepared != 77 || len(result.RouteResults) != 1 {
|
||||
t.Fatalf("PREPARE_RESULT=%+v gateway=%d", result, gateway.prepared)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlSessionActive, "", protocol.SessionActivePayload{SessionID: 77})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.Egress(context.Background(), 77, testRuntimeIPv4("192.168.13.10", "127.0.0.1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlStopSession, "", protocol.StopSessionPayload{SessionID: 77})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gateway.closed != 77 {
|
||||
t.Fatalf("closed gateway Session = %d", gateway.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRuntimeRejectsDefaultOnlyRoute(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.2")
|
||||
control := &fakeNodeControl{}
|
||||
gateway := &fakeGateway{}
|
||||
runtime, err := NewSiteRuntime(context.Background(), SiteConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.2"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
UDPPort: port, StatsInterval: time.Hour, Routes: &fakeRoutes{lookup: route.LookupDefaultOnly},
|
||||
Gateway: gateway, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
prepare := protocol.PrepareSessionPayload{SessionID: 78, EngineerOverlayIP: "127.0.0.1", TargetCIDRs: []string{"192.168.13.0/24"}}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlPrepareSession, "", prepare)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := control.last(t, protocol.ControlPrepareResult).payload.(protocol.PrepareResultPayload)
|
||||
if result.OK || result.ErrorCode != protocol.ErrorSiteNoRoute || gateway.prepared != 0 {
|
||||
t.Fatalf("PREPARE_RESULT=%+v gateway=%d", result, gateway.prepared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRuntimeRejectsPrepareAtFlowCapacity(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
tcpFlows int
|
||||
udpFlows int
|
||||
tcpLimit int
|
||||
udpLimit int
|
||||
}{
|
||||
{name: "tcp", tcpFlows: 2, tcpLimit: 2, udpLimit: 4},
|
||||
{name: "udp", udpFlows: 4, tcpLimit: 2, udpLimit: 4},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.2")
|
||||
control := &fakeNodeControl{}
|
||||
gateway := &fakeGateway{tcpFlows: test.tcpFlows, udpFlows: test.udpFlows}
|
||||
runtime, err := NewSiteRuntime(context.Background(), SiteConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.2"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"),
|
||||
UDPPort: port, TCPFlowLimit: test.tcpLimit, UDPFlowLimit: test.udpLimit, StatsInterval: time.Hour,
|
||||
Routes: &fakeRoutes{lookup: route.LookupDirect}, Gateway: gateway, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
prepare := protocol.PrepareSessionPayload{SessionID: 79, EngineerOverlayIP: "127.0.0.1", TargetCIDRs: []string{"192.168.13.0/24"}}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlPrepareSession, "", prepare)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := control.last(t, protocol.ControlPrepareResult).payload.(protocol.PrepareResultPayload)
|
||||
if result.OK || result.ErrorCode != protocol.ErrorFlowLimitReached || gateway.prepared != 0 {
|
||||
t.Fatalf("PREPARE_RESULT=%+v gateway=%d", result, gateway.prepared)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineerRuntimeInjectionFailureStopsOnlySession(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.1")
|
||||
control := &fakeNodeControl{}
|
||||
device := &fakePacketDevice{injectErr: errors.New("wintun write failed")}
|
||||
runtime, err := NewEngineerRuntime(context.Background(), EngineerConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.1"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"), UDPPort: port,
|
||||
StatsInterval: time.Hour, Routes: &fakeRoutes{}, Device: device, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if _, err := runtime.CreateSession(context.Background(), "site", []string{"192.168.13.0/24"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlSessionConfig, control.last(t, protocol.ControlCreateSession).requestID, protocol.SessionConfigPayload{
|
||||
SessionID: 91, PeerOverlayIP: "127.0.0.2", CIDRs: []string{"192.168.13.0/24"}, MTU: 1280, UDPPort: port,
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.inject(context.Background(), 91, testRuntimeIPv4("192.168.13.10", "127.0.0.1")); err == nil {
|
||||
t.Fatal("injection failure was hidden")
|
||||
}
|
||||
stop := control.last(t, protocol.ControlStopSession).payload.(protocol.StopSessionPayload)
|
||||
if stop.Reason != string(protocol.ErrorSessionInjectFailed) || runtime.Snapshot().ID != 0 || device.router != nil {
|
||||
t.Fatalf("STOP=%+v snapshot=%+v router=%v", stop, runtime.Snapshot(), device.router)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRuntimeInjectionFailureClosesGatewaySession(t *testing.T) {
|
||||
port := availableUDPPort(t, "127.0.0.2")
|
||||
control := &fakeNodeControl{}
|
||||
gateway := &fakeGateway{injectErr: errors.New("netstack inject failed")}
|
||||
runtime, err := NewSiteRuntime(context.Background(), SiteConfig{
|
||||
LocalOverlayIP: netip.MustParseAddr("127.0.0.2"), OverlayCIDR: netip.MustParsePrefix("127.0.0.0/8"), UDPPort: port,
|
||||
StatsInterval: time.Hour, Routes: &fakeRoutes{lookup: route.LookupDirect}, Gateway: gateway, Control: control,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
prepare := protocol.PrepareSessionPayload{SessionID: 92, EngineerOverlayIP: "127.0.0.1", TargetCIDRs: []string{"192.168.13.0/24"}}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlPrepareSession, "", prepare)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.HandleControl(context.Background(), envelope(t, protocol.ControlSessionActive, "", protocol.SessionActivePayload{SessionID: 92})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runtime.inject(context.Background(), 92, testRuntimeIPv4("127.0.0.1", "192.168.13.10")); err == nil {
|
||||
t.Fatal("injection failure was hidden")
|
||||
}
|
||||
stop := control.last(t, protocol.ControlStopSession).payload.(protocol.StopSessionPayload)
|
||||
if stop.Reason != string(protocol.ErrorSessionInjectFailed) || gateway.closed != 92 {
|
||||
t.Fatalf("STOP=%+v gateway.closed=%d", stop, gateway.closed)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNodeControl struct {
|
||||
mu sync.Mutex
|
||||
messages []nodeMessage
|
||||
}
|
||||
|
||||
type nodeMessage struct {
|
||||
messageType protocol.ControlMessageType
|
||||
requestID string
|
||||
payload any
|
||||
}
|
||||
|
||||
func (f *fakeNodeControl) Send(_ context.Context, messageType protocol.ControlMessageType, requestID string, payload any) error {
|
||||
f.mu.Lock()
|
||||
f.messages = append(f.messages, nodeMessage{messageType: messageType, requestID: requestID, payload: payload})
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNodeControl) last(t *testing.T, messageType protocol.ControlMessageType) nodeMessage {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for index := len(f.messages) - 1; index >= 0; index-- {
|
||||
if f.messages[index].messageType == messageType {
|
||||
return f.messages[index]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s message", messageType)
|
||||
return nodeMessage{}
|
||||
}
|
||||
|
||||
func (f *fakeNodeControl) count(messageType protocol.ControlMessageType) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
count := 0
|
||||
for _, message := range f.messages {
|
||||
if message.messageType == messageType {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type fakePacketDevice struct {
|
||||
router *clientwg.PacketMux
|
||||
injectErr error
|
||||
}
|
||||
|
||||
func (f *fakePacketDevice) SetPacketMux(router *clientwg.PacketMux) { f.router = router }
|
||||
func (f *fakePacketDevice) InjectInbound([]byte) error { return f.injectErr }
|
||||
|
||||
type fakeRoutes struct {
|
||||
added, removed []netip.Prefix
|
||||
conflict bool
|
||||
lookup route.LookupResult
|
||||
}
|
||||
|
||||
func (f *fakeRoutes) AddRemote(prefix netip.Prefix) error {
|
||||
f.added = append(f.added, prefix)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRoutes) RemoveRemote(prefix netip.Prefix) error {
|
||||
f.removed = append(f.removed, prefix)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRoutes) Conflicts(prefix netip.Prefix) ([]route.Entry, error) {
|
||||
if f.conflict {
|
||||
return []route.Entry{{Destination: netip.MustParsePrefix("192.168.0.0/16")}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeRoutes) Reconcile() error { return nil }
|
||||
func (f *fakeRoutes) Lookup(netip.Addr) (route.LookupResult, error) {
|
||||
if f.lookup == "" {
|
||||
return route.LookupNoRoute, nil
|
||||
}
|
||||
return f.lookup, nil
|
||||
}
|
||||
|
||||
type fakeGateway struct {
|
||||
prepared, closed uint64
|
||||
injectErr error
|
||||
tcpFlows int
|
||||
udpFlows int
|
||||
}
|
||||
|
||||
func (f *fakeGateway) Prepare(_ context.Context, config subnetgateway.SessionConfig) error {
|
||||
f.prepared = config.SessionID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGateway) InjectIPv4(context.Context, uint64, []byte) error { return f.injectErr }
|
||||
func (f *fakeGateway) CloseSession(_ context.Context, id uint64) error {
|
||||
f.closed = id
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGateway) FlowCounts() (int, int) { return f.tcpFlows, f.udpFlows }
|
||||
func (*fakeGateway) Close() error { return nil }
|
||||
|
||||
func availableUDPPort(t *testing.T, host string) int {
|
||||
t.Helper()
|
||||
connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP(host), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := connection.LocalAddr().(*net.UDPAddr).Port
|
||||
if err := connection.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func testRuntimeIPv4(sourceText, destinationText string) []byte {
|
||||
packet := make([]byte, 20)
|
||||
packet[0] = 0x45
|
||||
packet[3] = 20
|
||||
source := netip.MustParseAddr(sourceText).As4()
|
||||
destination := netip.MustParseAddr(destinationText).As4()
|
||||
copy(packet[12:16], source[:])
|
||||
copy(packet[16:20], destination[:])
|
||||
return packet
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/platform/windows/route"
|
||||
"remlink/internal/protocol"
|
||||
"remlink/internal/subnet"
|
||||
"remlink/internal/subnetgateway"
|
||||
)
|
||||
|
||||
type SiteRoutes interface {
|
||||
Lookup(netip.Addr) (route.LookupResult, error)
|
||||
}
|
||||
|
||||
type SiteGateway interface {
|
||||
subnetgateway.SubnetGateway
|
||||
FlowCounts() (int, int)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type SiteConfig struct {
|
||||
LocalOverlayIP netip.Addr
|
||||
OverlayCIDR netip.Prefix
|
||||
UDPPort int
|
||||
TCPFlowLimit int
|
||||
UDPFlowLimit int
|
||||
StatsInterval time.Duration
|
||||
Routes SiteRoutes
|
||||
Gateway SiteGateway
|
||||
Control ControlSender
|
||||
OnSession func(model.SessionStatus, uint64, string)
|
||||
OnRoute func(uint64, netip.Prefix, route.LookupResult)
|
||||
OnPacketReject subnet.RejectHandler
|
||||
}
|
||||
|
||||
type SiteRuntime struct {
|
||||
mu sync.Mutex
|
||||
config SiteConfig
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
registry *subnet.Registry
|
||||
listener *subnet.Listener
|
||||
sessions map[uint64]*siteSession
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type siteSession struct {
|
||||
id uint64
|
||||
engineer netip.Addr
|
||||
prefixes []netip.Prefix
|
||||
sender *subnet.Sender
|
||||
status model.SessionStatus
|
||||
}
|
||||
|
||||
func NewSiteRuntime(parent context.Context, config SiteConfig) (*SiteRuntime, error) {
|
||||
if !config.LocalOverlayIP.Is4() || !config.OverlayCIDR.IsValid() || !config.OverlayCIDR.Addr().Is4() ||
|
||||
config.UDPPort < 1 || config.UDPPort > 65535 || config.Routes == nil || config.Gateway == nil || config.Control == nil {
|
||||
return nil, errors.New("Site runtime requires Overlay addressing, UDP port, routes, netstack gateway, and Control")
|
||||
}
|
||||
if config.TCPFlowLimit <= 0 {
|
||||
config.TCPFlowLimit = 2048
|
||||
}
|
||||
if config.UDPFlowLimit <= 0 {
|
||||
config.UDPFlowLimit = 4096
|
||||
}
|
||||
if config.StatsInterval <= 0 {
|
||||
config.StatsInterval = DefaultStatsInterval
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
runtime := &SiteRuntime{
|
||||
config: config, ctx: ctx, cancel: cancel, registry: subnet.NewRegistry(),
|
||||
sessions: make(map[uint64]*siteSession), done: make(chan struct{}),
|
||||
}
|
||||
listener, err := subnet.NewListener(config.LocalOverlayIP, config.UDPPort, runtime.registry, runtime.inject, config.OnPacketReject)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
runtime.listener = listener
|
||||
go runtime.run()
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) HandleControl(ctx context.Context, envelope protocol.ControlEnvelope) error {
|
||||
switch envelope.Type {
|
||||
case protocol.ControlPrepareSession:
|
||||
var payload protocol.PrepareSessionPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.prepare(ctx, payload)
|
||||
case protocol.ControlSessionActive:
|
||||
var payload protocol.SessionActivePayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.activate(payload.SessionID)
|
||||
case protocol.ControlStopSession:
|
||||
var payload protocol.StopSessionPayload
|
||||
if err := envelope.DecodePayload(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.stop(ctx, payload)
|
||||
case protocol.ControlRebootstrapRequired:
|
||||
return protocol.ErrRebootstrapRequired
|
||||
default:
|
||||
return fmt.Errorf("unexpected Site Control message %s", envelope.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) prepare(ctx context.Context, payload protocol.PrepareSessionPayload) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if payload.SessionID == 0 || s.sessions[payload.SessionID] != nil {
|
||||
return errors.New("invalid or duplicate PREPARE_SESSION")
|
||||
}
|
||||
engineer, err := netip.ParseAddr(payload.EngineerOverlayIP)
|
||||
if err != nil || !engineer.Is4() || !s.config.OverlayCIDR.Contains(engineer) {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, nil, protocol.ErrorCIDRInvalid, "Engineer Overlay IP is invalid")
|
||||
}
|
||||
prefixes, code := validateCIDRs(payload.TargetCIDRs, s.config.OverlayCIDR)
|
||||
if code != "" {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, nil, code, "Remote CIDR validation failed")
|
||||
}
|
||||
routeResults := make([]protocol.RouteResult, 0, len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
result, err := s.config.Routes.Lookup(prefix.Addr())
|
||||
if err != nil {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, routeResults, protocol.ErrorSiteNoRoute, err.Error())
|
||||
}
|
||||
routeResults = append(routeResults, protocol.RouteResult{CIDR: prefix.String(), Result: string(result)})
|
||||
if s.config.OnRoute != nil {
|
||||
s.config.OnRoute(payload.SessionID, prefix, result)
|
||||
}
|
||||
if result == route.LookupNoRoute || result == route.LookupDefaultOnly {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, routeResults, protocol.ErrorSiteNoRoute, "Site has no route to Remote CIDR")
|
||||
}
|
||||
if result == route.LookupOverlayConflict {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, routeResults, protocol.ErrorCIDROverlayConflict, "Remote CIDR overlaps Overlay")
|
||||
}
|
||||
}
|
||||
tcpFlows, udpFlows := s.config.Gateway.FlowCounts()
|
||||
if tcpFlows >= s.config.TCPFlowLimit || udpFlows >= s.config.UDPFlowLimit {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, routeResults, protocol.ErrorFlowLimitReached, "Site netstack flow capacity is exhausted")
|
||||
}
|
||||
if err := s.config.Gateway.Prepare(ctx, subnetgateway.SessionConfig{
|
||||
SessionID: payload.SessionID, EngineerOverlayIP: engineer, RemoteCIDRs: prefixes,
|
||||
}); err != nil {
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, routeResults, protocol.ErrorNetstackUnavailable, err.Error())
|
||||
}
|
||||
sender, err := subnet.NewSender(s.ctx, subnet.SenderConfig{
|
||||
SessionID: payload.SessionID, LocalIP: s.config.LocalOverlayIP, PeerIP: engineer, PeerPort: s.config.UDPPort,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.config.Gateway.CloseSession(ctx, payload.SessionID)
|
||||
return s.sendPrepareFailure(ctx, payload.SessionID, routeResults, protocol.ErrorNetstackUnavailable, err.Error())
|
||||
}
|
||||
if err := s.registry.Upsert(subnet.SessionBinding{
|
||||
SessionID: payload.SessionID, PeerOverlayIP: engineer, EngineerOverlayIP: engineer,
|
||||
RemoteCIDRs: prefixes, Direction: subnet.EngineerToSite, Active: false,
|
||||
}); err != nil {
|
||||
_ = sender.Close()
|
||||
_ = s.config.Gateway.CloseSession(ctx, payload.SessionID)
|
||||
return err
|
||||
}
|
||||
s.sessions[payload.SessionID] = &siteSession{
|
||||
id: payload.SessionID, engineer: engineer, prefixes: prefixes, sender: sender, status: model.SessionReady,
|
||||
}
|
||||
result := protocol.PrepareResultPayload{
|
||||
SessionID: payload.SessionID, OK: true, RouteResults: routeResults, SubnetGatewayStatus: "netstack",
|
||||
TCPCapacity: s.config.TCPFlowLimit - tcpFlows, UDPCapacity: s.config.UDPFlowLimit - udpFlows,
|
||||
}
|
||||
if err := s.config.Control.Send(ctx, protocol.ControlPrepareResult, "", result); err != nil {
|
||||
s.cleanupLocked(ctx, payload.SessionID)
|
||||
return err
|
||||
}
|
||||
s.notify(model.SessionReady, payload.SessionID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) sendPrepareFailure(ctx context.Context, sessionID uint64, results []protocol.RouteResult, code protocol.ErrorCode, message string) error {
|
||||
return s.config.Control.Send(ctx, protocol.ControlPrepareResult, "", protocol.PrepareResultPayload{
|
||||
SessionID: sessionID, OK: false, RouteResults: results, SubnetGatewayStatus: "netstack",
|
||||
ErrorCode: code, Error: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) activate(sessionID uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current := s.sessions[sessionID]
|
||||
if current == nil || current.status != model.SessionReady {
|
||||
return errors.New("SESSION_ACTIVE does not match Site READY Session")
|
||||
}
|
||||
binding, _ := s.registry.Lookup(sessionID)
|
||||
binding.Active = true
|
||||
if err := s.registry.Upsert(binding); err != nil {
|
||||
return err
|
||||
}
|
||||
current.status = model.SessionActive
|
||||
s.notify(model.SessionActive, sessionID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) stop(ctx context.Context, payload protocol.StopSessionPayload) error {
|
||||
s.mu.Lock()
|
||||
if s.sessions[payload.SessionID] == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.cleanupLocked(ctx, payload.SessionID)
|
||||
s.mu.Unlock()
|
||||
s.notify(model.SessionClosed, payload.SessionID, payload.Reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) inject(ctx context.Context, sessionID uint64, packet []byte) error {
|
||||
if err := s.config.Gateway.InjectIPv4(ctx, sessionID, packet); err != nil {
|
||||
_ = s.config.Control.Send(context.Background(), protocol.ControlStopSession, "", protocol.StopSessionPayload{
|
||||
SessionID: sessionID, Reason: string(protocol.ErrorSessionInjectFailed),
|
||||
})
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked(context.Background(), sessionID)
|
||||
s.mu.Unlock()
|
||||
s.notify(model.SessionFailed, sessionID, string(protocol.ErrorSessionInjectFailed))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Egress is the netstack callback for raw Site-to-Engineer IPv4 packets.
|
||||
func (s *SiteRuntime) Egress(_ context.Context, sessionID uint64, packet []byte) error {
|
||||
s.mu.Lock()
|
||||
current := s.sessions[sessionID]
|
||||
if current == nil || current.status != model.SessionActive {
|
||||
s.mu.Unlock()
|
||||
return errors.New("netstack egress references an inactive Session")
|
||||
}
|
||||
sender := current.sender
|
||||
s.mu.Unlock()
|
||||
if !sender.Enqueue(append([]byte(nil), packet...)) {
|
||||
return errors.New("Session UDP send queue is full")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) run() {
|
||||
defer close(s.done)
|
||||
listenerErrors := make(chan error, 1)
|
||||
go func() { listenerErrors <- s.listener.Run(s.ctx) }()
|
||||
ticker := time.NewTicker(s.config.StatsInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
_ = s.listener.Close()
|
||||
return
|
||||
case <-listenerErrors:
|
||||
s.cancel()
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.reportStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) reportStats() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, current := range s.sessions {
|
||||
if current.status != model.SessionActive {
|
||||
continue
|
||||
}
|
||||
uploadBytes, uploadPackets := s.listener.SessionCounters(current.id)
|
||||
downloadBytes, downloadPackets, _ := current.sender.Counters()
|
||||
counters := model.SessionCounters{
|
||||
UploadBytes: uploadBytes, UploadPackets: uploadPackets,
|
||||
DownloadBytes: downloadBytes, DownloadPackets: downloadPackets,
|
||||
}
|
||||
_ = s.config.Control.Send(s.ctx, protocol.ControlSessionStats, "", protocol.SessionStatsPayload{SessionID: current.id, Counters: counters})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) cleanupLocked(ctx context.Context, sessionID uint64) {
|
||||
current := s.sessions[sessionID]
|
||||
if current == nil {
|
||||
return
|
||||
}
|
||||
s.registry.Remove(sessionID)
|
||||
_ = current.sender.Close()
|
||||
_ = s.config.Gateway.CloseSession(ctx, sessionID)
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) notify(status model.SessionStatus, id uint64, reason string) {
|
||||
if s.config.OnSession != nil {
|
||||
s.config.OnSession(status, id, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SiteRuntime) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.cancel()
|
||||
s.mu.Lock()
|
||||
for id := range s.sessions {
|
||||
s.cleanupLocked(context.Background(), id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = s.listener.Close()
|
||||
_ = s.config.Gateway.Close()
|
||||
<-s.done
|
||||
})
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user