初版功能完成
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
+44
View File
@@ -0,0 +1,44 @@
// Package nodeagent composes Bootstrap, identity, Wintun, wireguard-go, and Control.
package nodeagent
import (
"errors"
"log/slog"
"net/netip"
"time"
"remlink/internal/logging"
"remlink/internal/model"
"remlink/internal/protocol"
sessionruntime "remlink/internal/session"
)
type Options struct {
NodeType model.NodeType
NodeName string
ServerURL string
JoinToken string
IdentityPath string
Version string
Logger *slog.Logger
ApplicationLogger *logging.Logger
Capabilities protocol.NodeCapabilities
TCPFlowLimit int
UDPFlowLimit int
UDPIdleTimeout time.Duration
OnOverlayReady func(netip.Addr)
OnControlState func(bool)
OnLatency func(time.Duration)
OnNodeList func(protocol.NodeListPayload)
OnSession func(model.SessionStatus, uint64, string)
OnEngineerReady func(*sessionruntime.EngineerRuntime)
OnSiteReady func()
OnRoute func(uint64, netip.Prefix, string)
}
func (o Options) validate() error {
if !o.NodeType.Valid() || o.NodeName == "" || o.ServerURL == "" || o.IdentityPath == "" {
return errors.New("Node type, name, Server URL, and identity path are required")
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package nodeagent
import (
"context"
"errors"
)
var ErrWindowsRequired = errors.New("RemLink Engineer and Site Nodes require Windows")
func Run(context.Context, Options) error { return ErrWindowsRequired }
+342
View File
@@ -0,0 +1,342 @@
//go:build windows
package nodeagent
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/netip"
"net/url"
"time"
"remlink/internal/bootstrap"
"remlink/internal/control"
"remlink/internal/identity"
"remlink/internal/localization"
"remlink/internal/logging"
"remlink/internal/model"
"remlink/internal/overlay/clientwg"
windowsplatform "remlink/internal/platform/windows"
"remlink/internal/platform/windows/dpapi"
"remlink/internal/platform/windows/netinfo"
"remlink/internal/platform/windows/route"
"remlink/internal/protocol"
sessionruntime "remlink/internal/session"
netstackgateway "remlink/internal/subnetgateway/netstack"
)
// Run starts one Windows Node and rebuilds all network-derived state whenever
// the Server requires a new public Bootstrap configuration.
func Run(ctx context.Context, options Options) error {
if err := options.validate(); err != nil {
return err
}
bootstrapLogger := nodeLogger(options, logging.ModuleBootstrap)
for {
err := runOnce(ctx, options)
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
return nil
}
if !errors.Is(err, protocol.ErrRebootstrapRequired) {
return err
}
if bootstrapLogger != nil {
bootstrapLogger.Info("正在根据公网 Bootstrap 配置重建节点网络运行时")
}
}
}
func runOnce(ctx context.Context, options Options) error {
bootstrapLogger := nodeLogger(options, logging.ModuleBootstrap)
wgLogger := nodeLogger(options, logging.ModuleWG)
controlLogger := nodeLogger(options, logging.ModuleControl)
sessionLogger := nodeLogger(options, logging.ModuleSession)
routeLogger := nodeLogger(options, logging.ModuleRoute)
identityStore, err := identity.NewStore(options.IdentityPath, dpapi.Protector{})
if err != nil {
return err
}
bootstrapClient, err := bootstrap.NewClient(options.ServerURL, nil)
if err != nil {
return err
}
nodeIdentity, network, err := bootstrap.Enroll(ctx, identityStore, bootstrapClient, bootstrap.EnrollConfig{
NodeType: options.NodeType, NodeName: options.NodeName, ServerURL: options.ServerURL,
JoinToken: options.JoinToken, Version: options.Version, OSVersion: bootstrap.CurrentOSVersion(),
})
if err != nil {
return err
}
if bootstrapLogger != nil {
bootstrapLogger.Info("已收到 Server 下发的权威 Bootstrap 配置", "node_id", nodeIdentity.NodeID, "config_version", network.ConfigVersion)
}
overlayCIDR, _ := netip.ParsePrefix(network.OverlayCIDR)
localPrefixes, err := netinfo.DirectIPv4Prefixes(windowsplatform.AdapterName)
if err != nil {
return err
}
if conflict, found := netinfo.FindConflict(overlayCIDR, localPrefixes); found {
return fmt.Errorf("%sOverlay 网段 %s 与本地网络 %s 冲突", protocol.ErrorOverlayLocalConflict, overlayCIDR, conflict)
}
nodeIP, _ := netip.ParseAddr(network.OverlayIP)
adapter, err := windowsplatform.OpenRemLink(windowsplatform.AdapterConfig{
Address: netip.PrefixFrom(nodeIP, overlayCIDR.Bits()), MTU: network.MTU,
})
if err != nil {
return err
}
ownedByWireGuard := false
defer func() {
if !ownedByWireGuard {
_ = adapter.Close()
}
}()
wireGuard, err := clientwg.NewFromAdapter(adapter, wgLogger)
if err != nil {
return err
}
ownedByWireGuard = true
defer wireGuard.Close()
privateKey, err := clientwg.ParseKeyBase64(nodeIdentity.PrivateKey.String())
if err != nil {
return err
}
serverPublicKey, err := clientwg.ParseKeyBase64(network.ServerWGPublicKey)
if err != nil {
return err
}
if err := wireGuard.Configure(clientwg.Config{
PrivateKey: privateKey, ServerPublicKey: serverPublicKey,
ServerEndpoint: network.ServerWGEndpoint, OverlayAllowedIPs: []netip.Prefix{overlayCIDR},
PersistentKeepalive: 25 * time.Second,
}); err != nil {
return err
}
if err := wireGuard.Up(); err != nil {
return err
}
if err := waitForOverlayControl(ctx, network.ControlURL); err != nil {
return err
}
if wgLogger != nil {
wgLogger.Info("节点 Overlay 网络已就绪", "node_id", nodeIdentity.NodeID, "overlay_ip", nodeIP, "server", network.ServerWGEndpoint)
}
if options.OnOverlayReady != nil {
options.OnOverlayReady(nodeIP)
}
routeManager, err := route.NewManager(adapter.LUID(), overlayCIDR, identityStore)
if err != nil {
return err
}
capabilities := options.Capabilities
tcpFlowLimit := options.TCPFlowLimit
if tcpFlowLimit <= 0 {
tcpFlowLimit = netstackgateway.DefaultTCPFlowLimit
}
udpFlowLimit := options.UDPFlowLimit
if udpFlowLimit <= 0 {
udpFlowLimit = netstackgateway.DefaultUDPFlowLimit
}
udpIdleTimeout := options.UDPIdleTimeout
if udpIdleTimeout <= 0 {
udpIdleTimeout = netstackgateway.DefaultUDPIdleTimeout
}
if options.NodeType == model.NodeTypeSite {
capabilities = protocol.NodeCapabilities{
RemoteSubnet: true, NetstackStatus: "netstack",
TCPCapacity: tcpFlowLimit, UDPCapacity: udpFlowLimit,
}
}
var handleEnvelope func(context.Context, protocol.ControlEnvelope) error
onPacketReject := func(rejectErr error, source netip.Addr) {
if options.ApplicationLogger != nil {
_, _ = options.ApplicationLogger.SampleSecurityWarning(context.Background(), logging.ModuleSubnet, "session-datagram-rejected", "会话数据报已被拒绝",
slog.String("source_overlay_ip", source.String()), slog.String("reason", rejectErr.Error()))
}
}
onPacketDrop := func(event clientwg.DropEvent) {
if options.ApplicationLogger != nil {
_, _ = options.ApplicationLogger.SampleSecurityWarning(context.Background(), logging.ModuleTUN, string(event.Reason), "PacketMux 已丢弃数据包",
slog.String("reason", string(event.Reason)), slog.String("destination", event.Destination.String()))
}
}
var controlClient *control.Client
controlClient, err = control.NewClient(control.ClientConfig{
URL: network.ControlURL,
OnConnectionState: options.OnControlState,
OnHeartbeatRTT: options.OnLatency,
Hello: protocol.HelloPayload{
NodeID: nodeIdentity.NodeID, NodeToken: nodeIdentity.NodeToken,
ConfigVersion: nodeIdentity.ConfigVersion, Capabilities: capabilities,
OSVersion: bootstrap.CurrentOSVersion(), Version: options.Version,
},
}, func(messageContext context.Context, envelope protocol.ControlEnvelope) error {
if controlLogger != nil {
controlLogger.Info("收到 Control 控制消息", "type", envelope.Type, "request_id", envelope.RequestID)
}
if envelope.Type == protocol.ControlRebootstrapRequired {
response, configErr := bootstrapClient.Config(messageContext, bootstrap.ConfigRequest{
NodeID: nodeIdentity.NodeID, NodeToken: nodeIdentity.NodeToken,
})
if configErr == nil {
configErr = bootstrap.ValidateNetworkConfig(response.Network)
}
if configErr == nil {
nextOverlay, _ := netip.ParsePrefix(response.Network.OverlayCIDR)
prefixes, prefixErr := netinfo.DirectIPv4Prefixes(windowsplatform.AdapterName)
if prefixErr != nil {
return prefixErr
}
if conflict, found := netinfo.FindConflict(nextOverlay, prefixes); found {
if controlLogger != nil {
controlLogger.Error("节点拒绝 Overlay 网段迁移", "error_code", protocol.ErrorOverlayLocalConflict,
"overlay_cidr", nextOverlay, "local_prefix", conflict)
}
reportContext, cancelReport := context.WithTimeout(messageContext, 3*time.Second)
reportErr := controlClient.Send(reportContext, protocol.ControlHeartbeat,
fmt.Sprintf("overlay-conflict-%d", time.Now().UnixNano()), protocol.HeartbeatPayload{
Timestamp: time.Now().UTC(), Status: string(protocol.ErrorOverlayLocalConflict),
})
cancelReport()
if reportErr != nil {
return fmt.Errorf("report %s: %v: %w", protocol.ErrorOverlayLocalConflict, reportErr, protocol.ErrRebootstrapRequired)
}
// Keep the old Overlay active long enough for the Server to receive
// the status. The migration will close this old Control path.
return nil
}
}
// A public Bootstrap refresh is also the fallback when preflight
// could not be completed over the old Control path.
return protocol.ErrRebootstrapRequired
}
if handleEnvelope == nil {
return errors.New("节点会话运行时尚未初始化")
}
return handleEnvelope(messageContext, envelope)
})
if err != nil {
return err
}
var closeRuntime func() error
switch options.NodeType {
case model.NodeTypeEngineer:
engineerRuntime, err := sessionruntime.NewEngineerRuntime(ctx, sessionruntime.EngineerConfig{
LocalOverlayIP: nodeIP, OverlayCIDR: overlayCIDR, UDPPort: network.SessionUDPPort,
Routes: routeManager, Device: wireGuard.MuxTun(), Control: controlClient,
OnPacketReject: onPacketReject, OnPacketDrop: onPacketDrop,
OnNodeList: options.OnNodeList,
OnSession: func(status model.SessionStatus, sessionID uint64, reason string) {
if sessionLogger != nil {
sessionLogger.Info("Engineer 会话状态变更", "status", localization.SessionStatus(string(status)), "session_id", sessionID, "reason", localization.Reason(reason))
}
if options.OnSession != nil {
options.OnSession(status, sessionID, reason)
}
},
})
if err != nil {
return err
}
handleEnvelope = engineerRuntime.HandleControl
closeRuntime = engineerRuntime.Close
if options.OnEngineerReady != nil {
options.OnEngineerReady(engineerRuntime)
}
case model.NodeTypeSite:
var siteRuntime *sessionruntime.SiteRuntime
gateway, err := netstackgateway.New(netstackgateway.Config{
MTU: network.MTU, TCPFlowLimit: tcpFlowLimit,
UDPFlowLimit: udpFlowLimit, UDPIdleTimeout: udpIdleTimeout,
Egress: func(packetContext context.Context, sessionID uint64, packet []byte) error {
if siteRuntime == nil {
return errors.New("Site 会话运行时尚未就绪")
}
return siteRuntime.Egress(packetContext, sessionID, packet)
},
})
if err != nil {
return fmt.Errorf("初始化 Site netstack 网关失败:%w", err)
}
siteRuntime, err = sessionruntime.NewSiteRuntime(ctx, sessionruntime.SiteConfig{
LocalOverlayIP: nodeIP, OverlayCIDR: overlayCIDR, UDPPort: network.SessionUDPPort,
TCPFlowLimit: tcpFlowLimit, UDPFlowLimit: udpFlowLimit,
Routes: routeManager, Gateway: gateway, Control: controlClient,
OnPacketReject: onPacketReject,
OnRoute: func(sessionID uint64, prefix netip.Prefix, result route.LookupResult) {
if routeLogger != nil {
routeLogger.Info("Site 路由查询", "session_id", sessionID, "cidr", prefix, "result", localization.RouteResult(string(result)))
}
if options.OnRoute != nil {
options.OnRoute(sessionID, prefix, string(result))
}
},
OnSession: func(status model.SessionStatus, sessionID uint64, reason string) {
if sessionLogger != nil {
sessionLogger.Info("Site 会话状态变更", "status", localization.SessionStatus(string(status)), "session_id", sessionID, "reason", localization.Reason(reason))
}
if options.OnSession != nil {
options.OnSession(status, sessionID, reason)
}
},
})
if err != nil {
_ = gateway.Close()
return err
}
handleEnvelope = siteRuntime.HandleControl
closeRuntime = siteRuntime.Close
if options.OnSiteReady != nil {
options.OnSiteReady()
}
default:
return errors.New("不支持的节点类型")
}
defer func() {
_ = closeRuntime()
if options.OnEngineerReady != nil && options.NodeType == model.NodeTypeEngineer {
options.OnEngineerReady(nil)
}
}()
err = controlClient.Run(ctx)
return err
}
func nodeLogger(options Options, module logging.Module) *slog.Logger {
if options.ApplicationLogger != nil {
logger, err := options.ApplicationLogger.For(module)
if err == nil {
return logger
}
}
return options.Logger
}
func waitForOverlayControl(ctx context.Context, controlURL string) error {
parsed, err := url.Parse(controlURL)
if err != nil {
return err
}
waitContext, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
dialer := &net.Dialer{Timeout: 3 * time.Second}
var lastErr error
for {
connection, err := dialer.DialContext(waitContext, "tcp4", parsed.Host)
if err == nil {
return connection.Close()
}
lastErr = err
if ctx.Err() != nil {
return ctx.Err()
}
select {
case <-waitContext.Done():
return fmt.Errorf("Overlay Control endpoint is unreachable: %v: %w", lastErr, protocol.ErrRebootstrapRequired)
case <-time.After(time.Second):
}
}
}
+36
View File
@@ -0,0 +1,36 @@
//go:build windows
package nodeagent
import (
"context"
"net"
"testing"
"time"
)
func TestWaitForOverlayControlConnectsTCP(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
accepted := make(chan struct{})
go func() {
connection, err := listener.Accept()
if err == nil {
_ = connection.Close()
close(accepted)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := waitForOverlayControl(ctx, "ws://"+listener.Addr().String()+"/control"); err != nil {
t.Fatal(err)
}
select {
case <-accepted:
case <-time.After(time.Second):
t.Fatal("Overlay Control probe was not accepted")
}
}