初版功能完成
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"remlink/internal/localization"
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/nodeagent"
|
||||
"remlink/internal/protocol"
|
||||
sessionruntime "remlink/internal/session"
|
||||
"remlink/internal/siteprofile"
|
||||
)
|
||||
|
||||
type EngineerApp struct {
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
options nodeagent.Options
|
||||
engine *sessionruntime.EngineerRuntime
|
||||
profiles *siteprofile.Store
|
||||
state EngineerState
|
||||
}
|
||||
|
||||
type EngineerState struct {
|
||||
ServerConnected bool `json:"serverConnected"`
|
||||
ControlConnected bool `json:"controlConnected"`
|
||||
ServerURL string `json:"serverURL"`
|
||||
Version string `json:"version"`
|
||||
OverlayIP string `json:"overlayIP"`
|
||||
Sites []protocol.SiteSummary `json:"sites"`
|
||||
SiteCIDRs map[string][]string `json:"siteCIDRs"`
|
||||
Session EngineerSessionState `json:"session"`
|
||||
Logs []EngineerLog `json:"logs"`
|
||||
}
|
||||
|
||||
type EngineerSessionState struct {
|
||||
ID string `json:"id"`
|
||||
SiteName string `json:"siteName"`
|
||||
CIDRs []string `json:"cidrs"`
|
||||
Status string `json:"status"`
|
||||
UploadBytes uint64 `json:"uploadBytes"`
|
||||
DownloadBytes uint64 `json:"downloadBytes"`
|
||||
UploadPackets uint64 `json:"uploadPackets"`
|
||||
DownloadPackets uint64 `json:"downloadPackets"`
|
||||
LatencyMS int `json:"latencyMS"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type EngineerLog struct {
|
||||
Time time.Time `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewEngineerApp(options nodeagent.Options, profiles *siteprofile.Store) *EngineerApp {
|
||||
return &EngineerApp{options: options, profiles: profiles, state: EngineerState{
|
||||
ServerURL: options.ServerURL, Version: options.Version,
|
||||
Sites: []protocol.SiteSummary{}, SiteCIDRs: map[string][]string{}, Logs: []EngineerLog{},
|
||||
Session: EngineerSessionState{CIDRs: []string{}, Status: "IDLE"},
|
||||
}}
|
||||
}
|
||||
|
||||
func (a *EngineerApp) CheckCIDRs(cidrs []string) error {
|
||||
a.mu.RLock()
|
||||
engine := a.engine
|
||||
a.mu.RUnlock()
|
||||
if engine == nil {
|
||||
return errors.New("Engineer 网络运行时尚未就绪")
|
||||
}
|
||||
return engine.PreflightCIDRs(cidrs)
|
||||
}
|
||||
|
||||
func (a *EngineerApp) Startup(wailsContext context.Context) {
|
||||
a.mu.Lock()
|
||||
a.ctx, a.cancel = context.WithCancel(wailsContext)
|
||||
ctx := a.ctx
|
||||
a.mu.Unlock()
|
||||
if a.profiles != nil {
|
||||
profiles, err := a.profiles.Load()
|
||||
if err != nil {
|
||||
a.appendLog("ERROR", "读取现场网段配置失败:"+err.Error())
|
||||
} else {
|
||||
a.mu.Lock()
|
||||
for siteID, profile := range profiles {
|
||||
a.state.SiteCIDRs[siteID] = append([]string(nil), profile.CIDRs...)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
options := a.options
|
||||
options.OnOverlayReady = func(address netip.Addr) {
|
||||
a.mu.Lock()
|
||||
a.state.ServerConnected = true
|
||||
a.state.OverlayIP = address.String()
|
||||
a.mu.Unlock()
|
||||
a.appendLog("INFO", "Overlay 已就绪:"+address.String())
|
||||
a.emit()
|
||||
}
|
||||
options.OnControlState = func(connected bool) {
|
||||
a.mu.Lock()
|
||||
a.state.ControlConnected = connected
|
||||
a.mu.Unlock()
|
||||
if connected {
|
||||
a.appendLog("INFO", "Control WebSocket 已连接")
|
||||
} else {
|
||||
a.appendLog("WARN", "Control WebSocket 已断开,正在重连")
|
||||
}
|
||||
a.emit()
|
||||
}
|
||||
options.OnLatency = func(delay time.Duration) {
|
||||
milliseconds := int(delay.Round(time.Millisecond) / time.Millisecond)
|
||||
if milliseconds < 1 {
|
||||
milliseconds = 1
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.state.Session.LatencyMS = milliseconds
|
||||
a.mu.Unlock()
|
||||
a.emit()
|
||||
}
|
||||
options.OnNodeList = func(payload protocol.NodeListPayload) {
|
||||
a.mu.Lock()
|
||||
a.state.Sites = append([]protocol.SiteSummary{}, payload.Sites...)
|
||||
a.mu.Unlock()
|
||||
a.emit()
|
||||
}
|
||||
options.OnSession = a.onSession
|
||||
options.OnEngineerReady = func(engine *sessionruntime.EngineerRuntime) {
|
||||
a.mu.Lock()
|
||||
a.engine = engine
|
||||
a.mu.Unlock()
|
||||
a.emit()
|
||||
}
|
||||
go func() {
|
||||
if err := nodeagent.Run(ctx, options); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.appendLog("ERROR", localization.ErrorMessage(err.Error()))
|
||||
a.mu.Lock()
|
||||
a.state.ServerConnected = false
|
||||
a.state.ControlConnected = false
|
||||
a.mu.Unlock()
|
||||
a.emit()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *EngineerApp) Shutdown(context.Context) {
|
||||
a.mu.Lock()
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *EngineerApp) GetState() EngineerState {
|
||||
a.mu.RLock()
|
||||
state := a.state
|
||||
// Wails serialises nil slices as JSON null. The frontend contract requires
|
||||
// arrays even when they are empty, otherwise the first Vue render fails.
|
||||
state.Sites = append([]protocol.SiteSummary{}, a.state.Sites...)
|
||||
state.SiteCIDRs = make(map[string][]string, len(a.state.SiteCIDRs))
|
||||
for siteID, cidrs := range a.state.SiteCIDRs {
|
||||
state.SiteCIDRs[siteID] = append([]string(nil), cidrs...)
|
||||
}
|
||||
state.Logs = append([]EngineerLog{}, a.state.Logs...)
|
||||
state.Session.CIDRs = append([]string{}, a.state.Session.CIDRs...)
|
||||
engine := a.engine
|
||||
a.mu.RUnlock()
|
||||
if engine != nil {
|
||||
snapshot := engine.Snapshot()
|
||||
if snapshot.ID != 0 {
|
||||
state.Session.ID = strconv.FormatUint(snapshot.ID, 10)
|
||||
state.Session.Status = string(snapshot.Status)
|
||||
state.Session.UploadBytes = snapshot.Counters.UploadBytes
|
||||
state.Session.DownloadBytes = snapshot.Counters.DownloadBytes
|
||||
state.Session.UploadPackets = snapshot.Counters.UploadPackets
|
||||
state.Session.DownloadPackets = snapshot.Counters.DownloadPackets
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// SaveSiteCIDRs persists one Site's non-secret Remote CIDR profile beside the
|
||||
// Engineer executable. An empty list clears only that Site's profile.
|
||||
func (a *EngineerApp) SaveSiteCIDRs(siteNodeID string, cidrs []string) error {
|
||||
a.mu.RLock()
|
||||
engine := a.engine
|
||||
sites := append([]protocol.SiteSummary(nil), a.state.Sites...)
|
||||
profiles := a.profiles
|
||||
a.mu.RUnlock()
|
||||
if profiles == nil {
|
||||
return errors.New("现场网段配置存储尚未就绪")
|
||||
}
|
||||
siteName := ""
|
||||
for _, site := range sites {
|
||||
if site.NodeID == siteNodeID {
|
||||
siteName = site.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if siteName == "" {
|
||||
return errors.New("所选 Site 不存在")
|
||||
}
|
||||
if len(cidrs) > 0 {
|
||||
if engine == nil {
|
||||
return errors.New("Engineer 网络运行时尚未就绪")
|
||||
}
|
||||
if err := engine.PreflightCIDRs(cidrs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := profiles.Save(siteNodeID, siteName, cidrs); err != nil {
|
||||
return err
|
||||
}
|
||||
a.mu.Lock()
|
||||
if len(cidrs) == 0 {
|
||||
delete(a.state.SiteCIDRs, siteNodeID)
|
||||
} else {
|
||||
a.state.SiteCIDRs[siteNodeID] = append([]string(nil), cidrs...)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
a.appendLog("INFO", fmt.Sprintf("已保存 %s 的远程网段配置(%d 个)", siteName, len(cidrs)))
|
||||
a.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *EngineerApp) CreateSession(siteNodeID string, cidrs []string) (string, error) {
|
||||
a.mu.RLock()
|
||||
engine, ctx := a.engine, a.ctx
|
||||
sites := append([]protocol.SiteSummary(nil), a.state.Sites...)
|
||||
a.mu.RUnlock()
|
||||
if engine == nil || ctx == nil {
|
||||
return "", errors.New("Engineer 网络运行时尚未就绪")
|
||||
}
|
||||
if len(cidrs) == 0 {
|
||||
return "", errors.New("至少输入一个 Remote CIDR")
|
||||
}
|
||||
siteName := ""
|
||||
for _, site := range sites {
|
||||
if site.NodeID == siteNodeID {
|
||||
if !site.Online {
|
||||
return "", errors.New("所选 Site 当前离线")
|
||||
}
|
||||
if !site.RemoteSubnetCapability {
|
||||
return "", errors.New("所选 Site 不支持 Remote Subnet")
|
||||
}
|
||||
siteName = site.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if siteName == "" {
|
||||
return "", errors.New("所选 Site 不存在")
|
||||
}
|
||||
if err := a.SaveSiteCIDRs(siteNodeID, cidrs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.mu.Lock()
|
||||
previousSession := a.state.Session
|
||||
now := time.Now().UTC()
|
||||
a.state.Session = EngineerSessionState{
|
||||
SiteName: siteName, CIDRs: append([]string(nil), cidrs...), Status: "IDLE",
|
||||
LatencyMS: previousSession.LatencyMS, StartedAt: &now,
|
||||
}
|
||||
a.mu.Unlock()
|
||||
requestID, err := engine.CreateSession(ctx, siteNodeID, cidrs)
|
||||
if err != nil {
|
||||
a.mu.Lock()
|
||||
a.state.Session = previousSession
|
||||
a.mu.Unlock()
|
||||
a.appendLog("ERROR", localization.ErrorMessage(err.Error()))
|
||||
a.emit()
|
||||
return "", err
|
||||
}
|
||||
a.appendLog("INFO", fmt.Sprintf("正在连接 %s:%v", siteName, cidrs))
|
||||
a.emit()
|
||||
return requestID, nil
|
||||
}
|
||||
|
||||
func (a *EngineerApp) DisconnectSession() error {
|
||||
a.mu.RLock()
|
||||
engine, ctx := a.engine, a.ctx
|
||||
a.mu.RUnlock()
|
||||
if engine == nil || ctx == nil {
|
||||
return errors.New("Engineer 网络运行时尚未就绪")
|
||||
}
|
||||
return engine.Disconnect(ctx, "ENGINEER_OPERATOR")
|
||||
}
|
||||
|
||||
func (a *EngineerApp) onSession(status model.SessionStatus, sessionID uint64, reason string) {
|
||||
a.mu.Lock()
|
||||
a.state.Session.Status = string(status)
|
||||
if sessionID != 0 {
|
||||
a.state.Session.ID = strconv.FormatUint(sessionID, 10)
|
||||
}
|
||||
a.state.Session.Reason = reason
|
||||
if status == model.SessionClosed || status == model.SessionFailed {
|
||||
a.state.Session.StartedAt = nil
|
||||
}
|
||||
a.mu.Unlock()
|
||||
message := fmt.Sprintf("会话 %d → %s", sessionID, localization.SessionStatus(string(status)))
|
||||
if reason != "" {
|
||||
message += ";原因:" + localization.Reason(reason)
|
||||
}
|
||||
a.appendLog("INFO", message)
|
||||
a.emit()
|
||||
}
|
||||
|
||||
func (a *EngineerApp) appendLog(level, message string) {
|
||||
a.mu.Lock()
|
||||
a.state.Logs = append(a.state.Logs, EngineerLog{Time: time.Now().UTC(), Level: level, Message: message})
|
||||
if len(a.state.Logs) > 200 {
|
||||
a.state.Logs = append([]EngineerLog(nil), a.state.Logs[len(a.state.Logs)-200:]...)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *EngineerApp) emit() {
|
||||
a.mu.RLock()
|
||||
ctx := a.ctx
|
||||
a.mu.RUnlock()
|
||||
if ctx != nil {
|
||||
runtime.EventsEmit(ctx, "remlink:state")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"remlink/internal/nodeagent"
|
||||
)
|
||||
|
||||
func TestGetStateSerializesEmptyCollectionsAsArrays(t *testing.T) {
|
||||
app := NewEngineerApp(nodeagent.Options{}, nil)
|
||||
|
||||
// Simulate an empty NodeList payload, which previously replaced the
|
||||
// initialized slices with nil slices.
|
||||
app.state.Sites = nil
|
||||
app.state.Logs = nil
|
||||
app.state.Session.CIDRs = nil
|
||||
app.state.SiteCIDRs = nil
|
||||
|
||||
encoded, err := json.Marshal(app.GetState())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Engineer state: %v", err)
|
||||
}
|
||||
jsonState := string(encoded)
|
||||
for _, field := range []string{`"sites":[]`, `"siteCIDRs":{}`, `"logs":[]`, `"cidrs":[]`} {
|
||||
if !strings.Contains(jsonState, field) {
|
||||
t.Fatalf("expected %s in state JSON, got %s", field, jsonState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
|
||||
engineerui "remlink/frontend/engineer"
|
||||
"remlink/internal/appdir"
|
||||
"remlink/internal/config"
|
||||
"remlink/internal/identity"
|
||||
"remlink/internal/logging"
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/nodeagent"
|
||||
"remlink/internal/siteprofile"
|
||||
"remlink/internal/version"
|
||||
)
|
||||
|
||||
const joinTokenEnvironment = "REMLINK_JOIN_TOKEN"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "remlink-engineer: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
defaultConfigPath, err := appdir.Join("engineer.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flags := flag.NewFlagSet("remlink-engineer", flag.ContinueOnError)
|
||||
configPath := flags.String("config", defaultConfigPath, "Engineer YAML configuration path")
|
||||
joinToken := flags.String("join-token", os.Getenv(joinTokenEnvironment), "first-registration Join Token (CLI/environment override YAML)")
|
||||
identityPath := flags.String("identity", "", "override DPAPI identity path")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
clientConfig, err := config.LoadEngineer(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedJoinToken := config.ResolveJoinToken(*joinToken, clientConfig.JoinToken)
|
||||
if *identityPath == "" {
|
||||
*identityPath, err = identity.DefaultPath(model.NodeTypeEngineer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
applicationLogger, err := logging.New(logging.DefaultConfig(filepath.Join(filepath.Dir(*identityPath), "logs", "engineer.jsonl")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer applicationLogger.Close()
|
||||
logger, _ := applicationLogger.For(logging.ModuleCore)
|
||||
profileStore, err := siteprofile.NewStore(filepath.Join(filepath.Dir(*identityPath), "site-profiles.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := NewEngineerApp(nodeagent.Options{
|
||||
NodeType: model.NodeTypeEngineer, NodeName: clientConfig.NodeName,
|
||||
ServerURL: clientConfig.Server, JoinToken: resolvedJoinToken, IdentityPath: *identityPath,
|
||||
Version: version.String(), Logger: logger, ApplicationLogger: applicationLogger,
|
||||
}, profileStore)
|
||||
return wails.Run(&options.App{
|
||||
Title: "RemLink Engineer", Width: 1400, Height: 880, MinWidth: 1024, MinHeight: 720,
|
||||
BackgroundColour: &options.RGBA{R: 247, G: 248, B: 252, A: 255},
|
||||
AssetServer: &assetserver.Options{Assets: engineerui.Assets},
|
||||
Debug: options.Debug{OpenInspectorOnStartup: true},
|
||||
OnStartup: app.Startup, OnShutdown: app.Shutdown,
|
||||
Bind: []interface{}{app},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "remlink-engineer: Windows is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "RemLink Engineer",
|
||||
"outputfilename": "RemLinkEngineer",
|
||||
"frontend:dir": "../../frontend/engineer",
|
||||
"frontend:install": "npm install --prefix ..",
|
||||
"frontend:build": "npm run build:engineer --prefix ..",
|
||||
"frontend:dev:watcher": "npm run dev:engineer --prefix ..",
|
||||
"frontend:dev:serverUrl": "http://127.0.0.1:34115",
|
||||
"author": { "name": "RemLink" }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "phase1-node: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
func run([]string) error {
|
||||
return errors.New("Phase 1 Node POC requires Windows")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"remlink/internal/overlay/clientwg"
|
||||
windowsplatform "remlink/internal/platform/windows"
|
||||
"remlink/internal/platform/windows/wintunruntime"
|
||||
)
|
||||
|
||||
const privateKeyEnvironment = "REMLINK_POC_PRIVATE_KEY"
|
||||
|
||||
func run(arguments []string) error {
|
||||
flags := flag.NewFlagSet("phase1-node", flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
runtimeProbe := flags.Bool("runtime-probe", false, "install and load the pinned Wintun DLL without creating an adapter")
|
||||
adapterProbe := flags.Bool("adapter-probe", false, "create/configure the RemLink adapter, print details, and exit")
|
||||
addressText := flags.String("address", "", "Node Overlay address with prefix, for example 10.88.0.2/16")
|
||||
serverEndpoint := flags.String("server-endpoint", "", "Server WireGuard endpoint, for example 203.0.113.10:51820")
|
||||
serverPublicKeyText := flags.String("server-public-key", "", "Server WireGuard public key in standard base64 format")
|
||||
keepaliveSeconds := flags.Uint("keepalive", 25, "persistent keepalive interval in seconds")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
if *runtimeProbe {
|
||||
path, version, err := wintunruntime.Probe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Wintun %s loaded from %s\n", version, path)
|
||||
return nil
|
||||
}
|
||||
if *addressText == "" {
|
||||
return errors.New("--address is required")
|
||||
}
|
||||
address, err := netip.ParsePrefix(*addressText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse --address: %w", err)
|
||||
}
|
||||
var privateKey clientwg.Key
|
||||
var serverPublicKey clientwg.Key
|
||||
if !*adapterProbe {
|
||||
if *serverEndpoint == "" {
|
||||
return errors.New("--server-endpoint is required unless --adapter-probe is used")
|
||||
}
|
||||
if *serverPublicKeyText == "" {
|
||||
return errors.New("--server-public-key is required unless --adapter-probe is used")
|
||||
}
|
||||
privateKeyText := os.Getenv(privateKeyEnvironment)
|
||||
if privateKeyText == "" {
|
||||
return fmt.Errorf("%s must contain the Node private key in standard base64 format", privateKeyEnvironment)
|
||||
}
|
||||
privateKey, err = clientwg.ParseKeyBase64(privateKeyText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", privateKeyEnvironment, err)
|
||||
}
|
||||
serverPublicKey, err = clientwg.ParseKeyBase64(*serverPublicKeyText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse --server-public-key: %w", err)
|
||||
}
|
||||
if *keepaliveSeconds > 65535 {
|
||||
return errors.New("--keepalive must not exceed 65535 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
adapter, err := windowsplatform.OpenRemLink(windowsplatform.AdapterConfig{
|
||||
Address: address,
|
||||
MTU: windowsplatform.DefaultMTU,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownedByWireGuard := false
|
||||
defer func() {
|
||||
if !ownedByWireGuard {
|
||||
_ = adapter.Close()
|
||||
}
|
||||
}()
|
||||
name, err := adapter.Device().Name()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read adapter name: %w", err)
|
||||
}
|
||||
mtu, err := adapter.Device().MTU()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read adapter MTU: %w", err)
|
||||
}
|
||||
fmt.Printf("Adapter ready: name=%s index=%d luid=%d address=%s mtu=%d\n", name, adapter.InterfaceIndex(), adapter.LUID(), address, mtu)
|
||||
if *adapterProbe {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})).With("module", "WG")
|
||||
wireguardDevice, err := clientwg.NewFromAdapter(adapter, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownedByWireGuard = true
|
||||
defer wireguardDevice.Close()
|
||||
if err := wireguardDevice.Configure(clientwg.Config{
|
||||
PrivateKey: privateKey,
|
||||
ServerPublicKey: serverPublicKey,
|
||||
ServerEndpoint: *serverEndpoint,
|
||||
OverlayAllowedIPs: []netip.Prefix{address.Masked()},
|
||||
PersistentKeepalive: time.Duration(*keepaliveSeconds) * time.Second,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wireguardDevice.Up(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("wireguard-go is up; press Ctrl+C after completing Overlay ping checks\n")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
serverui "remlink/frontend/server"
|
||||
"remlink/internal/admin"
|
||||
"remlink/internal/bootstrap"
|
||||
"remlink/internal/config"
|
||||
"remlink/internal/control"
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/ipam"
|
||||
"remlink/internal/logging"
|
||||
"remlink/internal/overlay/serverwg"
|
||||
sessionmanager "remlink/internal/session"
|
||||
"remlink/internal/version"
|
||||
)
|
||||
|
||||
const wireGuardEndpointEnvironment = "REMLINK_WG_ENDPOINT"
|
||||
const adminTokenEnvironment = "REMLINK_ADMIN_TOKEN"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "remlink-server: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
flags := flag.NewFlagSet("remlink-server", flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
configPath := flags.String("config", "config/server.yaml", "Server YAML configuration path")
|
||||
wgEndpoint := flags.String("wg-endpoint", os.Getenv(wireGuardEndpointEnvironment), "public WireGuard host:port (or REMLINK_WG_ENDPOINT)")
|
||||
rotateJoinToken := flags.Bool("rotate-join-token", false, "rotate Join Token in SQLite, print it, and exit")
|
||||
printJoinToken := flags.Bool("print-join-token", false, "print current Join Token and exit")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
serverConfig, err := config.LoadServer(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
databasePath := filepath.Join(serverConfig.Data.Directory, "remlink.db")
|
||||
db, err := database.Open(ctx, databasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
store := database.NewStore(db)
|
||||
joins := bootstrap.NewJoinTokens(store)
|
||||
if *rotateJoinToken || *printJoinToken {
|
||||
var token string
|
||||
if *rotateJoinToken {
|
||||
token, err = joins.Rotate(ctx)
|
||||
} else {
|
||||
token, err = joins.Ensure(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(token)
|
||||
return nil
|
||||
}
|
||||
closedSessions, err := store.CloseOpenSessions(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storedNetwork, err := admin.LoadStoredNetwork(ctx, store, admin.Network{
|
||||
OverlayCIDR: serverConfig.Network.OverlayCIDR, ServerOverlayIP: serverConfig.Network.ServerOverlayIP,
|
||||
WireGuardPort: serverConfig.Server.WireGuardPort, SessionUDPPort: serverConfig.Network.SessionUDPPort,
|
||||
MTU: serverConfig.Network.MTU, ConfigVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverConfig.Network.OverlayCIDR = storedNetwork.OverlayCIDR
|
||||
serverConfig.Network.ServerOverlayIP = storedNetwork.ServerOverlayIP
|
||||
serverConfig.Network.SessionUDPPort = storedNetwork.SessionUDPPort
|
||||
serverConfig.Network.MTU = storedNetwork.MTU
|
||||
serverConfig.Server.WireGuardPort = storedNetwork.WireGuardPort
|
||||
_, controlPortText, err := net.SplitHostPort(serverConfig.Server.ControlListen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverConfig.Server.ControlListen = net.JoinHostPort(storedNetwork.ServerOverlayIP, controlPortText)
|
||||
if err := validateEndpoint(*wgEndpoint, serverConfig.Server.WireGuardPort); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger, err := logging.New(logging.DefaultConfig(filepath.Join(serverConfig.Data.Directory, "logs", "server.jsonl")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logger.Close()
|
||||
coreLogger, _ := logger.For(logging.ModuleCore)
|
||||
wgLogger, _ := logger.For(logging.ModuleWG)
|
||||
bootstrapLogger, _ := logger.For(logging.ModuleBootstrap)
|
||||
|
||||
overlayCIDR, _ := netip.ParsePrefix(serverConfig.Network.OverlayCIDR)
|
||||
serverIP, _ := netip.ParseAddr(serverConfig.Network.ServerOverlayIP)
|
||||
serverAddress := netip.PrefixFrom(serverIP, overlayCIDR.Bits())
|
||||
wireGuard, err := serverwg.New(ctx, serverwg.Config{
|
||||
InterfaceName: serverwg.DefaultInterfaceName, Address: serverAddress,
|
||||
ListenPort: serverConfig.Server.WireGuardPort,
|
||||
PrivateKeyPath: filepath.Join(serverConfig.Data.Directory, "server-wg.key"),
|
||||
EnableForwarding: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("kernel WireGuard preflight/configuration failed: %w", err)
|
||||
}
|
||||
defer wireGuard.Close()
|
||||
wgLogger.Info("内核 WireGuard 中心接口已就绪", "interface", serverwg.DefaultInterfaceName,
|
||||
"address", serverAddress, "listen_port", serverConfig.Server.WireGuardPort)
|
||||
|
||||
nodes, err := store.ListNodes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers := make([]serverwg.Peer, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
peers = append(peers, serverwg.Peer{PublicKey: node.WGPublicKey, Address: node.OverlayIP})
|
||||
}
|
||||
if err := wireGuard.ReconcilePeers(ctx, peers); err != nil {
|
||||
return err
|
||||
}
|
||||
wgLogger.Info("WireGuard 对等节点已完成同步", "count", len(peers))
|
||||
|
||||
ipamManager, err := ipam.New(store, overlayCIDR, serverIP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverID, err := bootstrap.EnsureServerID(ctx, store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
joinToken, err := joins.Ensure(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapLogger.Info("Join Token 已就绪;可使用 Server 命令行查询或轮换", "token_initialized", joinToken != "")
|
||||
service, err := bootstrap.NewService(store, ipamManager, joins, wireGuard, bootstrap.ServiceConfig{
|
||||
ServerID: serverID, Version: version.String(), WGPublicKey: wireGuard.PublicKey(),
|
||||
WGEndpoint: *wgEndpoint, OverlayCIDR: overlayCIDR, ServerOverlayIP: serverIP,
|
||||
ControlURL: "ws://" + serverConfig.Server.ControlListen + "/control",
|
||||
SessionUDPPort: serverConfig.Network.SessionUDPPort, MTU: serverConfig.Network.MTU,
|
||||
ConfigVersion: storedNetwork.ConfigVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
controlHub, err := control.NewHub(service, store, nil, control.HubConfig{
|
||||
NetworkConfigVersion: storedNetwork.ConfigVersion, EnforceRemoteIP: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionManager, err := sessionmanager.NewManager(store, controlHub, sessionmanager.Config{
|
||||
OverlayCIDR: overlayCIDR, MTU: serverConfig.Network.MTU, UDPPort: serverConfig.Network.SessionUDPPort,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service.SetNodeBootstrapHandler(func(ctx context.Context, nodeID string) error {
|
||||
return sessionManager.DisconnectNode(ctx, nodeID, "NODE_RUNTIME_REBUILT")
|
||||
})
|
||||
controlHub.SetMessageHandler(sessionManager)
|
||||
coreLogger.Info("会话恢复完成", "closed_nonterminal_sessions", closedSessions)
|
||||
controlMux := http.NewServeMux()
|
||||
controlMux.Handle("/control", controlHub)
|
||||
_, controlPortText, _ = net.SplitHostPort(serverConfig.Server.ControlListen)
|
||||
controlPort, _ := strconv.Atoi(controlPortText)
|
||||
controlSupervisor, err := control.NewSupervisor(controlMux, controlPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeContext, cancelRuntime := context.WithCancel(context.Background())
|
||||
defer cancelRuntime()
|
||||
if err := controlSupervisor.Start(runtimeContext, serverIP); err != nil {
|
||||
return err
|
||||
}
|
||||
defer controlSupervisor.Close()
|
||||
networkManager, err := admin.NewNetworkManager(store, ipamManager, wireGuard, service, controlHub, sessionManager,
|
||||
storedNetwork, func(address netip.Addr) error { return controlSupervisor.Rebind(address) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminHandler, err := admin.Handler(admin.HandlerConfig{
|
||||
Store: store, IPAM: ipamManager, Peers: wireGuard, Control: controlHub,
|
||||
Sessions: sessionManager, Network: networkManager, JoinTokens: joins,
|
||||
AdminToken: os.Getenv(adminTokenEnvironment),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicMux := http.NewServeMux()
|
||||
publicMux.Handle("/api/v1/admin/", adminHandler)
|
||||
bootstrapHandler := bootstrap.Handler(service)
|
||||
publicMux.Handle("/api/v1/server/", bootstrapHandler)
|
||||
publicMux.Handle("/api/v1/bootstrap/", bootstrapHandler)
|
||||
webUI, err := serverui.Handler()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicMux.Handle("/", webUI)
|
||||
bootstrapServer := &http.Server{
|
||||
Addr: serverConfig.Server.HTTPListen, Handler: publicMux,
|
||||
ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
type runtimeError struct {
|
||||
component string
|
||||
err error
|
||||
}
|
||||
serverErrors := make(chan runtimeError, 3)
|
||||
go func() {
|
||||
coreLogger.Info("公网 Bootstrap API 已开始监听", "address", bootstrapServer.Addr, "version", version.String())
|
||||
serverErrors <- runtimeError{component: "Bootstrap API", err: bootstrapServer.ListenAndServe()}
|
||||
}()
|
||||
go func() {
|
||||
coreLogger.Info("Overlay Control WebSocket 已开始监听", "address", serverConfig.Server.ControlListen)
|
||||
serverErrors <- runtimeError{component: "Control WebSocket", err: controlSupervisor.Wait(runtimeContext)}
|
||||
}()
|
||||
go func() {
|
||||
serverErrors <- runtimeError{component: "Control heartbeat monitor", err: controlHub.Run(runtimeContext)}
|
||||
}()
|
||||
|
||||
signalContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
select {
|
||||
case <-signalContext.Done():
|
||||
cancelRuntime()
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := bootstrapServer.Shutdown(shutdownContext); err != nil {
|
||||
return fmt.Errorf("shutdown public Bootstrap API: %w", err)
|
||||
}
|
||||
if err := controlSupervisor.Close(); err != nil {
|
||||
return fmt.Errorf("shutdown Control WebSocket: %w", err)
|
||||
}
|
||||
coreLogger.Info("Server 已停止")
|
||||
return nil
|
||||
case failure := <-serverErrors:
|
||||
cancelRuntime()
|
||||
if errors.Is(failure.err, http.ErrServerClosed) || errors.Is(failure.err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s failed: %w", failure.component, failure.err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateEndpoint(endpoint string, listenPort int) error {
|
||||
host, portText, err := net.SplitHostPort(endpoint)
|
||||
if err != nil || host == "" {
|
||||
return fmt.Errorf("--wg-endpoint (or %s) must be a public host:port", wireGuardEndpointEnvironment)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port != listenPort {
|
||||
return fmt.Errorf("public WireGuard endpoint port must match configured listen port %d", listenPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"remlink/internal/database"
|
||||
"remlink/internal/model"
|
||||
)
|
||||
|
||||
func TestValidateEndpoint(t *testing.T) {
|
||||
for _, endpoint := range []string{"203.0.113.1:51820", "vpn.example.test:51820"} {
|
||||
if err := validateEndpoint(endpoint, 51820); err != nil {
|
||||
t.Errorf("validateEndpoint(%q): %v", endpoint, err)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []string{"", "203.0.113.1", ":51820", "203.0.113.1:1234"} {
|
||||
if err := validateEndpoint(endpoint, 51820); err == nil {
|
||||
t.Errorf("validateEndpoint(%q) accepted invalid endpoint", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJoinTokenDoesNotCloseActiveSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
dataDirectory := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.MkdirAll(dataDirectory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "server.yaml")
|
||||
configText := fmt.Sprintf("data:\n directory: %q\n", dataDirectory)
|
||||
if err := os.WriteFile(configPath, []byte(configText), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, filepath.Join(dataDirectory, "remlink.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := database.NewStore(db)
|
||||
for _, node := range []model.Node{
|
||||
{ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer", OverlayIP: netip.MustParseAddr("10.88.0.2"), WGPublicKey: "engineer-key", NodeTokenHash: []byte("a")},
|
||||
{ID: "site", Type: model.NodeTypeSite, Name: "Site", OverlayIP: netip.MustParseAddr("10.88.0.3"), WGPublicKey: "site-key", NodeTokenHash: []byte("b")},
|
||||
} {
|
||||
if err := store.CreateNode(ctx, node); err != nil {
|
||||
db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const sessionID = 42
|
||||
if err := store.CreateSession(ctx, model.Session{
|
||||
ID: sessionID, EngineerNodeID: "engineer", SiteNodeID: "site",
|
||||
Status: model.SessionActive, CIDRs: []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")},
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := run([]string{"-config", configPath, "-print-join-token"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err = database.Open(ctx, filepath.Join(dataDirectory, "remlink.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
session, err := database.NewStore(db).GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Status != model.SessionActive {
|
||||
t.Fatalf("print Join Token changed active Session status to %s", session.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"remlink/internal/localization"
|
||||
"remlink/internal/model"
|
||||
)
|
||||
|
||||
type siteConsole struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func newSiteConsole(w io.Writer) *siteConsole { return &siteConsole{w: w} }
|
||||
|
||||
func (c *siteConsole) line(format string, args ...any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fmt.Fprintf(c.w, "%s ", time.Now().Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(c.w, format, args...)
|
||||
fmt.Fprintln(c.w)
|
||||
}
|
||||
|
||||
func (c *siteConsole) Header(server, node, version string) {
|
||||
c.line("RemLink Site %s", version)
|
||||
c.line("服务器=%s 节点=%s", server, node)
|
||||
c.line("OverlayIP=等待分配 WireGuard=正在连接 Control=正在连接 远程网段=正在初始化 子网网关=正在初始化")
|
||||
}
|
||||
|
||||
func (c *siteConsole) OverlayReady(address netip.Addr) {
|
||||
c.line("OverlayIP=%s WireGuard=已连接", address)
|
||||
}
|
||||
|
||||
func (c *siteConsole) ControlState(online bool) {
|
||||
state := "正在重连"
|
||||
if online {
|
||||
state = "已连接"
|
||||
}
|
||||
c.line("Control=%s", state)
|
||||
}
|
||||
|
||||
func (c *siteConsole) SiteReady() {
|
||||
c.line("远程网段=就绪 子网网关=gVisor netstack/就绪")
|
||||
}
|
||||
|
||||
func (c *siteConsole) Session(status model.SessionStatus, sessionID uint64, reason string) {
|
||||
if reason == "" {
|
||||
reason = "无"
|
||||
} else {
|
||||
reason = localization.Reason(reason)
|
||||
}
|
||||
c.line("[会话/SESSION] ID=%d 状态=%s 原因=%s", sessionID, localization.SessionStatus(string(status)), reason)
|
||||
}
|
||||
|
||||
func (c *siteConsole) Route(sessionID uint64, prefix netip.Prefix, result string) {
|
||||
c.line("[路由/ROUTE] 会话=%d 网段=%s 结果=%s", sessionID, prefix, localization.RouteResult(result))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"remlink/internal/model"
|
||||
)
|
||||
|
||||
func TestSiteConsoleShowsRequiredStatusAndEvents(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
console := newSiteConsole(&output)
|
||||
console.Header("https://server.example", "Site-A", "1.0.0")
|
||||
console.OverlayReady(netip.MustParseAddr("10.88.0.20"))
|
||||
console.ControlState(true)
|
||||
console.SiteReady()
|
||||
console.Session(model.SessionActive, 42, "")
|
||||
console.Route(42, netip.MustParsePrefix("192.168.13.0/24"), "DIRECT")
|
||||
text := output.String()
|
||||
for _, wanted := range []string{"服务器=", "节点=", "OverlayIP=", "WireGuard=", "Control=", "远程网段=", "子网网关=", "[会话/SESSION]", "活动中(ACTIVE)", "[路由/ROUTE]", "直连路由(DIRECT)"} {
|
||||
if !strings.Contains(text, wanted) {
|
||||
t.Fatalf("console output missing %q: %s", wanted, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"remlink/internal/appdir"
|
||||
"remlink/internal/config"
|
||||
"remlink/internal/identity"
|
||||
"remlink/internal/logging"
|
||||
"remlink/internal/model"
|
||||
"remlink/internal/nodeagent"
|
||||
"remlink/internal/version"
|
||||
)
|
||||
|
||||
const joinTokenEnvironment = "REMLINK_JOIN_TOKEN"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "remlink-site: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
defaultConfigPath, err := appdir.Join("site.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flags := flag.NewFlagSet("remlink-site", flag.ContinueOnError)
|
||||
configPath := flags.String("config", defaultConfigPath, "Site YAML configuration path")
|
||||
joinToken := flags.String("join-token", os.Getenv(joinTokenEnvironment), "first-registration Join Token (CLI/environment override YAML)")
|
||||
identityPath := flags.String("identity", "", "override DPAPI identity path")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
clientConfig, err := config.LoadSite(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedJoinToken := config.ResolveJoinToken(*joinToken, clientConfig.JoinToken)
|
||||
if *identityPath == "" {
|
||||
*identityPath, err = identity.DefaultPath(model.NodeTypeSite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
applicationLogger, err := logging.New(logging.DefaultConfig(filepath.Join(filepath.Dir(*identityPath), "logs", "site.jsonl")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer applicationLogger.Close()
|
||||
logger, _ := applicationLogger.For(logging.ModuleCore)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
logger.Info("RemLink Site 正在启动", "version", version.String())
|
||||
console := newSiteConsole(os.Stdout)
|
||||
console.Header(clientConfig.Server, clientConfig.NodeName, version.String())
|
||||
return nodeagent.Run(ctx, nodeagent.Options{
|
||||
NodeType: model.NodeTypeSite, NodeName: clientConfig.NodeName,
|
||||
ServerURL: clientConfig.Server, JoinToken: resolvedJoinToken, IdentityPath: *identityPath,
|
||||
Version: version.String(), Logger: logger, ApplicationLogger: applicationLogger,
|
||||
TCPFlowLimit: clientConfig.Netstack.TCPFlowLimit,
|
||||
UDPFlowLimit: clientConfig.Netstack.UDPFlowLimit,
|
||||
UDPIdleTimeout: time.Duration(clientConfig.Netstack.UDPIdleSeconds) * time.Second,
|
||||
OnOverlayReady: console.OverlayReady,
|
||||
OnControlState: console.ControlState,
|
||||
OnSiteReady: console.SiteReady,
|
||||
OnSession: console.Session,
|
||||
OnRoute: console.Route,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user