初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+404
View File
@@ -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
}
+549
View File
@@ -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
}
+453
View File
@@ -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
}
+387
View File
@@ -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
}
+322
View File
@@ -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
}