初版功能完成
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user