初版功能完成
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
+333
View File
@@ -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")
}
}
+33
View File
@@ -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)
}
}
}
+81
View File
@@ -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},
})
}
+13
View File
@@ -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)
}
+11
View File
@@ -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" }
}