commit 142e5dc7d6dc1a807a7e181137c037fcad186528 Author: qsc Date: Sat Aug 29 13:12:17 2026 +0800 初版功能完成 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1310f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.idea +.vscode +build +dist +data +runtime +frontend/node_modules +frontend/engineer/dist +frontend/server/dist +*.db +*.log +*.log.* +docs/design +.codex-qa +evidence +*.docx diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6d3571f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +* text=auto +*.go text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.ts text eol=lf +*.vue text eol=lf +*.css text eol=lf +*.html text eol=lf +*.sh text eol=lf +Dockerfile* text eol=lf +*.ps1 text eol=crlf +*.dll binary +*.exe binary +*.zip binary +*.png binary +*.docx binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b089fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + go: + name: Go checks (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check repository hygiene + shell: pwsh + run: ./scripts/maintenance/Test-RepositoryHygiene.ps1 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + check-latest: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.14.0 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install pinned frontend dependencies + run: npm ci --prefix frontend + + - name: Type-check and build embedded frontends + run: npm run typecheck --prefix frontend && npm run build --prefix frontend + + - name: Reject production Demo fixtures and fallbacks + shell: pwsh + run: ./scripts/validation/Test-FrontendProduction.ps1 + + - name: Enforce architecture constraints + shell: pwsh + run: ./scripts/validation/Test-Architecture.ps1 + + - name: Self-test acceptance evidence tooling + shell: pwsh + run: ./scripts/validation/Test-AcceptanceTools.ps1 + + - name: Verify module files are tidy + run: go mod tidy + + - name: Check module-file diff + run: git diff --exit-code -- go.mod go.sum + + - name: Build + run: go build ./... + + - name: Test + run: go test -count=1 ./... + + - name: Race-check concurrent data/control paths + if: runner.os == 'Linux' + run: go test -race ./internal/control ./internal/session ./internal/subnet ./internal/overlay/clientwg + + - name: Vet + run: go vet ./... + + - name: Build Server container baseline + if: runner.os == 'Linux' + run: docker build --build-arg REMLINK_COMMIT=${{ github.sha }} -f deploy/docker/Dockerfile -t remlink/server:ci . + + - name: Validate Compose deployment mapping + if: runner.os == 'Linux' + env: + REMLINK_WG_ENDPOINT: 203.0.113.10:51820 + REMLINK_WG_PORT: 51820 + run: docker compose -f deploy/docker/compose.yaml config --quiet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c7b23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Go build and test outputs +/bin/ +/build/ +/dist/ +*.exe +*.test +*.out +*.prof +coverage*.txt +/coverage/ +*.dll +!internal/platform/windows/wintunruntime/assets/**/wintun.dll + +# Local configuration and runtime state +/config/*.yaml +!/config/*.example.yaml +/.env +.env +.env.* +!.env.example +!.env.*.example +/data/ +/deploy/docker/data/ +/runtime/ +**/identity.json +**/site-profiles.json +*.db +*.db-* +*.sqlite +*.sqlite-* +*.log +*.log.* +*.jsonl +/logs/ + +# Local credentials and certificates +*.key +*.pem +*.p12 +*.pfx + +# Frontend dependencies and outputs +node_modules/ +frontend/*/dist/ + +# Editors and operating systems +.idea/ +.vscode/ +.vs/ +.fleet/ +.DS_Store +Thumbs.db +Desktop.ini +*.swp +*.tmp +*~ +/.cache/ +/tmp/ +.codex-qa/ +evidence/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..38a6909 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# RemLink + +RemLink v1.0 是面向工业网络的 IPv4 三层远程接入系统。Linux Server 使用内核 WireGuard 作为中心节点;每个 Windows 节点只复用一个内嵌 wireguard-go/Wintun 适配器;Engineer 使用 PacketMux;Site 使用 gVisor 用户态网关访问现场子网。 + +源码仓库根目录的 `RemLink_v1.0_技术设计与AI开发规格书_Netstack版.docx` 是权威需求文档,`specs/` 是分阶段实现与验收拆解。若 Markdown 与 DOCX 不一致,以 DOCX 为准。 + +## 当前状态 + +Phase 0–10 的生产代码、前端、自动化测试、发布打包和 Docker 基线均已实现。自动化覆盖 Session 状态机、Bootstrap/IPAM/数据库、Control 重连与重新 Bootstrap、数据包验证、进程内 gVisor TCP/UDP/ICMP 往返、网络迁移以及重复现场 CIDR 隔离。 + +Gate A–D 和物理 T01–T18 保持 `NOT_RUN`:当前工作区没有所需的 Linux 内核 WireGuard 与两台/四台管理员权限 Windows 实机拓扑。不能用单元测试或浏览器测试冒充物理验收结果。 + +## 仓库结构 + +| 路径 | 内容 | +|---|---| +| `cmd/` | Engineer、Site、Server 三端程序入口 | +| `internal/` | 控制面、WireGuard、Session、netstack、持久化和平台实现 | +| `frontend/` | Engineer Wails/Vue 界面与 Server Vue 管理界面 | +| `config/` | 三端安全示例配置;真实本机配置不会提交 | +| `deploy/` | Server Docker Compose、Dockerfile 与部署基线 | +| `scripts/` | 发布构建、验收、架构与仓库检查工具 | +| `specs/` | 分阶段开发任务和验收清单 | +| `docs/` | 中文部署、实现状态、验证手册和设计资料 | +| `third_party/` | 随项目分发的第三方许可文件 | + +根目录 DOCX 是权威需求文档,源码实现和阶段验收入口分别位于 `cmd/`、`internal/` 与 `specs/`,不要把 `dist/` 发布包当成源码提交。 + +## 三端部署与使用 + +从零部署 Server、Engineer、Site,完成注册、连接、运维、备份、升级和故障排查,请直接阅读: + +- [三端部署与使用指南](docs/deployment-and-usage.md) +- [T01–T18 验收执行手册](docs/validation/T01-T18-runbook.md) +- [需求与证据矩阵](docs/validation/requirements-evidence.md) +- [当前实现状态](docs/implementation-status.md) + +发布包中的 `docker/compose.release.yaml` 可直接使用预编译 Linux Server 构建最小运行镜像;源码仓库开发构建使用 `deploy/docker/compose.yaml`。 + +## 构建与验证 + +在仓库根目录执行: + +~~~powershell +./scripts/maintenance/Test-RepositoryHygiene.ps1 +npm ci --prefix frontend +npm run typecheck --prefix frontend +npm run build --prefix frontend +go test -count=1 ./... +go vet ./... +./scripts/validation/Test-Architecture.ps1 +./scripts/validation/Test-AcceptanceTools.ps1 +./scripts/build-release.ps1 +~~~ + +发布脚本在 `dist/` 生成 Engineer、Site、Server 三个相互独立的目录和 ZIP。Windows 两端的默认 YAML 位于各自 EXE 旁,运行生成的 DPAPI 身份、日志和 `wintun.dll` 也只写入各自包目录;Server 包单独包含 Linux 二进制、Docker 文件、部署指南、验收工具与校验和。 + +## Git 提交边界 + +- 应提交源码、测试、Markdown/DOCX 规格、示例 YAML、示例 `.env`、CI、第三方许可和内嵌的官方 Wintun DLL。 +- 不提交 `build/`、`dist/`、`node_modules/`、前端编译目录、验收证据、本机数据库、日志或运行时身份。 +- 不提交真实 `.env`、Engineer/Site 实际 YAML、`identity.json`、`site-profiles.json`、Node Token、Join Token、Admin Token、WireGuard 私钥或 TLS 私钥。 +- ZIP/EXE 应通过 GitHub Release 或其他发布渠道分发,不应直接进入源码历史。 + +首次提交和每次推送前运行仓库检查;它会同时检查 Git 候选文件、敏感配置、超大文件和 Markdown 相对链接: + +~~~powershell +./scripts/maintenance/Test-RepositoryHygiene.ps1 +git status --short +git diff --check +~~~ + +## 安全边界 + +- Windows YAML 保存 Server URL、节点名称、首次注册 Join Token 和 Site netstack 上限;Join Token 是明文便捷配置,Node Token 与 WireGuard 私钥仍由机器级 DPAPI 保护,不得写入 YAML。 +- Site YAML 不保存现场 CIDR;远程网段由 Session 动态下发。 +- Docker 只增加 `NET_ADMIN`,映射 `/dev/net/tun`,不使用 privileged。 +- 公网只需要 WireGuard UDP 和受保护的 Bootstrap/Admin 入口;`7001/tcp` 与 `6200/udp` 只在 Overlay 内使用。 +- v1.0 不内置 HTTPS。公网 Bootstrap/Admin 必须放在外部 HTTPS 反向代理后;否则 Join Token、Node Token 和管理请求不会被 HTTP 层加密。WireGuard 不保护这条独立公网 HTTP 路径。 + +## 验收证据 + +~~~powershell +./scripts/validation/New-AcceptanceRun.ps1 -OutputDirectory evidence/run-001 +./scripts/validation/Collect-WindowsEvidence.ps1 -Role Engineer -OutputDirectory evidence/run-001/engineer-a +./scripts/validation/Collect-WindowsEvidence.ps1 -Role Site -OutputDirectory evidence/run-001/site-a +$engineerEvidence = (Get-ChildItem evidence/run-001/engineer-a -Filter '*-engineer-network.json' | Sort-Object LastWriteTimeUtc | Select-Object -Last 1).FullName +$siteEvidence = (Get-ChildItem evidence/run-001/site-a -Filter '*-site-network.json' | Sort-Object LastWriteTimeUtc | Select-Object -Last 1).FullName +./scripts/validation/Set-AcceptanceResult.ps1 -RunDirectory evidence/run-001 -ID T01 -Status PASS -EvidencePath $engineerEvidence,$siteEvidence +./scripts/validation/Test-AcceptanceRun.ps1 -RunDirectory evidence/run-001 +~~~ + +实际记录时必须按执行手册采集所有指定主机证据。工具会校验证据路径、大小、SHA-256 和 Gate 前置条件,但不会替代人工判断证据内容是否真正证明场景。 diff --git a/RemLink_v1.0_技术设计与AI开发规格书_Netstack版.docx b/RemLink_v1.0_技术设计与AI开发规格书_Netstack版.docx new file mode 100644 index 0000000..70e3833 Binary files /dev/null and b/RemLink_v1.0_技术设计与AI开发规格书_Netstack版.docx differ diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9386cbe --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,31 @@ +# RemLink 第三方组件声明 + +RemLink v1.0 使用以下固定版本第三方组件。源码发行包和依赖锁文件记录准确版本及传递依赖图;版权归各自作者所有。 + +| 组件 | 固定版本 | 许可证 | +|---|---:|---| +| wireguard-go | `ecfc5a8d5446` | MIT | +| WireGuard Go 控制库(wgctrl) | `a9ab2273dd10` | MIT | +| Wintun Go 绑定与 Wintun 运行库 | `0fa3db229ce2` / `0.14.1` | MIT | +| WireGuard for Windows Go 包 | `v1.0.1` | MIT | +| gVisor netstack | `39ed1f5ac29c` | Apache-2.0 | +| coder/websocket | `v1.8.15` | ISC | +| Wails | `v2.13.0` | MIT | +| modernc.org/sqlite | `v1.57.0` | BSD-3-Clause;内置 SQLite 属于公有领域 | +| Vue | `v3.5.41` | MIT | +| Vite | `v8.2.2` | MIT | + +完整许可证文本可在对应上游源码发行物和模块/包缓存中查看: + +- [wireguard-go](https://git.zx2c4.com/wireguard-go/) +- [Wintun](https://www.wintun.net/) +- [gVisor](https://gvisor.dev/) +- [coder/websocket](https://github.com/coder/websocket) +- [Wails](https://github.com/wailsapp/wails) +- [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) +- [Vue](https://github.com/vuejs/core) +- [Vite](https://github.com/vitejs/vite) + +内嵌 `wintun.dll` 是 Wintun 0.14.1 官方签名的 AMD64 二进制文件。RemLink 加载前验证 SHA-256: + +`e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce` diff --git a/cmd/engineer/app_windows.go b/cmd/engineer/app_windows.go new file mode 100644 index 0000000..722b4c3 --- /dev/null +++ b/cmd/engineer/app_windows.go @@ -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") + } +} diff --git a/cmd/engineer/app_windows_test.go b/cmd/engineer/app_windows_test.go new file mode 100644 index 0000000..ee2cde2 --- /dev/null +++ b/cmd/engineer/app_windows_test.go @@ -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) + } + } +} diff --git a/cmd/engineer/main.go b/cmd/engineer/main.go new file mode 100644 index 0000000..abcca66 --- /dev/null +++ b/cmd/engineer/main.go @@ -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}, + }) +} diff --git a/cmd/engineer/main_other.go b/cmd/engineer/main_other.go new file mode 100644 index 0000000..0ef438b --- /dev/null +++ b/cmd/engineer/main_other.go @@ -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) +} diff --git a/cmd/engineer/wails.json b/cmd/engineer/wails.json new file mode 100644 index 0000000..d5a3d18 --- /dev/null +++ b/cmd/engineer/wails.json @@ -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" } +} diff --git a/cmd/phase1-node/main.go b/cmd/phase1-node/main.go new file mode 100644 index 0000000..6225e59 --- /dev/null +++ b/cmd/phase1-node/main.go @@ -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) + } +} diff --git a/cmd/phase1-node/run_other.go b/cmd/phase1-node/run_other.go new file mode 100644 index 0000000..c53eb33 --- /dev/null +++ b/cmd/phase1-node/run_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "errors" + +func run([]string) error { + return errors.New("Phase 1 Node POC requires Windows") +} diff --git a/cmd/phase1-node/run_windows.go b/cmd/phase1-node/run_windows.go new file mode 100644 index 0000000..61f6d51 --- /dev/null +++ b/cmd/phase1-node/run_windows.go @@ -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 +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..390e50e --- /dev/null +++ b/cmd/server/main.go @@ -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 +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..384c50d --- /dev/null +++ b/cmd/server/main_test.go @@ -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) + } +} diff --git a/cmd/site/console.go b/cmd/site/console.go new file mode 100644 index 0000000..9203cb5 --- /dev/null +++ b/cmd/site/console.go @@ -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)) +} diff --git a/cmd/site/console_test.go b/cmd/site/console_test.go new file mode 100644 index 0000000..0223f89 --- /dev/null +++ b/cmd/site/console_test.go @@ -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) + } + } +} diff --git a/cmd/site/main.go b/cmd/site/main.go new file mode 100644 index 0000000..e8ad287 --- /dev/null +++ b/cmd/site/main.go @@ -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, + }) +} diff --git a/config/engineer.example.yaml b/config/engineer.example.yaml new file mode 100644 index 0000000..042e4b7 --- /dev/null +++ b/config/engineer.example.yaml @@ -0,0 +1,8 @@ +server: "http://1.2.3.4:8080" +node_name: "Engineer-QSC" +join_token: "" + +# Join Token 只用于首次注册;可以从 Server 查询后直接填写在上面。 +# NodeID、NodeToken 和 WireGuard 私钥仍由程序管理并使用 DPAPI 保护。 +# 远程 CIDR 在界面中按 Site 分别保存到 EXE 旁的 site-profiles.json, +# 不写入本 YAML,也不包含 Node Token、WireGuard 私钥等敏感数据。 diff --git a/config/server.example.yaml b/config/server.example.yaml new file mode 100644 index 0000000..13f8f6c --- /dev/null +++ b/config/server.example.yaml @@ -0,0 +1,14 @@ +server: + http_listen: "0.0.0.0:8080" + control_listen: "10.88.0.1:7001" + wireguard_port: 51820 + +data: + directory: "./data" + +network: + overlay_cidr: "10.88.0.0/16" + server_overlay_ip: "10.88.0.1" + session_udp_port: 6200 + mtu: 1280 + diff --git a/config/site.example.yaml b/config/site.example.yaml new file mode 100644 index 0000000..8da8f8c --- /dev/null +++ b/config/site.example.yaml @@ -0,0 +1,12 @@ +server: "http://1.2.3.4:8080" +node_name: "Qingdao-Site-01" +join_token: "" + +netstack: + tcp_flow_limit: 2048 + udp_flow_limit: 4096 + udp_idle_seconds: 60 + +# Join Token 只用于首次注册;可以从 Server 查询后直接填写在上面。 +# NodeID、NodeToken 和 WireGuard 私钥仍由程序管理并使用 DPAPI 保护。 +# Site LAN CIDR 由 Engineer Session 动态下发。 diff --git a/deploy/docker/.env.china.example b/deploy/docker/.env.china.example new file mode 100644 index 0000000..f729fa2 --- /dev/null +++ b/deploy/docker/.env.china.example @@ -0,0 +1,7 @@ +REMLINK_WG_ENDPOINT=vpn.example.com:51820 +REMLINK_WG_PORT=51820 +REMLINK_HTTP_BIND=127.0.0.1 +REMLINK_ADMIN_TOKEN=replace-with-a-long-random-value +REMLINK_APT_FORCE_IPV4=1 +REMLINK_APT_DEBIAN_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian +REMLINK_APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example new file mode 100644 index 0000000..c151fa1 --- /dev/null +++ b/deploy/docker/.env.example @@ -0,0 +1,7 @@ +REMLINK_WG_ENDPOINT=vpn.example.com:51820 +REMLINK_WG_PORT=51820 +REMLINK_HTTP_BIND=127.0.0.1 +REMLINK_ADMIN_TOKEN=replace-with-a-long-random-value +REMLINK_APT_FORCE_IPV4=1 +REMLINK_APT_DEBIAN_MIRROR= +REMLINK_APT_SECURITY_MIRROR= diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..a3f7259 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.7 +FROM node:24.14.0-bookworm-slim AS frontend +WORKDIR /src +COPY frontend/package.json frontend/package-lock.json ./frontend/ +RUN npm ci --prefix frontend +COPY frontend ./frontend +RUN npm run typecheck --prefix frontend && npm run build --prefix frontend + +FROM golang:1.26.7-bookworm AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=frontend /src/frontend/engineer/dist ./frontend/engineer/dist +COPY --from=frontend /src/frontend/server/dist ./frontend/server/dist +ARG REMLINK_VERSION=1.0.0 +ARG REMLINK_COMMIT=container +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \ + -ldflags "-s -w -X remlink/internal/version.Version=${REMLINK_VERSION} -X remlink/internal/version.Commit=${REMLINK_COMMIT}" \ + -o /out/remlink-server ./cmd/server + +FROM debian:bookworm-slim +ARG APT_FORCE_IPV4=1 +ARG APT_DEBIAN_MIRROR= +ARG APT_SECURITY_MIRROR= +RUN apt_options="-o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30"; \ + if [ -n "$APT_SECURITY_MIRROR" ]; then sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi; \ + if [ -n "$APT_DEBIAN_MIRROR" ]; then sed -i "s|http://deb.debian.org/debian|$APT_DEBIAN_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi; \ + if [ -n "$APT_DEBIAN_MIRROR" ] || [ -n "$APT_SECURITY_MIRROR" ]; then \ + apt_options="$apt_options -o Acquire::http::Pipeline-Depth=0"; \ + fi; \ + if [ "$APT_FORCE_IPV4" = "1" ]; then apt_options="$apt_options -o Acquire::ForceIPv4=true"; fi; \ + apt-get $apt_options update \ + && apt-get $apt_options install -y --no-install-recommends \ + ca-certificates curl iproute2 iptables wireguard-tools \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /out/remlink-server /usr/local/bin/remlink-server +COPY deploy/docker/preflight.sh /usr/local/bin/remlink-preflight +COPY THIRD_PARTY_NOTICES.md /usr/share/doc/remlink/THIRD_PARTY_NOTICES.md +RUN chmod 0755 /usr/local/bin/remlink-server /usr/local/bin/remlink-preflight \ + && mkdir -p /app/data +VOLUME ["/app/data"] +EXPOSE 8080/tcp 51820/udp +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=4 \ + CMD curl --fail --silent --show-error http://127.0.0.1:8080/api/v1/server/info >/dev/null || exit 1 +ENTRYPOINT ["/usr/local/bin/remlink-preflight"] +CMD ["/usr/local/bin/remlink-server", "-config", "/etc/remlink/server.yaml"] diff --git a/deploy/docker/Dockerfile.release b/deploy/docker/Dockerfile.release new file mode 100644 index 0000000..fc147ff --- /dev/null +++ b/deploy/docker/Dockerfile.release @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.7 +FROM debian:bookworm-slim + +ARG APT_FORCE_IPV4=1 +ARG APT_DEBIAN_MIRROR= +ARG APT_SECURITY_MIRROR= +RUN apt_options="-o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30"; \ + if [ -n "$APT_SECURITY_MIRROR" ]; then sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi; \ + if [ -n "$APT_DEBIAN_MIRROR" ]; then sed -i "s|http://deb.debian.org/debian|$APT_DEBIAN_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi; \ + if [ -n "$APT_DEBIAN_MIRROR" ] || [ -n "$APT_SECURITY_MIRROR" ]; then \ + apt_options="$apt_options -o Acquire::http::Pipeline-Depth=0"; \ + fi; \ + if [ "$APT_FORCE_IPV4" = "1" ]; then apt_options="$apt_options -o Acquire::ForceIPv4=true"; fi; \ + apt-get $apt_options update \ + && apt-get $apt_options install -y --no-install-recommends \ + ca-certificates curl iproute2 iptables wireguard-tools \ + && rm -rf /var/lib/apt/lists/* + +COPY linux-amd64/remlink-server /usr/local/bin/remlink-server +COPY docker/preflight.sh /usr/local/bin/remlink-preflight +COPY linux-amd64/THIRD_PARTY_NOTICES.md /usr/share/doc/remlink/THIRD_PARTY_NOTICES.md + +RUN chmod 0755 /usr/local/bin/remlink-server /usr/local/bin/remlink-preflight \ + && mkdir -p /app/data + +VOLUME ["/app/data"] +EXPOSE 8080/tcp 51820/udp +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=4 \ + CMD curl --fail --silent --show-error http://127.0.0.1:8080/api/v1/server/info >/dev/null || exit 1 +ENTRYPOINT ["/usr/local/bin/remlink-preflight"] +CMD ["/usr/local/bin/remlink-server", "-config", "/etc/remlink/server.yaml"] diff --git a/deploy/docker/README.md b/deploy/docker/README.md new file mode 100644 index 0000000..1245e02 --- /dev/null +++ b/deploy/docker/README.md @@ -0,0 +1,43 @@ +# Server Docker 部署 + +完整生产流程见 `docs/deployment-and-usage.md`;在源码仓库中该文件位于 `../../docs/deployment-and-usage.md`,在发布包中位于 `../docs/deployment-and-usage.md`。 + +Compose 基线删除全部 Linux capability 后只增加 `NET_ADMIN`,映射 `/dev/net/tun`,从不启用 privileged。入口预检会在启动 Server 前检查 TUN、内核 WireGuard、capability 和 IPv4 forwarding。 + +## 从发布包部署 + +在解压后的 `RemLink-Server-v1.0.0-linux-amd64/docker` 目录: + +~~~bash +cp .env.example .env +chmod 600 .env +# 编辑 .env 与 server.yaml +mkdir -p data +docker compose --env-file .env -f compose.release.yaml config --quiet +docker compose --env-file .env -f compose.release.yaml up --build -d +docker compose --env-file .env -f compose.release.yaml exec server remlink-server -config /etc/remlink/server.yaml -print-join-token +~~~ + +`compose.release.yaml` 使用发布包中的 `linux-amd64/remlink-server`,不会要求完整源码。 + +中国大陆网络建议改为 `cp .env.china.example .env`。该模板把 Debian 主仓库和安全仓库切换到清华 TUNA,同时强制 IPv4、关闭 apt HTTP pipelining,并保留超时/重试。Debian 12 容器使用 `/etc/apt/sources.list.d/debian.sources` 的 DEB822 格式,Dockerfile 会通过构建参数替换其中 URI。TUNA 也提示安全镜像可能有同步延迟;网络允许时可把 `REMLINK_APT_SECURITY_MIRROR` 留空以继续使用 Debian 官方安全源。详情见 [TUNA Debian 帮助](https://mirrors.tuna.tsinghua.edu.cn/help/debian/) 和 [TUNA Debian Security 帮助](https://mirrors.tuna.tsinghua.edu.cn/help/debian-security/)。 + +## 从源码仓库构建 + +在仓库 `deploy/docker` 目录: + +~~~bash +cp .env.example .env +chmod 600 .env +docker compose --env-file .env -f compose.yaml up --build -d +~~~ + +源码 Compose 会构建前端和 Go Server。 + +若构建停在 Debian `apt-get`,先用 `docker run --rm debian:bookworm-slim sh -c "apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=2 -o Acquire::http::Timeout=30 update"` 验证容器网络。Dockerfile 默认强制 IPv4,并为 apt 配置 30 秒超时和 5 次重试;确认环境只有 IPv6 时才把 `REMLINK_APT_FORCE_IPV4` 改为 `0`。中国大陆环境优先使用 `.env.china.example`;测试命令仍超时时,应修复 Docker daemon 的 DNS/代理或在 Ubuntu 上用 `docker build --network=host`,而不是扩大容器权限。 + +只映射 `8080/tcp` 和配置的 WireGuard UDP 端口。Control `7001/tcp` 与 Session `6200/udp` 只绑定 Overlay,不发布。数据库、密钥和日志写入 `/app/data`,由宿主机 `./data` 持久化。 + +v1.0 的 `8080/tcp` 不内置 TLS。将 `REMLINK_HTTP_BIND` 设为 `127.0.0.1` 并使用外部 HTTPS 反向代理,或绑定到可信管理接口;不要把未加密 Bootstrap/Admin 直接暴露到 Internet。 + +管理页修改 WireGuard 端口后,必须同步更新 `REMLINK_WG_PORT` 和 `REMLINK_WG_ENDPOINT`,再强制重建服务,使宿主机与容器映射跟随数据库中的新端口。`./data` 不会因容器重建丢失。 diff --git a/deploy/docker/compose.release.yaml b/deploy/docker/compose.release.yaml new file mode 100644 index 0000000..2ed09e0 --- /dev/null +++ b/deploy/docker/compose.release.yaml @@ -0,0 +1,30 @@ +services: + server: + build: + context: .. + dockerfile: docker/Dockerfile.release + args: + APT_FORCE_IPV4: "${REMLINK_APT_FORCE_IPV4:-1}" + APT_DEBIAN_MIRROR: "${REMLINK_APT_DEBIAN_MIRROR:-}" + APT_SECURITY_MIRROR: "${REMLINK_APT_SECURITY_MIRROR:-}" + image: remlink/server:1.0.0 + restart: unless-stopped + cap_drop: + - ALL + cap_add: + - NET_ADMIN + security_opt: + - no-new-privileges:true + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + net.ipv4.ip_forward: "1" + ports: + - "${REMLINK_HTTP_BIND:-0.0.0.0}:8080:8080/tcp" + - "${REMLINK_WG_PORT:-51820}:${REMLINK_WG_PORT:-51820}/udp" + environment: + REMLINK_WG_ENDPOINT: ${REMLINK_WG_ENDPOINT:?set REMLINK_WG_ENDPOINT to the public host and published UDP port} + REMLINK_ADMIN_TOKEN: ${REMLINK_ADMIN_TOKEN:-} + volumes: + - ./server.yaml:/etc/remlink/server.yaml:ro + - ./data:/app/data diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml new file mode 100644 index 0000000..0a9023f --- /dev/null +++ b/deploy/docker/compose.yaml @@ -0,0 +1,31 @@ +services: + server: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile + args: + REMLINK_VERSION: "1.0.0" + APT_FORCE_IPV4: "${REMLINK_APT_FORCE_IPV4:-1}" + APT_DEBIAN_MIRROR: "${REMLINK_APT_DEBIAN_MIRROR:-}" + APT_SECURITY_MIRROR: "${REMLINK_APT_SECURITY_MIRROR:-}" + image: remlink/server:1.0.0 + restart: unless-stopped + cap_drop: + - ALL + cap_add: + - NET_ADMIN + security_opt: + - no-new-privileges:true + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + net.ipv4.ip_forward: "1" + ports: + - "${REMLINK_HTTP_BIND:-0.0.0.0}:8080:8080/tcp" + - "${REMLINK_WG_PORT:-51820}:${REMLINK_WG_PORT:-51820}/udp" + environment: + REMLINK_WG_ENDPOINT: ${REMLINK_WG_ENDPOINT:?set REMLINK_WG_ENDPOINT to the public host and published UDP port} + REMLINK_ADMIN_TOKEN: ${REMLINK_ADMIN_TOKEN:-} + volumes: + - ./server.yaml:/etc/remlink/server.yaml:ro + - ./data:/app/data diff --git a/deploy/docker/preflight.sh b/deploy/docker/preflight.sh new file mode 100644 index 0000000..41ce32a --- /dev/null +++ b/deploy/docker/preflight.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +if [ ! -c /dev/net/tun ]; then + echo "RemLink preflight: /dev/net/tun is missing; map the TUN device into the container" >&2 + exit 1 +fi + +probe_interface="rl-wg-probe" +cleanup() { + ip link delete dev "$probe_interface" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +if probe_error="$(ip link add dev "$probe_interface" type wireguard 2>&1)"; then + : +else + echo "RemLink preflight: kernel WireGuard or CAP_NET_ADMIN is unavailable" >&2 + if [ -n "$probe_error" ]; then + echo "RemLink preflight: ip link error: $probe_error" >&2 + fi + exit 1 +fi +cleanup +trap - EXIT INT TERM + +if [ "$(cat /proc/sys/net/ipv4/ip_forward)" != "1" ]; then + echo "RemLink preflight: net.ipv4.ip_forward must be 1" >&2 + exit 1 +fi + +exec "$@" diff --git a/deploy/docker/server.yaml b/deploy/docker/server.yaml new file mode 100644 index 0000000..1b3dabb --- /dev/null +++ b/deploy/docker/server.yaml @@ -0,0 +1,13 @@ +server: + http_listen: "0.0.0.0:8080" + control_listen: "10.88.0.1:7001" + wireguard_port: 51820 + +data: + directory: "/app/data" + +network: + overlay_cidr: "10.88.0.0/16" + server_overlay_ip: "10.88.0.1" + session_udp_port: 6200 + mtu: 1280 diff --git a/deploy/phase1-server/README.md b/deploy/phase1-server/README.md new file mode 100644 index 0000000..435f314 --- /dev/null +++ b/deploy/phase1-server/README.md @@ -0,0 +1,46 @@ +# Phase 1 Gate A Server 手工 POC + +本目录只用于 Ubuntu/Linux 内核 WireGuard 的一次性 Phase 1 验证,不是 Phase 2 之后的受管 Server。 + +## Server 准备 + +1. 安装发行版 WireGuard 工具,确认 `ip link add dev wg-test type wireguard` 成功,然后立即删除测试接口。 +2. 用 `wg genkey` 和 `wg pubkey` 生成 Server 密钥对,私钥只留在 Server。 +3. 在每个 Windows POC 节点分别生成密钥对,私钥只留在该节点。 +4. 把 `wg0.conf.example` 复制到 `/etc/wireguard/wg0.conf`,权限设为 `0600`,替换全部占位符。 +5. 启用 IPv4 forwarding,并允许流量从 `wg0` 转发回 `wg0`;不要添加 SNAT/MASQUERADE。 +6. 执行 `wg-quick up wg0`,确认 `wg show wg0` 显示 UDP 51820。 + +每个 Server Peer 的 `AllowedIPs` 只能是该节点 Overlay `/32`,绝不能添加远端 Site LAN CIDR。 + +## Windows 节点 + +在每个节点的管理员 PowerShell 中运行。两个只读/局部探针可单独执行: + +~~~powershell +go run ./cmd/phase1-node --runtime-probe +go run ./cmd/phase1-node --adapter-probe --address 10.88.0.2/16 +~~~ + +适配器探针需要管理员权限,会配置并保留可复用的 `RemLink` 适配器,但会关闭活动 Wintun 会话,不会删除适配器。 + +~~~powershell +$secret = Read-Host '节点私钥' -AsSecureString +$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) +try { + $env:REMLINK_POC_PRIVATE_KEY = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) +} finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) +} +go run ./cmd/phase1-node --address 10.88.0.2/16 --server-endpoint :51820 --server-public-key +~~~ + +节点 B 使用 `10.88.0.3/16`。两个进程都报告 wireguard-go 就绪后,执行双向 Overlay ping。 + +## Gate A 证据 + +- 每台 Windows 节点 `Get-NetAdapter -Name RemLink` 恰好返回一个适配器。 +- `Get-NetIPConfiguration -InterfaceAlias RemLink` 显示分配地址与 MTU 1280。 +- `wg show wg0` 显示两个 `/32` peer 的近期握手和计数。 +- 双向 Overlay ping 经 Server 成功。 +- 抓包或 Server 计数证明路径为 Node A → Server `wg0` → Node B,不存在 P2P endpoint。 diff --git a/deploy/phase1-server/wg0.conf.example b/deploy/phase1-server/wg0.conf.example new file mode 100644 index 0000000..810b62f --- /dev/null +++ b/deploy/phase1-server/wg0.conf.example @@ -0,0 +1,18 @@ +# Phase 1 only: copy to /etc/wireguard/wg0.conf after replacing every placeholder. +# The Server uses Linux Kernel WireGuard and routes Overlay traffic wg0 -> wg0. + +[Interface] +Address = 10.88.0.1/16 +ListenPort = 51820 +PrivateKey = + +[Peer] +# Windows Node A +PublicKey = +AllowedIPs = 10.88.0.2/32 + +[Peer] +# Windows Node B +PublicKey = +AllowedIPs = 10.88.0.3/32 + diff --git a/docs/deployment-and-usage.md b/docs/deployment-and-usage.md new file mode 100644 index 0000000..8bade64 --- /dev/null +++ b/docs/deployment-and-usage.md @@ -0,0 +1,477 @@ +# RemLink v1.0 三端部署与使用指南 + +本文覆盖 Linux Server、Windows Engineer、Windows Site 三端从准备、注册、联通、使用到备份升级的完整流程。根目录 DOCX 是需求权威来源;本文只描述当前代码和发布包已经提供的能力,不把自动化测试等同于物理环境验收。 + +## 1. 部署边界与端口 + +RemLink 是中心辐射结构: + +- Server 运行在 Linux amd64,使用内核 WireGuard `wg0`,负责注册、Overlay 地址分配、Control、Session 编排和管理页面。 +- Engineer 运行在 Windows amd64,提供 GUI;每台 Engineer 同时只允许一个非终态远程 Session。 +- Site 运行在另一台 Windows amd64,提供控制台和进程内 gVisor netstack 网关。它用本机普通套接字访问现场目标,因此 PLC 不需要返回 Overlay 的路由。 +- 两类 Windows 节点都只创建并复用一个名为 `RemLink` 的 Wintun。不要在同一 Windows 主机同时部署 Engineer 和 Site。 +- Overlay 流量全部经 Server 中转,不建立 P2P,不使用 WinNAT、Windows IP Forwarding、SNAT/MASQUERADE,也不在 Server 为现场 LAN 配置 WireGuard `AllowedIPs`。 + +| 端口 | 作用 | 暴露范围 | +|---|---|---| +| `8080/tcp` | Web UI、Bootstrap、Admin API | 仅可信管理网;公网部署应由外部 HTTPS 反向代理保护 | +| `51820/udp` | WireGuard 公网入口 | Engineer 和 Site 必须可达 | +| `7001/tcp` | Overlay Control WebSocket | 只监听 Server Overlay IP,不做公网映射 | +| `6200/udp` | Overlay Session 数据报 | 只在 Overlay 内使用,不做公网映射 | + +`7001/tcp` 和 `6200/udp` 绝不能添加到公网端口映射。 + +## 2. 环境准备 + +### 2.1 Linux Server + +准备一台 Linux amd64 主机,并确认: + +- 有稳定公网 IPv4 或域名;NAT 场景已把 WireGuard UDP 端口转发到 Server。 +- 内核支持 WireGuard,存在 `/dev/net/tun`,Docker 和 Compose 插件可用;原生部署还需要 `iproute2`、`iptables` 和 `wireguard-tools`。 +- 主机时间同步正常。 +- 默认 Overlay `10.88.0.0/16` 与任一 Windows 主机本地直连网段不冲突。 +- 现场 CIDR 不与 Overlay、Engineer 本地网段或保留地址冲突,且不使用 `0.0.0.0/0`。 + +先完成宿主机检查: + +~~~bash +uname -m +test -c /dev/net/tun && echo "TUN 就绪" +sudo modprobe wireguard +sudo ip link add dev wg-probe type wireguard +sudo ip link del dev wg-probe +docker version +docker compose version +~~~ + +若临时 `wg-probe` 创建失败,先修复内核支持,不要用 privileged 容器绕过预检。 + +### 2.2 Windows Engineer 与 Site + +每台 Windows amd64 主机需要管理员权限,并且能访问 Server 的 HTTPS/HTTP Bootstrap 地址和 WireGuard 公网 UDP 地址。主机不能有与 Overlay 冲突的本地直连网段,也不能运行另一个占用 `RemLink` 适配器的 RemLink 角色。 + +Site 还必须从 Windows 本机访问每个现场目标网段。普通默认路由不算 Site 路由能力;目标应为直连网段或有明确非默认路由: + +~~~powershell +Get-NetRoute -AddressFamily IPv4 | Sort-Object DestinationPrefix,RouteMetric | Format-Table DestinationPrefix,NextHop,InterfaceAlias,RouteMetric +~~~ + +## 3. 获取并核验发布包 + +三端发布物完全分开,构建后得到三个互不包含对方程序的 ZIP: + +- `RemLink-Engineer-v1.0.0-windows-amd64.zip`:只包含 Engineer EXE、`engineer.yaml`、文档和校验工具。 +- `RemLink-Site-v1.0.0-windows-amd64.zip`:只包含 Site EXE、`site.yaml`、文档和校验工具。 +- `RemLink-Server-v1.0.0-linux-amd64.zip`:只包含 Linux Server、Docker 部署目录、文档和验收工具。 + +Engineer 与 Site 是便携式目录程序。配置、身份、日志和首次释放的 `wintun.dll` 都以各自 EXE 所在目录为根,不依赖当前工作目录,也不写入 `C:\ProgramData\RemLink`。Engineer 还会在同目录生成不含秘密的 `site-profiles.json`,按 Site 记忆 Remote CIDR。不得把两个 Windows 包合并到同一个目录。 + +先比对可信渠道公布的 ZIP SHA-256,再解压。Windows 可校验整个发布目录: + +~~~powershell +.\RemLink-Engineer-v1.0.0-windows-amd64\scripts\validation\Test-ReleasePackage.ps1 -PackagePath .\RemLink-Engineer-v1.0.0-windows-amd64 -Role Engineer +.\RemLink-Site-v1.0.0-windows-amd64\scripts\validation\Test-ReleasePackage.ps1 -PackagePath .\RemLink-Site-v1.0.0-windows-amd64 -Role Site +.\RemLink-Server-v1.0.0-linux-amd64\scripts\validation\Test-ReleasePackage.ps1 -PackagePath .\RemLink-Server-v1.0.0-linux-amd64 -Role Server +~~~ + +Linux 可在包顶层执行: + +~~~bash +cd RemLink-Server-v1.0.0-linux-amd64 +sha256sum -c SHA256SUMS.txt +~~~ + +当前构建未做 Authenticode 签名;若组织策略要求签名,应先完成内部签名发布流程。 + +## 4. 部署 Server + +### 4.1 推荐:发布包 Docker Compose + +将发布包固定放到 `/opt/remlink`,因为持久数据位于 `docker/data`: + +~~~bash +sudo mkdir -p /opt/remlink +sudo cp -a RemLink-Server-v1.0.0-linux-amd64/. /opt/remlink/ +cd /opt/remlink/docker +sudo cp .env.example .env +sudo chmod 600 .env +sudo mkdir -p data +~~~ + +中国大陆网络把复制命令改为 `sudo cp .env.china.example .env`。该模板使用清华 TUNA 的 Debian 主仓库和安全仓库、强制 IPv4,并关闭可能导致连接重置的 apt HTTP pipelining。Debian 12 容器的软件源是 DEB822 文件,Dockerfile 会按构建参数替换 URI。TUNA 提示安全镜像可能存在同步延迟;如果官方安全源在你的网络中稳定,可把 `REMLINK_APT_SECURITY_MIRROR` 留空。参考 [TUNA Debian 镜像说明](https://mirrors.tuna.tsinghua.edu.cn/help/debian/)。 + +编辑 `.env`: + +~~~dotenv +REMLINK_WG_ENDPOINT=vpn.example.com:51820 +REMLINK_WG_PORT=51820 +REMLINK_HTTP_BIND=127.0.0.1 +REMLINK_ADMIN_TOKEN=替换为足够长的随机管理令牌 +REMLINK_APT_FORCE_IPV4=1 +REMLINK_APT_DEBIAN_MIRROR= +REMLINK_APT_SECURITY_MIRROR= +~~~ + +- `REMLINK_WG_ENDPOINT` 必须是 Windows 实际可达的公网 `主机:端口`。 +- `REMLINK_WG_PORT` 必须与 endpoint 端口、`server.yaml` 的 `wireguard_port`、NAT 和防火墙完全一致。 +- `REMLINK_HTTP_BIND=127.0.0.1` 用于同机 HTTPS 反向代理;可信管理网直连 HTTP 时改为该管理接口的具体 IP。仅在明确接受风险时使用 `0.0.0.0`。 +- `REMLINK_ADMIN_TOKEN` 建议始终设置,不能提交到版本库、截图或验收证据。 +- `REMLINK_APT_FORCE_IPV4=1` 让镜像构建阶段的 apt 避开常见 IPv6 黑洞;确认构建网络只有 IPv6 时才设为 `0`。 +- 两个 `REMLINK_APT_*_MIRROR` 只影响镜像构建,不影响 Ubuntu 宿主机软件源;中国模板已填入 TUNA URI,通用模板保持空值并使用 Debian 官方源。 + +`server.yaml` 默认内容如下;初次使用非默认 WireGuard 端口时同步修改: + +~~~yaml +server: + http_listen: "0.0.0.0:8080" + control_listen: "10.88.0.1:7001" + wireguard_port: 51820 +data: + directory: "/app/data" +network: + overlay_cidr: "10.88.0.0/16" + server_overlay_ip: "10.88.0.1" + session_udp_port: 6200 + mtu: 1280 +~~~ + +校验并启动: + +~~~bash +cd /opt/remlink/docker +sudo docker compose --env-file .env -f compose.release.yaml config --quiet +sudo docker compose --env-file .env -f compose.release.yaml up --build -d +sudo docker compose --env-file .env -f compose.release.yaml ps +sudo docker compose --env-file .env -f compose.release.yaml logs --tail=100 server +curl --fail http://127.0.0.1:8080/api/v1/server/info +~~~ + +容器只保留 `NET_ADMIN`、映射 `/dev/net/tun`,不启用 privileged。预检失败时根据日志修复 TUN、内核 WireGuard、转发或 capability 问题,不要扩大权限。 + +防火墙应允许 Windows 来源访问 `51820/udp`。直接使用 HTTP 时只允许可信管理网访问 `8080/tcp`;反向代理时只开放 `443/tcp` 并保留 `REMLINK_HTTP_BIND=127.0.0.1`;不要开放 `7001` 和 `6200`。 + +### 4.2 HTTPS 反向代理 + +RemLink v1.0 本身不终止 TLS。公网 Bootstrap 若直接使用 HTTP,Join Token、Node Token 和 Admin 请求不会被 HTTP 层加密;WireGuard 不能保护这条独立公网路径。 + +可用 Nginx、Caddy 或组织网关终止 HTTPS。Nginx 最小代理段如下,证书按实际配置: + +~~~nginx +server { + listen 443 ssl; + server_name remlink.example.com; + ssl_certificate /etc/ssl/remlink/fullchain.pem; + ssl_certificate_key /etc/ssl/remlink/privkey.pem; + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} +~~~ + +Windows YAML 的 `server` 随后填写 `https://remlink.example.com`,不能附加路径、查询参数、片段或 URL 用户名密码。 + +### 4.3 获取和轮换 Join Token + +首次启动成功后获取当前 Token: + +~~~bash +cd /opt/remlink/docker +sudo docker compose --env-file .env -f compose.release.yaml exec server remlink-server -config /etc/remlink/server.yaml -print-join-token +~~~ + +Token 在轮换前可登记多个节点,不是每使用一次自动失效。全部预期节点注册后立即轮换: + +~~~bash +sudo docker compose --env-file .env -f compose.release.yaml exec server remlink-server -config /etc/remlink/server.yaml -rotate-join-token +~~~ + +也可在管理页面“网络”页轮换;新值仅在本次响应中显示。 + +### 4.4 可选:原生 Linux 服务 + +~~~bash +sudo apt-get update +sudo apt-get install -y ca-certificates iproute2 iptables wireguard-tools +sudo install -m 0755 linux-amd64/remlink-server /usr/local/bin/remlink-server +sudo install -d -m 0750 /etc/remlink /var/lib/remlink +sudo cp linux-amd64/server.yaml /etc/remlink/server.yaml +sudo chmod 0640 /etc/remlink/server.yaml +~~~ + +把 `/etc/remlink/server.yaml` 的 `data.directory` 改为 `/var/lib/remlink`。创建 root 专用的 `/etc/remlink/remlink.env`: + +~~~dotenv +REMLINK_WG_ENDPOINT=vpn.example.com:51820 +REMLINK_ADMIN_TOKEN=替换为足够长的随机管理令牌 +~~~ + +创建 `/etc/systemd/system/remlink-server.service`: + +~~~ini +[Unit] +Description=RemLink Server +After=network-online.target +Wants=network-online.target +[Service] +Type=simple +User=root +WorkingDirectory=/var/lib/remlink +EnvironmentFile=/etc/remlink/remlink.env +ExecStart=/usr/local/bin/remlink-server -config /etc/remlink/server.yaml +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +[Install] +WantedBy=multi-user.target +~~~ + +~~~bash +sudo chmod 600 /etc/remlink/remlink.env +sudo systemctl daemon-reload +sudo systemctl enable --now remlink-server +sudo systemctl status remlink-server +sudo journalctl -u remlink-server -n 100 --no-pager +curl --fail http://127.0.0.1:8080/api/v1/server/info +sudo /usr/local/bin/remlink-server -config /etc/remlink/server.yaml -print-join-token +~~~ + +原生进程需管理 `wg0`、路由和转发规则;当前基线以 root 运行,但不关闭主机防火墙,也不配置 SNAT。 + +## 5. 部署 Engineer + +将 Engineer ZIP 单独解压到 Engineer 主机。建议把解压后的顶层目录固定为 `C:\RemLink\Engineer`,然后以管理员身份打开 PowerShell: + +~~~powershell +Set-Location C:\RemLink\Engineer +notepad .\engineer.yaml +~~~ + +配置文件直接包含首次注册所需的 Join Token: + +~~~yaml +server: "https://remlink.example.com" +node_name: "Engineer-Shanghai-01" +join_token: "粘贴从 Server 获取的 Join Token" +~~~ + +`join_token` 以明文保存在本机 YAML 中,仅在尚无 `identity.json` 时用于首次注册。`NodeID`、`NodeToken` 和 WireGuard 私钥仍由程序管理,不能加入 YAML。默认文件位置: + +- 配置:`C:\RemLink\Engineer\engineer.yaml` +- 身份:`C:\RemLink\Engineer\identity.json` +- 日志:`C:\RemLink\Engineer\logs\engineer.jsonl` +- Wintun:`C:\RemLink\Engineer\wintun.dll`,由 EXE 首次释放并校验 SHA-256,不与 Site 共用文件。 + +保存配置后直接首次启动: + +~~~powershell +.\RemLinkEngineer.exe +~~~ + +程序使用 Windows 机器级 DPAPI 保护注册后的身份。确认管理页显示节点 ONLINE 后,关闭 GUI并重新启动一次: + +~~~powershell +.\RemLinkEngineer.exe +~~~ + +生产运行仍需管理员权限。Engineer 是交互式 GUI,不要注册为 SYSTEM 后台任务。 + +## 6. 部署 Site + +将 Site ZIP 单独解压到另一台 Site 主机。建议把解压后的顶层目录固定为 `C:\RemLink\Site`,然后以管理员身份打开 PowerShell: + +~~~powershell +Set-Location C:\RemLink\Site +notepad .\site.yaml +~~~ + +~~~yaml +server: "https://remlink.example.com" +node_name: "Qingdao-Site-01" +join_token: "粘贴从 Server 获取的 Join Token" +netstack: + tcp_flow_limit: 2048 + udp_flow_limit: 4096 + udp_idle_seconds: 60 +~~~ + +Site YAML 不允许保存现场 LAN CIDR;CIDR 由每次 Engineer Session 动态下发。三个 netstack 数值必须为正,`udp_idle_seconds` 范围为 1–86400 秒。 + +保存配置后直接首次启动: + +~~~powershell +.\RemLinkSite.exe +~~~ + +看到以下状态后按 `Ctrl+C` 停止,再不带 Token 重启: + +~~~text +OverlayIP=<分配地址> WireGuard=CONNECTED +Control=CONNECTED +RemoteSubnet=READY SubnetGateway=GVISOR_NETSTACK/READY +~~~ + +Site 的配置位于 `C:\RemLink\Site\site.yaml`,身份位于 `C:\RemLink\Site\identity.json`,日志位于 `C:\RemLink\Site\logs\site.jsonl`,Wintun 位于 `C:\RemLink\Site\wintun.dll`。这些文件均属于 Site 包目录,不与 Engineer 共用。Engineer/Site 的 Token 取值优先级均为命令行 `-join-token`、环境变量 `REMLINK_JOIN_TOKEN`、YAML `join_token`;日常部署只填写 YAML 即可。 + +如需随系统启动,先在前台完成注册和联通验证,停止前台进程,再注册最高权限任务: + +~~~powershell +$action = New-ScheduledTaskAction -Execute 'C:\RemLink\Site\RemLinkSite.exe' -Argument '-config "C:\RemLink\Site\site.yaml"' -WorkingDirectory 'C:\RemLink\Site' +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +Register-ScheduledTask -TaskName 'RemLink Site' -Action $action -Trigger $trigger -Principal $principal +Start-ScheduledTask -TaskName 'RemLink Site' +Get-ScheduledTaskInfo -TaskName 'RemLink Site' +~~~ + +不要同时运行前台 Site 和计划任务。SYSTEM 可读取同机机器级 DPAPI 身份;身份复制到另一台机器后不能解密,迁移主机必须重新注册。 + +## 7. 首次联通检查 + +Server: + +~~~bash +cd /opt/remlink/docker +sudo docker compose --env-file .env -f compose.release.yaml ps +sudo docker compose --env-file .env -f compose.release.yaml exec server wg show wg0 +sudo docker compose --env-file .env -f compose.release.yaml logs --tail=100 server +~~~ + +应看到两个 Windows peer 的最近握手和计数;每个 peer 在 Server 只应有自己的 Overlay `/32`。 + +分别在 Engineer 和 Site: + +~~~powershell +$adapter = @(Get-NetAdapter -Name RemLink -ErrorAction Stop) +if ($adapter.Count -ne 1) { throw "RemLink 适配器数量不是 1" } +$adapter | Format-List Name,Status,InterfaceDescription,ifIndex +Get-NetIPAddress -InterfaceAlias RemLink -AddressFamily IPv4 +Get-NetIPConfiguration -InterfaceAlias RemLink +~~~ + +应只有一个 `RemLink` 适配器,地址属于 Overlay,MTU 为 1280。不要安装 WireGuardNT 第二适配器。 + +浏览器打开受保护的 Server URL。若配置了管理令牌,在左侧令牌框输入后点击“应用”。依次检查: + +- “节点”:Engineer/Site 为 ONLINE,Overlay IP 唯一,WG Handshake 与 LastSeen 更新。 +- “会话”:首次部署为空。 +- “网络”:Overlay、Server IP、端口和 MTU 正确。 +- “日志”:没有持续 ERROR。 + +全部节点登记后轮换 Join Token。 + +## 8. 建立并使用 Session + +1. 在 Site 本机先验证目标,例如 `ping 192.168.13.10` 和 `Test-NetConnection 192.168.13.10 -Port 502`。目标网段必须有直连或明确非默认路由。 +2. 启动 Engineer,确认“服务器已连接”和“Control 在线”。 +3. 选择 ONLINE 且 Remote Subnet“可用”的 Site。 +4. 输入规范 CIDR,例如 `192.168.13.0/24`;可添加多个。 +5. 等待本地冲突预检显示“通过,无冲突”。失败时处理 Engineer 已有直连/路由,不能强行绕过。 +6. 点击“连接现场”。正常状态为“正在创建(`CREATING`)”→“正在准备现场端(`PREPARING_SITE`)”→“准备就绪(`READY`)”→“活动中(`ACTIVE`)”。 +7. ACTIVE 后用原生工具访问目标: + +~~~powershell +ping 192.168.13.10 +Test-NetConnection 192.168.13.10 -Port 102 +Test-NetConnection 192.168.13.10 -Port 502 +Test-NetConnection 192.168.13.10 -Port 80 +~~~ + +TCP、UDP 和受约束的 ICMP Echo 走通用 netstack/主机套接字,不依赖协议专用代理。Site 日志记录 Session 和路由结果,不记录数据包载荷。 + +8. 结束后点击“断开会话”,确认 Engineer 远程路由删除。异常退出后,下次启动会清理本程序拥有的陈旧路由。 + +每个 Engineer 同时只能有一个非终态 Session;切换 Site 前先断开。多个 Engineer 可访问同一 Site,两个 Site 也可各自使用相同现场 CIDR,SessionID 会隔离数据流。 + +Engineer 的 Remote CIDR 按 Site NodeID 独立保存。选择现场时只加载并发送该现场的网段,例如现场 A 可保存 `192.168.17.0/24`,现场 B 可保存 `192.168.107.0/24`,不需要来回删除和重建。Site 心跳超过离线阈值后,Server 会以 `SITE_OFFLINE` 自动关闭相关会话并通知 Engineer 清理本地路由。 + +## 9. 日常管理 + +- “节点”页可改名称或 Overlay IP、撤销节点。修改在线节点 IP 会关闭相关 Session 并触发重新 Bootstrap;撤销后旧 Node Token 失效。 +- “会话”页显示 SessionID、两端节点、CIDR、状态、计数和持续时间,可强制断开 ACTIVE Session。 +- “日志”页可按时间、级别、模块、Node ID 和 Session ID 过滤。 +- Server 文件日志:Docker 为 `/opt/remlink/docker/data/logs/server.jsonl`;原生为 `/var/lib/remlink/logs/server.jsonl`。 + +三端面向操作员的日志消息、状态、级别、模块和常见错误均显示中文。协议状态、模块名与错误码会保留在括号中,例如“现场端没有通往远程网段的明确路由(`SITE_NO_ROUTE`)”,便于按文档和接口继续检索。Server 管理页会把旧版本已经写入数据库的常见英文事件即时翻译为中文;数据库原始记录不会被批量改写。JSON 文件日志的 `time`、`level`、`module`、`session_id` 等字段名保持稳定,供脚本和采集系统使用,`msg` 内容改为中文。 + +同机重新登记时,先停止角色进程/任务,把对应 `identity.json` 移到受控备份位置,再使用新 Join Token;不要编辑 DPAPI 内容。 + +修改完整 Overlay 前,先检查所有 Windows 主机无本地冲突。保存后 Server 会暂停新 Session、关闭现有 Session、更新数据库与 `wg0`,通知节点重新 Bootstrap,再切换监听。 + +Docker 修改 WireGuard 端口后,还要把 `.env` 的 `REMLINK_WG_PORT` 和 `REMLINK_WG_ENDPOINT` 改成同一端口并重建映射: + +~~~bash +cd /opt/remlink/docker +sudo docker compose --env-file .env -f compose.release.yaml up -d --force-recreate +~~~ + +重建期间短暂离线,`docker/data` 中数据库和密钥保留。 + +## 10. 备份、恢复与升级 + +Docker 权威数据均在 `docker/data`。一致性备份: + +~~~bash +cd /opt/remlink/docker +sudo docker compose --env-file .env -f compose.release.yaml stop server +sudo tar -C /opt/remlink/docker -czf /安全备份目录/remlink-data-$(date +%F-%H%M%S).tgz data +sudo docker compose --env-file .env -f compose.release.yaml start server +~~~ + +同时备份 `server.yaml` 和受保护的 `.env`。恢复时先停止 Server,恢复到原位置并保持权限,再启动检查节点重连。原生部署对应备份 `/var/lib/remlink`、`/etc/remlink/server.yaml` 和 `remlink.env`。 + +Windows `identity.json` 是机器级 DPAPI 密文,只能在生成它的主机恢复,不能用于跨机器迁移。更换主机应撤销旧节点并重新登记。 + +从旧版 `ProgramData` 目录模型升级时,必须先停止对应 Windows 进程。在同一台主机上,可把旧的 `C:\ProgramData\RemLink\Engineer\identity.json` 或 `C:\ProgramData\RemLink\Site\identity.json` 手工复制到新包 EXE 旁;机器级 DPAPI 密文仍可解密。日志可按需归档,不要把 Engineer 身份复制给 Site,也不要跨主机复制。确认新包已正常重连后,再决定是否归档旧目录;新版本不会继续读写旧目录。 + +升级顺序: + +1. 核验新包,断开 Session,备份 Server 数据。 +2. 停止三端。 +3. 固定使用 `/opt/remlink` 时保留 `docker/data` 和本机 `docker/.env`,替换其余发布文件;原生部署替换 Server 二进制。 +4. 分别替换 Engineer、Site 包目录中的 EXE;保留同目录的 YAML、`identity.json`、`logs` 和已验证的 `wintun.dll`。不要用另一端的包覆盖当前目录。 +5. 先启动 Server,再 Site,最后 Engineer。 +6. 核对版本、节点 ONLINE、`wg show wg0`、日志和一条测试 Session。 + +Server 重启会关闭数据库中遗留的非终态 Session;升级后应新建 Session,不要期待旧 Session 自动恢复。 + +## 11. 常见故障 + +| 现象或错误 | 处理 | +|---|---| +| endpoint 端口不匹配 | `REMLINK_WG_ENDPOINT` 端口必须等于已保存的 `wireguard_port`;同时核对 `REMLINK_WG_PORT`、NAT、防火墙 | +| 容器预检失败 | 检查 `/dev/net/tun`、内核 WireGuard、`NET_ADMIN` 和 IPv4 forwarding;不要改 privileged | +| 构建长时间停在 `apt-get` | 中国大陆先使用 `.env.china.example`;再运行 `docker run --rm debian:bookworm-slim sh -c "apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=2 -o Acquire::http::Timeout=30 update"`;若仍超时,修复 Docker daemon DNS/代理或用 `docker build --network=host` | +| Admin 401 | 输入与 `REMLINK_ADMIN_TOKEN` 完全一致的 Bearer token | +| `JOIN_TOKEN_INVALID` | 安全获取当前 Token;不要继续使用已轮换值 | +| `NODE_AUTH_FAILED` | 身份被撤销、损坏或复制到其他机器;隔离旧身份并重新登记 | +| `OVERLAY_LOCAL_CONFLICT` | Overlay 与 Windows 本地直连网段重叠;恢复或选择无冲突 Overlay | +| `CIDR_LOCAL_CONFLICT` | Engineer 本机已有覆盖远程 CIDR 的网络/路由;处理后重新预检 | +| `SITE_NO_ROUTE` | Site 只有默认路由或无路由;增加真实直连/静态路由并先在 Site 验证 | +| `NETSTACK_UNAVAILABLE` / `FLOW_LIMIT_REACHED` | 检查 Site 就绪、流量上限和长连接;按容量调整配置并重启 | +| `SESSION_INJECT_FAILED` | 当前 Session 会关闭;检查适配器、路由和日志后新建 | +| `ENGINEER_SESSION_EXISTS` | 先断开当前非终态 Session | +| 节点 ONLINE 但业务不通 | 依次检查 Site 本机目标、Server `wg show`、Session ACTIVE、Engineer 路由、Site 日志和主机防火墙 | + +## 12. 验收、停用 + +发布包中初始化验收: + +~~~powershell +.\scripts\validation\New-AcceptanceRun.ps1 -OutputDirectory C:\RemLink-Evidence\run-001 +~~~ + +按 `docs/validation/T01-T18-runbook.md` 采证,用 `Set-AcceptanceResult.ps1` 记录,再运行: + +~~~powershell +.\scripts\validation\Test-AcceptanceRun.ps1 -RunDirectory C:\RemLink-Evidence\run-001 +~~~ + +证据不存在、哈希不一致或前置 Gate 未通过时,不得标记 PASS。至少验证单适配器、Overlay 双向连通、目标 ICMP/TCP/UDP、断线重连、Server 重启、陈旧路由清理、节点 IP/Overlay 迁移和重复现场网段隔离。 + +停用时:Engineer 先断开 Session;Site 计划任务先执行 `Stop-ScheduledTask -TaskName 'RemLink Site'`,永久取消再执行 `Unregister-ScheduledTask -TaskName 'RemLink Site'`;Server 停止后归档 data 和配置;撤销不再使用的节点并轮换 Join Token。 + +当前包没有 Windows 卸载器。持久 `RemLink` Wintun 适配器是设计行为;若只暂停使用,可在确认没有 RemLink 进程后禁用。删除 Engineer 或 Site 包目录会同时删除该端身份和日志;执行前必须备份,并确认明确放弃该主机身份。不要用不明脚本删除第三方网络适配器。 diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..9b10347 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,18 @@ +# RemLink 界面设计记录 + +Engineer 和 Server 在实现前先生成全页面视觉参考,再翻译为 Vue/CSS;概念图没有被直接嵌入产品。两端统一使用冷色近白工作区、深蓝紫操作色、薄荷绿/琥珀/红状态色、细边框、紧凑表格和清晰左侧导航,适合运维桌面。 + +## 资源 + +- `engineer-concept.png`:Engineer 连接/Session 概念图。 +- `server-concept.png`:Server 运维仪表板概念图。 +- `engineer-render.png`:交互 QA 后的 1440×900 实际渲染。 +- `server-render.png`:交互 QA 后的 1440×900 实际渲染。 + +## 还原记录 + +- 保留:整体壳层、字号层级、连接拓扑线、状态色、表单密度、卡片、表格和主操作。 +- 调整:Server 实际渲染补充 Nodes/Sessions/Logs 完整运维内容;Engineer 远程 CIDR 列表支持真实多前缀纵向增长。 +- 功能新增:Admin token、运行时长、过滤、编辑/撤销、强制断开、Join Token 轮换、响应式表格滚动和真实心跳 RTT。 + +浏览器 QA 因交互式 Browser 插件不可用,使用已安装 Microsoft Edge 与项目内 Playwright。1440×900 和 1024×720 的核心交互通过,无控制台/页面/HTTP 错误,也无页面级横向溢出。 diff --git a/docs/design/engineer-concept.png b/docs/design/engineer-concept.png new file mode 100644 index 0000000..ae5caff Binary files /dev/null and b/docs/design/engineer-concept.png differ diff --git a/docs/design/engineer-render.png b/docs/design/engineer-render.png new file mode 100644 index 0000000..d3dca21 Binary files /dev/null and b/docs/design/engineer-render.png differ diff --git a/docs/design/server-concept.png b/docs/design/server-concept.png new file mode 100644 index 0000000..731f0be Binary files /dev/null and b/docs/design/server-concept.png differ diff --git a/docs/design/server-render.png b/docs/design/server-render.png new file mode 100644 index 0000000..e1e1d79 Binary files /dev/null and b/docs/design/server-render.png differ diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..79a7591 --- /dev/null +++ b/docs/implementation-status.md @@ -0,0 +1,50 @@ +# RemLink v1.0 实现状态 + +日期:2026-08-25 + +Phase 0–10 的实现工作均已进入代码。`specs/tasks.md` 明确区分“已实现子任务”和“需要外部主机的验收项”。 + +## 已完成的代码与自动化测试 + +- 稳定协议:20 字节 Session 头、13 种 Control 消息、14 个精确错误码。 +- Linux 内核 WireGuard 编排、SQLite 迁移、IPAM、Bootstrap、Join/Node Token、Control Hub、心跳状态与重连策略。 +- 单 Wintun wireguard-go Client、PacketMux、自有 Windows 路由、本地重叠检查、普通 Overlay UDP Session 传输和严格数据包验证。 +- 单进程 gVisor netstack 网关、通用 TCP/UDP relay 与受约束 ICMP Echo relay;不使用 WinNAT、Windows forwarding 或协议专用代理。 +- Site TCP/UDP flow 上限和 UDP 空闲超时可配置,默认 `2048/4096/60s`,并通过 capability 上报。 +- 七状态双边 Session 编排、超时、统计、reconcile、rebootstrap、Admin 审计事件和七步 Overlay 网络迁移。 +- 网络迁移会原子暂停 Session 创建、关闭现有 Session、发布新 Bootstrap、在旧 Control 仍在线时通知节点、切换 `wg0`/监听,并在失败时恢复内核和应用状态。 +- Windows 节点在旧 Control 在线时预检新 Overlay;本地重叠会保持旧配置以报告 `OVERLAY_LOCAL_CONFLICT`,Server 记录 ERROR 事件。 +- PREPARE 拒绝与 CREATE 请求关联,陈旧结果不能终止后续请求;完全相同的 netstack PREPARE 重试幂等。 +- Site 和 Server 信任边界都拒绝 `DEFAULT_ONLY`。数据包注入失败只关闭受影响 Session 并报告 `SESSION_INJECT_FAILED`,节点监听仍可用。 +- 被拒 Session 数据报和 PacketMux 丢包只输出元数据、限速安全警告;默认不记录高频数据包 DEBUG,也没有接收载荷字节的日志 API。 +- PacketMux 在拦截/注入边界计数;UDP relay 空闲回收、Sender 关闭与 netstack 重试路径有 race/幂等覆盖。 +- Wails Engineer GUI 和内嵌 Vue Server Web UI 已实现;Server 五个管理页及指定 API 完整。节点显示 WG 握手,Session 显示持续时间,日志支持五维过滤,Token 轮换只显示一次,Engineer 显示 Site capability 与 LastSeen。 +- 浏览器侧 SessionID 使用十进制字符串,避免 JavaScript 舍入随机 `uint64`。新的 Node Bootstrap 会关闭 Server 侧本地运行时已经丢失的 Session;短 Control 重连保留运行时。 +- Engineer GUI 对所有非终态单 Session 状态做操作门禁;生产启动和输入默认失败关闭;Server 运行表刷新不会覆盖正在编辑的网络配置。 +- 多 Engineer、多 Site 并发与重复 CIDR flow 隔离已实现。 +- 配置、Bootstrap、IPAM、Client WG、Session 和 Admin 信任边界都拒绝 Overlay 网络/广播地址以及 `/0` Exit Node。Server 事件模块限制为规格定义的 11 个名称。 +- Server WireGuard 私钥并发发布原子;成功的 Linux 内核变更不会被误报为取消。 +- 固定依赖、CI、Windows/Linux 发布脚本、受限 Docker Compose、启动预检、第三方声明和自包含 T01–T18 证据工具已提供。 +- Docker 使用 `./data:/app/data` 持久化和对称 `REMLINK_WG_PORT` 映射,并支持限制 HTTP bind。源码 Compose 与发布包预编译二进制 Compose 分离。 +- Server 的 Join Token 打印/轮换 CLI 在 Session 恢复清理之前退出,不会因运维读取 Token 而关闭活动 Session。 +- Admin 空节点/Session/事件列表统一编码为 `[]`,Server 前端也会把旧版 `null` 响应归一为空数组,避免首次正确鉴权后的概览白屏。 +- 发布验证器会解压 ZIP、检查必需项、重算全部校验和、拒绝未覆盖文件,并冒烟运行包内验收初始化器,不伪造结果。 +- Engineer、Site、Server 生成三个独立目录和 ZIP,发布验证器会拒绝混入其他角色的可执行文件;Windows 两端默认以各自 EXE 目录保存 YAML、DPAPI 身份、日志和校验后的 Wintun DLL,不再写入或共享 `ProgramData` 文件。 +- Engineer/Site YAML 支持明文 `join_token` 作为自用部署便捷项;命令行和 `REMLINK_JOIN_TOKEN` 仍可覆盖 YAML,注册后的 Node Token 与 WireGuard 私钥继续由 DPAPI 保护。 +- Engineer 按 Site NodeID 在 EXE 旁的 `site-profiles.json` 独立记忆 Remote CIDR;选择现场时只加载该现场网段。Site 心跳进入 OFFLINE 后,Server 自动以 `SITE_OFFLINE` 关闭相关会话并释放 Engineer 会话门禁。 +- Server、Engineer、Site 的操作员日志与界面状态已中文化;稳定协议状态、模块名和错误码以括号形式保留,Server 管理页还能翻译旧数据库中的常见英文事件。 +- Engineer 发布构建固定使用 Wails `desktop,production` 标签,并写入 `BUILD-INFO.json`;架构检查会拒绝退回缺少标签、启动时只弹 Wails 错误框的普通 Go 构建。 + +## 待外部物理验收 + +Gate A–D 与 T01–T18 当前为 `NOT_RUN`,不是失败也不是通过。它们需要 Linux 内核 WireGuard、管理员权限 Windows Engineer/Site、真实路由、重复现场网络、目标服务、热点切换以及进程/网络重启。执行方式见 `docs/validation/T01-T18-runbook.md`。 + +## 本地验证说明 + +本地基线已执行 npm 清洁安装、Vue 类型检查和生产构建、`go mod verify`、全量 Go test、`go vet`、架构策略扫描、Windows/Linux 交叉构建、发布校验和验证以及包内验收初始化冒烟。 + +渲染 QA 使用已安装 Microsoft Edge 与 Playwright:Engineer 的 ACTIVE→断开→IDLE 与 Settings 导航通过;Server 的 SESSION 日志过滤和 Join Token 轮换通过;两个页面均无框架错误覆盖和浏览器 warning/error。 + +本机没有 Docker,因此不在本地声称镜像已构建;Linux CI 负责源码镜像构建。由于本机没有 C 编译器,race 测试由 Linux CI 承担。Windows EXE 未做 Authenticode 签名;工作区没有 commit 时 `BUILD-INFO.json` 会记录 `unknown`。这些限制均不能记为已通过的 Gate 或物理验收结果。 + +本次中文文档与发布包部署入口修改后,应以最新一次 `scripts/build-release.ps1` 生成的 Engineer、Site、Server 三个独立 ZIP、各包 `SHA256SUMS.txt` 和实际命令输出为准,不沿用旧包哈希。 diff --git a/docs/packages/engineer-readme.md b/docs/packages/engineer-readme.md new file mode 100644 index 0000000..88903fc --- /dev/null +++ b/docs/packages/engineer-readme.md @@ -0,0 +1,24 @@ +# RemLink Engineer 独立便携包 + +本目录只属于 Engineer,不包含 Site 或 Server 程序。请把整个目录放在可持续写入的位置,例如 `C:\RemLink\Engineer`;不要只复制 EXE,也不要与 Site 解压到同一目录。 + +## 首次使用 + +1. 以管理员身份打开 PowerShell,进入本目录。 +2. 编辑 `engineer.yaml`,填写 Server URL、Engineer 名称和 `join_token`。 +3. 直接执行: + +~~~powershell +.\RemLinkEngineer.exe +~~~ + +`join_token` 会以明文保存在 YAML 中,只在首次注册时使用;注册后的 Node Token 和 WireGuard 私钥仍由 DPAPI 保护。后续直接以管理员身份运行 `RemLinkEngineer.exe`。默认配置路径始终是 EXE 旁的 `engineer.yaml`,与启动时的当前工作目录无关。 + +## 本目录中的运行文件 + +- `identity.json`:NodeID、Node Token 和 WireGuard 私钥等 DPAPI 保护身份,仅可在生成它的 Windows 主机解密。 +- `site-profiles.json`:由界面自动生成,按 Site NodeID 保存各现场的 Remote CIDR;不包含密钥或 Token。切换现场时会自动加载对应网段。 +- `logs\engineer.jsonl`:Engineer 日志。 +- `wintun.dll`:EXE 内嵌的固定版本 Wintun 首次运行释放文件,程序会校验 SHA-256。 + +备份或升级前先退出 Engineer。升级时保留 `engineer.yaml`、`identity.json` 和 `logs`,只替换通过校验的新 EXE;完整三端流程见 `docs\deployment-and-usage.md`。 diff --git a/docs/packages/site-readme.md b/docs/packages/site-readme.md new file mode 100644 index 0000000..c2a2485 --- /dev/null +++ b/docs/packages/site-readme.md @@ -0,0 +1,23 @@ +# RemLink Site 独立便携包 + +本目录只属于 Site,不包含 Engineer 或 Server 程序。请把整个目录放在可持续写入的位置,例如 `C:\RemLink\Site`;不要只复制 EXE,也不要与 Engineer 解压到同一目录。 + +## 首次使用 + +1. 以管理员身份打开 PowerShell,进入本目录。 +2. 编辑 `site.yaml`,填写 Server URL、Site 名称、`join_token` 和 netstack 容量。 +3. 直接执行: + +~~~powershell +.\RemLinkSite.exe +~~~ + +`join_token` 会以明文保存在 YAML 中,只在首次注册时使用;注册后的 Node Token 和 WireGuard 私钥仍由 DPAPI 保护。确认 Overlay、Control 和 RemoteSubnet 均就绪后停止前台程序。后续直接运行 `RemLinkSite.exe`,或按完整指南注册最高权限启动任务;默认配置路径始终是 EXE 旁的 `site.yaml`。 + +## 本目录中的运行文件 + +- `identity.json`:NodeID、Node Token 和 WireGuard 私钥等 DPAPI 保护身份,仅可在生成它的 Windows 主机解密。 +- `logs\site.jsonl`:Site 日志。 +- `wintun.dll`:EXE 内嵌的固定版本 Wintun 首次运行释放文件,程序会校验 SHA-256。 + +备份或升级前先停止前台进程和计划任务。升级时保留 `site.yaml`、`identity.json` 和 `logs`,只替换通过校验的新 EXE;完整三端流程见 `docs\deployment-and-usage.md`。 diff --git a/docs/phase-0-validation.md b/docs/phase-0-validation.md new file mode 100644 index 0000000..676f73b --- /dev/null +++ b/docs/phase-0-validation.md @@ -0,0 +1,26 @@ +# Phase 0 验证记录 + +- 日期:2026-08-25 +- 主机:Windows amd64 +- Go 工具链:1.26.7 +- 范围:仓库和基础模型 + +## 验证证据 + +| 检查 | 结果 | +|---|---| +| `gofmt` 清洁检查 | PASS | +| `go mod verify` | PASS(全部模块已验证) | +| `go test -count=1 ./...` | PASS | +| `go vet ./...` | PASS | +| Windows/amd64 `go build ./...` | PASS | +| Linux/amd64 CGO=0 交叉构建 | PASS | +| 运行三端 Phase 0 入口 | PASS | + +协议测试覆盖 20 字节 Session 头黄金字节、帧往返、畸形头拒绝、13 种 Control 消息和权威规格实际枚举的 14 个错误码。 + +配置测试覆盖默认值、IPv4 网络、Client URL、未知/敏感 YAML 字段拒绝和多文档 YAML 拒绝。日志测试覆盖 11 个模块、结构化字段、滚动文件、幂等关闭、默认禁用包日志,以及显式启用采样后的逐键限速。 + +## 非阻塞环境说明 + +本机 Windows Go 环境 `CGO_ENABLED=0` 且没有 C 编译器,因此额外的 `go test -race ./...` 无法执行。Phase 0 验收不要求 race detector;日志采样器仍由互斥锁保护并有单元测试,CI 在 Windows 与 Linux 执行要求的构建、测试和 vet。 diff --git a/docs/phase-0.md b/docs/phase-0.md new file mode 100644 index 0000000..35f6c5c --- /dev/null +++ b/docs/phase-0.md @@ -0,0 +1,21 @@ +# Phase 0 实现说明 + +## 范围 + +Phase 0 包含仓库结构、共享模型、YAML 配置加载、结构化滚动日志、协议常量与编解码器、测试和 CI;不包含 Wintun、WireGuard、数据包路由、gVisor、数据库、Control 传输或 GUI。 + +## 协议决定 + +权威规格确定 Session 头字段和宽度,但没有指定字节序。RemLink 对全部多字节 Session 头字段使用网络字节序(大端);黄金字节测试固定该行为,防止后续实现静默分歧。 + +权威 DOCX 和 `specs/spec.md` 实际枚举 14 个错误码。早期 Phase 0 任务文字写成“15 个”;实现遵循权威枚举并修正文档计数,不虚构第 15 个错误码。 + +## 固定依赖 + +- Go 1.26.7 +- `gopkg.in/yaml.v3` v3.0.1 +- `gopkg.in/natefinch/lumberjack.v2` v2.2.1 +- `actions/checkout` v6.0.2(按 commit SHA 固定) +- `actions/setup-go` v7.0.0(按 commit SHA 固定) + +使用 YAML v3 是因为实现时 v4 仍只有候选版本。 diff --git a/docs/phase-1-validation.md b/docs/phase-1-validation.md new file mode 100644 index 0000000..5ef7536 --- /dev/null +++ b/docs/phase-1-validation.md @@ -0,0 +1,33 @@ +# Phase 1 验证记录 + +- 日期:2026-08-25 +- 状态:进行中;Gate A 尚未通过 +- 主机:Windows amd64,非管理员 Codex 进程 +- Go 工具链:1.26.7 + +## 已完成自动化证据 + +| 检查 | 结果 | +|---|---| +| Wintun 0.14.1 ZIP 官方 SHA-256 | PASS | +| 内嵌 AMD64 DLL SHA-256 | PASS(`e5da8447...20dafce`) | +| 将内嵌 Wintun 安装到指定便携目录并校验/修复 | PASS | +| 官方 Go 绑定报告 Wintun 0.14.1 | PASS | +| MuxTun `tun.Device` 编译期断言 | PASS | +| MuxTun 代理与幂等关闭测试 | PASS | +| wireguard-go UAPI 黄金配置测试 | PASS | +| 全量 Go test/vet、Windows 构建、Linux 交叉构建、模块校验 | PASS | + +旧版 `ProgramData` 路径证据已作废。便携目录改造后,从系统临时目录启动位于 `.codex-qa\portable-runtime-probe` 的运行库探针,实际加载路径为该探针 EXE 旁的 `wintun.dll`;其 SHA-256 为固定值 `e5da8447...20dafce`。单元测试同时验证了指定便携目录下的原子安装、复用和异常文件修复。 + +确认主机不存在 `RemLink` 适配器或 `10.88.0.0/16` 路由后尝试管理员适配器探针;它在明确的管理员预检处以 `administrator privileges are required` 停止,因此没有创建或修改网络适配器。 + +## Gate A 仍需证据 + +- 在管理员进程运行 `phase1-node --adapter-probe`,确认一个可复用 `RemLink`、指定 Overlay IPv4 和 MTU 1280。 +- 用 `deploy/phase1-server` 准备 Linux 内核 WireGuard `wg0`。 +- 在两台管理员权限 Windows 节点运行内嵌 wireguard-go POC,Server peer 各自只用唯一 Overlay `/32`。 +- 采集 `wg show`、Windows 适配器清单和双向 Overlay ping。 +- 确认每节点恰好一个 RemLink Wintun 且没有 WireGuardNT。 + +完成全部物理证据前,Phase 1 和 Gate A 必须保持未勾选。 diff --git a/docs/validation/T01-T18-runbook.md b/docs/validation/T01-T18-runbook.md new file mode 100644 index 0000000..42ca172 --- /dev/null +++ b/docs/validation/T01-T18-runbook.md @@ -0,0 +1,80 @@ +# RemLink v1.0 T01–T18 验收执行手册 + +本文是证据模板,不代表真实环境已经通过。每次运行先执行 `scripts/validation/New-AcceptanceRun.ps1`;在指定主机证据齐全前,场景保持 `NOT_RUN`。每个 PASS 都必须在同一运行目录内包含时间戳、节点名、应用日志和指定主机快照。 + +只能通过 `Set-AcceptanceResult.ps1` 记录结果;脚本要求证据文件位于运行目录内,并保存 SHA-256 和大小。每批记录后及归档前执行 `Test-AcceptanceRun.ps1`。验证器会拒绝缺失、移动或修改过的证据并执行 Gate 前置条件,但不会代替人工判断所选截图是否真正证明场景。 + +~~~powershell +./scripts/validation/Set-AcceptanceResult.ps1 -RunDirectory evidence/run-001 -ID T01 -Status PASS -EvidencePath engineer-a/inventory.json,site-a/inventory.json +./scripts/validation/Test-AcceptanceRun.ps1 -RunDirectory evidence/run-001 +~~~ + +## 必需拓扑 + +- 一台 Linux Server:内核 WireGuard、公网可达 UDP endpoint,只公开受保护的 Bootstrap/Admin 入口和 `51820/udp`。 +- Engineer-A、Engineer-B、Site-A、Site-B:分别位于受支持 Windows 系统,RemLink 进程均以管理员运行。 +- Site-A 和 Site-B 各自可达一个 `192.168.13.0/24` 测试网段;Site-A 另外可达 `192.168.21.0/24`。 +- 目标提供 ICMP Echo、TCP 102、TCP 502、HTTP、RDP 和普通 UDP echo;不能用 RemLink 内部的协议专用代码替代。 + +在相关场景前后,分别在 Windows 节点运行 `Collect-WindowsEvidence.ps1`,在 Server 运行 `Collect-ServerEvidence.sh`。第三方 WinNAT 只做只读快照。Server 采集器故意只使用公开 WireGuard 视图,绝不执行可能暴露私钥或预共享密钥的 `wg show ... dump`。 + +## 场景步骤与通过条件 + +| ID | 执行与必需证据 | 通过条件 | +|---|---|---| +| T01 | 启动 Engineer/Site,采集 Admin 节点页和 Windows inventory | 两者 ONLINE;每节点恰好一个 `RemLink` 适配器;无 WireGuardNT | +| T02 | Engineer 执行 `ping `,采集 ping 和 Server `wg show` | 经 Server hub 回复成功,WireGuard 计数增加 | +| T03 | Engineer 连接 Site,CIDR 为 `192.168.13.0/24`;采 GUI 与 Session 日志 | Session 到 ACTIVE,且只出现一条自有 Remote route | +| T04 | Engineer 本地接入 `192.168.13.0/24` 后请求相同远程网段 | 在 Server 创建前以 `CIDR_LOCAL_CONFLICT` 拒绝 | +| T05 | 删除 Site 到 `192.168.13.0/24` 的全部非默认路由后请求 | PREPARE 以 `SITE_NO_ROUTE` 失败;无 ACTIVE Session 或泄漏 flow | +| T06 | T03/T07–T09 前后分别快照 Hyper-V/Docker/WinNAT | 远程访问成功,第三方 NAT 快照完全一致 | +| T07 | 对 PLC/测试目标执行 `Test-RemoteTargets.ps1` | 四个 Echo Reply 保留目标身份,无注入错误 | +| T08 | 探测 TCP 102/502/80/3389,并对每项运行一次真实连接 | 全部服务经通用 TCP relay 连接;源码审计无协议代理 | +| T09 | 向 UDP echo 发送不同数据报,等待超过 idle timeout 后再发 | 两次均成功;间隔后 flow 数回到基线 | +| T10 | 一个 Session 同时请求 `192.168.13.0/24` 和 `192.168.21.0/24` | 两网段目标均通过 ICMP 及至少一项 TCP/UDP | +| T11 | Engineer-A→Site-A 与 Engineer-B→Site-B 同时连接;两个 Site 都声明 `192.168.13.0/24` | 两个 SessionID 保持 ACTIVE;载荷标记只回到来源 Engineer | +| T12 | 两个 Engineer 同时连接 Site-A,以不同标记访问同一 endpoint/port | Site flow/事件正确区分 SessionID 和 Engineer Overlay IP,无串流 | +| T13 | Engineer-A 已 ACTIVE 时请求第二个 Site | UI 禁止选择;构造请求被 Server 以 `ENGINEER_SESSION_EXISTS` 拒绝 | +| T14 | 持续 ping/TCP 时把 Engineer Wi-Fi 切到手机热点 | WireGuard 与 Control 在有限中断后重连;用户可重建不会自动恢复的 Session | +| T15 | ACTIVE 流量期间重启 Server | 数据库非终态 Session 变 CLOSED;节点自动重连并能新建 Session | +| T16 | 强制终止 Engineer,管理员重启,采集前后路由 | 接受新 Session 前 reconcile 删除自有陈旧 Remote route | +| T17 | 在 Admin UI 修改一个在线 Node 的 Overlay IP | Session 关闭;Node 公开 Bootstrap;新 IP 下单适配器重连 | +| T18 | 在 Admin UI 修改完整 Overlay CIDR | 全部 Session 关闭;`wg0`、数据库分配、Node 适配器、Control 和 Bootstrap 使用递增配置;Site NAT 快照不变 | + +## 建议证据目录 + +~~~text +evidence/run-001/ + acceptance-run.json + T01-T18-runbook.md + server/ + engineer-a/ + engineer-b/ + site-a/ + site-b/ + targets/ + screenshots/ +~~~ + +文件名应包含场景 ID、主机和 UTC 时间。严禁把 Join Token、Admin token、Node Token、WireGuard 私钥、预共享密钥或数据包载荷放入证据。需要展示配置时只截取非敏感字段。 + +## Gate 记录 + +- Gate A 使用 T01/T02 证据,并增加双向 Overlay ping。 +- Gate B 还需持续远程流量,证明同一个适配器承载普通外层 UDP 且无 busy loop/deadlock。 +- Gate C 需要目标侧 TCP/UDP echo 和 Site netstack 日志。 +- Gate D 需要返回 raw IPv4 身份以及 T07/T08/T09 证据。 + +不能仅根据单元/进程内测试标记 Gate 或场景 PASS。自动化测试证明实现不变量,本手册证明指定物理部署行为。 + +## 一次完整执行顺序 + +1. 初始化运行目录,记录版本、包哈希、拓扑、主机名、时间同步状态和人员。 +2. 采集五台主机基线,确认无秘密进入证据。 +3. 先执行 Gate A 与 T01/T02,验证单适配器和中心辐射 Overlay。 +4. 执行 T03–T10,覆盖路由冲突、Site 路由、NAT 不变、ICMP/TCP/UDP 和多 CIDR。 +5. 执行 T11–T13,覆盖并发、重复 CIDR、同 Site 多 Engineer 和单 Engineer 限制。 +6. 执行 T14–T18,覆盖网络切换、Server 重启、异常退出、节点 IP 与 Overlay 迁移。 +7. 采集结束快照、应用日志和目标侧证据,记录每项 PASS/FAIL/NOT_RUN。 +8. 执行验证器;修复证据路径/哈希问题,但不得为通过验证器而改写真实结果。 +9. 将完整运行目录只读归档,并单独保存发布包和外部 ZIP 哈希。 diff --git a/docs/validation/automated-coverage.md b/docs/validation/automated-coverage.md new file mode 100644 index 0000000..6e153b1 --- /dev/null +++ b/docs/validation/automated-coverage.md @@ -0,0 +1,33 @@ +# 自动化验证覆盖 + +权威的 R1–R16 与 Phase 0–10 实现/证据矩阵见 `requirements-evidence.md`;本文补充自动化覆盖细节。 + +自动化测试覆盖协议 framing、精确错误码、Bootstrap/IPAM/数据库、Control 状态、PacketMux、Session UDP 验证、完整 Session 状态机、进程内 gVisor TCP/UDP/ICMP 往返、Admin 网络迁移、rebootstrap 通知和重复 CIDR 隔离。测试还固定 v1 的 `DEFAULT_ONLY` 拒绝、Session 级注入失败清理、拒绝注入后的监听连续性、限速安全警告、WG `/32` peer、唯一 Server client peer、Admin 日志时间过滤和 Site Console 状态/事件字段。 + +回归套件还覆盖: + +- PREPARE 失败关联、重试和陈旧拒绝隔离; +- netstack 幂等、PacketMux 计数/队列丢包、严格 IPv4 framing、Sender 关闭 race、UDP 空闲回收; +- Site flow 上限、Server WG 密钥并发发布、可用 Overlay 主机地址、`/0` 拒绝和事件模块分类; +- 迁移期间 Session quiesce、迁移后 Session 配置、旧 Control 通知顺序、Overlay 冲突事件、Control rebind 回滚和 Linux 内核变更回滚; +- 全宽随机 SessionID 的无损十进制 JSON、新 Bootstrap 清理陈旧 Session、严格公网/Overlay endpoint、Site 容量拒绝和 Engineer 单 Session GUI 门禁。 + +验收工具自测证明:没有证据文件不能 PASS;Gate 前置条件被执行;证据 SHA-256 与大小会持久化;记录后篡改可被发现。架构扫描还禁止 Server 采集器导出 WireGuard dump/私钥,检查 Docker data mount、可配置 WireGuard 映射、发布 Compose 权限和预编译二进制入口。 + +发布验证器会独立解压 ZIP、检查必需项、重算每条 SHA-256、拒绝未纳入清单的文件,并运行包内验收初始化器,保持全部 Gate/T 为 `NOT_RUN`。 + +前端渲染 QA 的开发 fixture 只提供展示数据,实际使用与内嵌构建相同的 Vue 组件。最近一次检查覆盖 Server Nodes/Sessions/Network/Logs 交互以及 Engineer Site capability/LastSeen 和导航;页面有有效 DOM,浏览器无 warning/error。该 UI QA 不声称物理网络操作成功。 + +以下映射只作为支持证据,不能把真实验收场景标记 PASS: + +| 验收区域 | 自动化证据 | 仍需真实证据 | +|---|---|---| +| T03/T05/T13 | `internal/session` manager/runtime 测试 | 管理员 Engineer/Site 与实际路由 | +| T04/T16 | Windows 路由、冲突、reconcile 测试 | 真实主机前后路由清单 | +| T07/T08/T09 | gVisor 主机套接字往返 | 两台 Windows 间 PLC/服务 | +| T10 | 多 CIDR 状态机与 PacketMux | 两个物理现场子网 | +| T11/T12 | 并发 Session 与重复 CIDR flow key | 四节点载荷隔离 | +| T15 | SQLite 关闭开放 Session 与 Control 重连 | 流量中 Server 重启 | +| T17/T18 | Admin 更新/rebootstrap/迁移回滚测试 | 真实适配器和 `wg0` 迁移 | + +Gate A–D 和 T01–T18 在执行手册证据齐全前保持 `NOT_RUN`。 diff --git a/docs/validation/requirements-evidence.md b/docs/validation/requirements-evidence.md new file mode 100644 index 0000000..8a11663 --- /dev/null +++ b/docs/validation/requirements-evidence.md @@ -0,0 +1,67 @@ +# RemLink v1.0 需求与证据矩阵 + +日期:2026-08-25 + +本矩阵审计根目录权威 DOCX、`specs/spec.md`、`specs/tasks.md` 和 `specs/checklist.md`,并映射到生产代码与可重复验证。若派生 Markdown 与 DOCX 冲突,以 DOCX 为准。 + +状态词汇: + +- `CODE_TESTED`:存在生产实现,并有可重复自动化测试或策略扫描覆盖该边界。 +- `BUILD_VERIFIED`:目标已编译/类型检查,但不声称管理员权限或物理网络行为成功。 +- `PHYSICAL_NOT_RUN`:Gate/场景需要真实 Linux/Windows 拓扑,不能从单元、集成、浏览器或交叉构建推导 PASS。 + +## 需求覆盖 + +| 需求 | 生产证据 | 自动化/构建证据 | 审计状态 | 仍需物理证据 | +|---|---|---|---|---| +| R1 架构与不可变约束 | `internal/overlay`、`internal/subnet`、`internal/subnetgateway`、Windows 平台层、`serverwg` | 架构扫描;重复 CIDR manager/netstack 测试;双平台发布构建 | `CODE_TESTED` | Gate A–D、T01–T02、T06、T11–T12 | +| R2 Overlay 与 Server IPAM | `internal/ipam`、Admin network/handler、Bootstrap、serverwg | IPAM 稳定分配;原子 Node/network 设置;七步迁移、回滚、通知顺序、Node IP 更新/删除 | `CODE_TESTED` | T17–T18 真实适配器、`wg0`、Control | +| R3 单 Wintun MuxTun/PacketMux | clientwg、Windows Wintun/runtime | 分类、framing、计数、丢包、关闭测试;DLL 并发发布;Windows 构建 | `CODE_TESTED + BUILD_VERIFIED` | Gate A–B、T01–T02 管理员 Windows | +| R4 Remote Subnet Session | `internal/session`、`internal/subnet`、Windows route | 完整状态机、请求关联、超时、多 CIDR、冲突、双向数据报验证、并发 SessionID 和 MaxUint64 JSON | `CODE_TESTED` | T03–T05、T10、T13 与真实路由 | +| R5 Site gVisor 网关 | netstack、TCP/UDP/ping relay | 进程内主机套接字往返、prepare 幂等、容量和 UDP idle-GC | `CODE_TESTED` | Gate C–D、T06–T09 真实目标 | +| R6 Bootstrap、Control、身份 | Bootstrap、Control、nodeagent、identity、DPAPI | 严格 API/config/URL、token/DPAPI、HELLO/心跳/状态/重连/rebootstrap | `CODE_TESTED` | T14–T15 中断 | +| R7 Server | `cmd/server`、database、Admin、serverwg、Server 前端 | 数据库/Admin/Control;Linux 构建;前端生产扫描;Docker 架构与发布入口 | `CODE_TESTED + BUILD_VERIFIED` | Linux WG/Docker 启动与 UI 拓扑 | +| R8 Engineer | `cmd/engineer`、Engineer 前端、Engineer Session、Windows adapter/route | runtime 测试;禁止 Demo fallback;Vue 构建;Windows GUI 构建 | `CODE_TESTED + BUILD_VERIFIED` | 管理员 GUI、路由、Wintun、Gate/T | +| R9 Site | `cmd/site`、Site Session、subnetgateway | Console 字段/事件;route/capacity/injection/relay/reconcile;Windows console 构建 | `CODE_TESTED + BUILD_VERIFIED` | Site Console 与真实 LAN TCP/UDP/ICMP | +| R10 数据库与模型 | database、model、migrations | 六表迁移/store;原子网络/IP 替换;启动关闭开放 Session | `CODE_TESTED` | T15 部署 Server 重启 | +| R11 API 与消息 | Bootstrap HTTP、Admin handler、protocol、Control | 严格 JSON/framing/auth/direction/error 与 handler 测试 | `CODE_TESTED` | T03/T17/T18 指定抓包/API | +| R12 冲突、失败、恢复 | route、nodeagent、Bootstrap reconcile、Session runtime、迁移事务 | 本地/Overlay 冲突、陈旧路由、新 Node 清 Session、重连、超时、注入失败、迁移回滚 | `CODE_TESTED` | T04–T05、T14–T18 | +| R13 日志、统计、可观测性 | logging、eventlog、Session counter/reporter、Admin log API | 模块分类、限速、仅元数据包警告、统计持久化/过滤、秘密扫描 | `CODE_TESTED` | T07–T12 流量计数与日志 | +| R14 安全边界 | Node/Join token 哈希、DPAPI、WG `/32`、严格 listener/validator、Admin token | token/auth、地址/URL、listener/validator、证据秘密扫描 | `CODE_TESTED` | 防火墙、暴露面、AllowedIPs | +| R15 结构、接口、依赖 | monorepo、Go/npm 锁、内嵌 Wintun、第三方声明 | 模块验证、架构扫描、前端清洁安装、双平台构建、发布校验和 | `BUILD_VERIFIED` | 部署策略要求时的签名 | +| R16 阶段与验收 | tasks/checklist、执行手册、证据脚本 | 自测执行证据哈希、范围、前置条件和篡改检测 | `CODE_TESTED`;物理状态独立 | Gate A–D、T01–T18 均 `PHYSICAL_NOT_RUN` | + +## 阶段审计 + +| 阶段 | 实现状态 | 验收状态 | +|---|---|---| +| Phase 0 | 仓库、模型、日志、配置、协议、CI 已实现并测试 | 自动化验收完成 | +| Phase 1 | Wintun/MuxTun/wireguard-go 与内嵌 DLL;Windows 可构建 | Gate A `PHYSICAL_NOT_RUN` | +| Phase 2 | 数据库、IPAM、Bootstrap、Server WG、身份 store | 模拟 Node/IPAM 完成;部署由后续物理测试覆盖 | +| Phase 3 | Control Hub、心跳、节点列表、capability、重连 | 自动化集成完成 | +| Phase 4 | PacketMux、自有路由与冲突逻辑 | 物理 Wintun 拦截 `PHYSICAL_NOT_RUN` | +| Phase 5 | 普通 UDP 重入传输和验证 | Gate B `PHYSICAL_NOT_RUN` | +| Phase 6 | gVisor netstack TCP/UDP 网关 | Gate C `PHYSICAL_NOT_RUN` | +| Phase 7 | 对称返回传输和 ICMP Echo relay | Gate D `PHYSICAL_NOT_RUN` | +| Phase 8 | 双边 Session 状态机、错误、统计、reconcile | T03/T04/T05/T13/T16 `PHYSICAL_NOT_RUN` | +| Phase 9 | Engineer GUI、Server 五页 UI、Admin API | UI 驱动真实工作流 `PHYSICAL_NOT_RUN` | +| Phase 10 | 并发、重复 CIDR、打包、Docker、证据工具 | T01–T18 与最终重复子网拓扑 `PHYSICAL_NOT_RUN` | + +## 复现命令 + +在仓库根目录运行: + +~~~powershell +go mod verify +go test -count=1 ./... +go vet ./... +npm ci --prefix frontend +npm run typecheck --prefix frontend +npm run build --prefix frontend +./scripts/validation/Test-FrontendProduction.ps1 +./scripts/validation/Test-Architecture.ps1 +./scripts/validation/Test-AcceptanceTools.ps1 +./scripts/build-release.ps1 -Version 1.0.0 +~~~ + +发布构建会在生成二进制和校验和前重复关键测试。真实结果必须先用 `New-AcceptanceRun.ps1` 初始化,采证后用 `Set-AcceptanceResult.ps1` 记录,最后运行 `Test-AcceptanceRun.ps1`。只有证据文件在该运行目录内且哈希通过时,Gate/T 才允许标记 PASS。 diff --git a/frontend/engineer/embed.go b/frontend/engineer/embed.go new file mode 100644 index 0000000..55e0482 --- /dev/null +++ b/frontend/engineer/embed.go @@ -0,0 +1,7 @@ +// Package engineerui embeds the production Wails frontend. +package engineerui + +import "embed" + +//go:embed all:dist +var Assets embed.FS diff --git a/frontend/engineer/embed_test.go b/frontend/engineer/embed_test.go new file mode 100644 index 0000000..ca24197 --- /dev/null +++ b/frontend/engineer/embed_test.go @@ -0,0 +1,49 @@ +package engineerui + +import ( + "io" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + wailsassetserver "github.com/wailsapp/wails/v2/pkg/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +func TestEmbeddedProductionAssetsAreServedByWails(t *testing.T) { + handler, err := wailsassetserver.NewAssetHandler(assetserver.Options{Assets: Assets}, nil) + if err != nil { + t.Fatalf("create Wails asset handler: %v", err) + } + + index := getEmbeddedAsset(t, handler, "/", "text/html") + references := regexp.MustCompile(`(?:src|href)="(/assets/[^"]+)"`).FindAllStringSubmatch(string(index), -1) + if len(references) < 2 { + t.Fatalf("expected JavaScript and CSS references in index.html, got %q", index) + } + for _, reference := range references { + getEmbeddedAsset(t, handler, reference[1], "") + } +} + +func getEmbeddedAsset(t *testing.T, handler http.Handler, path string, expectedContentType string) []byte { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "http://wails.localhost"+path, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String()) + } + if expectedContentType != "" && response.Header().Get("Content-Type") != expectedContentType+"; charset=utf-8" { + t.Fatalf("GET %s returned Content-Type %q", path, response.Header().Get("Content-Type")) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read GET %s response: %v", path, err) + } + if len(body) == 0 { + t.Fatalf("GET %s returned an empty body", path) + } + return body +} diff --git a/frontend/engineer/index.html b/frontend/engineer/index.html new file mode 100644 index 0000000..7939414 --- /dev/null +++ b/frontend/engineer/index.html @@ -0,0 +1,14 @@ + + + + + + + + RemLink Engineer + + +
+ + + diff --git a/frontend/engineer/src/App.vue b/frontend/engineer/src/App.vue new file mode 100644 index 0000000..1f0b937 --- /dev/null +++ b/frontend/engineer/src/App.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/engineer/src/Icon.vue b/frontend/engineer/src/Icon.vue new file mode 100644 index 0000000..caab408 --- /dev/null +++ b/frontend/engineer/src/Icon.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/engineer/src/api.ts b/frontend/engineer/src/api.ts new file mode 100644 index 0000000..f87e90e --- /dev/null +++ b/frontend/engineer/src/api.ts @@ -0,0 +1,82 @@ +import type { EngineerState } from './types' + +const dev = import.meta.env.DEV +const demoState: EngineerState = { + serverConnected: true, + controlConnected: true, + serverURL: 'https://remlink.example.com', + version: '1.0.0', + overlayIP: '10.88.0.2', + sites: [ + { node_id: 'site-qingdao', name: '青岛现场 01', overlay_ip: '10.88.0.25', online: true, remote_subnet_capability: true, last_seen: new Date().toISOString() }, + { node_id: 'site-shanghai', name: '上海实验室', overlay_ip: '10.88.0.31', online: false, remote_subnet_capability: true }, + ], + siteCIDRs: { + 'site-qingdao': ['192.168.17.0/24'], + 'site-shanghai': ['192.168.107.0/24'], + }, + session: { + id: '4488624737516445881', siteName: '青岛现场 01', cidrs: ['192.168.17.0/24'], status: 'ACTIVE', + uploadBytes: 1321205, downloadBytes: 2940838, uploadPackets: 12345, downloadPackets: 15402, latencyMS: 28, + startedAt: new Date(Date.now() - 18 * 60_000).toISOString(), + }, + logs: [ + { time: new Date(Date.now() - 50_000).toISOString(), level: 'INFO', message: 'Overlay 隧道已建立,连接路径正常' }, + { time: new Date(Date.now() - 34_000).toISOString(), level: 'INFO', message: 'Remote CIDR 已生效:192.168.17.0/24' }, + { time: new Date(Date.now() - 12_000).toISOString(), level: 'INFO', message: '192.168.17.5:ICMP 回复,时延 28ms' }, + ], +} + +const native = () => window.go?.main?.EngineerApp + +function normalizeState(value: EngineerState): EngineerState { + return { + ...value, + sites: value.sites ?? [], + siteCIDRs: Object.fromEntries(Object.entries(value.siteCIDRs ?? {}).map(([siteID, cidrs]) => [siteID, Array.from(cidrs ?? [])])), + logs: value.logs ?? [], + session: { ...value.session, cidrs: value.session?.cidrs ?? [] }, + } +} + +export async function saveSiteCIDRs(siteNodeID: string, cidrs: string[]): Promise { + const current = native() + if (current) return current.SaveSiteCIDRs(siteNodeID, cidrs) + if (!dev) return nativeUnavailable() + if (cidrs.length === 0) delete demoState.siteCIDRs[siteNodeID] + else demoState.siteCIDRs[siteNodeID] = Array.from(cidrs) +} + +function nativeUnavailable(): never { + throw new Error('RemLink native runtime is unavailable; production Demo fallback is disabled') +} + +export async function getState(): Promise { + const current = native() + if (current) return normalizeState(await current.GetState()) + if (!dev) return nativeUnavailable() + return structuredClone(demoState) +} + +export async function createSession(siteNodeID: string, cidrs: string[]): Promise { + const current = native() + if (current) return current.CreateSession(siteNodeID, cidrs) + if (!dev) return nativeUnavailable() + demoState.session = { ...demoState.session, id: 'pending', siteName: demoState.sites.find(site => site.node_id === siteNodeID)?.name ?? '', cidrs: Array.from(cidrs), status: 'CREATING' } + setTimeout(() => { demoState.session.status = 'ACTIVE'; demoState.session.id = '4488624737516445881' }, 450) + return 'dev-request' +} + +export async function disconnectSession(): Promise { + const current = native() + if (current) return current.DisconnectSession() + if (!dev) return nativeUnavailable() + demoState.session.status = 'IDLE' + demoState.session.id = '' +} + +export async function checkCIDRs(cidrs: string[]): Promise { + const current = native() + if (current) return current.CheckCIDRs(cidrs) + if (!dev) return nativeUnavailable() +} diff --git a/frontend/engineer/src/env.d.ts b/frontend/engineer/src/env.d.ts new file mode 100644 index 0000000..c6f5727 --- /dev/null +++ b/frontend/engineer/src/env.d.ts @@ -0,0 +1,22 @@ +/// + +import type { EngineerState } from './types' + +declare global { + interface Window { + go?: { + main?: { + EngineerApp?: { + GetState(): Promise + CreateSession(siteNodeID: string, cidrs: string[]): Promise + SaveSiteCIDRs(siteNodeID: string, cidrs: string[]): Promise + DisconnectSession(): Promise + CheckCIDRs(cidrs: string[]): Promise + } + } + } + runtime?: { + EventsOn(name: string, callback: (payload: unknown) => void): () => void + } + } +} diff --git a/frontend/engineer/src/layout-overrides.css b/frontend/engineer/src/layout-overrides.css new file mode 100644 index 0000000..daf1452 --- /dev/null +++ b/frontend/engineer/src/layout-overrides.css @@ -0,0 +1,12 @@ +.table-head, +.site-row { + grid-template-columns: minmax(105px, 1.25fr) minmax(48px, .48fr) minmax(82px, .75fr) minmax(72px, .72fr) minmax(68px, .72fr); + column-gap: 8px; +} + +.site-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/frontend/engineer/src/main.ts b/frontend/engineer/src/main.ts new file mode 100644 index 0000000..bdc84ed --- /dev/null +++ b/frontend/engineer/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' +import './layout-overrides.css' + +createApp(App).mount('#app') diff --git a/frontend/engineer/src/styles.css b/frontend/engineer/src/styles.css new file mode 100644 index 0000000..ab49f88 --- /dev/null +++ b/frontend/engineer/src/styles.css @@ -0,0 +1 @@ +:root{font-family:Inter,"Segoe UI","Microsoft YaHei UI",sans-serif;color:#172033;background:#f7f8fc;font-synthesis:none;--accent:#3451e8;--accent-soft:#edf1ff;--line:#dfe4ef;--muted:#6e7890;--green:#0aa36c;--red:#db4a55}*{box-sizing:border-box}body{margin:0;min-width:980px;min-height:100vh;background:#f7f8fc}button,input{font:inherit}.app-shell{display:grid;grid-template-columns:172px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:172px;background:#fff;border-right:1px solid var(--line);display:flex;flex-direction:column}.brand{height:76px;padding:20px 22px;color:var(--accent);font-size:23px;font-weight:750;letter-spacing:-.5px;border-bottom:1px solid var(--line)}.brand span{display:block;margin-top:2px;color:#7e88a0;font-size:10px;font-weight:650;letter-spacing:1.7px;text-transform:uppercase}.sidebar nav{padding:18px 10px;display:grid;gap:6px}.nav-item{height:48px;border:0;background:transparent;color:#59647a;display:flex;gap:13px;align-items:center;padding:0 16px;border-radius:8px;font-size:14px;cursor:pointer}.nav-item svg{width:21px}.nav-item.selected{color:var(--accent);background:var(--accent-soft);font-weight:700;box-shadow:inset 3px 0 var(--accent)}.sidebar-foot{margin-top:auto;padding:18px 22px;border-top:1px solid var(--line);display:grid;gap:4px;color:#8b94a7;font-size:10px}.version{color:#44506a;font-weight:700}.app-shell>main{grid-column:2;min-width:0}.status-strip{height:76px;background:#fff;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(3,1fr);align-items:center;padding:0 4.5vw;color:#4d5870;font-size:13px}.status-strip>div{display:flex;align-items:center;gap:7px;justify-content:center;border-right:1px solid var(--line)}.status-strip>div:last-child{border:0}.status-strip strong{color:#182238;font-weight:650}.status-strip i,.site-row i{width:8px;height:8px;border-radius:50%;display:inline-block}.healthy{background:var(--green)}.offline{background:#aeb5c3}.workspace{padding:22px clamp(18px,2.5vw,36px) 28px;max-width:1500px;margin:auto}.connection-path{height:76px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px;padding:0 4vw}.endpoint{display:flex;align-items:center;gap:12px;min-width:170px}.endpoint.site{justify-content:flex-end}.endpoint-icon{width:46px;height:46px;border:1px solid #b8c6ff;border-radius:50%;background:#f6f8ff;color:var(--accent);display:grid;place-items:center}.endpoint-icon svg{width:23px}.endpoint div{display:grid;gap:4px}.endpoint b{font-size:13px}.endpoint small{color:var(--muted);font-size:11px}.path-line{display:flex;align-items:center;gap:8px}.path-line span{height:1px;background:var(--accent);flex:1;position:relative}.path-line span:first-child:after{content:"";position:absolute;right:-1px;top:-3px;border-left:6px solid var(--accent);border-top:3px solid transparent;border-bottom:3px solid transparent}.path-line i{width:5px;height:5px;border-radius:50%;background:#92a5f7}.setup-grid{display:grid;grid-template-columns:1fr 1.08fr;gap:12px}.panel,.session-panel,.logs-panel{background:#fff;border:1px solid var(--line);border-radius:9px}.panel{min-height:294px;padding-bottom:14px}.panel-heading{height:54px;padding:0 18px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}h2{margin:0;font-size:16px;letter-spacing:-.2px}.panel-heading>span{font-size:11px;color:var(--muted)}.table-head,.site-row{display:grid;grid-template-columns:1.45fr .55fr .72fr;align-items:center}.table-head{padding:14px 20px 8px;color:#7f899e;font-size:10px;font-weight:650;text-transform:uppercase}.site-row{width:calc(100% - 28px);height:45px;margin:3px 14px;border:1px solid transparent;background:transparent;border-radius:7px;text-align:left;color:#29334a;font-size:12px;cursor:pointer}.site-row>span:first-child{display:flex;align-items:center;gap:9px;font-weight:650}.site-row.selected{border-color:var(--accent);background:#f4f6ff;color:#233fc9}.site-row.disabled{opacity:.45}.good-text{color:var(--green)!important}.muted{color:#9aa2b2}.wide{width:calc(100% - 28px);margin:16px 14px 0}.primary,.danger,.secondary,.text-action{border-radius:7px;height:38px;font-size:12px;font-weight:700;cursor:pointer}.primary{border:1px solid var(--accent);background:var(--accent);color:#fff}.primary:disabled{border-color:#cbd1df;background:#e7eaf1;color:#9aa2b2;cursor:not-allowed}.cidr-panel label{display:block;margin:12px 18px 6px;color:#556078;font-size:11px}.cidr-panel form{display:grid;grid-template-columns:1fr auto;gap:8px;margin:0 18px}.cidr-panel input{height:42px;padding:0 13px;border:1px solid #b9c3d6;border-radius:7px;outline:none;font-size:13px}.cidr-panel input:focus{border-color:var(--accent);box-shadow:0 0 0 3px #e7ebff}.secondary,.text-action{border:1px solid #b9c7ff;background:#fff;color:var(--accent);padding:0 14px}.text-action{height:32px;display:flex;align-items:center;gap:6px}.text-action svg{width:14px}.cidr-list{margin:10px 18px;display:grid;gap:6px}.cidr-list>div{height:36px;border:1px solid var(--line);border-radius:6px;display:flex;align-items:center;gap:9px;padding:0 10px}.cidr-list code{font-family:"Cascadia Code",monospace;font-size:11px;flex:1}.grip{color:#a6adba}.cidr-list button{border:0;background:transparent;color:#9aa2b2;display:grid;place-items:center}.cidr-list button svg{width:14px}.preflight{margin:8px 18px 0;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;gap:8px;color:#4a566c;font-size:11px}.preflight>svg{width:17px;color:var(--green)}.preflight strong{color:var(--green)}.preflight button{margin-left:auto;border:0;background:transparent;color:var(--accent);font-size:11px;font-weight:650}.error-banner{padding:10px 14px;background:#fff2f3;color:#b52f3c;border:1px solid #f2c2c6;border-radius:7px}.session-panel{margin-top:12px;min-height:148px;padding:18px;display:grid;grid-template-columns:minmax(300px,1.7fr) repeat(3,minmax(100px,.62fr)) auto;align-items:center}.session-copy{align-self:stretch;padding-right:20px;border-right:1px solid var(--line)}.session-copy dl{display:grid;grid-template-columns:82px 1fr;gap:5px;margin:12px 0 0;font-size:10px}.session-copy dt{color:#7e8799}.session-copy dd{margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mono{font-family:"Cascadia Code",monospace}.metric{height:88px;border-right:1px solid var(--line);display:grid;place-content:center;text-align:center;gap:7px}.metric span,.metric small{font-size:10px;color:var(--muted)}.metric strong{font-size:22px;color:#2848d8;letter-spacing:-.5px}.danger{margin-left:22px;border:1px solid #ed6973;background:#fff;color:#cf3542;padding:0 18px}.danger:disabled{opacity:.4}.logs-panel{margin-top:12px;min-height:168px;overflow:hidden}.logs{font-family:"Cascadia Code",monospace;font-size:10px;padding:10px 18px;display:grid;gap:8px}.logs>div{display:grid;grid-template-columns:85px 44px 1fr;gap:10px}.logs time{color:#778197}.logs b{color:#315be1}.logs b.warn{color:#d48a00}.logs b.error{color:#d13a48}.loading{height:100vh;display:grid;place-items:center;color:#667086}.error-banner{font-size:12px}@media(max-width:1120px){.app-shell{grid-template-columns:80px 1fr}.sidebar{width:80px}.brand{font-size:17px;padding:22px 10px;text-align:center}.brand span,.nav-item:not(.selected)::after,.nav-item{font-size:0}.nav-item{justify-content:center;padding:0}.sidebar-foot{padding:14px 8px;text-align:center}.setup-grid{grid-template-columns:1fr}.session-panel{grid-template-columns:1.5fr repeat(3,.6fr)}.session-panel .danger{grid-column:1/-1;margin:14px 0 0}.connection-path{padding:0}.workspace{padding:16px}.status-strip{padding:0 12px}}@media(prefers-reduced-motion:no-preference){.path-line i{animation:pulse 1.8s ease-in-out infinite}.path-line i:nth-of-type(2){animation-delay:.2s}.path-line i:nth-of-type(3){animation-delay:.4s}@keyframes pulse{50%{transform:scale(1.5);opacity:.45}}}.preflight.fail,.preflight.fail strong{color:var(--red)}.preflight.checking,.preflight.checking strong{color:var(--muted)}.settings-panel{max-width:760px;margin-top:38px}.settings-panel dl{display:grid;grid-template-columns:170px 1fr;gap:18px 28px;padding:24px;margin:0}.settings-panel dt{color:var(--muted)}.settings-panel dd{margin:0;font-weight:650}.settings-panel>p{margin:0;padding:18px 24px;border-top:1px solid var(--line);color:var(--muted);line-height:1.7} diff --git a/frontend/engineer/src/types.ts b/frontend/engineer/src/types.ts new file mode 100644 index 0000000..a32360b --- /dev/null +++ b/frontend/engineer/src/types.ts @@ -0,0 +1,35 @@ +export type NodeStatus = 'ONLINE' | 'UNSTABLE' | 'OFFLINE' +export type SessionStatus = 'IDLE' | 'CREATING' | 'PREPARING_SITE' | 'READY' | 'ACTIVE' | 'STOPPING' | 'CLOSED' | 'FAILED' + +export interface SiteSummary { + node_id: string + name: string + overlay_ip: string + online: boolean + remote_subnet_capability: boolean + last_seen?: string +} + +export interface EngineerState { + serverConnected: boolean + controlConnected: boolean + serverURL: string + version: string + overlayIP: string + sites: SiteSummary[] + siteCIDRs: Record + session: { + id: string + siteName: string + cidrs: string[] + status: SessionStatus + uploadBytes: number + downloadBytes: number + uploadPackets: number + downloadPackets: number + latencyMS: number + startedAt?: string + reason?: string + } + logs: Array<{ time: string; level: 'INFO' | 'WARN' | 'ERROR'; message: string }> +} diff --git a/frontend/engineer/src/zh-cn.ts b/frontend/engineer/src/zh-cn.ts new file mode 100644 index 0000000..88517ff --- /dev/null +++ b/frontend/engineer/src/zh-cn.ts @@ -0,0 +1,27 @@ +const statusLabels: Record = { + IDLE: '空闲(IDLE)', CREATING: '正在创建(CREATING)', PREPARING_SITE: '正在准备现场端(PREPARING_SITE)', + READY: '准备就绪(READY)', ACTIVE: '活动中(ACTIVE)', STOPPING: '正在停止(STOPPING)', + CLOSED: '已关闭(CLOSED)', FAILED: '失败(FAILED)', +} +const levelLabels: Record = { INFO: '信息', WARN: '警告', ERROR: '错误' } +const errorLabels: Record = { + SITE_NO_ROUTE: '现场端没有通往远程网段的明确路由', SESSION_TIMEOUT: '会话建立超时', SITE_OFFLINE: '现场端离线', + CIDR_INVALID: '远程网段格式无效', CIDR_LOCAL_CONFLICT: '远程网段与本地网络冲突', + CIDR_OVERLAY_CONFLICT: '远程网段与 Overlay 网段冲突', NETSTACK_UNAVAILABLE: '现场端 netstack 网关不可用', + FLOW_LIMIT_REACHED: '现场端连接流数量已达到上限', SESSION_INJECT_FAILED: '会话数据包注入失败', + ENGINEER_SESSION_EXISTS: 'Engineer 已存在未结束的会话', +} + +export function statusLabel(value: string) { return statusLabels[value] ?? value } +export function levelLabel(value: string) { return levelLabels[value] ?? value } +export function errorLabel(cause: unknown) { + const value = String(cause).replace(/^Error:\s*/, '') + for (const [code, label] of Object.entries(errorLabels)) if (value.includes(code)) return `${label}(${code})` + if (value.includes('administrator privileges are required')) return '需要以管理员身份运行,才能管理 RemLink Wintun 网卡' + if (value.includes('decrypt WireGuard private key')) return '无法解密 WireGuard 私钥:identity.json 不是由当前 Windows 系统生成,请重新注册节点' + if (value.includes('Join Token is required')) return '首次注册需要在 engineer.yaml 中填写 Join Token' + if (value.includes('conflicts with existing route')) return `远程网段与现有本地路由冲突;原始信息:${value}` + if (value.includes('Site has no route to Remote CIDR')) return '现场端没有通往远程网段的明确路由(SITE_NO_ROUTE)' + if (value.includes('native runtime is unavailable')) return 'RemLink 原生运行时不可用,请使用正式 Engineer.exe 启动' + return value +} diff --git a/frontend/engineer/tsconfig.json b/frontend/engineer/tsconfig.json new file mode 100644 index 0000000..f4be426 --- /dev/null +++ b/frontend/engineer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/engineer/vite.config.ts b/frontend/engineer/vite.config.ts new file mode 100644 index 0000000..b42fc5a --- /dev/null +++ b/frontend/engineer/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [vue()], + build: { outDir: 'dist', emptyOutDir: true, sourcemap: false }, + server: { host: '127.0.0.1', port: 34115, strictPort: true }, +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7704632 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1155 @@ +{ + "name": "remlink-frontends", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "remlink-frontends", + "version": "1.0.0", + "dependencies": { + "vue": "3.5.41" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.8", + "typescript": "5.9.3", + "vite": "8.2.2", + "vue-tsc": "3.3.11" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d7899b7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "remlink-frontends", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "npm run build:engineer && npm run build:server", + "build:engineer": "vite build --config engineer/vite.config.ts", + "build:server": "vite build --config server/vite.config.ts", + "dev:engineer": "vite --config engineer/vite.config.ts", + "dev:server": "vite --config server/vite.config.ts", + "typecheck": "vue-tsc --noEmit -p engineer/tsconfig.json && vue-tsc --noEmit -p server/tsconfig.json" + }, + "dependencies": { + "vue": "3.5.41" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.8", + "typescript": "5.9.3", + "vite": "8.2.2", + "vue-tsc": "3.3.11" + } +} diff --git a/frontend/server/embed.go b/frontend/server/embed.go new file mode 100644 index 0000000..4593977 --- /dev/null +++ b/frontend/server/embed.go @@ -0,0 +1,19 @@ +// Package serverui embeds the production Server Web UI. +package serverui + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed all:dist +var assets embed.FS + +func Handler() (http.Handler, error) { + dist, err := fs.Sub(assets, "dist") + if err != nil { + return nil, err + } + return http.FileServer(http.FS(dist)), nil +} diff --git a/frontend/server/index.html b/frontend/server/index.html new file mode 100644 index 0000000..9945e24 --- /dev/null +++ b/frontend/server/index.html @@ -0,0 +1,14 @@ + + + + + + + + RemLink Server + + +
+ + + diff --git a/frontend/server/src/App.vue b/frontend/server/src/App.vue new file mode 100644 index 0000000..e5b1bca --- /dev/null +++ b/frontend/server/src/App.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/server/src/Icon.vue b/frontend/server/src/Icon.vue new file mode 100644 index 0000000..53c7597 --- /dev/null +++ b/frontend/server/src/Icon.vue @@ -0,0 +1,12 @@ + + diff --git a/frontend/server/src/api.ts b/frontend/server/src/api.ts new file mode 100644 index 0000000..ad4d633 --- /dev/null +++ b/frontend/server/src/api.ts @@ -0,0 +1,61 @@ +import type { EventRecord, LogFilter, NetworkConfig, NodeRecord, SessionRecord } from './types' + +const now = Date.now() +const demoNodes: NodeRecord[] = [ + { node_id: 'eng-01', type: 'engineer', name: 'Engineer-01', overlay_ip: '10.88.0.10', wg_public_key: 'E7y0lQYJb0fYfS0uA8GpR0JtXj4Ff7r2O8lD...', wg_handshake: new Date(now - 6000).toISOString(), status: 'ONLINE', version: '1.0.0', os_version: 'Windows 11', last_seen: new Date(now - 4000).toISOString() }, + { node_id: 'eng-02', type: 'engineer', name: 'Engineer-02', overlay_ip: '10.88.0.11', wg_public_key: 'Q1r9...', status: 'ONLINE', version: '1.0.0', os_version: 'Windows 11', last_seen: new Date(now - 8000).toISOString() }, + { node_id: 'site-a', type: 'site', name: 'Site-A', overlay_ip: '10.88.0.20', wg_public_key: 'L8c2...', status: 'UNSTABLE', version: '1.0.0', os_version: 'Windows Server 2022', last_seen: new Date(now - 22_000).toISOString() }, + { node_id: 'site-b', type: 'site', name: 'Site-B', overlay_ip: '10.88.0.21', wg_public_key: 'M5x4...', status: 'OFFLINE', version: '1.0.0', os_version: 'Windows 10', last_seen: new Date(now - 2_400_000).toISOString() }, +] +const demoSessions: SessionRecord[] = [ + { session_id: '8648912340291133', engineer_node_id: 'eng-01', site_node_id: 'site-a', status: 'ACTIVE', cidrs: ['192.168.10.0/24'], created_at: new Date(now - 1_220_000).toISOString(), active_at: new Date(now - 1_200_000).toISOString(), counters: { upload_bytes: 1258291, download_bytes: 3586129, upload_packets: 8912, download_packets: 14022 } }, + { session_id: '7066248371127201', engineer_node_id: 'eng-02', site_node_id: 'site-a', status: 'ACTIVE', cidrs: ['192.168.20.0/24'], created_at: new Date(now - 550_000).toISOString(), active_at: new Date(now - 530_000).toISOString(), counters: { upload_bytes: 712004, download_bytes: 1153434, upload_packets: 4220, download_packets: 6741 } }, +] +let demoNetwork: NetworkConfig = { overlay_cidr: '10.88.0.0/16', server_overlay_ip: '10.88.0.1', wireguard_port: 51820, session_udp_port: 6200, mtu: 1280, config_version: 1, uptime_seconds: 48376 } +const demoEvents: EventRecord[] = [ + { id: 5, time: new Date(now - 5000).toISOString(), level: 'INFO', module: 'CONTROL', node_id: 'eng-01', message: '节点上线,Overlay 10.88.0.10', fields: {} }, + // Keep two legacy English records in development so the presentation-layer + // translator is exercised against events persisted by older Server builds. + { id: 4, time: new Date(now - 12_000).toISOString(), level: 'INFO', module: 'SESSION', session_id: '8648912340291133', message: 'Session status changed to ACTIVE', fields: {} }, + { id: 3, time: new Date(now - 31_000).toISOString(), level: 'WARN', module: 'CONTROL', node_id: 'site-a', message: 'Node heartbeat status changed to UNSTABLE', fields: {} }, + { id: 2, time: new Date(now - 80_000).toISOString(), level: 'ERROR', module: 'CONTROL', node_id: 'site-b', message: '节点离线', fields: {} }, +] + +const dev = import.meta.env.DEV +let token = localStorage.getItem('remlink-admin-token') ?? '' +export function setAdminToken(value: string) { token = value.trim(); localStorage.setItem('remlink-admin-token', token) } +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { ...init, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...init?.headers } }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + const code = body?.error?.code + const message = body?.error?.message ?? `HTTP ${response.status}` + throw new Error(code ? `${code}: ${message}` : message) + } + return response.status === 204 ? undefined as T : response.json() +} +export const api = { + nodes: () => dev ? Promise.resolve(structuredClone(demoNodes)) : request('/api/v1/admin/nodes'), + sessions: () => dev ? Promise.resolve(structuredClone(demoSessions)) : request('/api/v1/admin/sessions'), + network: () => dev ? Promise.resolve(structuredClone(demoNetwork)) : request('/api/v1/admin/network'), + logs: (filter: LogFilter = {}) => { + const params = new URLSearchParams({ limit: String(filter.limit ?? 200) }) + for (const key of ['level','module','node_id','session_id','from','to'] as const) if (filter[key]) params.set(key, String(filter[key])) + if (!dev) return request(`/api/v1/admin/logs?${params}`) + const from = filter.from ? Date.parse(filter.from) : Number.NEGATIVE_INFINITY + const to = filter.to ? Date.parse(filter.to) : Number.POSITIVE_INFINITY + return Promise.resolve(structuredClone(demoEvents.filter(event => (!filter.level || event.level === filter.level) && (!filter.module || event.module === filter.module) && (!filter.node_id || event.node_id === filter.node_id) && (!filter.session_id || String(event.session_id ?? '') === filter.session_id) && Date.parse(event.time) >= from && Date.parse(event.time) <= to))) + }, + patchNode: async (id: string, patch: Partial>) => dev ? Object.assign(demoNodes.find(node => node.node_id === id)!, patch) : request(`/api/v1/admin/nodes/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) }), + deleteNode: async (id: string) => dev ? demoNodes.splice(demoNodes.findIndex(node => node.node_id === id), 1) : request(`/api/v1/admin/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' }), + disconnect: async (id: string) => dev ? Object.assign(demoSessions.find(session => session.session_id === id)!, { status: 'CLOSED' }) : request(`/api/v1/admin/sessions/${id}/disconnect`, { method: 'POST' }), + updateNetwork: async (network: NetworkConfig) => { + if (dev) { + const { rotate_join_token: rotate, join_token: _, ...input } = network + demoNetwork = { ...input, config_version: network.config_version + 1, ...(rotate ? { join_token: 'demo-join-token-after-rotation' } : {}) } + return structuredClone(demoNetwork) + } + const { config_version: _, uptime_seconds: __, join_token: ___, ...input } = network + return request('/api/v1/admin/network', { method: 'PUT', body: JSON.stringify(input) }) + }, +} diff --git a/frontend/server/src/components/LogTable.vue b/frontend/server/src/components/LogTable.vue new file mode 100644 index 0000000..44f2e07 --- /dev/null +++ b/frontend/server/src/components/LogTable.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/server/src/components/NetworkForm.vue b/frontend/server/src/components/NetworkForm.vue new file mode 100644 index 0000000..16c866e --- /dev/null +++ b/frontend/server/src/components/NetworkForm.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/server/src/components/NodeTable.vue b/frontend/server/src/components/NodeTable.vue new file mode 100644 index 0000000..0f0f294 --- /dev/null +++ b/frontend/server/src/components/NodeTable.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/server/src/components/SessionTable.vue b/frontend/server/src/components/SessionTable.vue new file mode 100644 index 0000000..49203a3 --- /dev/null +++ b/frontend/server/src/components/SessionTable.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/server/src/components/StatusDot.vue b/frontend/server/src/components/StatusDot.vue new file mode 100644 index 0000000..85d4af6 --- /dev/null +++ b/frontend/server/src/components/StatusDot.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/server/src/main.ts b/frontend/server/src/main.ts new file mode 100644 index 0000000..fdbdce5 --- /dev/null +++ b/frontend/server/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' + +createApp(App).mount('#app') diff --git a/frontend/server/src/styles.css b/frontend/server/src/styles.css new file mode 100644 index 0000000..3ef4630 --- /dev/null +++ b/frontend/server/src/styles.css @@ -0,0 +1 @@ +:root{font-family:Inter,"Segoe UI","Microsoft YaHei UI",sans-serif;color:#172033;background:#f7f8fc;font-synthesis:none;--accent:#3d3ce3;--accent2:#3454ea;--soft:#f0f1ff;--line:#dfe4ef;--muted:#758097;--green:#0aa36c;--amber:#e7a009;--red:#dd4351}*{box-sizing:border-box}body{margin:0;min-width:980px;min-height:100vh;background:#f7f8fc}button,input,select{font:inherit}.admin-shell{display:grid;grid-template-columns:220px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:220px;background:#fff;border-right:1px solid var(--line);display:flex;flex-direction:column}.brand{height:72px;padding:0 20px;display:flex;align-items:center;gap:11px;color:#3438cc;font-size:17px;font-weight:750;border-bottom:1px solid var(--line)}.brand b{font-weight:500}.brand-mark{width:26px;height:26px;position:relative}.brand-mark i{position:absolute;width:9px;height:9px;border-radius:50%;background:#3c43db}.brand-mark i:nth-child(1){top:0;left:9px}.brand-mark i:nth-child(2){bottom:0;left:1px}.brand-mark i:nth-child(3){bottom:0;right:1px}.sidebar nav{padding:14px 11px;display:grid;gap:5px}.sidebar nav button{height:45px;padding:0 17px;border:0;border-radius:7px;background:transparent;color:#536079;display:flex;align-items:center;gap:13px;font-size:13px;font-weight:600;cursor:pointer}.sidebar nav button svg{width:20px}.sidebar nav button.selected{background:linear-gradient(100deg,#3d3ce3,#334fe6);color:#fff}.token-box{margin:auto 10px 12px;padding:12px;border:1px solid var(--line);border-radius:8px}.token-box label{font-size:9px;color:var(--muted)}.token-box div{display:flex;margin-top:8px}.token-box input{min-width:0;width:100%;height:33px;border:1px solid var(--line);border-radius:5px 0 0 5px;padding:0 8px;font-size:10px}.token-box button{border:0;background:var(--soft);color:var(--accent);border-radius:0 5px 5px 0;font-size:10px;font-weight:700}.sidebar footer{text-align:center;padding:0 0 22px;color:#7a8499;font-size:10px}.admin-shell>main{grid-column:2;min-width:0}.admin-shell>main>header{height:58px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:flex-end;padding:0 34px;gap:17px;color:#6f7990;font-size:10px}.admin-shell>main>header>i{height:18px;width:1px;background:#dfe4ef}.admin-shell>main>header strong{color:#263047}.admin-shell>main>header strong:before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:6px}.content{padding:18px 26px 28px;max-width:none;margin:0}.title-row{min-height:54px;display:flex;justify-content:space-between;align-items:flex-start}.title-row h1{font-size:20px;margin:3px 0;letter-spacing:-.4px}.title-row p{font-size:10px;color:var(--muted);margin:6px 0}.primary{height:36px;border:0;border-radius:6px;padding:0 15px;background:linear-gradient(100deg,#3d3ce3,#334fe6);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:7px;justify-content:center;cursor:pointer}.primary svg{width:15px}.primary:disabled{opacity:.5}.panel{background:#fff;border:1px solid var(--line);border-radius:8px}.section-title{min-height:42px;padding:0 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.section-title h2{font-size:12px;margin:0}.section-title>span,.section-title p{font-size:9px;color:var(--muted)}.topology{margin-bottom:10px}.topology-heading{display:flex;align-items:baseline;gap:12px;min-width:0}.topology-heading p{margin:0;white-space:nowrap}.legend{display:flex;gap:18px;font-size:8px;color:#667087}.legend span,.status{display:inline-flex;align-items:center;gap:6px}.legend i,.status i,.health-list i{width:7px;height:7px;border-radius:50%;display:inline-block}.online{background:var(--green)!important}.unstable{background:var(--amber)!important}.offline{background:var(--red)!important}.active{background:var(--green)!important}.topology-body{min-height:142px;display:grid;grid-template-columns:220px minmax(0,1fr) 285px;align-items:center;padding:13px 24px}.server-node{height:96px;border:1px solid #bac4d8;border-radius:7px;display:flex;align-items:center;gap:10px;padding:0 24px}.server-node>svg{width:32px;color:#3443d4}.server-node div{display:grid;gap:6px}.server-node b{font-size:11px}.server-node small{font-size:9px;color:var(--muted)}.topology-line{height:3px;background:#6d78a9;display:flex;justify-content:space-around;position:relative}.topology-line span{position:relative;top:-5px;width:13px;height:13px;border-radius:50%;background:var(--green);text-align:center}.topology-line span>i{display:block;width:100%;height:100%;border-radius:50%;background:inherit;border:2px solid #fff}.topology-line span>b,.topology-line span>em,.topology-line span>small{display:block;position:relative;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:9px}.topology-line span>b{margin-top:7px}.topology-line span>em{margin-top:3px;color:#59647a;font-size:8px;font-style:normal;font-weight:700}.topology-line span>small{color:var(--muted);margin-top:2px}.topology-body dl{border-left:1px solid var(--line);margin:0;padding:0 24px;display:grid;grid-template-columns:1fr auto;gap:11px;font-size:10px}.topology-body dt{font-weight:650}.topology-body dd{margin:0;color:var(--green);font-weight:750}.amber{color:var(--amber)!important}.red{color:var(--red)!important}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1fr) 370px;gap:10px}.dashboard-grid>div{display:grid;gap:10px;align-content:start}.table-panel{overflow:hidden}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:10px}th{height:34px;background:#fafbfe;color:#4f5b72;text-align:left;font-weight:700;padding:0 16px;white-space:nowrap}td{height:38px;border-top:1px solid var(--line);padding:5px 16px;white-space:nowrap;color:#344057}tbody tr:hover{background:#fafbff}td b{display:block}td small{display:block;color:var(--muted);margin-top:3px}.mono{font-family:"Cascadia Code",monospace}.icon-button{border:0;background:transparent;color:#3b5dd7;padding:4px;cursor:pointer}.icon-button svg{width:13px}.icon-button.destructive{color:var(--red)}.disconnect{height:25px;border:1px solid #ef8d95;border-radius:4px;background:#fff;color:var(--red);font-size:9px;font-weight:700}.disconnect:disabled{opacity:.35}.empty{text-align:center;color:var(--muted);height:54px}.log-level{display:inline;color:#3d5bd6;font-weight:750;padding:0;border:0;border-radius:0;background:transparent;font-size:inherit;line-height:inherit;box-shadow:none}.log-level.warn{color:var(--amber)}.log-level.error{color:var(--red)}.network-form{padding-bottom:13px}.network-form .section-title{min-height:54px}.health-list{padding:10px 15px;border-bottom:1px solid var(--line);display:grid;gap:9px}.health-list span{display:flex;justify-content:space-between;font-size:9px}.health-list b{font-weight:600}.fields{padding:11px 15px;display:grid;gap:9px}.fields label{font-size:8px;color:#536079;font-weight:650}.fields input{display:block;width:100%;height:30px;margin-top:5px;padding:0 9px;border:1px solid #bdc6d8;border-radius:5px;outline:none;color:#263249;font-size:9px}.fields input:focus{border-color:var(--accent);box-shadow:0 0 0 2px #ececff}.network-form .save{width:calc(100% - 30px);margin:3px 15px}.form-note{font-size:8px;color:#8790a3;margin:10px 15px 0;line-height:1.5}.network-form.full{max-width:760px}.network-form.full .fields{grid-template-columns:1fr 1fr}.secondary-action{height:34px;margin:9px 15px 0;width:calc(100% - 30px);border:1px solid #afbaf0;border-radius:6px;background:#fff;color:var(--accent);font-size:10px;font-weight:700}.filters{display:flex;gap:7px}.filters select,.filters input,.filters button{height:28px;border:1px solid var(--line);border-radius:5px;background:#fff;color:#566177;font-size:9px;padding:0 8px}.logs-page{overflow:hidden}.page-error{padding:9px 12px;background:#fff1f2;border:1px solid #f4c4c8;border-radius:6px;color:#b72e3b;font-size:10px}@media(max-width:1120px){.admin-shell{grid-template-columns:76px 1fr}.sidebar{width:76px}.brand{padding:0 17px}.brand>span:last-child,.sidebar nav button{font-size:0}.sidebar nav button{justify-content:center;padding:0}.token-box,.sidebar footer{display:none}.dashboard-grid{grid-template-columns:1fr}.network-form{display:grid;grid-template-columns:1fr 1.3fr;align-items:start}.network-form .section-title{grid-column:1/-1}.network-form .health-list{border-right:1px solid var(--line)}.network-form .fields{grid-column:2;grid-row:2/5}.network-form .save,.network-form .form-note{grid-column:1}.content{padding:16px}.topology-heading p{display:none}.topology-body{grid-template-columns:160px 1fr}.topology-body dl{display:none}.server-node{height:76px;padding:0 16px}}@media(prefers-reduced-motion:no-preference){tbody tr,.sidebar nav button{transition:background .15s ease,color .15s ease}} diff --git a/frontend/server/src/types.ts b/frontend/server/src/types.ts new file mode 100644 index 0000000..287b1d1 --- /dev/null +++ b/frontend/server/src/types.ts @@ -0,0 +1,6 @@ +export interface NodeRecord { node_id: string; type: 'engineer' | 'site'; name: string; overlay_ip: string; wg_public_key: string; wg_handshake?: string; status: 'ONLINE' | 'UNSTABLE' | 'OFFLINE'; version: string; os_version: string; last_seen?: string } +export interface Counters { upload_bytes: number; download_bytes: number; upload_packets: number; download_packets: number } +export interface SessionRecord { session_id: string; engineer_node_id: string; site_node_id: string; status: string; cidrs: string[]; created_at: string; active_at?: string; closed_at?: string; error_code?: string; counters: Counters } +export interface NetworkConfig { overlay_cidr: string; server_overlay_ip: string; wireguard_port: number; session_udp_port: number; mtu: number; config_version: number; uptime_seconds?: number; rotate_join_token?: boolean; join_token?: string } +export interface EventRecord { id: number; time: string; level: string; module: string; node_id?: string; session_id?: string; message: string; fields: Record } +export interface LogFilter { level?: string; module?: string; node_id?: string; session_id?: string; from?: string; to?: string; limit?: number } diff --git a/frontend/server/src/zh-cn.ts b/frontend/server/src/zh-cn.ts new file mode 100644 index 0000000..f07026b --- /dev/null +++ b/frontend/server/src/zh-cn.ts @@ -0,0 +1,61 @@ +const statusLabels: Record = { + ONLINE: '在线(ONLINE)', UNSTABLE: '连接不稳定(UNSTABLE)', OFFLINE: '离线(OFFLINE)', + CREATING: '正在创建(CREATING)', PREPARING_SITE: '正在准备现场端(PREPARING_SITE)', + READY: '准备就绪(READY)', ACTIVE: '活动中(ACTIVE)', STOPPING: '正在停止(STOPPING)', + CLOSED: '已关闭(CLOSED)', FAILED: '失败(FAILED)', IDLE: '空闲(IDLE)', +} + +const levelLabels: Record = { INFO: '信息(INFO)', WARN: '警告(WARN)', ERROR: '错误(ERROR)', DEBUG: '调试(DEBUG)' } +const moduleLabels: Record = { + CORE: '核心(CORE)', BOOTSTRAP: '节点接入(BOOTSTRAP)', WG: 'WireGuard(WG)', IPAM: '地址分配(IPAM)', + CONTROL: '控制通道(CONTROL)', SESSION: '会话(SESSION)', ROUTE: '路由(ROUTE)', NETSTACK: '网络栈(NETSTACK)', + TUN: '虚拟网卡(TUN)', SUBNET: '远程网段(SUBNET)', SYSTEM: '系统(SYSTEM)', +} +const reasonLabels: Record = { + SITE_NO_ROUTE: '现场端没有通往远程网段的明确路由(SITE_NO_ROUTE)', + SESSION_TIMEOUT: '会话建立超时(SESSION_TIMEOUT)', + SITE_OFFLINE: '现场端离线(SITE_OFFLINE)', + CIDR_INVALID: '远程网段格式无效(CIDR_INVALID)', + CIDR_LOCAL_CONFLICT: '远程网段与 Engineer 本地网络冲突(CIDR_LOCAL_CONFLICT)', + CIDR_OVERLAY_CONFLICT: '远程网段与 Overlay 网段冲突(CIDR_OVERLAY_CONFLICT)', + NETSTACK_UNAVAILABLE: '现场端 netstack 网关不可用(NETSTACK_UNAVAILABLE)', + FLOW_LIMIT_REACHED: '现场端连接流数量已达到上限(FLOW_LIMIT_REACHED)', + SESSION_INJECT_FAILED: '会话数据包注入失败(SESSION_INJECT_FAILED)', + INVALID_REQUEST: '请求内容无效(INVALID_REQUEST)', NODE_NOT_FOUND: '没有找到指定节点(NODE_NOT_FOUND)', + NODE_UPDATE_FAILED: '节点更新失败(NODE_UPDATE_FAILED)', SESSION_DISCONNECT_FAILED: '会话断开失败(SESSION_DISCONNECT_FAILED)', + PEER_REVOKE_FAILED: 'WireGuard 对等节点撤销失败(PEER_REVOKE_FAILED)', NODE_DELETE_FAILED: '节点删除失败(NODE_DELETE_FAILED)', + INVALID_SESSION_ID: '会话 ID 无效(INVALID_SESSION_ID)', NETWORK_UPDATE_FAILED: '网络配置更新失败(NETWORK_UPDATE_FAILED)', + JOIN_TOKEN_ROTATE_FAILED: 'Join Token 轮换失败(JOIN_TOKEN_ROTATE_FAILED)', +} + +const oldMessages: Record = { + 'Node Control connected': '节点 Control 通道已连接', + 'Node rejected Overlay network configuration': '节点拒绝了 Overlay 网络配置', + 'Session preparation started': '会话准备已开始', + 'Node updated': '节点配置已更新', + 'Node revoked': '节点已撤销', + 'Session disconnected by administrator': '管理员已强制断开会话', + 'Network configuration updated': '网络配置已更新', +} + +export function statusLabel(value: string) { return statusLabels[value] ?? value } +export function levelLabel(value: string) { return levelLabels[value] ?? value } +export function moduleLabel(value: string) { return moduleLabels[value] ?? value } +export function reasonLabel(value: string) { return reasonLabels[value] ?? value } + +export function logMessageLabel(value: string) { + if (oldMessages[value]) return oldMessages[value] + let match = /^Session status changed to ([A-Z_]+)$/.exec(value) + if (match) return `会话状态变更为 ${statusLabel(match[1])}` + match = /^Node heartbeat status changed to ([A-Z_]+)$/.exec(value) + if (match) return `节点心跳状态变更为 ${statusLabel(match[1])}` + return value +} + +export function errorLabel(cause: unknown) { + const value = String(cause).replace(/^Error:\s*/, '') + for (const [code, label] of Object.entries(reasonLabels)) if (value.includes(code)) return `${label};原始信息:${value}` + if (value.includes('valid Bearer Admin Token required')) return '需要有效的管理员 Bearer Token' + if (value.includes('Failed to fetch')) return '无法连接 Server API,请检查服务地址、端口和防火墙' + return value +} diff --git a/frontend/server/tsconfig.json b/frontend/server/tsconfig.json new file mode 100644 index 0000000..f4be426 --- /dev/null +++ b/frontend/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/server/vite.config.ts b/frontend/server/vite.config.ts new file mode 100644 index 0000000..017ff8c --- /dev/null +++ b/frontend/server/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [vue()], + resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js' } }, + build: { outDir: 'dist', emptyOutDir: true, sourcemap: false }, + server: { host: '127.0.0.1', port: 34116, strictPort: true }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9c79e6a --- /dev/null +++ b/go.mod @@ -0,0 +1,63 @@ +module remlink + +go 1.26.7 + +require ( + github.com/coder/websocket v1.8.15 + github.com/google/uuid v1.6.0 + github.com/vishvananda/netlink v1.3.1 + github.com/wailsapp/wails/v2 v2.13.0 + golang.org/x/net v0.54.0 + golang.org/x/sys v0.47.0 + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 + golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 + golang.zx2c4.com/wireguard/windows v1.0.1 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c + modernc.org/sqlite v1.57.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.8.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..496b7a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,169 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg= +github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= +golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= +golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/admin/handler.go b/internal/admin/handler.go new file mode 100644 index 0000000..fd58c23 --- /dev/null +++ b/internal/admin/handler.go @@ -0,0 +1,328 @@ +package admin + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "strconv" + "strings" + "time" + + "remlink/internal/database" + "remlink/internal/logging" + "remlink/internal/model" + "remlink/internal/protocol" +) + +const maxAdminBody = 1 << 20 + +type JoinTokenRotator interface { + Rotate(context.Context) (string, error) +} + +type HandlerConfig struct { + Store *database.Store + IPAM IPAM + Peers PeerManager + Control ControlNetwork + Sessions SessionControl + Network *NetworkManager + JoinTokens JoinTokenRotator + AdminToken string +} + +// Handler exposes only the exact /api/v1/admin surface from R11. +func Handler(config HandlerConfig) (http.Handler, error) { + if config.Store == nil || config.IPAM == nil || config.Peers == nil || config.Control == nil || + config.Sessions == nil || config.Network == nil || config.JoinTokens == nil { + return nil, errors.New("Admin handler dependencies are required") + } + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/admin/nodes", func(writer http.ResponseWriter, request *http.Request) { + nodes, err := config.Store.ListNodes(request.Context()) + if err != nil { + writeResult(writer, nil, err) + return + } + views := make([]nodeView, 0, len(nodes)) + for _, node := range nodes { + handshake, handshakeErr := config.Peers.LastHandshake(request.Context(), node.WGPublicKey) + if handshakeErr != nil { + writeResult(writer, nil, handshakeErr) + return + } + views = append(views, nodeView{Node: node, WGHandshake: handshake}) + } + writeResult(writer, views, nil) + }) + mux.HandleFunc("PATCH /api/v1/admin/nodes/{id}", func(writer http.ResponseWriter, request *http.Request) { + var input struct { + Name *string `json:"name"` + OverlayIP *string `json:"overlay_ip"` + } + if err := decodeAdminJSON(writer, request, &input); err != nil { + writeAdminError(writer, http.StatusBadRequest, "INVALID_REQUEST", err) + return + } + if input.Name == nil && input.OverlayIP == nil { + writeAdminError(writer, http.StatusBadRequest, "INVALID_REQUEST", errors.New("name or overlay_ip is required")) + return + } + var desiredName *string + if input.Name != nil { + name := strings.TrimSpace(*input.Name) + if name == "" || len(name) > 128 { + writeAdminError(writer, http.StatusBadRequest, "INVALID_NODE_NAME", errors.New("name must contain 1 to 128 bytes")) + return + } + desiredName = &name + } + var desiredAddress netip.Addr + if input.OverlayIP != nil { + var parseErr error + desiredAddress, parseErr = netip.ParseAddr(*input.OverlayIP) + if parseErr != nil || !desiredAddress.Is4() { + writeAdminError(writer, http.StatusBadRequest, "INVALID_OVERLAY_IP", errors.New("overlay_ip must be IPv4")) + return + } + } + nodeID := request.PathValue("id") + before, err := config.Store.GetNode(request.Context(), nodeID) + if err != nil { + writeAdminError(writer, http.StatusNotFound, "NODE_NOT_FOUND", err) + return + } + addressChanged := input.OverlayIP != nil && desiredAddress != before.OverlayIP + if addressChanged { + if err := config.Sessions.DisconnectNode(request.Context(), nodeID, "NODE_OVERLAY_IP_CHANGED"); err != nil { + writeAdminError(writer, http.StatusConflict, "SESSION_DISCONNECT_FAILED", err) + return + } + if err := config.IPAM.ChangeNodeAddress(request.Context(), nodeID, desiredAddress); err != nil { + writeAdminError(writer, http.StatusConflict, "OVERLAY_IP_UNAVAILABLE", err) + return + } + // The updated address is now visible through the public Bootstrap API. + // Notify over the still-usable old Peer before replacing its /32; + // changing the Peer first would cut the very Control path used by T17. + current := config.Network.Current() + _ = config.Control.Send(request.Context(), nodeID, protocol.ControlRebootstrapRequired, + protocol.RebootstrapRequiredPayload{ConfigVersion: current.ConfigVersion, Reason: "NODE_OVERLAY_IP_CHANGED"}) + if err := config.Peers.EnsurePeer(request.Context(), before.WGPublicKey, desiredAddress); err != nil { + _ = config.IPAM.ChangeNodeAddress(request.Context(), nodeID, before.OverlayIP) + _ = config.Peers.EnsurePeer(request.Context(), before.WGPublicKey, before.OverlayIP) + config.Control.ResetNodeConnection(nodeID, "Node Overlay IP update rolled back") + writeAdminError(writer, http.StatusInternalServerError, "PEER_UPDATE_FAILED", err) + return + } + } + if desiredName != nil { + if err := config.Store.UpdateNodeName(request.Context(), nodeID, *desiredName); err != nil { + if addressChanged { + _ = config.IPAM.ChangeNodeAddress(request.Context(), nodeID, before.OverlayIP) + _ = config.Peers.EnsurePeer(request.Context(), before.WGPublicKey, before.OverlayIP) + config.Control.ResetNodeConnection(nodeID, "Node update rolled back") + } + writeAdminError(writer, http.StatusBadRequest, "NODE_UPDATE_FAILED", err) + return + } + } + if addressChanged { + config.Control.ResetNodeConnection(nodeID, "Node Overlay IP changed") + } + updated, err := config.Store.GetNode(request.Context(), nodeID) + if err == nil { + recordAdminEvent(request.Context(), config.Store, nodeID, 0, "节点配置已更新", map[string]any{"overlay_ip": updated.OverlayIP.String(), "name": updated.Name}) + } + writeResult(writer, updated, err) + }) + mux.HandleFunc("DELETE /api/v1/admin/nodes/{id}", func(writer http.ResponseWriter, request *http.Request) { + nodeID := request.PathValue("id") + node, err := config.Store.GetNode(request.Context(), nodeID) + if err != nil { + writeAdminError(writer, http.StatusNotFound, "NODE_NOT_FOUND", err) + return + } + if err := config.Sessions.DisconnectNode(request.Context(), nodeID, "NODE_REVOKED"); err != nil { + writeAdminError(writer, http.StatusConflict, "SESSION_DISCONNECT_FAILED", err) + return + } + if err := config.Peers.RemovePeer(request.Context(), node.WGPublicKey); err != nil { + writeAdminError(writer, http.StatusInternalServerError, "PEER_REVOKE_FAILED", err) + return + } + if err := config.IPAM.ReleaseNode(request.Context(), nodeID); err != nil { + _ = config.Peers.EnsurePeer(request.Context(), node.WGPublicKey, node.OverlayIP) + writeAdminError(writer, http.StatusInternalServerError, "NODE_DELETE_FAILED", err) + return + } + config.Control.ResetNodeConnection(nodeID, "Node revoked") + recordAdminEvent(request.Context(), config.Store, nodeID, 0, "节点已撤销", nil) + writer.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /api/v1/admin/sessions", func(writer http.ResponseWriter, request *http.Request) { + sessions, err := config.Store.ListSessions(request.Context()) + writeResult(writer, sessions, err) + }) + mux.HandleFunc("POST /api/v1/admin/sessions/{id}/disconnect", func(writer http.ResponseWriter, request *http.Request) { + id, err := strconv.ParseUint(request.PathValue("id"), 10, 64) + if err != nil || id == 0 { + writeAdminError(writer, http.StatusBadRequest, "INVALID_SESSION_ID", errors.New("SessionID must be uint64")) + return + } + if err := config.Sessions.Disconnect(request.Context(), id, "ADMIN_DISCONNECT"); err != nil { + writeAdminError(writer, http.StatusConflict, "SESSION_DISCONNECT_FAILED", err) + return + } + recordAdminEvent(request.Context(), config.Store, "", id, "管理员已强制断开会话", nil) + writeAdminJSON(writer, http.StatusOK, map[string]any{"session_id": id, "status": model.SessionClosed}) + }) + mux.HandleFunc("GET /api/v1/admin/network", func(writer http.ResponseWriter, _ *http.Request) { + writeAdminJSON(writer, http.StatusOK, config.Network.View()) + }) + mux.HandleFunc("PUT /api/v1/admin/network", func(writer http.ResponseWriter, request *http.Request) { + var input NetworkUpdate + if err := decodeAdminJSON(writer, request, &input); err != nil { + writeAdminError(writer, http.StatusBadRequest, "INVALID_REQUEST", err) + return + } + current := config.Network.Current() + updated := current + var err error + if !networkInputMatches(input, current) { + updated, err = config.Network.Update(request.Context(), input) + if err != nil { + writeAdminError(writer, http.StatusConflict, "NETWORK_UPDATE_FAILED", err) + return + } + } + response := struct { + Network + JoinToken string `json:"join_token,omitempty"` + }{Network: updated} + if input.RotateJoinToken { + response.JoinToken, err = config.JoinTokens.Rotate(request.Context()) + if err != nil { + writeAdminError(writer, http.StatusInternalServerError, "JOIN_TOKEN_ROTATE_FAILED", err) + return + } + } + recordAdminEvent(request.Context(), config.Store, "", 0, "网络配置已更新", map[string]any{"config_version": updated.ConfigVersion, "join_token_rotated": input.RotateJoinToken}) + writeAdminJSON(writer, http.StatusOK, response) + }) + mux.HandleFunc("GET /api/v1/admin/logs", func(writer http.ResponseWriter, request *http.Request) { + filter := model.EventLogFilter{ + Level: request.URL.Query().Get("level"), Module: request.URL.Query().Get("module"), + NodeID: request.URL.Query().Get("node_id"), + } + if raw := request.URL.Query().Get("session_id"); raw != "" { + var parseErr error + filter.SessionID, parseErr = strconv.ParseUint(raw, 10, 64) + if parseErr != nil || filter.SessionID == 0 { + writeAdminError(writer, http.StatusBadRequest, "INVALID_SESSION_ID", errors.New("session_id must be uint64")) + return + } + } + if raw := request.URL.Query().Get("limit"); raw != "" { + var parseErr error + filter.Limit, parseErr = strconv.Atoi(raw) + if parseErr != nil || filter.Limit < 1 || filter.Limit > 1000 { + writeAdminError(writer, http.StatusBadRequest, "INVALID_LIMIT", errors.New("limit must be between 1 and 1000")) + return + } + } + for name, destination := range map[string]*time.Time{"from": &filter.From, "to": &filter.To} { + if raw := request.URL.Query().Get(name); raw != "" { + parsed, parseErr := time.Parse(time.RFC3339, raw) + if parseErr != nil { + writeAdminError(writer, http.StatusBadRequest, "INVALID_TIME", fmt.Errorf("%s must be RFC3339", name)) + return + } + *destination = parsed + } + } + if !filter.From.IsZero() && !filter.To.IsZero() && filter.From.After(filter.To) { + writeAdminError(writer, http.StatusBadRequest, "INVALID_TIME_RANGE", errors.New("from must not be after to")) + return + } + events, err := config.Store.ListEvents(request.Context(), filter) + writeResult(writer, events, err) + }) + return adminSecurity(config.AdminToken, mux), nil +} + +type nodeView struct { + model.Node + WGHandshake *time.Time `json:"wg_handshake,omitempty"` +} + +func networkInputMatches(input NetworkUpdate, current Network) bool { + return input.OverlayCIDR == current.OverlayCIDR && input.ServerOverlayIP == current.ServerOverlayIP && + input.WireGuardPort == current.WireGuardPort && input.SessionUDPPort == current.SessionUDPPort && input.MTU == current.MTU +} + +func recordAdminEvent(ctx context.Context, store *database.Store, nodeID string, sessionID uint64, message string, fields map[string]any) { + if fields == nil { + fields = map[string]any{} + } + raw, _ := json.Marshal(fields) + _ = store.AppendEvent(ctx, model.EventLog{ + Level: "INFO", Module: string(logging.ModuleCore), NodeID: nodeID, SessionID: sessionID, + Message: message, FieldsJSON: raw, + }) +} + +func adminSecurity(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") + if token != "" { + provided := strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer ") + if len(provided) != len(token) || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + writeAdminError(writer, http.StatusUnauthorized, "ADMIN_AUTH_FAILED", errors.New("valid Bearer Admin Token required")) + return + } + } + next.ServeHTTP(writer, request) + }) +} + +func decodeAdminJSON(writer http.ResponseWriter, request *http.Request, destination any) error { + if contentType := request.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "application/json") { + return errors.New("Content-Type must be application/json") + } + request.Body = http.MaxBytesReader(writer, request.Body, maxAdminBody) + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one JSON object") + } + return nil +} + +func writeResult(writer http.ResponseWriter, value any, err error) { + if err != nil { + writeAdminError(writer, http.StatusInternalServerError, "ADMIN_OPERATION_FAILED", err) + return + } + writeAdminJSON(writer, http.StatusOK, value) +} + +func writeAdminError(writer http.ResponseWriter, status int, code string, err error) { + writeAdminJSON(writer, status, map[string]any{"error": map[string]string{"code": code, "message": err.Error()}}) +} + +func writeAdminJSON(writer http.ResponseWriter, status int, value any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(value) +} diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go new file mode 100644 index 0000000..ed12beb --- /dev/null +++ b/internal/admin/handler_test.go @@ -0,0 +1,183 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "path/filepath" + "testing" + "time" + + "remlink/internal/bootstrap" + "remlink/internal/database" + "remlink/internal/ipam" + "remlink/internal/model" +) + +type fakeJoinTokenRotator struct{ count int } + +func (f *fakeJoinTokenRotator) Rotate(context.Context) (string, error) { + f.count++ + return "rotated-token", nil +} + +func TestAdminHandlerAuthNodeUpdateLogsAndRotateOnly(t *testing.T) { + ctx := context.Background() + db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := database.NewStore(db) + if err := store.CreateNode(ctx, model.Node{ + ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer", + OverlayIP: netip.MustParseAddr("10.88.0.2"), WGPublicKey: "wg-key", NodeTokenHash: []byte("hash"), + }); err != nil { + t.Fatal(err) + } + ipamManager, err := ipam.New(store, netip.MustParsePrefix("10.88.0.0/24"), netip.MustParseAddr("10.88.0.1")) + if err != nil { + t.Fatal(err) + } + peers := &fakeAdminPeers{} + bootstrapNetwork := &fakeBootstrapNetwork{config: bootstrap.ServiceConfig{ + WGEndpoint: "203.0.113.4:51820", ControlURL: "ws://10.88.0.1:7001/control", + OverlayCIDR: netip.MustParsePrefix("10.88.0.0/24"), ServerOverlayIP: netip.MustParseAddr("10.88.0.1"), + SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1, + }} + controlNetwork := &fakeAdminControl{} + sessions := &fakeSessionControl{} + network, err := NewNetworkManager(store, ipamManager, peers, bootstrapNetwork, controlNetwork, sessions, Network{ + OverlayCIDR: "10.88.0.0/24", ServerOverlayIP: "10.88.0.1", WireGuardPort: 51820, + SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1, + }, nil) + if err != nil { + t.Fatal(err) + } + rotator := &fakeJoinTokenRotator{} + handler, err := Handler(HandlerConfig{ + Store: store, IPAM: ipamManager, Peers: peers, Control: controlNetwork, + Sessions: sessions, Network: network, JoinTokens: rotator, AdminToken: "secret", + }) + if err != nil { + t.Fatal(err) + } + + unauthorized := httptest.NewRecorder() + handler.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/admin/nodes", nil)) + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf("unauthorized status = %d", unauthorized.Code) + } + nodesResponse := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/nodes", nil) + if nodesResponse.Code != http.StatusOK || !bytes.Contains(nodesResponse.Body.Bytes(), []byte(`"wg_handshake"`)) { + t.Fatalf("nodes response status=%d body=%s", nodesResponse.Code, nodesResponse.Body.String()) + } + + patch := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{"name": "Field Engineer"}) + if patch.Code != http.StatusOK || bytes.Contains(patch.Body.Bytes(), []byte("node_token")) { + t.Fatalf("PATCH response status=%d body=%s", patch.Code, patch.Body.String()) + } + updated, err := store.GetNode(ctx, "engineer") + if err != nil || updated.Name != "Field Engineer" { + t.Fatalf("updated node = %+v, %v", updated, err) + } + invalidCombined := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{ + "name": "Must Not Persist", "overlay_ip": "not-an-ip", + }) + unchanged, _ := store.GetNode(ctx, "engineer") + if invalidCombined.Code != http.StatusBadRequest || unchanged.Name != "Field Engineer" { + t.Fatalf("invalid combined PATCH status=%d node=%+v", invalidCombined.Code, unchanged) + } + + steps := make([]string, 0, 2) + peers.steps = &steps + controlNetwork.steps = &steps + addressPatch := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{ + "name": "Moved Engineer", "overlay_ip": "10.88.0.9", + }) + moved, moveErr := store.GetNode(ctx, "engineer") + if addressPatch.Code != http.StatusOK || moveErr != nil || moved.Name != "Moved Engineer" || moved.OverlayIP.String() != "10.88.0.9" { + t.Fatalf("address PATCH status=%d node=%+v error=%v body=%s", addressPatch.Code, moved, moveErr, addressPatch.Body.String()) + } + wantSteps := []string{"notify", "peers"} + if len(steps) != len(wantSteps) || steps[0] != wantSteps[0] || steps[1] != wantSteps[1] { + t.Fatalf("Node address change steps=%v, want=%v", steps, wantSteps) + } + if peers.ensured != moved.OverlayIP || !controlNetwork.resetNode || sessions.nodeDisconnects != 1 || sessions.nodeReason != "NODE_OVERLAY_IP_CHANGED" { + t.Fatalf("Node address orchestration peer=%s reset=%v disconnects=%d reason=%s", peers.ensured, controlNetwork.resetNode, sessions.nodeDisconnects, sessions.nodeReason) + } + peers.steps = nil + controlNetwork.steps = nil + + bad := adminRequest(t, handler, http.MethodPatch, "/api/v1/admin/nodes/engineer", map[string]any{"unknown": true}) + if bad.Code != http.StatusBadRequest { + t.Fatalf("unknown JSON field status = %d, want 400", bad.Code) + } + + rotate := adminRequest(t, handler, http.MethodPut, "/api/v1/admin/network", map[string]any{ + "overlay_cidr": "10.88.0.0/24", "server_overlay_ip": "10.88.0.1", + "wireguard_port": 51820, "session_udp_port": 6200, "mtu": 1280, "rotate_join_token": true, + }) + if rotate.Code != http.StatusOK || rotator.count != 1 || sessions.all || controlNetwork.resetAll { + t.Fatalf("rotate-only response=%s count=%d migrated=%v reset=%v", rotate.Body.String(), rotator.count, sessions.all, controlNetwork.resetAll) + } + var rotateBody map[string]any + _ = json.Unmarshal(rotate.Body.Bytes(), &rotateBody) + if rotateBody["join_token"] != "rotated-token" || rotateBody["config_version"] != float64(1) { + t.Fatalf("rotate response = %v", rotateBody) + } + + from := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + to := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + logs := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/logs?module=CORE&limit=10&from="+from+"&to="+to, nil) + if logs.Code != http.StatusOK { + t.Fatalf("logs status=%d body=%s", logs.Code, logs.Body.String()) + } + var events []model.EventLog + if err := json.Unmarshal(logs.Body.Bytes(), &events); err != nil || len(events) < 2 { + t.Fatalf("Admin events = %+v, %v", events, err) + } + badTime := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/logs?from=not-a-time", nil) + if badTime.Code != http.StatusBadRequest { + t.Fatalf("invalid log time status = %d", badTime.Code) + } + + view := adminRequest(t, handler, http.MethodGet, "/api/v1/admin/network", nil) + var networkView map[string]any + _ = json.Unmarshal(view.Body.Bytes(), &networkView) + if _, exists := networkView["uptime_seconds"]; !exists { + t.Fatalf("network response has no uptime_seconds: %s", view.Body.String()) + } + + deleted := adminRequest(t, handler, http.MethodDelete, "/api/v1/admin/nodes/engineer", nil) + if deleted.Code != http.StatusNoContent || peers.removed != 1 || sessions.nodeDisconnects != 2 || controlNetwork.resetNodeCount != 2 || controlNetwork.resetReason != "Node revoked" { + t.Fatalf("DELETE status=%d peer removals=%d disconnects=%d resets=%d reason=%s", + deleted.Code, peers.removed, sessions.nodeDisconnects, controlNetwork.resetNodeCount, controlNetwork.resetReason) + } + if _, err := store.GetNode(ctx, "engineer"); err == nil { + t.Fatal("deleted Node remains in the registry") + } +} + +func adminRequest(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var raw []byte + if body != nil { + var err error + raw, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + request := httptest.NewRequest(method, path, bytes.NewReader(raw)) + request.Header.Set("Authorization", "Bearer secret") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} diff --git a/internal/admin/network.go b/internal/admin/network.go new file mode 100644 index 0000000..30a8b6f --- /dev/null +++ b/internal/admin/network.go @@ -0,0 +1,301 @@ +// Package admin implements the v1 Server Admin API and network migration. +package admin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/netip" + "net/url" + "strconv" + "sync" + "time" + + "remlink/internal/bootstrap" + "remlink/internal/database" + "remlink/internal/ipam" + "remlink/internal/overlay/serverwg" + "remlink/internal/protocol" +) + +const networkSettingKey = "admin.network" + +type Network struct { + OverlayCIDR string `json:"overlay_cidr"` + ServerOverlayIP string `json:"server_overlay_ip"` + WireGuardPort int `json:"wireguard_port"` + SessionUDPPort int `json:"session_udp_port"` + MTU int `json:"mtu"` + ConfigVersion uint64 `json:"config_version"` +} + +type NetworkUpdate struct { + OverlayCIDR string `json:"overlay_cidr"` + ServerOverlayIP string `json:"server_overlay_ip"` + WireGuardPort int `json:"wireguard_port"` + SessionUDPPort int `json:"session_udp_port"` + MTU int `json:"mtu"` + RotateJoinToken bool `json:"rotate_join_token,omitempty"` +} + +type NetworkView struct { + Network + UptimeSeconds int64 `json:"uptime_seconds"` +} + +type IPAM interface { + ChangeNodeAddress(context.Context, string, netip.Addr) error + ReleaseNode(context.Context, string) error + Reconfigure(netip.Prefix, netip.Addr) error +} + +type PeerManager interface { + EnsurePeer(context.Context, string, netip.Addr) error + RemovePeer(context.Context, string) error + Reconfigure(context.Context, netip.Prefix, int, []serverwg.Peer) error + LastHandshake(context.Context, string) (*time.Time, error) +} + +type BootstrapNetwork interface { + NetworkSnapshot() bootstrap.ServiceConfig + UpdateNetwork(bootstrap.ServiceConfig) error +} + +type ControlNetwork interface { + Send(context.Context, string, protocol.ControlMessageType, any) error + SetNetworkConfigVersion(uint64) error + ResetNodeConnection(string, string) + ResetConnections(string) +} + +type SessionControl interface { + Disconnect(context.Context, uint64, string) error + DisconnectAll(context.Context, string) error + DisconnectNode(context.Context, string, string) error + BeginNetworkMigration(context.Context, string) error + EndNetworkMigration() + ReconfigureNetwork(netip.Prefix, int, int) error +} + +type NetworkManager struct { + mu sync.Mutex + store *database.Store + ipam IPAM + peers PeerManager + bootstrap BootstrapNetwork + control ControlNetwork + sessions SessionControl + current Network + startedAt time.Time + rebindControl func(netip.Addr) error +} + +func LoadStoredNetwork(ctx context.Context, store *database.Store, fallback Network) (Network, error) { + value, found, err := database.GetSetting(ctx, store.DB(), networkSettingKey) + if err != nil || !found { + return fallback, err + } + var network Network + if err := json.Unmarshal([]byte(value), &network); err != nil { + return Network{}, fmt.Errorf("decode stored Admin network: %w", err) + } + if _, _, err := validateNetwork(network); err != nil { + return Network{}, fmt.Errorf("validate stored Admin network: %w", err) + } + return network, nil +} + +func NewNetworkManager(store *database.Store, ipamManager IPAM, peers PeerManager, bootstrapService BootstrapNetwork, + control ControlNetwork, sessions SessionControl, initial Network, rebindControl func(netip.Addr) error) (*NetworkManager, error) { + if store == nil || ipamManager == nil || peers == nil || bootstrapService == nil || control == nil || sessions == nil { + return nil, errors.New("Admin NetworkManager dependencies are required") + } + if _, _, err := validateNetwork(initial); err != nil { + return nil, err + } + if rebindControl == nil { + rebindControl = func(netip.Addr) error { return nil } + } + return &NetworkManager{ + store: store, ipam: ipamManager, peers: peers, bootstrap: bootstrapService, + control: control, sessions: sessions, current: initial, startedAt: time.Now(), rebindControl: rebindControl, + }, nil +} + +func (m *NetworkManager) View() NetworkView { + m.mu.Lock() + defer m.mu.Unlock() + return NetworkView{Network: m.current, UptimeSeconds: int64(time.Since(m.startedAt).Seconds())} +} + +func (m *NetworkManager) Current() Network { + m.mu.Lock() + defer m.mu.Unlock() + return m.current +} + +func (m *NetworkManager) Update(ctx context.Context, input NetworkUpdate) (Network, error) { + m.mu.Lock() + defer m.mu.Unlock() + desired := Network{ + OverlayCIDR: input.OverlayCIDR, ServerOverlayIP: input.ServerOverlayIP, + WireGuardPort: input.WireGuardPort, SessionUDPPort: input.SessionUDPPort, MTU: input.MTU, + ConfigVersion: m.current.ConfigVersion + 1, + } + prefix, serverIP, err := validateNetwork(desired) + if err != nil { + return Network{}, err + } + nodes, err := m.store.ListNodes(ctx) + if err != nil { + return Network{}, err + } + assignments, err := ipam.PlanNodeAddresses(nodes, prefix, serverIP) + if err != nil { + return Network{}, err + } + if err := m.sessions.BeginNetworkMigration(ctx, "NETWORK_CONFIG_CHANGED"); err != nil { + return Network{}, err + } + defer m.sessions.EndNetworkMigration() + old := m.current + oldPrefix, oldServerIP, _ := validateNetwork(old) + oldAssignments := make(map[string]netip.Addr, len(nodes)) + oldPeers := make([]serverwg.Peer, 0, len(nodes)) + newPeers := make([]serverwg.Peer, 0, len(nodes)) + for _, node := range nodes { + oldAssignments[node.ID] = node.OverlayIP + oldPeers = append(oldPeers, serverwg.Peer{PublicKey: node.WGPublicKey, Address: node.OverlayIP}) + newPeers = append(newPeers, serverwg.Peer{PublicKey: node.WGPublicKey, Address: assignments[node.ID]}) + } + serviceConfig := m.bootstrap.NetworkSnapshot() + oldServiceConfig := serviceConfig + serviceConfig.OverlayCIDR = prefix + serviceConfig.ServerOverlayIP = serverIP + serviceConfig.WGEndpoint, err = replaceEndpointPort(serviceConfig.WGEndpoint, desired.WireGuardPort) + if err != nil { + return Network{}, err + } + serviceConfig.ControlURL, err = replaceControlHost(serviceConfig.ControlURL, serverIP) + if err != nil { + return Network{}, err + } + serviceConfig.SessionUDPPort = desired.SessionUDPPort + serviceConfig.MTU = desired.MTU + serviceConfig.ConfigVersion = desired.ConfigVersion + oldEncoded, _ := json.Marshal(old) + desiredEncoded, _ := json.Marshal(desired) + + if err := m.sessions.ReconfigureNetwork(prefix, desired.MTU, desired.SessionUDPPort); err != nil { + return Network{}, err + } + rollbackSessions := func() { _ = m.sessions.ReconfigureNetwork(oldPrefix, old.MTU, old.SessionUDPPort) } + if err := m.store.ReplaceNodeOverlayIPsAndSetting(ctx, assignments, networkSettingKey, string(desiredEncoded)); err != nil { + rollbackSessions() + return Network{}, err + } + rollbackDatabase := func() { + _ = m.store.ReplaceNodeOverlayIPsAndSetting(context.Background(), oldAssignments, networkSettingKey, string(oldEncoded)) + } + if err := m.ipam.Reconfigure(prefix, serverIP); err != nil { + rollbackDatabase() + rollbackSessions() + return Network{}, err + } + if err := m.bootstrap.UpdateNetwork(serviceConfig); err != nil { + _ = m.ipam.Reconfigure(oldPrefix, oldServerIP) + rollbackDatabase() + rollbackSessions() + return Network{}, err + } + if err := m.control.SetNetworkConfigVersion(desired.ConfigVersion); err != nil { + _ = m.bootstrap.UpdateNetwork(oldServiceConfig) + _ = m.ipam.Reconfigure(oldPrefix, oldServerIP) + rollbackDatabase() + rollbackSessions() + return Network{}, err + } + rollbackPublishedConfig := func() { + _ = m.control.SetNetworkConfigVersion(old.ConfigVersion) + _ = m.bootstrap.UpdateNetwork(oldServiceConfig) + _ = m.ipam.Reconfigure(oldPrefix, oldServerIP) + rollbackDatabase() + rollbackSessions() + } + + // Publish the authoritative Bootstrap snapshot before notifying over the + // still-live old Control path. Switching wg0 peers or the listener first + // would make REBOOTSTRAP_REQUIRED physically undeliverable. + for _, node := range nodes { + _ = m.control.Send(ctx, node.ID, protocol.ControlRebootstrapRequired, protocol.RebootstrapRequiredPayload{ + ConfigVersion: desired.ConfigVersion, Reason: "NETWORK_CONFIG_CHANGED", + }) + } + if err := m.peers.Reconfigure(ctx, netip.PrefixFrom(serverIP, prefix.Bits()), desired.WireGuardPort, newPeers); err != nil { + rollbackPublishedConfig() + return Network{}, err + } + rollbackKernel := func() { + _ = m.peers.Reconfigure(context.Background(), netip.PrefixFrom(oldServerIP, oldPrefix.Bits()), old.WireGuardPort, oldPeers) + } + if serverIP != oldServerIP { + if err := m.rebindControl(serverIP); err != nil { + rollbackKernel() + rollbackPublishedConfig() + return Network{}, fmt.Errorf("rebind Control listener after migration notification: %w", err) + } + } + m.control.ResetConnections("Network configuration changed") + m.current = desired + return desired, nil +} + +func validateNetwork(network Network) (netip.Prefix, netip.Addr, error) { + prefix, err := netip.ParsePrefix(network.OverlayCIDR) + if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 || prefix.Bits() > 30 { + return netip.Prefix{}, netip.Addr{}, errors.New("Overlay CIDR must be canonical IPv4 with usable hosts") + } + serverIP, err := netip.ParseAddr(network.ServerOverlayIP) + if err != nil || !serverIP.Is4() || !prefix.Contains(serverIP) || serverIP == prefix.Addr() || serverIP == lastIPv4(prefix) { + return netip.Prefix{}, netip.Addr{}, errors.New("Server Overlay IP must be a usable address in Overlay CIDR") + } + for name, port := range map[string]int{"WireGuard port": network.WireGuardPort, "Session UDP port": network.SessionUDPPort} { + if port < 1 || port > 65535 { + return netip.Prefix{}, netip.Addr{}, fmt.Errorf("%s must be between 1 and 65535", name) + } + } + if network.MTU < 576 || network.MTU > 65535 || network.ConfigVersion == 0 { + return netip.Prefix{}, netip.Addr{}, errors.New("MTU or config version is invalid") + } + return prefix, serverIP, nil +} + +func replaceEndpointPort(endpoint string, port int) (string, error) { + host, _, err := net.SplitHostPort(endpoint) + if err != nil { + return "", err + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil +} + +func replaceControlHost(raw string, host netip.Addr) (string, error) { + parsed, err := url.Parse(raw) + if err != nil { + return "", err + } + _, port, err := net.SplitHostPort(parsed.Host) + if err != nil { + return "", err + } + parsed.Host = net.JoinHostPort(host.String(), port) + return parsed.String(), nil +} + +func lastIPv4(prefix netip.Prefix) netip.Addr { + bytes := prefix.Masked().Addr().As4() + value := uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3]) + value |= ^uint32(0) >> prefix.Bits() + return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}) +} diff --git a/internal/admin/network_test.go b/internal/admin/network_test.go new file mode 100644 index 0000000..95e20ad --- /dev/null +++ b/internal/admin/network_test.go @@ -0,0 +1,243 @@ +package admin + +import ( + "context" + "errors" + "net/netip" + "path/filepath" + "testing" + "time" + + "remlink/internal/bootstrap" + "remlink/internal/database" + "remlink/internal/ipam" + "remlink/internal/model" + "remlink/internal/overlay/serverwg" + "remlink/internal/protocol" +) + +func TestNetworkManagerRunsSevenStepMigration(t *testing.T) { + ctx := context.Background() + db, err := database.Open(ctx, filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := database.NewStore(db) + for _, node := range []model.Node{ + {ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer", OverlayIP: netip.MustParseAddr("10.88.0.2"), WGPublicKey: "key-a", NodeTokenHash: []byte("a")}, + {ID: "site", Type: model.NodeTypeSite, Name: "Site", OverlayIP: netip.MustParseAddr("10.88.0.3"), WGPublicKey: "key-b", NodeTokenHash: []byte("b")}, + } { + if err := store.CreateNode(ctx, node); err != nil { + t.Fatal(err) + } + } + ipamManager, _ := ipam.New(store, netip.MustParsePrefix("10.88.0.0/24"), netip.MustParseAddr("10.88.0.1")) + steps := make([]string, 0, 4) + peers := &fakeAdminPeers{steps: &steps} + bootstrapService := &fakeBootstrapNetwork{config: bootstrap.ServiceConfig{ + WGEndpoint: "203.0.113.4:51820", ControlURL: "ws://10.88.0.1:7001/control", + OverlayCIDR: netip.MustParsePrefix("10.88.0.0/24"), ServerOverlayIP: netip.MustParseAddr("10.88.0.1"), ConfigVersion: 1, + }} + control := &fakeAdminControl{steps: &steps} + sessions := &fakeSessionControl{} + var rebound netip.Addr + manager, err := NewNetworkManager(store, ipamManager, peers, bootstrapService, control, sessions, Network{ + OverlayCIDR: "10.88.0.0/24", ServerOverlayIP: "10.88.0.1", WireGuardPort: 51820, + SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1, + }, func(address netip.Addr) error { steps = append(steps, "rebind"); rebound = address; return nil }) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(ctx, NetworkUpdate{ + OverlayCIDR: "10.99.0.0/24", ServerOverlayIP: "10.99.0.1", WireGuardPort: 51830, + SessionUDPPort: 6300, MTU: 1400, + }) + if err != nil { + t.Fatal(err) + } + if updated.ConfigVersion != 2 || !sessions.began || !sessions.ended || peers.address.String() != "10.99.0.1/24" || peers.port != 51830 { + t.Fatalf("migration state updated=%+v sessions=%+v peers=%s:%d", updated, sessions, peers.address, peers.port) + } + if sessions.prefix.String() != "10.99.0.0/24" || sessions.mtu != 1400 || sessions.udpPort != 6300 { + t.Fatalf("Session Manager network remained stale: prefix=%s mtu=%d udp=%d", sessions.prefix, sessions.mtu, sessions.udpPort) + } + engineer, _ := store.GetNode(ctx, "engineer") + site, _ := store.GetNode(ctx, "site") + if engineer.OverlayIP.String() != "10.99.0.2" || site.OverlayIP.String() != "10.99.0.3" { + t.Fatalf("migrated addresses Engineer=%s Site=%s", engineer.OverlayIP, site.OverlayIP) + } + if rebound.String() != "10.99.0.1" || control.version != 2 || control.notifications != 2 || !control.resetAll { + t.Fatalf("Control migration rebound=%s version=%d notifications=%d reset=%v", rebound, control.version, control.notifications, control.resetAll) + } + if bootstrapService.config.ControlURL != "ws://10.99.0.1:7001/control" || bootstrapService.config.WGEndpoint != "203.0.113.4:51830" { + t.Fatalf("Bootstrap config = %+v", bootstrapService.config) + } + wantSteps := []string{"notify", "notify", "peers", "rebind"} + if len(steps) != len(wantSteps) { + t.Fatalf("migration steps = %v, want %v", steps, wantSteps) + } + for index := range wantSteps { + if steps[index] != wantSteps[index] { + t.Fatalf("migration steps = %v, want %v", steps, wantSteps) + } + } + loaded, err := LoadStoredNetwork(ctx, store, Network{}) + if err != nil || loaded != updated { + t.Fatalf("stored network = %+v, %v", loaded, err) + } +} + +func TestNetworkManagerRollsBackKernelWhenControlRebindFails(t *testing.T) { + ctx := context.Background() + db, err := database.Open(ctx, filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := database.NewStore(db) + ipamManager, _ := ipam.New(store, netip.MustParsePrefix("10.88.0.0/24"), netip.MustParseAddr("10.88.0.1")) + peers := &fakeAdminPeers{} + bootstrapService := &fakeBootstrapNetwork{config: bootstrap.ServiceConfig{ + WGEndpoint: "203.0.113.4:51820", ControlURL: "ws://10.88.0.1:7001/control", + OverlayCIDR: netip.MustParsePrefix("10.88.0.0/24"), ServerOverlayIP: netip.MustParseAddr("10.88.0.1"), ConfigVersion: 1, + }} + sessions := &fakeSessionControl{} + manager, err := NewNetworkManager(store, ipamManager, peers, bootstrapService, &fakeAdminControl{}, sessions, Network{ + OverlayCIDR: "10.88.0.0/24", ServerOverlayIP: "10.88.0.1", WireGuardPort: 51820, + SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1, + }, func(netip.Addr) error { return errors.New("address unavailable") }) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Update(ctx, NetworkUpdate{ + OverlayCIDR: "10.99.0.0/24", ServerOverlayIP: "10.99.0.1", WireGuardPort: 51830, + SessionUDPPort: 6300, MTU: 1400, + }); err == nil { + t.Fatal("network migration unexpectedly succeeded despite Control rebind failure") + } + if current := manager.Current(); current.ConfigVersion != 1 || current.OverlayCIDR != "10.88.0.0/24" { + t.Fatalf("failed migration changed current config: %+v", current) + } + if peers.address.String() != "10.88.0.1/24" || peers.port != 51820 { + t.Fatalf("failed migration left kernel config at %s:%d", peers.address, peers.port) + } + if sessions.prefix.String() != "10.88.0.0/24" || sessions.mtu != 1280 || sessions.udpPort != 6200 { + t.Fatalf("failed migration did not restore Session Manager: %s mtu=%d udp=%d", sessions.prefix, sessions.mtu, sessions.udpPort) + } + if bootstrapService.config.OverlayCIDR.String() != "10.88.0.0/24" || bootstrapService.config.ConfigVersion != 1 { + t.Fatalf("failed migration did not restore Bootstrap config: %+v", bootstrapService.config) + } + stored, err := LoadStoredNetwork(ctx, store, Network{}) + if err != nil || stored != manager.Current() { + t.Fatalf("failed migration did not restore atomic database setting: stored=%+v current=%+v err=%v", stored, manager.Current(), err) + } +} + +func TestAdminNetworkRejectsExitNodeOverlay(t *testing.T) { + _, _, err := validateNetwork(Network{ + OverlayCIDR: "0.0.0.0/0", ServerOverlayIP: "10.88.0.1", + WireGuardPort: 51820, SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1, + }) + if err == nil { + t.Fatal("Admin network accepted 0.0.0.0/0 Exit Node Overlay") + } +} + +type fakeAdminPeers struct { + address netip.Prefix + port int + peers []serverwg.Peer + steps *[]string + ensured netip.Addr + removed int +} + +func (f *fakeAdminPeers) EnsurePeer(_ context.Context, _ string, address netip.Addr) error { + if f.steps != nil { + *f.steps = append(*f.steps, "peers") + } + f.ensured = address + return nil +} +func (f *fakeAdminPeers) RemovePeer(context.Context, string) error { + f.removed++ + return nil +} +func (*fakeAdminPeers) LastHandshake(context.Context, string) (*time.Time, error) { + value := time.Date(2026, 8, 25, 1, 2, 3, 0, time.UTC) + return &value, nil +} +func (f *fakeAdminPeers) Reconfigure(_ context.Context, address netip.Prefix, port int, peers []serverwg.Peer) error { + if f.steps != nil { + *f.steps = append(*f.steps, "peers") + } + f.address, f.port, f.peers = address, port, peers + return nil +} + +type fakeBootstrapNetwork struct{ config bootstrap.ServiceConfig } + +func (f *fakeBootstrapNetwork) NetworkSnapshot() bootstrap.ServiceConfig { return f.config } +func (f *fakeBootstrapNetwork) UpdateNetwork(config bootstrap.ServiceConfig) error { + f.config = config + return nil +} + +type fakeAdminControl struct { + version uint64 + notifications int + resetAll bool + steps *[]string + resetNode bool + resetNodeCount int + resetReason string +} + +func (f *fakeAdminControl) Send(_ context.Context, _ string, messageType protocol.ControlMessageType, _ any) error { + if messageType == protocol.ControlRebootstrapRequired { + f.notifications++ + if f.steps != nil { + *f.steps = append(*f.steps, "notify") + } + } + return nil +} +func (f *fakeAdminControl) SetNetworkConfigVersion(version uint64) error { + f.version = version + return nil +} +func (f *fakeAdminControl) ResetNodeConnection(_, reason string) { + f.resetNode, f.resetReason = true, reason + f.resetNodeCount++ +} +func (f *fakeAdminControl) ResetConnections(string) { f.resetAll = true } + +type fakeSessionControl struct { + all bool + began bool + ended bool + prefix netip.Prefix + mtu int + udpPort int + nodeDisconnects int + nodeReason string +} + +func (*fakeSessionControl) Disconnect(context.Context, uint64, string) error { return nil } +func (f *fakeSessionControl) DisconnectAll(context.Context, string) error { f.all = true; return nil } + +func (f *fakeSessionControl) DisconnectNode(_ context.Context, _, reason string) error { + f.nodeDisconnects++ + f.nodeReason = reason + return nil +} +func (f *fakeSessionControl) ReconfigureNetwork(prefix netip.Prefix, mtu, udpPort int) error { + f.prefix, f.mtu, f.udpPort = prefix, mtu, udpPort + return nil +} +func (f *fakeSessionControl) BeginNetworkMigration(context.Context, string) error { + f.began = true + return nil +} +func (f *fakeSessionControl) EndNetworkMigration() { f.ended = true } diff --git a/internal/appdir/appdir.go b/internal/appdir/appdir.go new file mode 100644 index 0000000..658bac4 --- /dev/null +++ b/internal/appdir/appdir.go @@ -0,0 +1,33 @@ +// Package appdir resolves files that belong to a portable RemLink package. +package appdir + +import ( + "fmt" + "os" + "path/filepath" +) + +// Executable returns the absolute directory containing the running executable. +// Engineer and Site use this directory as their portable data root so launching +// them from another working directory does not redirect generated files. +func Executable() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate executable: %w", err) + } + executable, err = filepath.Abs(executable) + if err != nil { + return "", fmt.Errorf("resolve executable path: %w", err) + } + return filepath.Dir(executable), nil +} + +// Join returns a path rooted at the directory containing the executable. +func Join(elements ...string) (string, error) { + root, err := Executable() + if err != nil { + return "", err + } + parts := append([]string{root}, elements...) + return filepath.Join(parts...), nil +} diff --git a/internal/appdir/appdir_test.go b/internal/appdir/appdir_test.go new file mode 100644 index 0000000..a344c9b --- /dev/null +++ b/internal/appdir/appdir_test.go @@ -0,0 +1,32 @@ +package appdir + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExecutableAndJoinUseRunningExecutableDirectory(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + want, err := filepath.Abs(filepath.Dir(executable)) + if err != nil { + t.Fatal(err) + } + root, err := Executable() + if err != nil { + t.Fatal(err) + } + if root != want { + t.Fatalf("Executable() = %q, want %q", root, want) + } + joined, err := Join("logs", "node.jsonl") + if err != nil { + t.Fatal(err) + } + if joined != filepath.Join(want, "logs", "node.jsonl") { + t.Fatalf("Join() = %q", joined) + } +} diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..20a78b2 --- /dev/null +++ b/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,473 @@ +package bootstrap + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/netip" + "path/filepath" + "strings" + "sync" + "testing" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "remlink/internal/database" + "remlink/internal/identity" + "remlink/internal/ipam" + "remlink/internal/model" +) + +type fakePeers struct { + mu sync.Mutex + entries map[string]netip.Addr + err error +} + +func (p *fakePeers) EnsurePeer(_ context.Context, key string, address netip.Addr) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + p.entries[key] = address + return nil +} + +func TestJoinTokenEnsureRotateAndVerify(t *testing.T) { + _, store, joins, _ := testService(t) + ctx := context.Background() + first, err := joins.Ensure(ctx) + if err != nil { + t.Fatal(err) + } + again, err := joins.Ensure(ctx) + if err != nil || again != first { + t.Fatalf("second Ensure = %q, %v; want stable token", again, err) + } + rotated, err := joins.Rotate(ctx) + if err != nil { + t.Fatal(err) + } + if rotated == first { + t.Fatal("Join Token rotation returned the previous value") + } + valid, err := joins.Verify(ctx, rotated) + if err != nil || !valid { + t.Fatalf("rotated token verification = %v, %v", valid, err) + } + valid, err = joins.Verify(ctx, first) + if err != nil || valid { + t.Fatalf("revoked token verification = %v, %v", valid, err) + } + serverID, err := EnsureServerID(ctx, store) + if err != nil { + t.Fatal(err) + } + serverIDAgain, err := EnsureServerID(ctx, store) + if err != nil || serverIDAgain != serverID { + t.Fatalf("Server ID = %q, %v; want %q", serverIDAgain, err, serverID) + } +} + +func TestValidateNetworkConfigRequiresOverlayControlEndpoint(t *testing.T) { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + valid := NetworkConfig{ + ConfigVersion: 1, OverlayCIDR: "10.88.0.0/16", OverlayIP: "10.88.0.2", ServerOverlayIP: "10.88.0.1", + ServerWGPublicKey: privateKey.PublicKey().String(), ServerWGEndpoint: "203.0.113.4:51820", + ControlURL: "ws://10.88.0.1:7001/control", SessionUDPPort: 6200, MTU: 1280, + } + if err := ValidateNetworkConfig(valid); err != nil { + t.Fatal(err) + } + for _, invalidURL := range []string{ + "http://10.88.0.1:7001/control", "ws://203.0.113.4:7001/control", "ws://10.88.0.1:7001/other", "ws://10.88.0.1/control", + "ws://user@10.88.0.1:7001/control", "ws://10.88.0.1:7001/control?unexpected=true", "ws://10.88.0.1:7001/control#fragment", + "ws://10.88.0.1:bad/control", "ws://10.88.0.1:7001/control?", + } { + invalid := valid + invalid.ControlURL = invalidURL + if err := ValidateNetworkConfig(invalid); err == nil { + t.Errorf("accepted invalid Control URL %q", invalidURL) + } + } + for _, invalidEndpoint := range []string{"", "missing-port", ":51820", "203.0.113.4:0", "203.0.113.4:65536"} { + invalid := valid + invalid.ServerWGEndpoint = invalidEndpoint + if err := ValidateNetworkConfig(invalid); err == nil { + t.Errorf("accepted invalid WireGuard endpoint %q", invalidEndpoint) + } + } + for name, mutate := range map[string]func(*NetworkConfig){ + "Node network address": func(config *NetworkConfig) { config.OverlayIP = "10.88.0.0" }, + "Node broadcast address": func(config *NetworkConfig) { config.OverlayIP = "10.88.255.255" }, + "Server network address": func(config *NetworkConfig) { + config.ServerOverlayIP = "10.88.0.0" + config.ControlURL = "ws://10.88.0.0:7001/control" + }, + "unusable prefix": func(config *NetworkConfig) { + config.OverlayCIDR = "10.88.0.0/31" + config.OverlayIP = "10.88.0.0" + config.ServerOverlayIP = "10.88.0.1" + config.ControlURL = "ws://10.88.0.1:7001/control" + }, + "Exit Node prefix": func(config *NetworkConfig) { + config.OverlayCIDR = "0.0.0.0/0" + }, + } { + invalid := valid + mutate(&invalid) + if err := ValidateNetworkConfig(invalid); err == nil { + t.Errorf("accepted %s", name) + } + } +} + +func TestRegisterTenNodesAndConfigIsStable(t *testing.T) { + service, store, joins, peers := testService(t) + joinToken, err := joins.Ensure(context.Background()) + if err != nil { + t.Fatal(err) + } + addresses := map[string]struct{}{} + for index := range 10 { + request := validRegisterRequest(t, index, joinToken) + response, err := service.Register(context.Background(), request) + if err != nil { + t.Fatalf("register %d: %v", index, err) + } + if _, duplicate := addresses[response.Network.OverlayIP]; duplicate { + t.Fatalf("duplicate address %s", response.Network.OverlayIP) + } + addresses[response.Network.OverlayIP] = struct{}{} + config, err := service.Config(context.Background(), ConfigRequest{NodeID: request.NodeID, NodeToken: response.NodeToken}) + if err != nil { + t.Fatalf("config %d: %v", index, err) + } + if config.Network.OverlayIP != response.Network.OverlayIP || config.Network.ConfigVersion != 1 { + t.Fatalf("unstable config: register=%+v config=%+v", response.Network, config.Network) + } + } + nodes, err := store.ListNodes(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(nodes) != 10 || len(peers.entries) != 10 { + t.Fatalf("nodes=%d peers=%d, want 10 each", len(nodes), len(peers.entries)) + } +} + +func TestReregisterRotatesNodeTokenAndPreservesAddress(t *testing.T) { + service, _, joins, _ := testService(t) + joinToken, _ := joins.Ensure(context.Background()) + request := validRegisterRequest(t, 1, joinToken) + first, err := service.Register(context.Background(), request) + if err != nil { + t.Fatal(err) + } + request.NodeName = "Renamed" + second, err := service.Register(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if first.Network.OverlayIP != second.Network.OverlayIP || first.NodeToken == second.NodeToken { + t.Fatalf("first=%+v second=%+v", first, second) + } + if _, err := service.Config(context.Background(), ConfigRequest{NodeID: request.NodeID, NodeToken: first.NodeToken}); !errors.Is(err, ErrNodeAuthFailed) { + t.Fatalf("old token config error = %v", err) + } + if _, err := service.Config(context.Background(), ConfigRequest{NodeID: request.NodeID, NodeToken: second.NodeToken}); err != nil { + t.Fatalf("new token config: %v", err) + } +} + +func TestConfigReconcilesOldNodeRuntimeBeforeBootstrap(t *testing.T) { + service, _, joins, _ := testService(t) + joinToken, err := joins.Ensure(context.Background()) + if err != nil { + t.Fatal(err) + } + registered, err := service.Register(context.Background(), validRegisterRequest(t, 77, joinToken)) + if err != nil { + t.Fatal(err) + } + var reconciledNode string + service.SetNodeBootstrapHandler(func(_ context.Context, nodeID string) error { + reconciledNode = nodeID + return nil + }) + request := ConfigRequest{NodeID: "00000000-0000-4000-8000-000000000077", NodeToken: registered.NodeToken} + if _, err := service.Config(context.Background(), request); err != nil { + t.Fatal(err) + } + if reconciledNode != request.NodeID { + t.Fatalf("reconciled Node = %q, want %q", reconciledNode, request.NodeID) + } + service.SetNodeBootstrapHandler(func(context.Context, string) error { return errors.New("Session cleanup failed") }) + if _, err := service.Config(context.Background(), request); err == nil { + t.Fatal("Bootstrap ignored Node runtime reconciliation failure") + } +} + +func TestPeerFailureRollsBackNewRegistration(t *testing.T) { + service, store, joins, peers := testService(t) + peers.err = errors.New("kernel unavailable") + joinToken, _ := joins.Ensure(context.Background()) + request := validRegisterRequest(t, 2, joinToken) + if _, err := service.Register(context.Background(), request); err == nil { + t.Fatal("registration unexpectedly succeeded") + } + if _, err := store.GetNode(context.Background(), request.NodeID); !errors.Is(err, database.ErrNodeNotFound) { + t.Fatalf("failed registration persisted: %v", err) + } +} + +func TestHTTPContractAndStrictJSON(t *testing.T) { + service, _, joins, _ := testService(t) + joinToken, _ := joins.Ensure(context.Background()) + handler := Handler(service) + + info := httptest.NewRecorder() + handler.ServeHTTP(info, httptest.NewRequest(http.MethodGet, "/api/v1/server/info", nil)) + if info.Code != http.StatusOK || info.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("server info status=%d headers=%v", info.Code, info.Header()) + } + + request := validRegisterRequest(t, 4, joinToken) + registered := performJSON(t, handler, http.MethodPost, "/api/v1/bootstrap/register", request) + if registered.Code != http.StatusCreated { + t.Fatalf("register status=%d body=%s", registered.Code, registered.Body.String()) + } + var response RegisterResponse + if err := json.Unmarshal(registered.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + configured := performJSON(t, handler, http.MethodPost, "/api/v1/bootstrap/config", ConfigRequest{ + NodeID: request.NodeID, NodeToken: response.NodeToken, + }) + if configured.Code != http.StatusOK { + t.Fatalf("config status=%d body=%s", configured.Code, configured.Body.String()) + } + + unknownField := httptest.NewRecorder() + unknownBody := bytes.NewBufferString(`{"node_id":"x","node_token":"x","surprise":true}`) + unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/bootstrap/config", unknownBody) + unknownRequest.Header.Set("Content-Type", "application/json") + handler.ServeHTTP(unknownField, unknownRequest) + if unknownField.Code != http.StatusBadRequest { + t.Fatalf("unknown JSON field status=%d", unknownField.Code) + } +} + +func TestBootstrapClient(t *testing.T) { + service, _, joins, _ := testService(t) + server := httptest.NewServer(Handler(service)) + defer server.Close() + client, err := NewClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + info, err := client.ServerInfo(ctx) + if err != nil || info.APIVersion != 1 { + t.Fatalf("ServerInfo = %+v, %v", info, err) + } + joinToken, _ := joins.Ensure(ctx) + request := validRegisterRequest(t, 8, joinToken) + registered, err := client.Register(ctx, request) + if err != nil { + t.Fatal(err) + } + configured, err := client.Config(ctx, ConfigRequest{NodeID: request.NodeID, NodeToken: registered.NodeToken}) + if err != nil || configured.Network.OverlayIP != registered.Network.OverlayIP { + t.Fatalf("Config = %+v, %v", configured, err) + } + _, err = client.Config(ctx, ConfigRequest{NodeID: request.NodeID, NodeToken: "wrong"}) + var clientError *ClientError + if !errors.As(err, &clientError) || clientError.Status != http.StatusUnauthorized { + t.Fatalf("wrong-token error = %#v", err) + } +} + +func TestEnrollPersistsIdentityAndUsesConfigOnRestart(t *testing.T) { + service, _, joins, _ := testService(t) + server := httptest.NewServer(Handler(service)) + defer server.Close() + client, err := NewClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + identityStore, err := identity.NewStore(filepath.Join(t.TempDir(), "identity.json"), xorProtector{}) + if err != nil { + t.Fatal(err) + } + joinToken, _ := joins.Ensure(context.Background()) + config := EnrollConfig{ + NodeType: model.NodeTypeSite, NodeName: "Site-A", ServerURL: server.URL, + JoinToken: joinToken, Version: "test", OSVersion: "windows/amd64", + } + firstIdentity, firstNetwork, err := Enroll(context.Background(), identityStore, client, config) + if err != nil { + t.Fatal(err) + } + if firstIdentity.NodeToken == "" || firstIdentity.ConfigVersion != firstNetwork.ConfigVersion { + t.Fatalf("incomplete enrolled identity: %+v", firstIdentity) + } + config.JoinToken = "" + secondIdentity, secondNetwork, err := Enroll(context.Background(), identityStore, client, config) + if err != nil { + t.Fatal(err) + } + if secondIdentity.NodeID != firstIdentity.NodeID || secondIdentity.NodeToken != firstIdentity.NodeToken || + secondNetwork.OverlayIP != firstNetwork.OverlayIP { + t.Fatalf("restart changed identity/config: first=%+v/%+v second=%+v/%+v", firstIdentity, firstNetwork, secondIdentity, secondNetwork) + } +} + +func TestEnrollRebindsOnlyUnregisteredPortableIdentity(t *testing.T) { + service, _, joins, _ := testService(t) + server := httptest.NewServer(Handler(service)) + defer server.Close() + client, err := NewClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + identityStore, err := identity.NewStore(filepath.Join(t.TempDir(), "identity.json"), xorProtector{}) + if err != nil { + t.Fatal(err) + } + draft, err := identity.New(model.NodeTypeEngineer, "Engineer-A", "http://192.0.2.1:8080") + if err != nil { + t.Fatal(err) + } + if err := identityStore.Save(draft); err != nil { + t.Fatal(err) + } + joinToken, _ := joins.Ensure(context.Background()) + config := EnrollConfig{ + NodeType: model.NodeTypeEngineer, NodeName: "Engineer-A", ServerURL: server.URL, + JoinToken: joinToken, Version: "test", OSVersion: "windows/amd64", + } + + registered, _, err := Enroll(context.Background(), identityStore, client, config) + if err != nil { + t.Fatalf("rebind unregistered identity: %v", err) + } + if registered.NodeID != draft.NodeID || registered.ServerURL != server.URL || registered.NodeToken == "" { + t.Fatalf("unexpected rebound identity: %+v", registered) + } + + config.ServerURL = "http://192.0.2.2:8080" + config.JoinToken = "" + if _, _, err := Enroll(context.Background(), identityStore, client, config); err == nil || + !strings.Contains(err.Error(), "已注册的 Node 身份") { + t.Fatalf("registered identity Server change error = %v", err) + } +} + +func TestUpdateNetworkRejectsInvalidBootstrapTrustBoundary(t *testing.T) { + service, _, _, _ := testService(t) + original := service.NetworkSnapshot() + tests := []struct { + name string + mutate func(*ServiceConfig) + }{ + {"exit-node-overlay", func(c *ServiceConfig) { c.OverlayCIDR = netip.MustParsePrefix("0.0.0.0/0") }}, + {"network-server-address", func(c *ServiceConfig) { c.ServerOverlayIP = netip.MustParseAddr("10.88.0.0") }}, + {"wireguard-endpoint", func(c *ServiceConfig) { c.WGEndpoint = "missing-port" }}, + {"control-host", func(c *ServiceConfig) { c.ControlURL = "ws://203.0.113.1:7001/control" }}, + {"control-user", func(c *ServiceConfig) { c.ControlURL = "ws://user@10.88.0.1:7001/control" }}, + {"control-empty-query", func(c *ServiceConfig) { c.ControlURL = "ws://10.88.0.1:7001/control?" }}, + {"session-port", func(c *ServiceConfig) { c.SessionUDPPort = 0 }}, + {"mtu", func(c *ServiceConfig) { c.MTU = 0 }}, + {"config-version", func(c *ServiceConfig) { c.ConfigVersion = 0 }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := original + test.mutate(&candidate) + if err := service.UpdateNetwork(candidate); err == nil { + t.Fatal("invalid Bootstrap network update was accepted") + } + if current := service.NetworkSnapshot(); current != original { + t.Fatalf("rejected update changed Bootstrap snapshot: %+v", current) + } + }) + } +} + +type xorProtector struct{} + +func (xorProtector) Protect(value []byte) ([]byte, error) { return xor(value), nil } +func (xorProtector) Unprotect(value []byte) ([]byte, error) { return xor(value), nil } + +func xor(value []byte) []byte { + result := append([]byte(nil), value...) + for index := range result { + result[index] ^= 0xA5 + } + return result +} + +func testService(t *testing.T) (*Service, *database.Store, *JoinTokens, *fakePeers) { + t.Helper() + db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "remlink.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + store := database.NewStore(db) + manager, err := ipam.New(store, netip.MustParsePrefix("10.88.0.0/16"), netip.MustParseAddr("10.88.0.1")) + if err != nil { + t.Fatal(err) + } + serverPrivate, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + peers := &fakePeers{entries: make(map[string]netip.Addr)} + joins := NewJoinTokens(store) + service, err := NewService(store, manager, joins, peers, ServiceConfig{ + ServerID: "23ac1928-2334-49bb-8b3f-272572d919da", Version: "test", + WGPublicKey: serverPrivate.PublicKey().String(), WGEndpoint: "203.0.113.1:51820", + OverlayCIDR: netip.MustParsePrefix("10.88.0.0/16"), ServerOverlayIP: netip.MustParseAddr("10.88.0.1"), + ControlURL: "ws://10.88.0.1:7001/control", SessionUDPPort: 6200, MTU: 1280, ConfigVersion: 1, + }) + if err != nil { + t.Fatal(err) + } + return service, store, joins, peers +} + +func validRegisterRequest(t *testing.T, index int, joinToken string) RegisterRequest { + t.Helper() + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + return RegisterRequest{ + JoinToken: joinToken, NodeID: fmt.Sprintf("00000000-0000-4000-8000-%012d", index), + NodeType: model.NodeTypeEngineer, NodeName: fmt.Sprintf("Engineer-%d", index), + WGPublicKey: privateKey.PublicKey().String(), Version: "test", OSVersion: "Windows test", + } +} + +func performJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(method, path, bytes.NewReader(encoded)) + request.Header.Set("Content-Type", "application/json") + handler.ServeHTTP(recorder, request) + return recorder +} diff --git a/internal/bootstrap/client.go b/internal/bootstrap/client.go new file mode 100644 index 0000000..c113cfa --- /dev/null +++ b/internal/bootstrap/client.go @@ -0,0 +1,117 @@ +package bootstrap + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + appconfig "remlink/internal/config" +) + +const maxResponseBody = 1 << 20 + +// Client calls the public Bootstrap API before Overlay connectivity exists. +type Client struct { + baseURL string + http *http.Client +} + +func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if err := appconfig.ValidateServerURL(baseURL); err != nil { + return nil, fmt.Errorf("invalid Bootstrap base URL: %w", err) + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + return &Client{baseURL: baseURL, http: httpClient}, nil +} + +func (c *Client) ServerInfo(ctx context.Context) (ServerInfo, error) { + var response ServerInfo + if err := c.do(ctx, http.MethodGet, "/api/v1/server/info", nil, &response); err != nil { + return ServerInfo{}, err + } + return response, nil +} + +func (c *Client) Register(ctx context.Context, request RegisterRequest) (RegisterResponse, error) { + var response RegisterResponse + if err := c.do(ctx, http.MethodPost, "/api/v1/bootstrap/register", request, &response); err != nil { + return RegisterResponse{}, err + } + return response, nil +} + +func (c *Client) Config(ctx context.Context, request ConfigRequest) (ConfigResponse, error) { + var response ConfigResponse + if err := c.do(ctx, http.MethodPost, "/api/v1/bootstrap/config", request, &response); err != nil { + return ConfigResponse{}, err + } + return response, nil +} + +func (c *Client) do(ctx context.Context, method, path string, input, output any) error { + var body io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("encode Bootstrap request: %w", err) + } + body = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return fmt.Errorf("create Bootstrap request: %w", err) + } + request.Header.Set("Accept", "application/json") + if input != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := c.http.Do(request) + if err != nil { + return fmt.Errorf("call Bootstrap API: %w", err) + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, maxResponseBody+1) + raw, err := io.ReadAll(limited) + if err != nil { + return fmt.Errorf("read Bootstrap response: %w", err) + } + if len(raw) > maxResponseBody { + return errors.New("Bootstrap response exceeds 1 MiB") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + var envelope apiErrorEnvelope + if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Code != "" { + return &ClientError{Status: response.StatusCode, Code: envelope.Error.Code, Message: envelope.Error.Message} + } + return &ClientError{Status: response.StatusCode, Code: "HTTP_ERROR", Message: strings.TrimSpace(string(raw))} + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return fmt.Errorf("decode Bootstrap response: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("Bootstrap response must contain one JSON value") + } + return nil +} + +// ClientError preserves the API status and machine-readable code. +type ClientError struct { + Status int + Code string + Message string +} + +func (e *ClientError) Error() string { + return fmt.Sprintf("Bootstrap API %s (HTTP %d): %s", e.Code, e.Status, e.Message) +} diff --git a/internal/bootstrap/enroll.go b/internal/bootstrap/enroll.go new file mode 100644 index 0000000..791c15b --- /dev/null +++ b/internal/bootstrap/enroll.go @@ -0,0 +1,146 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "net/url" + "os" + "runtime" + "strconv" + "strings" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "remlink/internal/identity" + "remlink/internal/model" +) + +// EnrollConfig contains first-run inputs which are deliberately not persisted +// as plaintext secrets. JoinToken is used only if identity has no NodeToken. +type EnrollConfig struct { + NodeType model.NodeType + NodeName string + ServerURL string + JoinToken string + Version string + OSVersion string +} + +// Enroll loads or creates an identity, calls register/config, validates the +// authoritative NetworkConfig, and persists the latest token/version. +func Enroll(ctx context.Context, store *identity.Store, client *Client, config EnrollConfig) (identity.Identity, NetworkConfig, error) { + current, err := store.Load() + if errors.Is(err, os.ErrNotExist) { + current, err = identity.New(config.NodeType, config.NodeName, config.ServerURL) + if err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + if err := store.Save(current); err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + } else if err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + if current.NodeType != config.NodeType { + return identity.Identity{}, NetworkConfig{}, errors.New("persisted Node type does not match this executable") + } + configuredServerURL := strings.TrimRight(strings.TrimSpace(config.ServerURL), "/") + if configuredServerURL != current.ServerURL { + // A first launch may create identity.json before the operator has filled + // in the real Server URL and Join Token. Until registration succeeds the + // identity has no server-side credentials or owned routes, so rebinding + // that local draft identity is safe and makes portable packages editable. + if current.NodeToken == "" && current.ConfigVersion == 0 && len(current.OwnedRoutes) == 0 { + current.ServerURL = configuredServerURL + if err := store.Save(current); err != nil { + return identity.Identity{}, NetworkConfig{}, fmt.Errorf("update unregistered Node identity Server URL: %w", err) + } + } else { + return identity.Identity{}, NetworkConfig{}, errors.New("配置的 Server URL 与已注册的 Node 身份不一致;如需切换 Server,请退出程序后删除 identity.json 并重新注册") + } + } + current.NodeName = strings.TrimSpace(config.NodeName) + var network NetworkConfig + if current.NodeToken == "" { + if config.JoinToken == "" { + return identity.Identity{}, NetworkConfig{}, errors.New("Join Token is required for first registration") + } + response, err := client.Register(ctx, RegisterRequest{ + JoinToken: config.JoinToken, NodeID: current.NodeID, NodeType: current.NodeType, + NodeName: current.NodeName, WGPublicKey: current.PublicKey(), + Version: config.Version, OSVersion: config.OSVersion, + }) + if err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + current.NodeToken = response.NodeToken + network = response.Network + } else { + response, err := client.Config(ctx, ConfigRequest{NodeID: current.NodeID, NodeToken: current.NodeToken}) + if err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + network = response.Network + } + if err := ValidateNetworkConfig(network); err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + current.ConfigVersion = network.ConfigVersion + if err := store.Save(current); err != nil { + return identity.Identity{}, NetworkConfig{}, err + } + return current, network, nil +} + +// ValidateNetworkConfig rejects malformed or internally inconsistent Server data. +func ValidateNetworkConfig(config NetworkConfig) error { + prefix, err := netip.ParsePrefix(config.OverlayCIDR) + if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 || prefix.Bits() > 30 { + return errors.New("NetworkConfig overlay_cidr must be a canonical IPv4 prefix") + } + nodeIP, err := netip.ParseAddr(config.OverlayIP) + if err != nil || !usableOverlayAddress(prefix, nodeIP) { + return errors.New("NetworkConfig overlay_ip must belong to overlay_cidr") + } + serverIP, err := netip.ParseAddr(config.ServerOverlayIP) + if err != nil || !usableOverlayAddress(prefix, serverIP) || serverIP == nodeIP { + return errors.New("NetworkConfig server_overlay_ip must be a distinct address in overlay_cidr") + } + if _, err := wgtypes.ParseKey(config.ServerWGPublicKey); err != nil { + return fmt.Errorf("NetworkConfig Server WireGuard key: %w", err) + } + if config.ServerWGEndpoint == "" || config.ControlURL == "" { + return errors.New("NetworkConfig endpoints must not be empty") + } + wgHost, wgPortText, err := net.SplitHostPort(config.ServerWGEndpoint) + if err != nil || strings.TrimSpace(wgHost) == "" { + return errors.New("NetworkConfig server_wg_endpoint must be host:port") + } + wgPort, err := strconv.Atoi(wgPortText) + if err != nil || wgPort < 1 || wgPort > 65535 { + return errors.New("NetworkConfig server_wg_endpoint port is invalid") + } + controlURL, err := url.Parse(config.ControlURL) + if err != nil || (controlURL.Scheme != "ws" && controlURL.Scheme != "wss") || controlURL.Path != "/control" || + controlURL.Opaque != "" || controlURL.User != nil || controlURL.RawQuery != "" || controlURL.ForceQuery || controlURL.Fragment != "" { + return errors.New("NetworkConfig control_url must be an absolute ws(s) /control URL") + } + controlHost, controlPortText, err := net.SplitHostPort(controlURL.Host) + if err != nil || controlHost != serverIP.String() { + return errors.New("NetworkConfig control_url must target server_overlay_ip with an explicit port") + } + controlPort, err := strconv.Atoi(controlPortText) + if err != nil || controlPort < 1 || controlPort > 65535 { + return errors.New("NetworkConfig control_url port is invalid") + } + if config.ConfigVersion == 0 || config.SessionUDPPort < 1 || config.SessionUDPPort > 65535 || config.MTU < 576 || config.MTU > 65535 { + return errors.New("NetworkConfig version, UDP port, or MTU is invalid") + } + return nil +} + +// CurrentOSVersion is a non-secret Bootstrap capability label. +func CurrentOSVersion() string { return runtime.GOOS + "/" + runtime.GOARCH } diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go new file mode 100644 index 0000000..b2924b0 --- /dev/null +++ b/internal/bootstrap/http.go @@ -0,0 +1,108 @@ +package bootstrap + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "remlink/internal/protocol" +) + +const maxRequestBody = 1 << 20 + +// Handler exposes the exact v1 public Bootstrap routes. +func Handler(service *Service) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/server/info", func(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, http.StatusOK, service.ServerInfo()) + }) + mux.HandleFunc("POST /api/v1/bootstrap/register", func(writer http.ResponseWriter, request *http.Request) { + var input RegisterRequest + if err := decodeJSON(writer, request, &input); err != nil { + writeAPIError(writer, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) + return + } + output, err := service.Register(request.Context(), input) + if err != nil { + switch { + case errors.Is(err, ErrJoinTokenInvalid): + writeAPIError(writer, http.StatusUnauthorized, string(protocol.ErrorJoinTokenInvalid), err.Error()) + case errors.Is(err, ErrNodeConflict): + writeAPIError(writer, http.StatusConflict, "NODE_CONFLICT", err.Error()) + default: + writeAPIError(writer, http.StatusBadRequest, "REGISTRATION_FAILED", err.Error()) + } + return + } + writeJSON(writer, http.StatusCreated, output) + }) + mux.HandleFunc("POST /api/v1/bootstrap/config", func(writer http.ResponseWriter, request *http.Request) { + var input ConfigRequest + if err := decodeJSON(writer, request, &input); err != nil { + writeAPIError(writer, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) + return + } + output, err := service.Config(request.Context(), input) + if err != nil { + if errors.Is(err, ErrNodeAuthFailed) { + writeAPIError(writer, http.StatusUnauthorized, string(protocol.ErrorNodeAuthFailed), err.Error()) + return + } + writeAPIError(writer, http.StatusInternalServerError, "CONFIG_FAILED", err.Error()) + return + } + writeJSON(writer, http.StatusOK, output) + }) + return securityHeaders(mux) +} + +func decodeJSON(writer http.ResponseWriter, request *http.Request, destination any) error { + if contentType := request.Header.Get("Content-Type"); contentType != "" && + !strings.HasPrefix(strings.ToLower(contentType), "application/json") { + return errors.New("Content-Type must be application/json") + } + request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBody) + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return fmt.Errorf("decode JSON: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one JSON object") + } + return nil +} + +type apiErrorEnvelope struct { + Error apiError `json:"error"` +} + +type apiError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeAPIError(writer http.ResponseWriter, status int, code, message string) { + writeJSONStatus(writer, status, apiErrorEnvelope{Error: apiError{Code: code, Message: message}}) +} + +func writeJSON(writer http.ResponseWriter, status int, value any) { + writeJSONStatus(writer, status, value) +} + +func writeJSONStatus(writer http.ResponseWriter, status int, value any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(value) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(writer, request) + }) +} diff --git a/internal/bootstrap/server_id.go b/internal/bootstrap/server_id.go new file mode 100644 index 0000000..3484fef --- /dev/null +++ b/internal/bootstrap/server_id.go @@ -0,0 +1,30 @@ +package bootstrap + +import ( + "context" + + "github.com/google/uuid" + + "remlink/internal/database" +) + +const serverIDSetting = "server_id" + +// EnsureServerID returns the stable Server UUID stored in SQLite. +func EnsureServerID(ctx context.Context, store *database.Store) (string, error) { + value, found, err := database.GetSetting(ctx, store.DB(), serverIDSetting) + if err != nil { + return "", err + } + if found { + if _, err := uuid.Parse(value); err != nil { + return "", err + } + return value, nil + } + value = uuid.NewString() + if err := database.SetSetting(ctx, store.DB(), serverIDSetting, value); err != nil { + return "", err + } + return value, nil +} diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go new file mode 100644 index 0000000..e0ef744 --- /dev/null +++ b/internal/bootstrap/service.go @@ -0,0 +1,342 @@ +// Package bootstrap implements the public Node enrollment and configuration API. +package bootstrap + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/google/uuid" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "remlink/internal/database" + "remlink/internal/ipam" + "remlink/internal/model" +) + +var ( + ErrJoinTokenInvalid = errors.New("Join Token invalid") + ErrNodeAuthFailed = errors.New("Node authentication failed") + ErrNodeConflict = errors.New("Node identity conflicts with existing registration") +) + +// PeerManager is the narrow kernel-WireGuard boundary used by Bootstrap. +type PeerManager interface { + EnsurePeer(context.Context, string, netip.Addr) error +} + +// ServiceConfig describes the Server values returned to Windows nodes. +type ServiceConfig struct { + ServerID string + Version string + WGPublicKey string + WGEndpoint string + OverlayCIDR netip.Prefix + ServerOverlayIP netip.Addr + ControlURL string + SessionUDPPort int + MTU int + ConfigVersion uint64 +} + +// Service coordinates registration, IPAM, token rotation, and peer creation. +type Service struct { + mu sync.RWMutex + store *database.Store + ipam *ipam.Manager + joins *JoinTokens + peers PeerManager + config ServiceConfig + onNodeBootstrap func(context.Context, string) error +} + +// SetNodeBootstrapHandler installs the Server runtime reconciliation boundary. +// A Node calls the public Config endpoint only when constructing a fresh local +// runtime; any old in-memory Session for that Node can no longer be resumed. +func (s *Service) SetNodeBootstrapHandler(handler func(context.Context, string) error) { + s.mu.Lock() + s.onNodeBootstrap = handler + s.mu.Unlock() +} + +func NewService(store *database.Store, ipamManager *ipam.Manager, joins *JoinTokens, peers PeerManager, config ServiceConfig) (*Service, error) { + if store == nil || ipamManager == nil || joins == nil || peers == nil { + return nil, errors.New("Bootstrap dependencies must not be nil") + } + if err := validateServiceConfig(config); err != nil { + return nil, err + } + return &Service{store: store, ipam: ipamManager, joins: joins, peers: peers, config: config}, nil +} + +func usableOverlayAddress(prefix netip.Prefix, address netip.Addr) bool { + if !address.Is4() || !prefix.Contains(address) || address == prefix.Addr() { + return false + } + base := prefix.Masked().Addr().As4() + value := uint32(base[0])<<24 | uint32(base[1])<<16 | uint32(base[2])<<8 | uint32(base[3]) + value |= ^uint32(0) >> prefix.Bits() + broadcast := netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}) + return address != broadcast +} + +type RegisterRequest struct { + JoinToken string `json:"join_token"` + NodeID string `json:"node_id"` + NodeType model.NodeType `json:"node_type"` + NodeName string `json:"node_name"` + WGPublicKey string `json:"wg_public_key"` + Version string `json:"version"` + OSVersion string `json:"os_version"` +} + +type ConfigRequest struct { + NodeID string `json:"node_id"` + NodeToken string `json:"node_token"` +} + +type ServerInfo struct { + ServerID string `json:"server_id"` + Version string `json:"version"` + APIVersion int `json:"api_version"` + WGEndpoint string `json:"wg_endpoint"` + RegistrationURL string `json:"registration_url"` +} + +type NetworkConfig struct { + ConfigVersion uint64 `json:"config_version"` + OverlayCIDR string `json:"overlay_cidr"` + OverlayIP string `json:"overlay_ip"` + ServerOverlayIP string `json:"server_overlay_ip"` + ServerWGPublicKey string `json:"server_wg_public_key"` + ServerWGEndpoint string `json:"server_wg_endpoint"` + ControlURL string `json:"control_url"` + SessionUDPPort int `json:"session_udp_port"` + MTU int `json:"mtu"` +} + +type RegisterResponse struct { + NodeToken string `json:"node_token"` + Network NetworkConfig `json:"network_config"` +} + +type ConfigResponse struct { + Network NetworkConfig `json:"network_config"` +} + +func (s *Service) ServerInfo() ServerInfo { + s.mu.RLock() + defer s.mu.RUnlock() + return ServerInfo{ + ServerID: s.config.ServerID, Version: s.config.Version, APIVersion: 1, + WGEndpoint: s.config.WGEndpoint, RegistrationURL: "/api/v1/bootstrap/register", + } +} + +// Register enrolls a new Node or safely rotates credentials for an identical identity. +func (s *Service) Register(ctx context.Context, request RegisterRequest) (RegisterResponse, error) { + validJoin, err := s.joins.Verify(ctx, request.JoinToken) + if err != nil { + return RegisterResponse{}, err + } + if !validJoin { + return RegisterResponse{}, ErrJoinTokenInvalid + } + request.NodeName = strings.TrimSpace(request.NodeName) + if err := validateRegistration(request); err != nil { + return RegisterResponse{}, err + } + key, _ := wgtypes.ParseKey(request.WGPublicKey) + canonicalKey := key.String() + plainToken, tokenHash, err := newNodeToken() + if err != nil { + return RegisterResponse{}, err + } + + existing, err := s.store.GetNode(ctx, request.NodeID) + if err == nil { + if existing.Type != request.NodeType || existing.WGPublicKey != canonicalKey { + return RegisterResponse{}, ErrNodeConflict + } + if err := s.reconcileNodeBootstrap(ctx, existing.ID); err != nil { + return RegisterResponse{}, err + } + if err := s.peers.EnsurePeer(ctx, canonicalKey, existing.OverlayIP); err != nil { + return RegisterResponse{}, fmt.Errorf("ensure existing WireGuard peer: %w", err) + } + existing.Name = request.NodeName + existing.NodeTokenHash = tokenHash + existing.Version = request.Version + existing.OSVersion = request.OSVersion + if err := s.store.UpdateNodeRegistration(ctx, existing); err != nil { + return RegisterResponse{}, err + } + return RegisterResponse{NodeToken: plainToken, Network: s.network(existing.OverlayIP)}, nil + } + if !errors.Is(err, database.ErrNodeNotFound) { + return RegisterResponse{}, err + } + + s.mu.RLock() + ipamManager := s.ipam + s.mu.RUnlock() + node, created, err := ipamManager.ReserveNode(ctx, model.Node{ + ID: request.NodeID, Type: request.NodeType, Name: request.NodeName, + WGPublicKey: canonicalKey, NodeTokenHash: tokenHash, Status: model.NodeOffline, + Version: request.Version, OSVersion: request.OSVersion, + }) + if err != nil { + return RegisterResponse{}, err + } + if !created { + return RegisterResponse{}, ErrNodeConflict + } + if err := s.peers.EnsurePeer(ctx, canonicalKey, node.OverlayIP); err != nil { + if rollbackErr := ipamManager.ReleaseNode(ctx, node.ID); rollbackErr != nil { + return RegisterResponse{}, fmt.Errorf("ensure WireGuard peer: %w (registration rollback failed: %v)", err, rollbackErr) + } + return RegisterResponse{}, fmt.Errorf("ensure WireGuard peer: %w", err) + } + return RegisterResponse{NodeToken: plainToken, Network: s.network(node.OverlayIP)}, nil +} + +// Config authenticates an enrolled Node and returns the authoritative latest configuration. +func (s *Service) Config(ctx context.Context, request ConfigRequest) (ConfigResponse, error) { + node, err := s.AuthenticateNode(ctx, request.NodeID, request.NodeToken) + if err != nil { + return ConfigResponse{}, err + } + if err := s.reconcileNodeBootstrap(ctx, node.ID); err != nil { + return ConfigResponse{}, err + } + if err := s.peers.EnsurePeer(ctx, node.WGPublicKey, node.OverlayIP); err != nil { + return ConfigResponse{}, fmt.Errorf("ensure WireGuard peer: %w", err) + } + return ConfigResponse{Network: s.network(node.OverlayIP)}, nil +} + +func (s *Service) reconcileNodeBootstrap(ctx context.Context, nodeID string) error { + s.mu.RLock() + handler := s.onNodeBootstrap + s.mu.RUnlock() + if handler == nil { + return nil + } + if err := handler(ctx, nodeID); err != nil { + return fmt.Errorf("reconcile Node runtime before Bootstrap: %w", err) + } + return nil +} + +// AuthenticateNode verifies application identity for Bootstrap and Control. +func (s *Service) AuthenticateNode(ctx context.Context, nodeID, nodeToken string) (model.Node, error) { + node, err := s.store.GetNode(ctx, nodeID) + if err != nil { + if errors.Is(err, database.ErrNodeNotFound) { + return model.Node{}, ErrNodeAuthFailed + } + return model.Node{}, err + } + if !nodeTokenMatches(node.NodeTokenHash, nodeToken) { + return model.Node{}, ErrNodeAuthFailed + } + return node, nil +} + +func validateRegistration(request RegisterRequest) error { + if _, err := uuid.Parse(request.NodeID); err != nil { + return fmt.Errorf("node_id must be a UUID: %w", err) + } + if !request.NodeType.Valid() { + return fmt.Errorf("invalid node_type %q", request.NodeType) + } + if request.NodeName == "" || len(request.NodeName) > 128 { + return errors.New("node_name must contain 1 to 128 bytes") + } + if _, err := wgtypes.ParseKey(request.WGPublicKey); err != nil { + return fmt.Errorf("wg_public_key must be a WireGuard key: %w", err) + } + return nil +} + +func (s *Service) network(nodeIP netip.Addr) NetworkConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.networkLocked(nodeIP) +} + +func (s *Service) networkLocked(nodeIP netip.Addr) NetworkConfig { + return NetworkConfig{ + ConfigVersion: s.config.ConfigVersion, OverlayCIDR: s.config.OverlayCIDR.String(), + OverlayIP: nodeIP.String(), ServerOverlayIP: s.config.ServerOverlayIP.String(), + ServerWGPublicKey: s.config.WGPublicKey, ServerWGEndpoint: s.config.WGEndpoint, + ControlURL: s.config.ControlURL, SessionUDPPort: s.config.SessionUDPPort, MTU: s.config.MTU, + } +} + +// NetworkSnapshot returns the current public configuration without a Node IP. +func (s *Service) NetworkSnapshot() ServiceConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.config +} + +// UpdateNetwork publishes a completed Server network migration to Bootstrap. +func (s *Service) UpdateNetwork(config ServiceConfig) error { + if err := validateServiceConfig(config); err != nil { + return fmt.Errorf("invalid updated Bootstrap network: %w", err) + } + s.mu.Lock() + s.config = config + s.mu.Unlock() + return nil +} + +func validateServiceConfig(config ServiceConfig) error { + if _, err := uuid.Parse(config.ServerID); err != nil { + return fmt.Errorf("invalid Server ID: %w", err) + } + if strings.TrimSpace(config.Version) == "" { + return errors.New("Bootstrap Server version is required") + } + if _, err := wgtypes.ParseKey(config.WGPublicKey); err != nil { + return fmt.Errorf("invalid Server WireGuard public key: %w", err) + } + if !config.OverlayCIDR.IsValid() || !config.OverlayCIDR.Addr().Is4() || config.OverlayCIDR != config.OverlayCIDR.Masked() || config.OverlayCIDR.Bits() == 0 || config.OverlayCIDR.Bits() > 30 { + return errors.New("Bootstrap overlay CIDR must be a canonical IPv4 prefix with usable hosts") + } + if !usableOverlayAddress(config.OverlayCIDR, config.ServerOverlayIP) { + return errors.New("Bootstrap Server overlay address must be usable inside overlay CIDR") + } + wgHost, wgPortText, err := net.SplitHostPort(config.WGEndpoint) + if err != nil || strings.TrimSpace(wgHost) == "" { + return errors.New("Bootstrap WireGuard endpoint must be host:port") + } + wgPort, err := strconv.Atoi(wgPortText) + if err != nil || wgPort < 1 || wgPort > 65535 { + return errors.New("Bootstrap WireGuard endpoint port is invalid") + } + controlURL, err := url.Parse(config.ControlURL) + if err != nil || (controlURL.Scheme != "ws" && controlURL.Scheme != "wss") || controlURL.Path != "/control" || + controlURL.Opaque != "" || controlURL.User != nil || controlURL.RawQuery != "" || controlURL.ForceQuery || controlURL.Fragment != "" { + return errors.New("Bootstrap Control URL must be an absolute ws(s) /control URL") + } + controlHost, controlPortText, err := net.SplitHostPort(controlURL.Host) + if err != nil || controlHost != config.ServerOverlayIP.String() { + return errors.New("Bootstrap Control URL must target the Server overlay IP") + } + controlPort, err := strconv.Atoi(controlPortText) + if err != nil || controlPort < 1 || controlPort > 65535 { + return errors.New("Bootstrap Control URL port is invalid") + } + if config.SessionUDPPort < 1 || config.SessionUDPPort > 65535 || config.MTU < 576 || config.MTU > 65535 || config.ConfigVersion == 0 { + return errors.New("Bootstrap Session UDP port, MTU, and config version must be valid") + } + return nil +} diff --git a/internal/bootstrap/token.go b/internal/bootstrap/token.go new file mode 100644 index 0000000..f2e264d --- /dev/null +++ b/internal/bootstrap/token.go @@ -0,0 +1,107 @@ +package bootstrap + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "sync" + + "remlink/internal/database" +) + +const joinTokenSetting = "join_token" + +// JoinTokens maintains the single rotatable enrollment secret in settings. +type JoinTokens struct { + mu sync.Mutex + db databaseSettings +} + +type databaseSettings interface { + Get(context.Context, string) (string, bool, error) + Set(context.Context, string, string) error +} + +type settingsAdapter struct{ store *database.Store } + +func (a settingsAdapter) Get(ctx context.Context, key string) (string, bool, error) { + return database.GetSetting(ctx, a.store.DB(), key) +} + +func (a settingsAdapter) Set(ctx context.Context, key, value string) error { + return database.SetSetting(ctx, a.store.DB(), key, value) +} + +// NewJoinTokens binds token management to Server settings. +func NewJoinTokens(store *database.Store) *JoinTokens { + return &JoinTokens{db: settingsAdapter{store: store}} +} + +// Ensure returns the current Join Token, generating it on first startup. +func (m *JoinTokens) Ensure(ctx context.Context) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + value, found, err := m.db.Get(ctx, joinTokenSetting) + if err != nil { + return "", err + } + if found { + return value, nil + } + return m.rotateLocked(ctx) +} + +// Rotate revokes the previous Join Token and returns a new one. +func (m *JoinTokens) Rotate(ctx context.Context) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.rotateLocked(ctx) +} + +// Verify compares a submitted token without data-dependent early exit. +func (m *JoinTokens) Verify(ctx context.Context, submitted string) (bool, error) { + current, err := m.Ensure(ctx) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare([]byte(current), []byte(submitted)) == 1, nil +} + +func (m *JoinTokens) rotateLocked(ctx context.Context) (string, error) { + value, err := randomToken() + if err != nil { + return "", err + } + if err := m.db.Set(ctx, joinTokenSetting, value); err != nil { + return "", err + } + return value, nil +} + +func randomToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func newNodeToken() (plain string, hash []byte, err error) { + plain, err = randomToken() + if err != nil { + return "", nil, err + } + digest := sha256.Sum256([]byte(plain)) + return plain, digest[:], nil +} + +func nodeTokenMatches(storedHash []byte, submitted string) bool { + if len(storedHash) != sha256.Size || submitted == "" { + return false + } + digest := sha256.Sum256([]byte(submitted)) + return subtle.ConstantTimeCompare(storedHash, digest[:]) == 1 +} diff --git a/internal/config/client.go b/internal/config/client.go new file mode 100644 index 0000000..70617d6 --- /dev/null +++ b/internal/config/client.go @@ -0,0 +1,106 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + DefaultSiteTCPFlowLimit = 2048 + DefaultSiteUDPFlowLimit = 4096 + DefaultSiteUDPIdleSeconds = 60 +) + +// EngineerConfig contains portable Engineer settings. JoinToken is an optional +// plaintext first-registration convenience; durable Node secrets stay in DPAPI. +type EngineerConfig struct { + Server string `yaml:"server"` + NodeName string `yaml:"node_name"` + JoinToken string `yaml:"join_token,omitempty"` +} + +// SiteConfig contains portable Site settings. JoinToken has the same +// first-registration-only semantics as EngineerConfig.JoinToken. +type SiteConfig struct { + Server string `yaml:"server"` + NodeName string `yaml:"node_name"` + JoinToken string `yaml:"join_token,omitempty"` + Netstack SiteNetstackConfig `yaml:"netstack"` +} + +type SiteNetstackConfig struct { + TCPFlowLimit int `yaml:"tcp_flow_limit"` + UDPFlowLimit int `yaml:"udp_flow_limit"` + UDPIdleSeconds int `yaml:"udp_idle_seconds"` +} + +// LoadEngineer loads and validates a strict Engineer YAML file. +func LoadEngineer(path string) (EngineerConfig, error) { + var config EngineerConfig + if err := decodeStrict(path, &config); err != nil { + return EngineerConfig{}, err + } + if err := validateClient(config.Server, config.NodeName); err != nil { + return EngineerConfig{}, fmt.Errorf("validate engineer config %q: %w", path, err) + } + config.JoinToken = strings.TrimSpace(config.JoinToken) + return config, nil +} + +// LoadSite loads and validates a strict Site YAML file. +func LoadSite(path string) (SiteConfig, error) { + config := SiteConfig{Netstack: SiteNetstackConfig{ + TCPFlowLimit: DefaultSiteTCPFlowLimit, UDPFlowLimit: DefaultSiteUDPFlowLimit, + UDPIdleSeconds: DefaultSiteUDPIdleSeconds, + }} + if err := decodeStrict(path, &config); err != nil { + return SiteConfig{}, err + } + if err := validateClient(config.Server, config.NodeName); err != nil { + return SiteConfig{}, fmt.Errorf("validate site config %q: %w", path, err) + } + config.JoinToken = strings.TrimSpace(config.JoinToken) + if config.Netstack.TCPFlowLimit < 1 || config.Netstack.UDPFlowLimit < 1 || config.Netstack.UDPIdleSeconds < 1 || config.Netstack.UDPIdleSeconds > 86400 { + return SiteConfig{}, fmt.Errorf("validate site config %q: netstack limits must be positive and udp_idle_seconds must be within 1..86400", path) + } + return config, nil +} + +// ResolveJoinToken applies the documented precedence. override is populated by +// the -join-token flag, whose default is REMLINK_JOIN_TOKEN. +func ResolveJoinToken(override, configured string) string { + if value := strings.TrimSpace(override); value != "" { + return value + } + return strings.TrimSpace(configured) +} + +func validateClient(server, nodeName string) error { + if strings.TrimSpace(nodeName) == "" { + return fmt.Errorf("node_name must not be empty") + } + return ValidateServerURL(server) +} + +// ValidateServerURL applies the single public Server URL trust boundary used +// by YAML loading, persisted Node identities, and the Bootstrap client. +func ValidateServerURL(server string) error { + parsed, err := url.Parse(server) + if err != nil || parsed.Host == "" || parsed.Opaque != "" { + return fmt.Errorf("server must be an absolute HTTP URL") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("server URL scheme must be http or https") + } + if parsed.User != nil { + return fmt.Errorf("server URL must not contain credentials") + } + if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" { + return fmt.Errorf("server URL must not contain a query or fragment") + } + if parsed.Path != "" && parsed.Path != "/" { + return fmt.Errorf("server URL must not contain a path") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3644d21 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadServerUsesDefaultsAndStrictFields(t *testing.T) { + t.Parallel() + path := writeConfig(t, "data:\n directory: ./custom-data\n") + config, err := LoadServer(path) + if err != nil { + t.Fatalf("LoadServer() error = %v", err) + } + if config.Data.Directory != "./custom-data" { + t.Fatalf("Data.Directory = %q, want ./custom-data", config.Data.Directory) + } + if config.Network.OverlayCIDR != DefaultOverlayCIDR { + t.Fatalf("OverlayCIDR = %q, want %q", config.Network.OverlayCIDR, DefaultOverlayCIDR) + } + + unknown := writeConfig(t, "node_token: must-not-be-in-yaml\n") + if _, err := LoadServer(unknown); err == nil || !strings.Contains(err.Error(), "field node_token not found") { + t.Fatalf("LoadServer() unknown-field error = %v", err) + } +} + +func TestServerConfigValidation(t *testing.T) { + t.Parallel() + config := DefaultServerConfig() + config.Server.ControlListen = "10.88.0.2:7001" + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "must bind") { + t.Fatalf("Validate() error = %v, want Control listen mismatch", err) + } + + config = DefaultServerConfig() + config.Network.OverlayCIDR = "10.88.0.1/16" + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "network address") { + t.Fatalf("Validate() error = %v, want unmasked CIDR error", err) + } + + config = DefaultServerConfig() + config.Network.OverlayCIDR = "0.0.0.0/0" + config.Network.ServerOverlayIP = "10.88.0.1" + config.Server.ControlListen = "10.88.0.1:7001" + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "Exit Node") { + t.Fatalf("Validate() /0 error = %v", err) + } +} + +func TestLoadEngineerAndSite(t *testing.T) { + t.Parallel() + valid := writeConfig(t, "server: http://127.0.0.1:8080\nnode_name: test-node\njoin_token: ' yaml-token '\n") + engineer, err := LoadEngineer(valid) + if err != nil { + t.Fatalf("LoadEngineer() error = %v", err) + } + if engineer.NodeName != "test-node" || engineer.JoinToken != "yaml-token" { + t.Fatalf("Engineer config = %+v", engineer) + } + site, err := LoadSite(valid) + if err != nil { + t.Fatalf("LoadSite() error = %v", err) + } + if site.JoinToken != "yaml-token" || site.Netstack.TCPFlowLimit != 2048 || site.Netstack.UDPFlowLimit != 4096 || site.Netstack.UDPIdleSeconds != 60 { + t.Fatalf("Site netstack defaults = %+v", site.Netstack) + } + customSite := writeConfig(t, "server: http://127.0.0.1:8080\nnode_name: site\nnetstack:\n tcp_flow_limit: 32\n udp_flow_limit: 64\n udp_idle_seconds: 15\n") + site, err = LoadSite(customSite) + if err != nil || site.Netstack.TCPFlowLimit != 32 || site.Netstack.UDPFlowLimit != 64 || site.Netstack.UDPIdleSeconds != 15 { + t.Fatalf("custom Site netstack = %+v, %v", site.Netstack, err) + } + invalidSite := writeConfig(t, "server: http://127.0.0.1:8080\nnode_name: site\nnetstack:\n udp_idle_seconds: 0\n") + if _, err := LoadSite(invalidSite); err == nil { + t.Fatal("LoadSite accepted zero UDP idle timeout") + } + + sensitive := writeConfig(t, "server: http://127.0.0.1:8080\nnode_name: test-node\nwg_private_key: secret\n") + if _, err := LoadEngineer(sensitive); err == nil || !strings.Contains(err.Error(), "field wg_private_key not found") { + t.Fatalf("LoadEngineer() sensitive-field error = %v", err) + } +} + +func TestResolveJoinTokenPrecedence(t *testing.T) { + t.Parallel() + if got := ResolveJoinToken(" cli-or-env ", "yaml"); got != "cli-or-env" { + t.Fatalf("ResolveJoinToken override = %q", got) + } + if got := ResolveJoinToken("", " yaml "); got != "yaml" { + t.Fatalf("ResolveJoinToken YAML fallback = %q", got) + } + if got := ResolveJoinToken(" ", " "); got != "" { + t.Fatalf("ResolveJoinToken empty = %q", got) + } +} + +func TestValidateServerURLRejectsAmbiguousAuthority(t *testing.T) { + t.Parallel() + invalid := []string{ + "ftp://example.test", "http://user:secret@example.test", "http://example.test/api", + "http://example.test?", "http://example.test?x=1", "http://example.test#fragment", "not-a-url", + } + for _, raw := range invalid { + if err := ValidateServerURL(raw); err == nil { + t.Errorf("ValidateServerURL(%q) accepted an unsafe or ambiguous URL", raw) + } + } + for _, raw := range []string{"http://127.0.0.1:8080", "https://example.test/"} { + if err := ValidateServerURL(raw); err != nil { + t.Errorf("ValidateServerURL(%q) error = %v", raw, err) + } + } +} + +func TestConfigRejectsMultipleDocuments(t *testing.T) { + t.Parallel() + path := writeConfig(t, "server: http://127.0.0.1:8080\nnode_name: one\n---\nserver: http://127.0.0.1:8080\nnode_name: two\n") + if _, err := LoadSite(path); err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("LoadSite() error = %v, want multiple-document error", err) + } +} + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..1e5f247 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,37 @@ +// Package config loads only non-sensitive RemLink YAML configuration. +package config + +import ( + "errors" + "fmt" + "io" + "os" + + "gopkg.in/yaml.v3" +) + +func decodeStrict(path string, target any) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open config %q: %w", path, err) + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + decoder.KnownFields(true) + if err := decoder.Decode(target); err != nil { + if errors.Is(err, io.EOF) { + return fmt.Errorf("config %q is empty", path) + } + return fmt.Errorf("decode config %q: %w", path, err) + } + + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err != nil { + return fmt.Errorf("decode trailing YAML document in %q: %w", path, err) + } + return fmt.Errorf("config %q must contain exactly one YAML document", path) + } + return nil +} diff --git a/internal/config/server.go b/internal/config/server.go new file mode 100644 index 0000000..cece8cc --- /dev/null +++ b/internal/config/server.go @@ -0,0 +1,162 @@ +package config + +import ( + "fmt" + "net" + "net/netip" + "strconv" + "strings" +) + +const ( + DefaultHTTPListen = "0.0.0.0:8080" + DefaultControlListen = "10.88.0.1:7001" + DefaultWireGuardPort = 51820 + DefaultDataDirectory = "./data" + DefaultOverlayCIDR = "10.88.0.0/16" + DefaultServerOverlayIP = "10.88.0.1" + DefaultSessionUDPPort = 6200 + DefaultMTU = 1280 +) + +// ServerConfig contains Server bootstrap values and initial network defaults. +type ServerConfig struct { + Server ServerListeners `yaml:"server"` + Data DataConfig `yaml:"data"` + Network NetworkConfig `yaml:"network"` +} + +// ServerListeners defines process listen endpoints. +type ServerListeners struct { + HTTPListen string `yaml:"http_listen"` + ControlListen string `yaml:"control_listen"` + WireGuardPort int `yaml:"wireguard_port"` +} + +// DataConfig defines the Server persistence directory. +type DataConfig struct { + Directory string `yaml:"directory"` +} + +// NetworkConfig supplies initial values later stored in Server SQLite. +type NetworkConfig struct { + OverlayCIDR string `yaml:"overlay_cidr"` + ServerOverlayIP string `yaml:"server_overlay_ip"` + SessionUDPPort int `yaml:"session_udp_port"` + MTU int `yaml:"mtu"` +} + +// DefaultServerConfig returns the Appendix B defaults. +func DefaultServerConfig() ServerConfig { + return ServerConfig{ + Server: ServerListeners{ + HTTPListen: DefaultHTTPListen, + ControlListen: DefaultControlListen, + WireGuardPort: DefaultWireGuardPort, + }, + Data: DataConfig{Directory: DefaultDataDirectory}, + Network: NetworkConfig{ + OverlayCIDR: DefaultOverlayCIDR, + ServerOverlayIP: DefaultServerOverlayIP, + SessionUDPPort: DefaultSessionUDPPort, + MTU: DefaultMTU, + }, + } +} + +// LoadServer loads one strict YAML document and validates the result. +func LoadServer(path string) (ServerConfig, error) { + config := DefaultServerConfig() + if err := decodeStrict(path, &config); err != nil { + return ServerConfig{}, err + } + if err := config.Validate(); err != nil { + return ServerConfig{}, fmt.Errorf("validate server config %q: %w", path, err) + } + return config, nil +} + +// Validate checks Phase 0 invariants without changing the host network. +func (c ServerConfig) Validate() error { + if _, err := validateIPv4Listen("server.http_listen", c.Server.HTTPListen); err != nil { + return err + } + controlIP, err := validateIPv4Listen("server.control_listen", c.Server.ControlListen) + if err != nil { + return err + } + if err := validatePort("server.wireguard_port", c.Server.WireGuardPort); err != nil { + return err + } + if strings.TrimSpace(c.Data.Directory) == "" { + return fmt.Errorf("data.directory must not be empty") + } + + prefix, err := netip.ParsePrefix(c.Network.OverlayCIDR) + if err != nil || !prefix.Addr().Is4() { + return fmt.Errorf("network.overlay_cidr must be a valid IPv4 CIDR") + } + if prefix != prefix.Masked() { + return fmt.Errorf("network.overlay_cidr must use its network address: got %s", prefix) + } + if prefix.Bits() == 0 { + return fmt.Errorf("network.overlay_cidr must not enable 0.0.0.0/0 Exit Node routing") + } + if prefix.Bits() > 30 { + return fmt.Errorf("network.overlay_cidr must leave addresses for the Server and at least one Node") + } + + serverIP, err := netip.ParseAddr(c.Network.ServerOverlayIP) + if err != nil || !serverIP.Is4() { + return fmt.Errorf("network.server_overlay_ip must be a valid IPv4 address") + } + if !prefix.Contains(serverIP) { + return fmt.Errorf("network.server_overlay_ip must belong to network.overlay_cidr") + } + if serverIP == prefix.Addr() || serverIP == lastIPv4(prefix) { + return fmt.Errorf("network.server_overlay_ip must not be the network or broadcast address") + } + if controlIP != serverIP { + return fmt.Errorf("server.control_listen must bind network.server_overlay_ip") + } + if err := validatePort("network.session_udp_port", c.Network.SessionUDPPort); err != nil { + return err + } + if c.Network.MTU < 576 || c.Network.MTU > 65535 { + return fmt.Errorf("network.mtu must be between 576 and 65535") + } + return nil +} + +func validateIPv4Listen(field, value string) (netip.Addr, error) { + host, portText, err := net.SplitHostPort(value) + if err != nil { + return netip.Addr{}, fmt.Errorf("%s must be an IPv4 host:port: %w", field, err) + } + address, err := netip.ParseAddr(host) + if err != nil || !address.Is4() { + return netip.Addr{}, fmt.Errorf("%s must use an IPv4 address", field) + } + port, err := strconv.Atoi(portText) + if err != nil { + return netip.Addr{}, fmt.Errorf("%s has an invalid port", field) + } + if err := validatePort(field, port); err != nil { + return netip.Addr{}, err + } + return address, nil +} + +func validatePort(field string, port int) error { + if port < 1 || port > 65535 { + return fmt.Errorf("%s must be between 1 and 65535", field) + } + return nil +} + +func lastIPv4(prefix netip.Prefix) netip.Addr { + bytes := prefix.Addr().As4() + value := uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3]) + value |= ^uint32(0) >> prefix.Bits() + return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}) +} diff --git a/internal/control/client.go b/internal/control/client.go new file mode 100644 index 0000000..ad3d750 --- /dev/null +++ b/internal/control/client.go @@ -0,0 +1,253 @@ +package control + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "remlink/internal/protocol" +) + +var ErrControlDisconnected = errors.New("Control WebSocket is not connected") + +var DefaultReconnectBackoff = [...]time.Duration{ + 1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second, +} + +const DefaultBootstrapRefreshAfter = 30 * time.Second + +type ClientConfig struct { + URL string + Hello protocol.HelloPayload + HeartbeatInterval time.Duration + HandshakeTimeout time.Duration + HTTPClient *http.Client + OnConnectionState func(bool) + OnHeartbeatRTT func(time.Duration) + BootstrapRefreshAfter time.Duration +} + +type EnvelopeHandler func(context.Context, protocol.ControlEnvelope) error + +// Client maintains one authenticated WebSocket with the specified backoff. +type Client struct { + config ClientConfig + handler EnvelopeHandler + mu sync.RWMutex + active *clientConnection +} + +type clientConnection struct { + socket *websocket.Conn + sendMu sync.Mutex +} + +func NewClient(config ClientConfig, handler EnvelopeHandler) (*Client, error) { + if config.URL == "" || config.Hello.NodeID == "" || config.Hello.NodeToken == "" { + return nil, errors.New("Control URL, NodeID, and NodeToken are required") + } + if config.HeartbeatInterval <= 0 { + config.HeartbeatInterval = DefaultHeartbeatInterval + } + if config.HandshakeTimeout <= 0 { + config.HandshakeTimeout = 10 * time.Second + } + if config.BootstrapRefreshAfter <= 0 { + config.BootstrapRefreshAfter = DefaultBootstrapRefreshAfter + } + return &Client{config: config, handler: handler}, nil +} + +// Run reconnects until ctx is canceled. Successful handshakes reset backoff. +func (c *Client) Run(ctx context.Context) error { + backoffIndex := 0 + disconnectedSince := time.Now() + for { + if time.Since(disconnectedSince) >= c.config.BootstrapRefreshAfter { + return fmt.Errorf("Control unavailable for %s: %w", c.config.BootstrapRefreshAfter, protocol.ErrRebootstrapRequired) + } + connected, err := c.runOnce(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, protocol.ErrRebootstrapRequired) { + return err + } + if connected { + backoffIndex = 0 + disconnectedSince = time.Now() + } + delay := DefaultReconnectBackoff[backoffIndex] + remaining := c.config.BootstrapRefreshAfter - time.Since(disconnectedSince) + if delay > remaining { + delay = remaining + } + if backoffIndex < len(DefaultReconnectBackoff)-1 { + backoffIndex++ + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + _ = err + } + } +} + +// Send writes one typed Node-to-Server message on the current authenticated +// connection. Callers may retry after ErrControlDisconnected. +func (c *Client) Send(ctx context.Context, messageType protocol.ControlMessageType, requestID string, payload any) error { + c.mu.RLock() + active := c.active + c.mu.RUnlock() + if active == nil { + return ErrControlDisconnected + } + envelope, err := protocol.NewControlEnvelope(messageType, requestID, payload) + if err != nil { + return err + } + return active.write(ctx, envelope) +} + +func (c *Client) runOnce(ctx context.Context) (bool, error) { + dialOptions := &websocket.DialOptions{HTTPClient: c.config.HTTPClient, CompressionMode: websocket.CompressionDisabled} + socket, response, err := websocket.Dial(ctx, c.config.URL, dialOptions) + if err != nil { + if response != nil { + return false, fmt.Errorf("dial Control WebSocket: HTTP %d: %w", response.StatusCode, err) + } + return false, fmt.Errorf("dial Control WebSocket: %w", err) + } + defer socket.Close(websocket.StatusNormalClosure, "Node stopping") + socket.SetReadLimit(maxControlMessage) + + handshakeContext, cancel := context.WithTimeout(ctx, c.config.HandshakeTimeout) + helloEnvelope, err := protocol.NewControlEnvelope(protocol.ControlHello, "", c.config.Hello) + if err != nil { + cancel() + return false, err + } + if err := wsjson.Write(handshakeContext, socket, helloEnvelope); err != nil { + cancel() + return false, fmt.Errorf("send HELLO: %w", err) + } + var welcomeEnvelope protocol.ControlEnvelope + if err := wsjson.Read(handshakeContext, socket, &welcomeEnvelope); err != nil { + cancel() + return false, fmt.Errorf("read WELCOME: %w", err) + } + cancel() + if welcomeEnvelope.Type != protocol.ControlWelcome { + return false, fmt.Errorf("first Server message is %s, want WELCOME", welcomeEnvelope.Type) + } + var welcome protocol.WelcomePayload + if err := welcomeEnvelope.DecodePayload(&welcome); err != nil { + return false, err + } + active := &clientConnection{socket: socket} + c.setActive(active) + if c.config.OnConnectionState != nil { + c.config.OnConnectionState(true) + } + defer func() { + c.clearActive(active) + if c.config.OnConnectionState != nil { + c.config.OnConnectionState(false) + } + }() + + connectionContext, cancelConnection := context.WithCancel(ctx) + defer cancelConnection() + readErrors := make(chan error, 1) + var heartbeatMu sync.Mutex + heartbeats := make(map[string]time.Time) + go func() { + for { + var envelope protocol.ControlEnvelope + if err := wsjson.Read(connectionContext, socket, &envelope); err != nil { + readErrors <- err + return + } + if envelope.Type == protocol.ControlHeartbeat { + heartbeatMu.Lock() + sentAt, found := heartbeats[envelope.RequestID] + if found { + delete(heartbeats, envelope.RequestID) + } + heartbeatMu.Unlock() + if found && c.config.OnHeartbeatRTT != nil { + c.config.OnHeartbeatRTT(time.Since(sentAt)) + } + continue + } + if !envelope.Type.Valid() { + readErrors <- fmt.Errorf("invalid Server Control type %q", envelope.Type) + return + } + if c.handler != nil { + if err := c.handler(connectionContext, envelope); err != nil { + readErrors <- err + return + } + } + } + }() + + ticker := time.NewTicker(c.config.HeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return true, ctx.Err() + case err := <-readErrors: + return true, fmt.Errorf("read Control message: %w", err) + case now := <-ticker.C: + requestID := fmt.Sprintf("hb-%d", now.UnixNano()) + envelope, err := protocol.NewControlEnvelope(protocol.ControlHeartbeat, requestID, protocol.HeartbeatPayload{ + Timestamp: now.UTC(), Status: "OK", + }) + if err != nil { + return true, err + } + heartbeatMu.Lock() + heartbeats[requestID] = time.Now() + heartbeatMu.Unlock() + err = active.write(ctx, envelope) + if err != nil { + heartbeatMu.Lock() + delete(heartbeats, requestID) + heartbeatMu.Unlock() + return true, fmt.Errorf("send HEARTBEAT: %w", err) + } + } + } +} + +func (c *Client) setActive(active *clientConnection) { + c.mu.Lock() + c.active = active + c.mu.Unlock() +} + +func (c *Client) clearActive(expected *clientConnection) { + c.mu.Lock() + if c.active == expected { + c.active = nil + } + c.mu.Unlock() +} + +func (c *clientConnection) write(ctx context.Context, envelope protocol.ControlEnvelope) error { + c.sendMu.Lock() + defer c.sendMu.Unlock() + return wsjson.Write(ctx, c.socket, envelope) +} diff --git a/internal/control/hub.go b/internal/control/hub.go new file mode 100644 index 0000000..1bae9a3 --- /dev/null +++ b/internal/control/hub.go @@ -0,0 +1,480 @@ +// Package control implements the Overlay-only Control WebSocket plane. +package control + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "remlink/internal/localization" + "remlink/internal/logging" + "remlink/internal/model" + "remlink/internal/protocol" +) + +const ( + DefaultHeartbeatInterval = 5 * time.Second + OnlineThreshold = 15 * time.Second + UnstableThreshold = 30 * time.Second + maxControlMessage = 1 << 20 + defaultSendTimeout = 10 * time.Second +) + +// Authenticator verifies NodeID+NodeToken without exposing token hashes. +type Authenticator interface { + AuthenticateNode(context.Context, string, string) (model.Node, error) +} + +// NodeStore persists heartbeat-derived Node state. +type NodeStore interface { + ListNodes(context.Context) ([]model.Node, error) + UpdateNodeHeartbeat(context.Context, string, model.NodeStatus, time.Time, string, string) error + UpdateNodeStatus(context.Context, string, model.NodeStatus) error +} + +type eventAppender interface { + AppendEvent(context.Context, model.EventLog) error +} + +// MessageHandler receives authenticated non-heartbeat messages. +type MessageHandler interface { + HandleControl(context.Context, model.Node, protocol.ControlEnvelope) error +} + +// NodeStatusChangeHandler receives authoritative heartbeat state transitions. +// Session orchestration uses OFFLINE transitions to close stale live Sessions. +type NodeStatusChangeHandler interface { + HandleNodeStatusChange(context.Context, model.Node, model.NodeStatus) error +} + +type HubConfig struct { + NetworkConfigVersion uint64 + EnforceRemoteIP bool + HeartbeatInterval time.Duration + HandshakeTimeout time.Duration +} + +// Hub owns at most one active Control socket per NodeID. +type Hub struct { + mu sync.RWMutex + authenticator Authenticator + store NodeStore + handler MessageHandler + config HubConfig + connections map[string]*connection + capabilities map[string]protocol.NodeCapabilities +} + +type connection struct { + node model.Node + socket *websocket.Conn + sendMu sync.Mutex +} + +func NewHub(authenticator Authenticator, store NodeStore, handler MessageHandler, config HubConfig) (*Hub, error) { + if authenticator == nil || store == nil { + return nil, errors.New("Control authenticator and NodeStore are required") + } + if config.NetworkConfigVersion == 0 { + return nil, errors.New("Control network config version must be positive") + } + if config.HeartbeatInterval <= 0 { + config.HeartbeatInterval = DefaultHeartbeatInterval + } + if config.HandshakeTimeout <= 0 { + config.HandshakeTimeout = 10 * time.Second + } + return &Hub{ + authenticator: authenticator, store: store, handler: handler, config: config, + connections: make(map[string]*connection), capabilities: make(map[string]protocol.NodeCapabilities), + }, nil +} + +// SetMessageHandler installs the post-handshake protocol handler. It is safe +// to call during startup before accepting Control connections. +func (h *Hub) SetMessageHandler(handler MessageHandler) { + h.mu.Lock() + h.handler = handler + h.mu.Unlock() +} + +// ServeHTTP upgrades only /control requests and requires HELLO as message one. +func (h *Hub) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + remoteIP, remoteErr := remoteAddress(request.RemoteAddr) + socket, err := websocket.Accept(writer, request, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return + } + socket.SetReadLimit(maxControlMessage) + defer socket.Close(websocket.StatusNormalClosure, "Control connection closed") + if remoteErr != nil { + _ = socket.Close(websocket.StatusPolicyViolation, "invalid Overlay source") + return + } + + handshakeContext, cancel := context.WithTimeout(context.Background(), h.config.HandshakeTimeout) + var helloEnvelope protocol.ControlEnvelope + if err := wsjson.Read(handshakeContext, socket, &helloEnvelope); err != nil { + cancel() + _ = socket.Close(websocket.StatusPolicyViolation, "HELLO required") + return + } + cancel() + if helloEnvelope.Type != protocol.ControlHello { + _ = socket.Close(websocket.StatusPolicyViolation, "HELLO must be first") + return + } + var hello protocol.HelloPayload + if err := helloEnvelope.DecodePayload(&hello); err != nil { + _ = socket.Close(websocket.StatusPolicyViolation, "invalid HELLO") + return + } + node, err := h.authenticator.AuthenticateNode(context.Background(), hello.NodeID, hello.NodeToken) + if err != nil { + _ = socket.Close(websocket.StatusPolicyViolation, "Node authentication failed") + return + } + if h.config.EnforceRemoteIP && node.OverlayIP != remoteIP { + _ = socket.Close(websocket.StatusPolicyViolation, "Overlay source mismatch") + return + } + + connected := &connection{node: node, socket: socket} + previous := h.register(connected, hello.Capabilities) + if previous != nil { + _ = previous.socket.Close(websocket.StatusPolicyViolation, "replaced by newer Node connection") + } + defer h.unregister(node.ID, connected) + now := time.Now().UTC() + if err := h.store.UpdateNodeHeartbeat(context.Background(), node.ID, model.NodeOnline, now, hello.Version, hello.OSVersion); err != nil { + _ = socket.Close(websocket.StatusInternalError, "persist HELLO failed") + return + } + connected.node.Status = model.NodeOnline + connected.node.LastSeen = &now + connected.node.Version = hello.Version + connected.node.OSVersion = hello.OSVersion + h.recordEvent(context.Background(), node.ID, "INFO", "节点 Control 通道已连接", map[string]any{ + "overlay_ip": node.OverlayIP.String(), "version": hello.Version, "os_version": hello.OSVersion, + }) + h.mu.RLock() + networkConfigVersion := h.config.NetworkConfigVersion + h.mu.RUnlock() + if err := h.send(connected, protocol.ControlWelcome, helloEnvelope.RequestID, protocol.WelcomePayload{ + ServerTime: now, NetworkConfigVersion: networkConfigVersion, + }); err != nil { + return + } + if hello.ConfigVersion != networkConfigVersion { + _ = h.send(connected, protocol.ControlRebootstrapRequired, "", protocol.RebootstrapRequiredPayload{ + ConfigVersion: networkConfigVersion, Reason: "CONFIG_VERSION_MISMATCH", + }) + return + } + if node.Type == model.NodeTypeEngineer { + if err := h.sendNodeList(context.Background(), connected); err != nil { + return + } + } + if node.Type == model.NodeTypeSite { + h.broadcastNodeLists(context.Background()) + } + + for { + var envelope protocol.ControlEnvelope + if err := wsjson.Read(context.Background(), socket, &envelope); err != nil { + return + } + if envelope.Type == protocol.ControlHello || !envelope.Type.Valid() { + _ = socket.Close(websocket.StatusPolicyViolation, "invalid Control message type") + return + } + if envelope.Type == protocol.ControlHeartbeat { + if err := h.handleHeartbeat(connected, envelope, hello.Version, hello.OSVersion); err != nil { + return + } + continue + } + h.mu.RLock() + handler := h.handler + h.mu.RUnlock() + if handler == nil { + _ = socket.Close(websocket.StatusUnsupportedData, "message is not available in this phase") + return + } + if err := handler.HandleControl(context.Background(), connected.node, envelope); err != nil { + _ = socket.Close(websocket.StatusPolicyViolation, "Control message rejected") + return + } + } +} + +// SetNetworkConfigVersion updates WELCOME after a completed network migration. +func (h *Hub) SetNetworkConfigVersion(version uint64) error { + if version == 0 { + return errors.New("network config version must be positive") + } + h.mu.Lock() + h.config.NetworkConfigVersion = version + h.mu.Unlock() + return nil +} + +// ResetNodeConnection forces one Node to bootstrap/reconnect after an +// authoritative address or credential change. +func (h *Hub) ResetNodeConnection(nodeID, reason string) { + h.mu.RLock() + connected := h.connections[nodeID] + h.mu.RUnlock() + if connected != nil { + _ = connected.socket.Close(websocket.StatusGoingAway, reason) + } +} + +// ResetConnections closes all current sockets after migration notifications. +func (h *Hub) ResetConnections(reason string) { + h.mu.RLock() + connections := make([]*connection, 0, len(h.connections)) + for _, connected := range h.connections { + connections = append(connections, connected) + } + h.mu.RUnlock() + for _, connected := range connections { + _ = connected.socket.Close(websocket.StatusGoingAway, reason) + } +} + +// Run classifies persisted heartbeat age until ctx is canceled. +func (h *Hub) Run(ctx context.Context) error { + ticker := time.NewTicker(h.config.HeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + h.closeAll() + return ctx.Err() + case now := <-ticker.C: + if err := h.Sweep(ctx, now.UTC()); err != nil { + return err + } + } + } +} + +// Sweep applies ONLINE/UNSTABLE/OFFLINE thresholds and refreshes Engineer lists. +func (h *Hub) Sweep(ctx context.Context, now time.Time) error { + nodes, err := h.store.ListNodes(ctx) + if err != nil { + return err + } + changed := false + for _, node := range nodes { + status := statusAt(node.LastSeen, now) + if node.Status == status { + continue + } + if err := h.store.UpdateNodeStatus(ctx, node.ID, status); err != nil { + return err + } + node.Status = status + level := "WARN" + if status == model.NodeOffline { + level = "ERROR" + } + h.recordEvent(ctx, node.ID, level, "节点心跳状态变更为 "+localization.NodeStatus(string(status)), nil) + if status == model.NodeOffline { + h.mu.RLock() + handler := h.handler + h.mu.RUnlock() + if listener, ok := handler.(NodeStatusChangeHandler); ok { + if err := listener.HandleNodeStatusChange(ctx, node, status); err != nil { + return fmt.Errorf("handle Node %s status %s: %w", node.ID, status, err) + } + } + } + changed = true + } + if changed { + h.broadcastNodeLists(ctx) + } + return nil +} + +func (h *Hub) recordEvent(ctx context.Context, nodeID, level, message string, fields map[string]any) { + appender, ok := h.store.(eventAppender) + if !ok { + return + } + if fields == nil { + fields = map[string]any{} + } + raw, _ := json.Marshal(fields) + _ = appender.AppendEvent(ctx, model.EventLog{ + Level: level, Module: string(logging.ModuleControl), NodeID: nodeID, Message: message, FieldsJSON: raw, + }) +} + +// Send routes a typed Server message to one connected Node. +func (h *Hub) Send(ctx context.Context, nodeID string, messageType protocol.ControlMessageType, payload any) error { + return h.SendRequest(ctx, nodeID, messageType, "", payload) +} + +// SendRequest preserves a request ID while routing a typed Server message. +func (h *Hub) SendRequest(ctx context.Context, nodeID string, messageType protocol.ControlMessageType, requestID string, payload any) error { + h.mu.RLock() + connected := h.connections[nodeID] + h.mu.RUnlock() + if connected == nil { + return fmt.Errorf("Node %s has no Control connection", nodeID) + } + return h.sendContext(ctx, connected, messageType, requestID, payload) +} + +func (h *Hub) handleHeartbeat(connected *connection, envelope protocol.ControlEnvelope, version, osVersion string) error { + var heartbeat protocol.HeartbeatPayload + if err := envelope.DecodePayload(&heartbeat); err != nil { + return err + } + if heartbeat.Status == string(protocol.ErrorOverlayLocalConflict) { + h.recordEvent(context.Background(), connected.node.ID, "ERROR", "节点拒绝了 Overlay 网络配置", map[string]any{ + "error_code": string(protocol.ErrorOverlayLocalConflict), "reported_at": heartbeat.Timestamp.UTC(), + }) + } + now := time.Now().UTC() + if err := h.store.UpdateNodeHeartbeat(context.Background(), connected.node.ID, model.NodeOnline, now, version, osVersion); err != nil { + return err + } + connected.node.Status = model.NodeOnline + connected.node.LastSeen = &now + return h.send(connected, protocol.ControlHeartbeat, envelope.RequestID, + protocol.HeartbeatPayload{Timestamp: now, Status: string(model.NodeOnline)}) +} + +func (h *Hub) register(connected *connection, capabilities protocol.NodeCapabilities) *connection { + h.mu.Lock() + defer h.mu.Unlock() + previous := h.connections[connected.node.ID] + h.connections[connected.node.ID] = connected + h.capabilities[connected.node.ID] = capabilities + return previous +} + +func (h *Hub) unregister(nodeID string, expected *connection) { + h.mu.Lock() + if h.connections[nodeID] == expected { + delete(h.connections, nodeID) + } + h.mu.Unlock() +} + +func (h *Hub) send(connected *connection, messageType protocol.ControlMessageType, requestID string, payload any) error { + return h.sendContext(context.Background(), connected, messageType, requestID, payload) +} + +func (h *Hub) sendContext(ctx context.Context, connected *connection, messageType protocol.ControlMessageType, requestID string, payload any) error { + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, defaultSendTimeout) + defer cancel() + } + envelope, err := protocol.NewControlEnvelope(messageType, requestID, payload) + if err != nil { + return err + } + connected.sendMu.Lock() + defer connected.sendMu.Unlock() + return wsjson.Write(ctx, connected.socket, envelope) +} + +func (h *Hub) sendNodeList(ctx context.Context, connected *connection) error { + payload, err := h.nodeList(ctx) + if err != nil { + return err + } + return h.sendContext(ctx, connected, protocol.ControlNodeList, "", payload) +} + +func (h *Hub) broadcastNodeLists(ctx context.Context) { + h.mu.RLock() + engineers := make([]*connection, 0) + for _, connected := range h.connections { + if connected.node.Type == model.NodeTypeEngineer { + engineers = append(engineers, connected) + } + } + h.mu.RUnlock() + for _, engineer := range engineers { + _ = h.sendNodeList(ctx, engineer) + } +} + +func (h *Hub) nodeList(ctx context.Context) (protocol.NodeListPayload, error) { + nodes, err := h.store.ListNodes(ctx) + if err != nil { + return protocol.NodeListPayload{}, err + } + h.mu.RLock() + defer h.mu.RUnlock() + payload := protocol.NodeListPayload{Sites: make([]protocol.SiteSummary, 0)} + for _, node := range nodes { + if node.Type != model.NodeTypeSite { + continue + } + site := protocol.SiteSummary{ + NodeID: node.ID, Name: node.Name, OverlayIP: node.OverlayIP.String(), + Online: node.Status == model.NodeOnline, + RemoteSubnetCapability: h.capabilities[node.ID].RemoteSubnet, + } + if node.LastSeen != nil { + site.LastSeen = *node.LastSeen + } + payload.Sites = append(payload.Sites, site) + } + return payload, nil +} + +func (h *Hub) closeAll() { + h.mu.Lock() + connections := make([]*connection, 0, len(h.connections)) + for _, connected := range h.connections { + connections = append(connections, connected) + } + h.connections = make(map[string]*connection) + h.mu.Unlock() + for _, connected := range connections { + _ = connected.socket.Close(websocket.StatusGoingAway, "Server stopping") + } +} + +func statusAt(lastSeen *time.Time, now time.Time) model.NodeStatus { + if lastSeen == nil { + return model.NodeOffline + } + age := now.Sub(*lastSeen) + if age <= OnlineThreshold { + return model.NodeOnline + } + if age <= UnstableThreshold { + return model.NodeUnstable + } + return model.NodeOffline +} + +func remoteAddress(remote string) (netip.Addr, error) { + host, _, err := net.SplitHostPort(remote) + if err != nil { + return netip.Addr{}, err + } + return netip.ParseAddr(host) +} diff --git a/internal/control/hub_test.go b/internal/control/hub_test.go new file mode 100644 index 0000000..eb9abde --- /dev/null +++ b/internal/control/hub_test.go @@ -0,0 +1,373 @@ +package control + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "remlink/internal/model" + "remlink/internal/protocol" +) + +type memoryNodes struct { + mu sync.Mutex + nodes map[string]model.Node + tokens map[string]string + events []model.EventLog +} + +type statusChangeRecorder struct { + mu sync.Mutex + changes []struct { + node model.Node + status model.NodeStatus + } +} + +func (*statusChangeRecorder) HandleControl(context.Context, model.Node, protocol.ControlEnvelope) error { + return nil +} + +func (r *statusChangeRecorder) HandleNodeStatusChange(_ context.Context, node model.Node, status model.NodeStatus) error { + r.mu.Lock() + r.changes = append(r.changes, struct { + node model.Node + status model.NodeStatus + }{node: node, status: status}) + r.mu.Unlock() + return nil +} + +func (m *memoryNodes) AppendEvent(_ context.Context, event model.EventLog) error { + m.mu.Lock() + m.events = append(m.events, event) + m.mu.Unlock() + return nil +} + +func (m *memoryNodes) AuthenticateNode(_ context.Context, nodeID, token string) (model.Node, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.tokens[nodeID] != token { + return model.Node{}, errors.New("authentication failed") + } + return m.nodes[nodeID], nil +} + +func (m *memoryNodes) ListNodes(context.Context) ([]model.Node, error) { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]model.Node, 0, len(m.nodes)) + for _, node := range m.nodes { + result = append(result, node) + } + return result, nil +} + +func (m *memoryNodes) UpdateNodeHeartbeat(_ context.Context, nodeID string, status model.NodeStatus, at time.Time, version, osVersion string) error { + m.mu.Lock() + defer m.mu.Unlock() + node := m.nodes[nodeID] + node.Status = status + node.LastSeen = &at + node.Version = version + node.OSVersion = osVersion + m.nodes[nodeID] = node + return nil +} + +func (m *memoryNodes) UpdateNodeStatus(_ context.Context, nodeID string, status model.NodeStatus) error { + m.mu.Lock() + defer m.mu.Unlock() + node := m.nodes[nodeID] + node.Status = status + m.nodes[nodeID] = node + return nil +} + +func TestHubHandshakeHeartbeatAndNodeList(t *testing.T) { + store := testNodes() + hub, err := NewHub(store, store, nil, HubConfig{NetworkConfigVersion: 3, HandshakeTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(hub) + defer server.Close() + controlURL := "ws" + strings.TrimPrefix(server.URL, "http") + + siteSocket := connectNode(t, controlURL, protocol.HelloPayload{ + NodeID: "site", NodeToken: "site-token", ConfigVersion: 3, + Capabilities: protocol.NodeCapabilities{RemoteSubnet: true, NetstackStatus: "READY", TCPCapacity: 2048, UDPCapacity: 4096}, + }) + defer siteSocket.Close(websocket.StatusNormalClosure, "test done") + engineerSocket := connectNode(t, controlURL, protocol.HelloPayload{ + NodeID: "engineer", NodeToken: "engineer-token", ConfigVersion: 3, + }) + defer engineerSocket.Close(websocket.StatusNormalClosure, "test done") + + var nodeListEnvelope protocol.ControlEnvelope + if err := wsjson.Read(context.Background(), engineerSocket, &nodeListEnvelope); err != nil { + t.Fatal(err) + } + if nodeListEnvelope.Type != protocol.ControlNodeList { + t.Fatalf("message type = %s, want NODE_LIST", nodeListEnvelope.Type) + } + var nodeList protocol.NodeListPayload + if err := nodeListEnvelope.DecodePayload(&nodeList); err != nil { + t.Fatal(err) + } + if len(nodeList.Sites) != 1 || !nodeList.Sites[0].Online || !nodeList.Sites[0].RemoteSubnetCapability { + t.Fatalf("unexpected Node list: %+v", nodeList) + } + store.mu.Lock() + eventCount := len(store.events) + store.mu.Unlock() + if eventCount < 2 { + t.Fatalf("Control connection events = %d, want at least 2", eventCount) + } + + heartbeat, _ := protocol.NewControlEnvelope(protocol.ControlHeartbeat, "hb-1", protocol.HeartbeatPayload{ + Timestamp: time.Now().UTC(), Status: "OK", + }) + if err := wsjson.Write(context.Background(), engineerSocket, heartbeat); err != nil { + t.Fatal(err) + } + var heartbeatReply protocol.ControlEnvelope + if err := wsjson.Read(context.Background(), engineerSocket, &heartbeatReply); err != nil { + t.Fatal(err) + } + if heartbeatReply.Type != protocol.ControlHeartbeat || heartbeatReply.RequestID != "hb-1" { + t.Fatalf("heartbeat reply = %+v", heartbeatReply) + } + + conflict, _ := protocol.NewControlEnvelope(protocol.ControlHeartbeat, "overlay-conflict-1", protocol.HeartbeatPayload{ + Timestamp: time.Now().UTC(), Status: string(protocol.ErrorOverlayLocalConflict), + }) + if err := wsjson.Write(context.Background(), engineerSocket, conflict); err != nil { + t.Fatal(err) + } + if err := wsjson.Read(context.Background(), engineerSocket, &heartbeatReply); err != nil { + t.Fatal(err) + } + store.mu.Lock() + var conflictEvent *model.EventLog + for index := range store.events { + if store.events[index].Message == "节点拒绝了 Overlay 网络配置" { + value := store.events[index] + conflictEvent = &value + } + } + store.mu.Unlock() + if conflictEvent == nil || conflictEvent.Level != "ERROR" || conflictEvent.NodeID != "engineer" { + t.Fatalf("Overlay conflict event = %+v", conflictEvent) + } + var fields map[string]any + if err := json.Unmarshal(conflictEvent.FieldsJSON, &fields); err != nil || fields["error_code"] != string(protocol.ErrorOverlayLocalConflict) { + t.Fatalf("Overlay conflict fields = %s, error=%v", conflictEvent.FieldsJSON, err) + } +} + +func TestHeartbeatThresholds(t *testing.T) { + now := time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + age time.Duration + want model.NodeStatus + }{ + {15 * time.Second, model.NodeOnline}, + {15*time.Second + time.Nanosecond, model.NodeUnstable}, + {30 * time.Second, model.NodeUnstable}, + {30*time.Second + time.Nanosecond, model.NodeOffline}, + } { + lastSeen := now.Add(-test.age) + if got := statusAt(&lastSeen, now); got != test.want { + t.Errorf("status at age %s = %s, want %s", test.age, got, test.want) + } + } + if got := statusAt(nil, now); got != model.NodeOffline { + t.Fatalf("nil last seen status = %s", got) + } +} + +func TestSweepNotifiesHandlerWhenSiteBecomesOffline(t *testing.T) { + store := testNodes() + recorder := &statusChangeRecorder{} + hub, err := NewHub(store, store, recorder, HubConfig{NetworkConfigVersion: 1}) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + if err := store.UpdateNodeHeartbeat(context.Background(), "site", model.NodeOnline, now.Add(-31*time.Second), "1.0", "test"); err != nil { + t.Fatal(err) + } + if err := hub.Sweep(context.Background(), now); err != nil { + t.Fatal(err) + } + recorder.mu.Lock() + defer recorder.mu.Unlock() + if len(recorder.changes) != 1 || recorder.changes[0].node.ID != "site" || recorder.changes[0].status != model.NodeOffline { + t.Fatalf("OFFLINE callbacks = %+v", recorder.changes) + } +} + +func TestHubRequiresRebootstrapOnConfigVersionMismatch(t *testing.T) { + store := testNodes() + hub, err := NewHub(store, store, nil, HubConfig{NetworkConfigVersion: 2}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(hub) + defer server.Close() + socket := connectNode(t, "ws"+strings.TrimPrefix(server.URL, "http"), protocol.HelloPayload{ + NodeID: "engineer", NodeToken: "engineer-token", ConfigVersion: 1, + }) + defer socket.Close(websocket.StatusNormalClosure, "test done") + var envelope protocol.ControlEnvelope + if err := wsjson.Read(context.Background(), socket, &envelope); err != nil { + t.Fatal(err) + } + if envelope.Type != protocol.ControlRebootstrapRequired { + t.Fatalf("message type = %s, want REBOOTSTRAP_REQUIRED", envelope.Type) + } + var payload protocol.RebootstrapRequiredPayload + if err := envelope.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + if payload.ConfigVersion != 2 || payload.Reason != "CONFIG_VERSION_MISMATCH" { + t.Fatalf("rebootstrap payload = %+v", payload) + } +} + +func TestClientCompletesHandshakeAndReceivesNodeList(t *testing.T) { + store := testNodes() + hub, err := NewHub(store, store, nil, HubConfig{NetworkConfigVersion: 1}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(hub) + defer server.Close() + received := make(chan protocol.ControlMessageType, 1) + client, err := NewClient(ClientConfig{ + URL: "ws" + strings.TrimPrefix(server.URL, "http"), + Hello: protocol.HelloPayload{NodeID: "engineer", NodeToken: "engineer-token", ConfigVersion: 1}, + HeartbeatInterval: 20 * time.Millisecond, + }, func(_ context.Context, envelope protocol.ControlEnvelope) error { + received <- envelope.Type + return nil + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := client.runOnce(ctx) + done <- err + }() + select { + case messageType := <-received: + if messageType != protocol.ControlNodeList { + t.Fatalf("received %s, want NODE_LIST", messageType) + } + cancel() + case <-time.After(2 * time.Second): + cancel() + t.Fatal("timed out waiting for Node list") + } + <-done +} + +func TestClientReportsHeartbeatRTT(t *testing.T) { + store := testNodes() + hub, err := NewHub(store, store, nil, HubConfig{NetworkConfigVersion: 1}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(hub) + defer server.Close() + rtt := make(chan time.Duration, 1) + client, err := NewClient(ClientConfig{ + URL: "ws" + strings.TrimPrefix(server.URL, "http"), + Hello: protocol.HelloPayload{NodeID: "engineer", NodeToken: "engineer-token", ConfigVersion: 1}, + HeartbeatInterval: 10 * time.Millisecond, + OnHeartbeatRTT: func(delay time.Duration) { rtt <- delay }, + }, nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := client.runOnce(ctx) + done <- err + }() + select { + case delay := <-rtt: + if delay < 0 || delay > time.Second { + t.Fatalf("unexpected heartbeat RTT: %s", delay) + } + cancel() + case <-time.After(2 * time.Second): + cancel() + t.Fatal("timed out waiting for heartbeat RTT") + } + <-done +} + +func TestClientRefreshesBootstrapAfterContinuousDisconnect(t *testing.T) { + client, err := NewClient(ClientConfig{ + URL: "ws://127.0.0.1:1", + Hello: protocol.HelloPayload{NodeID: "engineer", NodeToken: "token"}, + BootstrapRefreshAfter: 25 * time.Millisecond, + }, nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + err = client.Run(ctx) + if !errors.Is(err, protocol.ErrRebootstrapRequired) { + t.Fatalf("Run error = %v, want ErrRebootstrapRequired", err) + } +} + +func connectNode(t *testing.T, controlURL string, hello protocol.HelloPayload) *websocket.Conn { + t.Helper() + socket, _, err := websocket.Dial(context.Background(), controlURL, nil) + if err != nil { + t.Fatal(err) + } + envelope, err := protocol.NewControlEnvelope(protocol.ControlHello, "hello", hello) + if err != nil { + t.Fatal(err) + } + if err := wsjson.Write(context.Background(), socket, envelope); err != nil { + t.Fatal(err) + } + var welcomeEnvelope protocol.ControlEnvelope + if err := wsjson.Read(context.Background(), socket, &welcomeEnvelope); err != nil { + t.Fatal(err) + } + if welcomeEnvelope.Type != protocol.ControlWelcome { + t.Fatalf("first message = %s, want WELCOME", welcomeEnvelope.Type) + } + return socket +} + +func testNodes() *memoryNodes { + return &memoryNodes{ + nodes: map[string]model.Node{ + "engineer": {ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer", OverlayIP: netip.MustParseAddr("10.88.0.2"), Status: model.NodeOffline}, + "site": {ID: "site", Type: model.NodeTypeSite, Name: "Site", OverlayIP: netip.MustParseAddr("10.88.0.3"), Status: model.NodeOffline}, + }, + tokens: map[string]string{"engineer": "engineer-token", "site": "site-token"}, + } +} diff --git a/internal/control/supervisor.go b/internal/control/supervisor.go new file mode 100644 index 0000000..ca707fc --- /dev/null +++ b/internal/control/supervisor.go @@ -0,0 +1,134 @@ +package control + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strconv" + "sync" + "time" +) + +// Supervisor supports the required live Control-listener move during an +// Overlay network migration. +type Supervisor struct { + mu sync.Mutex + handler http.Handler + port int + ctx context.Context + server *http.Server + listener net.Listener + errors chan error + closed bool +} + +func NewSupervisor(handler http.Handler, port int) (*Supervisor, error) { + if handler == nil || port < 1 || port > 65535 { + return nil, errors.New("Control Supervisor requires Handler and valid port") + } + return &Supervisor{handler: handler, port: port, errors: make(chan error, 1)}, nil +} + +func (s *Supervisor) Start(ctx context.Context, address netip.Addr) error { + if !address.Is4() { + return errors.New("Control listener address must be IPv4") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.server != nil { + return errors.New("Control Supervisor already started") + } + s.ctx = ctx + server, listener, err := s.open(address) + if err != nil { + return err + } + s.server, s.listener = server, listener + s.serve(server, listener) + return nil +} + +func (s *Supervisor) Rebind(address netip.Addr) error { + if !address.Is4() { + return errors.New("Control listener address must be IPv4") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || s.server == nil { + return errors.New("Control Supervisor is not running") + } + newServer, newListener, err := s.open(address) + if err != nil { + return err + } + oldServer := s.server + oldListener := s.listener + s.server, s.listener = newServer, newListener + s.serve(newServer, newListener) + // From this point the rebind is committed and callers may safely switch the + // rest of the Overlay. An old-server close failure must not be reported as + // if the new listener were absent; that would cause the caller to roll back + // wg0 while this Supervisor remained bound to the new address. + _ = oldServer.Close() + _ = oldListener.Close() + return nil +} + +func (s *Supervisor) Wait(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-s.errors: + return err + } +} + +func (s *Supervisor) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + server := s.server + s.mu.Unlock() + if server == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + _ = server.Close() + return err + } + return nil +} + +func (s *Supervisor) open(address netip.Addr) (*http.Server, net.Listener, error) { + listenAddress := net.JoinHostPort(address.String(), strconv.Itoa(s.port)) + listener, err := net.Listen("tcp4", listenAddress) + if err != nil { + return nil, nil, fmt.Errorf("listen for Overlay Control on %s: %w", listenAddress, err) + } + server := &http.Server{ + Addr: listenAddress, Handler: s.handler, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 0, WriteTimeout: 0, IdleTimeout: 0, MaxHeaderBytes: 1 << 20, + } + return server, listener, nil +} + +func (s *Supervisor) serve(server *http.Server, listener net.Listener) { + go func() { + err := server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) { + return + } + select { + case s.errors <- err: + default: + } + }() +} diff --git a/internal/control/supervisor_test.go b/internal/control/supervisor_test.go new file mode 100644 index 0000000..b39fc93 --- /dev/null +++ b/internal/control/supervisor_test.go @@ -0,0 +1,79 @@ +package control + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "strconv" + "testing" + "time" +) + +func TestSupervisorRebindMovesListenerAndPreservesFailedRebind(t *testing.T) { + oldAddress := netip.MustParseAddr("127.0.0.1") + newAddress := netip.MustParseAddr("127.0.0.2") + port := availablePort(t, oldAddress) + handler := http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(writer, "ok") }) + supervisor, err := NewSupervisor(handler, port) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := supervisor.Start(ctx, oldAddress); err != nil { + t.Fatal(err) + } + defer supervisor.Close() + client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second} + assertHTTPBody(t, client, oldAddress, port, "ok") + + occupied, err := net.Listen("tcp4", net.JoinHostPort(newAddress.String(), strconv.Itoa(port))) + if err != nil { + t.Fatal(err) + } + if err := supervisor.Rebind(newAddress); err == nil { + occupied.Close() + t.Fatal("Rebind succeeded while the target address was occupied") + } + assertHTTPBody(t, client, oldAddress, port, "ok") + if err := occupied.Close(); err != nil { + t.Fatal(err) + } + + if err := supervisor.Rebind(newAddress); err != nil { + t.Fatal(err) + } + assertHTTPBody(t, client, newAddress, port, "ok") +} + +func availablePort(t *testing.T, address netip.Addr) int { + t.Helper() + listener, err := net.Listen("tcp4", net.JoinHostPort(address.String(), "0")) + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return port +} + +func assertHTTPBody(t *testing.T, client *http.Client, address netip.Addr, port int, want string) { + t.Helper() + response, err := client.Get(fmt.Sprintf("http://%s/", net.JoinHostPort(address.String(), strconv.Itoa(port)))) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != want { + t.Fatalf("body=%q, want %q", body, want) + } +} diff --git a/internal/database/.gitkeep b/internal/database/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/database/.gitkeep @@ -0,0 +1 @@ + diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..0d6d885 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,288 @@ +package database + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "math" + "net/netip" + "path/filepath" + "testing" + "time" + + "remlink/internal/model" +) + +func TestEmptyListRepositoriesEncodeAsArrays(t *testing.T) { + db := openTestDB(t) + store := NewStore(db) + ctx := context.Background() + + nodes, err := store.ListNodes(ctx) + if err != nil { + t.Fatal(err) + } + sessions, err := store.ListSessions(ctx) + if err != nil { + t.Fatal(err) + } + events, err := store.ListEvents(ctx, model.EventLogFilter{}) + if err != nil { + t.Fatal(err) + } + for name, value := range map[string]any{"nodes": nodes, "sessions": sessions, "events": events} { + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if string(encoded) != "[]" { + t.Errorf("empty %s encoded as %s, want []", name, encoded) + } + } +} + +func TestOpenMigratesAndReopens(t *testing.T) { + path := filepath.Join(t.TempDir(), "remlink.db") + ctx := context.Background() + db, err := Open(ctx, path) + if err != nil { + t.Fatal(err) + } + + wantTables := []string{"settings", "nodes", "sessions", "session_cidrs", "session_stats", "event_logs"} + for _, table := range wantTables { + var count int + if err := db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("table %s count = %d, want 1", table, count) + } + } + var migrationCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`).Scan(&migrationCount); err != nil { + t.Fatal(err) + } + if migrationCount != 1 { + t.Fatalf("migration count = %d, want 1", migrationCount) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + db, err = Open(ctx, path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`).Scan(&migrationCount); err != nil { + t.Fatal(err) + } + if migrationCount != 1 { + t.Fatalf("migration count after reopen = %d, want 1", migrationCount) + } +} + +func TestSettingsRoundTrip(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + if value, found, err := GetSetting(ctx, db, "overlay_cidr"); err != nil || found || value != "" { + t.Fatalf("missing GetSetting = %q, %v, %v", value, found, err) + } + if err := SetSetting(ctx, db, "overlay_cidr", "10.88.0.0/16"); err != nil { + t.Fatal(err) + } + if err := SetSetting(ctx, db, "overlay_cidr", "10.99.0.0/16"); err != nil { + t.Fatal(err) + } + value, found, err := GetSetting(ctx, db, "overlay_cidr") + if err != nil || !found || value != "10.99.0.0/16" { + t.Fatalf("GetSetting = %q, %v, %v", value, found, err) + } +} + +func TestNodeRepository(t *testing.T) { + db := openTestDB(t) + store := NewStore(db) + ctx := context.Background() + node := model.Node{ + ID: "96e5d037-2358-4ff6-b706-1fbf56d196bc", + Type: model.NodeTypeEngineer, + Name: "Engineer-A", + OverlayIP: netip.MustParseAddr("10.88.0.2"), + WGPublicKey: "public-key-a", + NodeTokenHash: []byte("token-hash-a"), + } + if err := store.CreateNode(ctx, node); err != nil { + t.Fatal(err) + } + got, err := store.GetNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != node.ID || got.OverlayIP != node.OverlayIP || got.Status != model.NodeOffline { + t.Fatalf("unexpected node: %+v", got) + } + + node.Name = "Engineer-Renamed" + node.NodeTokenHash = []byte("token-hash-b") + node.Version = "1.0.0" + if err := store.UpdateNodeRegistration(ctx, node); err != nil { + t.Fatal(err) + } + got, err = store.GetNode(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if got.Name != node.Name || got.Version != "1.0.0" || string(got.NodeTokenHash) != "token-hash-b" { + t.Fatalf("registration update not persisted: %+v", got) + } + + if err := store.UpdateNodeOverlayIP(ctx, node.ID, netip.MustParseAddr("10.88.0.50")); err != nil { + t.Fatal(err) + } + addresses, err := store.ListOverlayIPs(ctx) + if err != nil { + t.Fatal(err) + } + if len(addresses) != 1 || addresses[0].String() != "10.88.0.50" { + t.Fatalf("overlay addresses = %v", addresses) + } + if err := store.DeleteNode(ctx, node.ID); err != nil { + t.Fatal(err) + } + if _, err := store.GetNode(ctx, node.ID); !errors.Is(err, ErrNodeNotFound) { + t.Fatalf("GetNode after delete error = %v", err) + } +} + +func TestOverlayMigrationCommitsNodeIPsAndNetworkSettingAtomically(t *testing.T) { + db := openTestDB(t) + store := NewStore(db) + ctx := context.Background() + node := model.Node{ + ID: "engineer", Type: model.NodeTypeEngineer, Name: "Engineer", + OverlayIP: netip.MustParseAddr("10.88.0.2"), WGPublicKey: "engineer-key", NodeTokenHash: []byte("hash"), + } + if err := store.CreateNode(ctx, node); err != nil { + t.Fatal(err) + } + if err := SetSetting(ctx, db, "admin.network", "old"); err != nil { + t.Fatal(err) + } + if err := store.ReplaceNodeOverlayIPsAndSetting(ctx, + map[string]netip.Addr{"engineer": netip.MustParseAddr("10.99.0.2")}, "admin.network", "new"); err != nil { + t.Fatal(err) + } + migrated, _ := store.GetNode(ctx, "engineer") + setting, found, err := GetSetting(ctx, db, "admin.network") + if err != nil || !found || migrated.OverlayIP.String() != "10.99.0.2" || setting != "new" { + t.Fatalf("committed migration node=%s setting=%q found=%v error=%v", migrated.OverlayIP, setting, found, err) + } + + err = store.ReplaceNodeOverlayIPsAndSetting(ctx, map[string]netip.Addr{ + "engineer": netip.MustParseAddr("10.77.0.2"), + "missing": netip.MustParseAddr("10.77.0.3"), + }, "admin.network", "partial") + if err == nil { + t.Fatal("migration with a missing Node unexpectedly succeeded") + } + afterFailure, _ := store.GetNode(ctx, "engineer") + setting, _, _ = GetSetting(ctx, db, "admin.network") + if afterFailure.OverlayIP.String() != "10.99.0.2" || setting != "new" { + t.Fatalf("failed migration was not atomic: node=%s setting=%q", afterFailure.OverlayIP, setting) + } +} + +func TestForeignKeysEnabled(t *testing.T) { + db := openTestDB(t) + _, err := db.Exec(`INSERT INTO sessions(session_id, engineer_node_id, site_node_id, status, created_at) + VALUES('1', 'missing-a', 'missing-b', 'CREATING', '2026-08-25T00:00:00Z')`) + if err == nil { + t.Fatal("session with missing node references was accepted") + } +} + +func TestEventRepositoryFiltersByTimeAndNormalizesModule(t *testing.T) { + store := NewStore(openTestDB(t)) + ctx := context.Background() + base := time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC) + for index, eventTime := range []time.Time{base.Add(-time.Hour), base, base.Add(time.Hour)} { + if err := store.AppendEvent(ctx, model.EventLog{Time: eventTime, Level: "info", Module: "session", Message: fmt.Sprintf("event-%d", index)}); err != nil { + t.Fatal(err) + } + } + events, err := store.ListEvents(ctx, model.EventLogFilter{Module: "SESSION", From: base.Add(-time.Minute), To: base.Add(time.Minute)}) + if err != nil || len(events) != 1 || events[0].Message != "event-1" || events[0].Module != "SESSION" { + t.Fatalf("events = %+v, %v", events, err) + } + if err := store.AppendEvent(ctx, model.EventLog{Level: "INFO", Module: "CUSTOM", Message: "outside taxonomy"}); err == nil { + t.Fatal("AppendEvent accepted a twelfth logging module") + } +} + +func TestSessionRepositoryLifecycleAndStartupClose(t *testing.T) { + db := openTestDB(t) + store := NewStore(db) + ctx := context.Background() + createSessionTestNodes(t, store) + session := model.Session{ + ID: 18446744073709551614, EngineerNodeID: "engineer", SiteNodeID: "site", + Status: model.SessionCreating, CIDRs: []netip.Prefix{ + netip.MustParsePrefix("192.168.13.0/24"), netip.MustParsePrefix("172.20.0.0/16"), + }, + } + if err := store.CreateSession(ctx, session); err != nil { + t.Fatal(err) + } + if err := store.UpdateSessionStatus(ctx, session.ID, model.SessionActive, ""); err != nil { + t.Fatal(err) + } + counters := model.SessionCounters{UploadBytes: 100, DownloadBytes: 200, UploadPackets: 3, DownloadPackets: 4} + if err := store.UpdateSessionStats(ctx, session.ID, counters); err != nil { + t.Fatal(err) + } + got, err := store.GetSession(ctx, session.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != model.SessionActive || got.ActiveAt == nil || got.Counters != counters || len(got.CIDRs) != 2 { + t.Fatalf("unexpected persisted Session: %+v", got) + } + if err := store.UpdateSessionStats(ctx, session.ID, model.SessionCounters{UploadBytes: math.MaxUint64}); err == nil { + t.Fatal("SQLite-unsafe counter was accepted") + } + closed, err := store.CloseOpenSessions(ctx) + if err != nil || closed != 1 { + t.Fatalf("CloseOpenSessions = %d, %v", closed, err) + } + got, err = store.GetSession(ctx, session.ID) + if err != nil || got.Status != model.SessionClosed || got.ClosedAt == nil { + t.Fatalf("Session after startup close = %+v, %v", got, err) + } +} + +func createSessionTestNodes(t *testing.T, store *Store) { + t.Helper() + 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(context.Background(), node); err != nil { + t.Fatal(err) + } + } +} + +func openTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..317584c --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,53 @@ +// Package database owns SQLite setup, migrations, and persistence repositories. +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" +) + +// Open creates the parent directory, opens SQLite, enables required pragmas, +// runs embedded migrations, and verifies connectivity. +func Open(ctx context.Context, path string) (*sql.DB, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("database path must not be empty") + } + if path != ":memory:" { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + } + + dsn := path + if path != ":memory:" { + dsn = "file:" + filepath.ToSlash(path) + } + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + dsn += separator + "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open SQLite: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("ping SQLite: %w", err) + } + if err := Migrate(ctx, db); err != nil { + db.Close() + return nil, err + } + return db, nil +} diff --git a/internal/database/events.go b/internal/database/events.go new file mode 100644 index 0000000..a3c3ee4 --- /dev/null +++ b/internal/database/events.go @@ -0,0 +1,107 @@ +package database + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "remlink/internal/logging" + "remlink/internal/model" +) + +func (s *Store) AppendEvent(ctx context.Context, event model.EventLog) error { + if event.Time.IsZero() { + event.Time = time.Now().UTC() + } + if event.Level == "" || event.Module == "" || event.Message == "" { + return errors.New("event level, module, and message are required") + } + module := logging.Module(strings.ToUpper(event.Module)) + if !module.Valid() { + return fmt.Errorf("event module %q is outside the RemLink taxonomy", event.Module) + } + fields := event.FieldsJSON + if len(fields) == 0 { + fields = json.RawMessage(`{}`) + } + if !json.Valid(fields) { + return errors.New("event fields must be valid JSON") + } + var sessionID any + if event.SessionID != 0 { + sessionID = strconv.FormatUint(event.SessionID, 10) + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO event_logs(time, level, module, node_id, session_id, message, fields_json) + VALUES(?, ?, ?, NULLIF(?, ''), ?, ?, ?) + `, formatTime(event.Time), strings.ToUpper(event.Level), string(module), event.NodeID, sessionID, event.Message, string(fields)) + return err +} + +func (s *Store) ListEvents(ctx context.Context, filter model.EventLogFilter) ([]model.EventLog, error) { + if filter.Limit <= 0 || filter.Limit > 1000 { + filter.Limit = 200 + } + query := `SELECT id, time, level, module, node_id, session_id, message, fields_json FROM event_logs WHERE 1=1` + var arguments []any + if filter.Level != "" { + query += ` AND level = ?` + arguments = append(arguments, strings.ToUpper(filter.Level)) + } + if filter.Module != "" { + query += ` AND module = ?` + arguments = append(arguments, strings.ToUpper(filter.Module)) + } + if filter.NodeID != "" { + query += ` AND node_id = ?` + arguments = append(arguments, filter.NodeID) + } + if filter.SessionID != 0 { + query += ` AND session_id = ?` + arguments = append(arguments, strconv.FormatUint(filter.SessionID, 10)) + } + if !filter.From.IsZero() { + query += ` AND time >= ?` + arguments = append(arguments, formatTime(filter.From)) + } + if !filter.To.IsZero() { + query += ` AND time <= ?` + arguments = append(arguments, formatTime(filter.To)) + } + query += ` ORDER BY time DESC, id DESC LIMIT ?` + arguments = append(arguments, filter.Limit) + rows, err := s.db.QueryContext(ctx, query, arguments...) + if err != nil { + return nil, err + } + defer rows.Close() + events := make([]model.EventLog, 0) + for rows.Next() { + var event model.EventLog + var rawTime, fields string + var nodeID, sessionID sql.NullString + if err := rows.Scan(&event.ID, &rawTime, &event.Level, &event.Module, &nodeID, &sessionID, &event.Message, &fields); err != nil { + return nil, err + } + parsed, err := parseTime(rawTime) + if err != nil { + return nil, err + } + event.Time = parsed + event.NodeID = nodeID.String + if sessionID.Valid { + event.SessionID, err = strconv.ParseUint(sessionID.String, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse event SessionID: %w", err) + } + } + event.FieldsJSON = json.RawMessage(fields) + events = append(events, event) + } + return events, rows.Err() +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..4c4d360 --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,95 @@ +package database + +import ( + "context" + "database/sql" + "embed" + "fmt" + "io/fs" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// Migrate applies each embedded numbered migration exactly once. +func Migrate(ctx context.Context, db *sql.DB) error { + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + ) + `); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := fs.ReadDir(migrationFiles, "migrations") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + versionText, _, ok := strings.Cut(entry.Name(), "_") + if !ok { + return fmt.Errorf("migration %q lacks numeric prefix", entry.Name()) + } + version, err := strconv.Atoi(versionText) + if err != nil { + return fmt.Errorf("migration %q has invalid version: %w", entry.Name(), err) + } + applied, err := migrationApplied(ctx, db, version) + if err != nil { + return err + } + if applied { + continue + } + body, err := fs.ReadFile(migrationFiles, "migrations/"+entry.Name()) + if err != nil { + return fmt.Errorf("read migration %q: %w", entry.Name(), err) + } + if err := applyMigration(ctx, db, version, entry.Name(), string(body)); err != nil { + return err + } + } + return nil +} + +func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) { + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version).Scan(&count); err != nil { + return false, fmt.Errorf("query migration %d: %w", version, err) + } + return count != 0, nil +} + +func applyMigration(ctx context.Context, db *sql.DB, version int, name string, body string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %q: %w", name, err) + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, body); err != nil { + return fmt.Errorf("apply migration %q: %w", name, err) + } + if _, err := tx.ExecContext( + ctx, + `INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, ?)`, + version, + name, + time.Now().UTC().Format(time.RFC3339Nano), + ); err != nil { + return fmt.Errorf("record migration %q: %w", name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %q: %w", name, err) + } + return nil +} diff --git a/internal/database/migrations/0001_initial.sql b/internal/database/migrations/0001_initial.sql new file mode 100644 index 0000000..7583194 --- /dev/null +++ b/internal/database/migrations/0001_initial.sql @@ -0,0 +1,67 @@ +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE nodes ( + node_id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('engineer', 'site')), + name TEXT NOT NULL, + overlay_ip TEXT NOT NULL UNIQUE, + wg_public_key TEXT NOT NULL UNIQUE, + node_token_hash BLOB NOT NULL, + status TEXT NOT NULL DEFAULT 'OFFLINE', + version TEXT NOT NULL DEFAULT '', + os_version TEXT NOT NULL DEFAULT '', + last_seen TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_nodes_type_status ON nodes(type, status); + +CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + engineer_node_id TEXT NOT NULL REFERENCES nodes(node_id) ON DELETE CASCADE, + site_node_id TEXT NOT NULL REFERENCES nodes(node_id) ON DELETE CASCADE, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + active_at TEXT, + closed_at TEXT, + error_code TEXT +); + +CREATE INDEX idx_sessions_engineer_status ON sessions(engineer_node_id, status); +CREATE INDEX idx_sessions_site_status ON sessions(site_node_id, status); + +CREATE TABLE session_cidrs ( + session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, + cidr TEXT NOT NULL, + PRIMARY KEY (session_id, cidr) +); + +CREATE TABLE session_stats ( + session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE, + tx_bytes INTEGER NOT NULL DEFAULT 0 CHECK (tx_bytes >= 0), + rx_bytes INTEGER NOT NULL DEFAULT 0 CHECK (rx_bytes >= 0), + tx_packets INTEGER NOT NULL DEFAULT 0 CHECK (tx_packets >= 0), + rx_packets INTEGER NOT NULL DEFAULT 0 CHECK (rx_packets >= 0), + updated_at TEXT NOT NULL +); + +CREATE TABLE event_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time TEXT NOT NULL, + level TEXT NOT NULL, + module TEXT NOT NULL, + node_id TEXT, + session_id TEXT, + message TEXT NOT NULL, + fields_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_event_logs_time ON event_logs(time DESC); +CREATE INDEX idx_event_logs_node ON event_logs(node_id, time DESC); +CREATE INDEX idx_event_logs_session ON event_logs(session_id, time DESC); + diff --git a/internal/database/nodes.go b/internal/database/nodes.go new file mode 100644 index 0000000..bc422ae --- /dev/null +++ b/internal/database/nodes.go @@ -0,0 +1,364 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/netip" + "time" + + "remlink/internal/model" +) + +// ErrNodeNotFound is returned when a requested node does not exist. +var ErrNodeNotFound = errors.New("node not found") + +// Store groups repositories backed by a migrated RemLink database. +type Store struct { + db *sql.DB +} + +// NewStore creates repositories over db. Open must have migrated db first. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// DB exposes the underlying connection for transaction-oriented services. +func (s *Store) DB() *sql.DB { return s.db } + +// CreateNode persists a newly registered node. +func (s *Store) CreateNode(ctx context.Context, node model.Node) error { + if err := validateNode(node); err != nil { + return err + } + now := time.Now().UTC() + if node.CreatedAt.IsZero() { + node.CreatedAt = now + } + if node.UpdatedAt.IsZero() { + node.UpdatedAt = node.CreatedAt + } + if node.Status == "" { + node.Status = model.NodeOffline + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO nodes( + node_id, type, name, overlay_ip, wg_public_key, node_token_hash, + status, version, os_version, last_seen, created_at, updated_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, node.ID, node.Type, node.Name, node.OverlayIP.String(), node.WGPublicKey, + node.NodeTokenHash, node.Status, node.Version, node.OSVersion, + nullTime(node.LastSeen), formatTime(node.CreatedAt), formatTime(node.UpdatedAt)) + if err != nil { + return fmt.Errorf("create node %q: %w", node.ID, err) + } + return nil +} + +// GetNode returns a node by its stable UUID. +func (s *Store) GetNode(ctx context.Context, nodeID string) (model.Node, error) { + row := s.db.QueryRowContext(ctx, nodeSelect+` WHERE node_id = ?`, nodeID) + node, err := scanNode(row) + if errors.Is(err, sql.ErrNoRows) { + return model.Node{}, fmt.Errorf("%w: %s", ErrNodeNotFound, nodeID) + } + if err != nil { + return model.Node{}, fmt.Errorf("get node %q: %w", nodeID, err) + } + return node, nil +} + +// ListNodes returns all nodes in stable creation order. +func (s *Store) ListNodes(ctx context.Context) ([]model.Node, error) { + rows, err := s.db.QueryContext(ctx, nodeSelect+` ORDER BY created_at, node_id`) + if err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + defer rows.Close() + nodes := make([]model.Node, 0) + for rows.Next() { + node, err := scanNode(rows) + if err != nil { + return nil, fmt.Errorf("scan node: %w", err) + } + nodes = append(nodes, node) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate nodes: %w", err) + } + return nodes, nil +} + +// ListOverlayIPs returns every reserved node address. +func (s *Store) ListOverlayIPs(ctx context.Context) ([]netip.Addr, error) { + rows, err := s.db.QueryContext(ctx, `SELECT overlay_ip FROM nodes ORDER BY overlay_ip`) + if err != nil { + return nil, fmt.Errorf("list overlay addresses: %w", err) + } + defer rows.Close() + var addresses []netip.Addr + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, fmt.Errorf("scan overlay address: %w", err) + } + address, err := netip.ParseAddr(raw) + if err != nil || !address.Is4() { + return nil, fmt.Errorf("invalid stored overlay address %q", raw) + } + addresses = append(addresses, address) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate overlay addresses: %w", err) + } + return addresses, nil +} + +// UpdateNodeRegistration rotates the token and refreshes mutable client data. +func (s *Store) UpdateNodeRegistration(ctx context.Context, node model.Node) error { + if len(node.NodeTokenHash) == 0 { + return errors.New("node token hash must not be empty") + } + result, err := s.db.ExecContext(ctx, ` + UPDATE nodes + SET name = ?, node_token_hash = ?, version = ?, os_version = ?, updated_at = ? + WHERE node_id = ? + `, node.Name, node.NodeTokenHash, node.Version, node.OSVersion, + formatTime(time.Now().UTC()), node.ID) + if err != nil { + return fmt.Errorf("update node registration %q: %w", node.ID, err) + } + return requireChanged(result, node.ID) +} + +// UpdateNodeOverlayIP changes a node's authoritative allocation. +func (s *Store) UpdateNodeOverlayIP(ctx context.Context, nodeID string, address netip.Addr) error { + if !address.Is4() { + return errors.New("overlay address must be IPv4") + } + result, err := s.db.ExecContext(ctx, ` + UPDATE nodes SET overlay_ip = ?, updated_at = ? WHERE node_id = ? + `, address.String(), formatTime(time.Now().UTC()), nodeID) + if err != nil { + return fmt.Errorf("update node %q overlay address: %w", nodeID, err) + } + return requireChanged(result, nodeID) +} + +// UpdateNodeName applies the Admin API's bounded display-name edit. +func (s *Store) UpdateNodeName(ctx context.Context, nodeID, name string) error { + if name == "" || len(name) > 128 { + return errors.New("node name must contain 1 to 128 bytes") + } + result, err := s.db.ExecContext(ctx, `UPDATE nodes SET name = ?, updated_at = ? WHERE node_id = ?`, + name, formatTime(time.Now().UTC()), nodeID) + if err != nil { + return fmt.Errorf("update node %q name: %w", nodeID, err) + } + return requireChanged(result, nodeID) +} + +// ReplaceNodeOverlayIPs atomically migrates all Node allocations. Temporary +// values avoid UNIQUE collisions when old and new pools overlap. +func (s *Store) ReplaceNodeOverlayIPs(ctx context.Context, assignments map[string]netip.Addr) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err := replaceNodeOverlayIPs(ctx, tx, assignments); err != nil { + return err + } + return tx.Commit() +} + +// ReplaceNodeOverlayIPsAndSetting commits the T18 Node allocation plan and +// authoritative network setting in one SQLite transaction. A Server crash can +// therefore observe either the complete old network or the complete new one, +// never new Node IPs paired with an old global Overlay configuration. +func (s *Store) ReplaceNodeOverlayIPsAndSetting(ctx context.Context, assignments map[string]netip.Addr, settingKey, settingValue string) error { + if settingKey == "" { + return errors.New("network setting key is required") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err := replaceNodeOverlayIPs(ctx, tx, assignments); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, settingKey, settingValue, formatTime(time.Now().UTC())); err != nil { + return fmt.Errorf("set migration setting %q: %w", settingKey, err) + } + return tx.Commit() +} + +type nodeOverlayTransaction interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func replaceNodeOverlayIPs(ctx context.Context, tx nodeOverlayTransaction, assignments map[string]netip.Addr) error { + for nodeID, address := range assignments { + if nodeID == "" || !address.Is4() { + return errors.New("Overlay migration requires Node IDs and IPv4 addresses") + } + result, err := tx.ExecContext(ctx, `UPDATE nodes SET overlay_ip = ?, updated_at = ? WHERE node_id = ?`, + "migrating:"+nodeID, formatTime(time.Now().UTC()), nodeID) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return fmt.Errorf("%w: %s", ErrNodeNotFound, nodeID) + } + } + for nodeID, address := range assignments { + if _, err := tx.ExecContext(ctx, `UPDATE nodes SET overlay_ip = ?, updated_at = ? WHERE node_id = ?`, + address.String(), formatTime(time.Now().UTC()), nodeID); err != nil { + return err + } + } + return nil +} + +// DeleteNode revokes a node and releases its unique address reservation. +func (s *Store) DeleteNode(ctx context.Context, nodeID string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM nodes WHERE node_id = ?`, nodeID) + if err != nil { + return fmt.Errorf("delete node %q: %w", nodeID, err) + } + return requireChanged(result, nodeID) +} + +// UpdateNodeHeartbeat records liveness and current application metadata. +func (s *Store) UpdateNodeHeartbeat(ctx context.Context, nodeID string, status model.NodeStatus, at time.Time, version, osVersion string) error { + if !status.Valid() { + return fmt.Errorf("invalid node status %q", status) + } + result, err := s.db.ExecContext(ctx, ` + UPDATE nodes SET status = ?, last_seen = ?, version = ?, os_version = ?, updated_at = ? + WHERE node_id = ? + `, status, formatTime(at), version, osVersion, formatTime(time.Now().UTC()), nodeID) + if err != nil { + return fmt.Errorf("update node %q heartbeat: %w", nodeID, err) + } + return requireChanged(result, nodeID) +} + +// UpdateNodeStatus changes only the derived heartbeat status. +func (s *Store) UpdateNodeStatus(ctx context.Context, nodeID string, status model.NodeStatus) error { + if !status.Valid() { + return fmt.Errorf("invalid node status %q", status) + } + result, err := s.db.ExecContext(ctx, ` + UPDATE nodes SET status = ?, updated_at = ? WHERE node_id = ? + `, status, formatTime(time.Now().UTC()), nodeID) + if err != nil { + return fmt.Errorf("update node %q status: %w", nodeID, err) + } + return requireChanged(result, nodeID) +} + +const nodeSelect = `SELECT node_id, type, name, overlay_ip, wg_public_key, + node_token_hash, status, version, os_version, last_seen, created_at, updated_at FROM nodes` + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanNode(scanner rowScanner) (model.Node, error) { + var ( + node model.Node + nodeType string + status string + overlayIP string + lastSeen sql.NullString + createdAt string + updatedAt string + ) + if err := scanner.Scan(&node.ID, &nodeType, &node.Name, &overlayIP, + &node.WGPublicKey, &node.NodeTokenHash, &status, &node.Version, + &node.OSVersion, &lastSeen, &createdAt, &updatedAt); err != nil { + return model.Node{}, err + } + parsedType, err := model.ParseNodeType(nodeType) + if err != nil { + return model.Node{}, err + } + node.Type = parsedType + node.Status = model.NodeStatus(status) + if !node.Status.Valid() { + return model.Node{}, fmt.Errorf("invalid stored node status %q", status) + } + node.OverlayIP, err = netip.ParseAddr(overlayIP) + if err != nil || !node.OverlayIP.Is4() { + return model.Node{}, fmt.Errorf("invalid stored overlay address %q", overlayIP) + } + node.CreatedAt, err = parseTime(createdAt) + if err != nil { + return model.Node{}, fmt.Errorf("parse created_at: %w", err) + } + node.UpdatedAt, err = parseTime(updatedAt) + if err != nil { + return model.Node{}, fmt.Errorf("parse updated_at: %w", err) + } + if lastSeen.Valid { + value, err := parseTime(lastSeen.String) + if err != nil { + return model.Node{}, fmt.Errorf("parse last_seen: %w", err) + } + node.LastSeen = &value + } + return node, nil +} + +func validateNode(node model.Node) error { + if node.ID == "" { + return errors.New("node ID must not be empty") + } + if !node.Type.Valid() { + return fmt.Errorf("invalid node type %q", node.Type) + } + if node.Name == "" { + return errors.New("node name must not be empty") + } + if !node.OverlayIP.Is4() { + return errors.New("overlay address must be IPv4") + } + if node.WGPublicKey == "" { + return errors.New("WireGuard public key must not be empty") + } + if len(node.NodeTokenHash) == 0 { + return errors.New("node token hash must not be empty") + } + if node.Status != "" && !node.Status.Valid() { + return fmt.Errorf("invalid node status %q", node.Status) + } + return nil +} + +func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) } + +func parseTime(value string) (time.Time, error) { return time.Parse(time.RFC3339Nano, value) } + +func nullTime(value *time.Time) any { + if value == nil { + return nil + } + return formatTime(*value) +} + +func requireChanged(result sql.Result, nodeID string) error { + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read changed rows: %w", err) + } + if count == 0 { + return fmt.Errorf("%w: %s", ErrNodeNotFound, nodeID) + } + return nil +} diff --git a/internal/database/sessions.go b/internal/database/sessions.go new file mode 100644 index 0000000..da71121 --- /dev/null +++ b/internal/database/sessions.go @@ -0,0 +1,223 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "net/netip" + "strconv" + "time" + + "remlink/internal/model" +) + +var ErrSessionNotFound = errors.New("Session not found") + +func (s *Store) CreateSession(ctx context.Context, session model.Session) error { + if session.ID == 0 || session.EngineerNodeID == "" || session.SiteNodeID == "" || !session.Status.Valid() || len(session.CIDRs) == 0 { + return errors.New("Session requires ID, participants, valid status, and CIDRs") + } + if session.CreatedAt.IsZero() { + session.CreatedAt = time.Now().UTC() + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin create Session: %w", err) + } + defer tx.Rollback() + id := strconv.FormatUint(session.ID, 10) + if _, err := tx.ExecContext(ctx, ` + INSERT INTO sessions(session_id, engineer_node_id, site_node_id, status, created_at, error_code) + VALUES(?, ?, ?, ?, ?, NULL) + `, id, session.EngineerNodeID, session.SiteNodeID, session.Status, formatTime(session.CreatedAt)); err != nil { + return fmt.Errorf("insert Session %s: %w", id, err) + } + for _, prefix := range session.CIDRs { + if !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 { + return fmt.Errorf("invalid Session CIDR %s", prefix) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO session_cidrs(session_id, cidr) VALUES(?, ?)`, id, prefix.String()); err != nil { + return fmt.Errorf("insert Session CIDR: %w", err) + } + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO session_stats(session_id, updated_at) VALUES(?, ?) + `, id, formatTime(session.CreatedAt)); err != nil { + return fmt.Errorf("initialize Session stats: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit create Session: %w", err) + } + return nil +} + +func (s *Store) GetSession(ctx context.Context, sessionID uint64) (model.Session, error) { + id := strconv.FormatUint(sessionID, 10) + var ( + session model.Session + status, createdAt string + activeAt, closedAt, errorCode sql.NullString + ) + err := s.db.QueryRowContext(ctx, ` + SELECT s.engineer_node_id, s.site_node_id, s.status, s.created_at, s.active_at, s.closed_at, s.error_code, + st.tx_bytes, st.rx_bytes, st.tx_packets, st.rx_packets + FROM sessions s JOIN session_stats st ON st.session_id = s.session_id + WHERE s.session_id = ? + `, id).Scan(&session.EngineerNodeID, &session.SiteNodeID, &status, &createdAt, &activeAt, &closedAt, &errorCode, + &session.Counters.UploadBytes, &session.Counters.DownloadBytes, + &session.Counters.UploadPackets, &session.Counters.DownloadPackets) + if errors.Is(err, sql.ErrNoRows) { + return model.Session{}, fmt.Errorf("%w: %d", ErrSessionNotFound, sessionID) + } + if err != nil { + return model.Session{}, fmt.Errorf("get Session %d: %w", sessionID, err) + } + session.ID = sessionID + session.Status = model.SessionStatus(status) + if !session.Status.Valid() { + return model.Session{}, fmt.Errorf("invalid stored Session status %q", status) + } + session.CreatedAt, err = parseTime(createdAt) + if err != nil { + return model.Session{}, err + } + if activeAt.Valid { + value, err := parseTime(activeAt.String) + if err != nil { + return model.Session{}, err + } + session.ActiveAt = &value + } + if closedAt.Valid { + value, err := parseTime(closedAt.String) + if err != nil { + return model.Session{}, err + } + session.ClosedAt = &value + } + if errorCode.Valid { + session.ErrorCode = errorCode.String + } + rows, err := s.db.QueryContext(ctx, `SELECT cidr FROM session_cidrs WHERE session_id = ? ORDER BY cidr`, id) + if err != nil { + return model.Session{}, err + } + defer rows.Close() + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return model.Session{}, err + } + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return model.Session{}, err + } + session.CIDRs = append(session.CIDRs, prefix) + } + return session, rows.Err() +} + +// ListSessions returns complete Sessions newest first for the Admin API. +func (s *Store) ListSessions(ctx context.Context) ([]model.Session, error) { + rows, err := s.db.QueryContext(ctx, `SELECT session_id FROM sessions ORDER BY created_at DESC, session_id DESC`) + if err != nil { + return nil, fmt.Errorf("list Session IDs: %w", err) + } + var ids []uint64 + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + sessions := make([]model.Session, 0, len(ids)) + for _, id := range ids { + session, err := s.GetSession(ctx, id) + if err != nil { + return nil, err + } + sessions = append(sessions, session) + } + return sessions, nil +} + +func (s *Store) UpdateSessionStatus(ctx context.Context, sessionID uint64, status model.SessionStatus, errorCode string) error { + if !status.Valid() { + return fmt.Errorf("invalid Session status %q", status) + } + now := formatTime(time.Now().UTC()) + activeAt, closedAt := any(nil), any(nil) + if status == model.SessionActive { + activeAt = now + } + if status == model.SessionClosed || status == model.SessionFailed { + closedAt = now + } + var errorValue any + if errorCode != "" { + errorValue = errorCode + } + result, err := s.db.ExecContext(ctx, ` + UPDATE sessions SET status = ?, active_at = COALESCE(?, active_at), + closed_at = COALESCE(?, closed_at), error_code = ? WHERE session_id = ? + `, status, activeAt, closedAt, errorValue, strconv.FormatUint(sessionID, 10)) + if err != nil { + return fmt.Errorf("update Session %d status: %w", sessionID, err) + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("%w: %d", ErrSessionNotFound, sessionID) + } + return nil +} + +func (s *Store) UpdateSessionStats(ctx context.Context, sessionID uint64, counters model.SessionCounters) error { + if counters.UploadBytes > math.MaxInt64 || counters.DownloadBytes > math.MaxInt64 || + counters.UploadPackets > math.MaxInt64 || counters.DownloadPackets > math.MaxInt64 { + return errors.New("Session counters exceed SQLite INTEGER capacity") + } + result, err := s.db.ExecContext(ctx, ` + UPDATE session_stats SET tx_bytes = ?, rx_bytes = ?, tx_packets = ?, rx_packets = ?, updated_at = ? + WHERE session_id = ? + `, counters.UploadBytes, counters.DownloadBytes, counters.UploadPackets, counters.DownloadPackets, + formatTime(time.Now().UTC()), strconv.FormatUint(sessionID, 10)) + if err != nil { + return fmt.Errorf("update Session %d stats: %w", sessionID, err) + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("%w: %d", ErrSessionNotFound, sessionID) + } + return nil +} + +// CloseOpenSessions enforces the v1 no-resume rule on Server startup. +func (s *Store) CloseOpenSessions(ctx context.Context) (int64, error) { + now := formatTime(time.Now().UTC()) + result, err := s.db.ExecContext(ctx, ` + UPDATE sessions SET status = 'CLOSED', closed_at = ?, error_code = NULL + WHERE status NOT IN ('CLOSED', 'FAILED') + `, now) + if err != nil { + return 0, fmt.Errorf("close open Sessions at startup: %w", err) + } + return result.RowsAffected() +} diff --git a/internal/database/settings.go b/internal/database/settings.go new file mode 100644 index 0000000..7a4146c --- /dev/null +++ b/internal/database/settings.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// GetSetting returns a setting and whether it exists. +func GetSetting(ctx context.Context, db *sql.DB, key string) (string, bool, error) { + var value string + err := db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("get setting %q: %w", key, err) + } + return value, true, nil +} + +// SetSetting upserts a setting with an RFC3339 UTC timestamp. +func SetSetting(ctx context.Context, db *sql.DB, key, value string) error { + _, err := db.ExecContext(ctx, ` + INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, key, value, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return fmt.Errorf("set setting %q: %w", key, err) + } + return nil +} diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..c8edd4c --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,265 @@ +// Package identity persists the Windows Node identity without plaintext secrets. +package identity + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/netip" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "remlink/internal/config" + "remlink/internal/model" +) + +const diskVersion = 1 + +// Protector is implemented by the Windows DPAPI adapter. +type Protector interface { + Protect([]byte) ([]byte, error) + Unprotect([]byte) ([]byte, error) +} + +// Identity is the durable Node identity used by Bootstrap and reconciliation. +type Identity struct { + NodeID string + NodeType model.NodeType + NodeName string + PrivateKey wgtypes.Key + NodeToken string + ServerURL string + ConfigVersion uint64 + OwnedRoutes []string +} + +// PublicKey derives the shareable WireGuard key from the protected private key. +func (i Identity) PublicKey() string { return i.PrivateKey.PublicKey().String() } + +// New generates a UUID and independent WireGuard key pair. +func New(nodeType model.NodeType, nodeName, serverURL string) (Identity, error) { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return Identity{}, fmt.Errorf("generate Node WireGuard key: %w", err) + } + identity := Identity{ + NodeID: uuid.NewString(), NodeType: nodeType, NodeName: strings.TrimSpace(nodeName), + PrivateKey: privateKey, ServerURL: strings.TrimRight(strings.TrimSpace(serverURL), "/"), + } + if err := identity.Validate(false); err != nil { + return Identity{}, err + } + return identity, nil +} + +// Validate checks persistent identity invariants. requireToken is true after enrollment. +func (i Identity) Validate(requireToken bool) error { + if _, err := uuid.Parse(i.NodeID); err != nil { + return fmt.Errorf("Node ID must be a UUID: %w", err) + } + if !i.NodeType.Valid() { + return fmt.Errorf("invalid Node type %q", i.NodeType) + } + if i.NodeName == "" || len(i.NodeName) > 128 { + return errors.New("Node name must contain 1 to 128 bytes") + } + if err := config.ValidateServerURL(i.ServerURL); err != nil { + return fmt.Errorf("invalid Server URL: %w", err) + } + if i.PrivateKey == (wgtypes.Key{}) { + return errors.New("WireGuard private key must not be zero") + } + if requireToken && i.NodeToken == "" { + return errors.New("Node Token is required after registration") + } + return nil +} + +// Store saves one role-specific identity JSON file. +type Store struct { + path string + protector Protector +} + +func NewStore(path string, protector Protector) (*Store, error) { + if strings.TrimSpace(path) == "" || protector == nil { + return nil, errors.New("identity path and Protector are required") + } + return &Store{path: path, protector: protector}, nil +} + +// Path returns the exact identity artifact location. +func (s *Store) Path() string { return s.path } + +// LoadOwnedRoutes implements RouteManager's persistence boundary. +func (s *Store) LoadOwnedRoutes() ([]netip.Prefix, error) { + current, err := s.Load() + if err != nil { + return nil, err + } + routes := make([]netip.Prefix, 0, len(current.OwnedRoutes)) + for _, raw := range current.OwnedRoutes { + prefix, err := netip.ParsePrefix(raw) + if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() { + return nil, fmt.Errorf("invalid owned Remote route %q", raw) + } + routes = append(routes, prefix) + } + return routes, nil +} + +// SaveOwnedRoutes atomically updates RouteManager ownership metadata. +func (s *Store) SaveOwnedRoutes(routes []netip.Prefix) error { + current, err := s.Load() + if err != nil { + return err + } + current.OwnedRoutes = make([]string, 0, len(routes)) + for _, prefix := range routes { + if !prefix.Addr().Is4() || prefix != prefix.Masked() { + return fmt.Errorf("owned Remote route must be canonical IPv4: %s", prefix) + } + current.OwnedRoutes = append(current.OwnedRoutes, prefix.String()) + } + return s.Save(current) +} + +// Load decrypts and validates a persisted identity. +func (s *Store) Load() (Identity, error) { + raw, err := os.ReadFile(s.path) + if err != nil { + return Identity{}, fmt.Errorf("read Node identity: %w", err) + } + var stored diskIdentity + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&stored); err != nil { + return Identity{}, fmt.Errorf("decode Node identity: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Identity{}, errors.New("Node identity must contain one JSON object") + } + if stored.Version != diskVersion { + return Identity{}, fmt.Errorf("unsupported Node identity version %d", stored.Version) + } + privateKeyRaw, err := s.unprotect(stored.ProtectedPrivateKey) + if err != nil { + return Identity{}, fmt.Errorf("decrypt WireGuard private key: %w", err) + } + privateKey, err := wgtypes.ParseKey(string(privateKeyRaw)) + clear(privateKeyRaw) + if err != nil { + return Identity{}, fmt.Errorf("parse decrypted WireGuard private key: %w", err) + } + nodeToken := "" + if stored.ProtectedNodeToken != "" { + nodeTokenRaw, err := s.unprotect(stored.ProtectedNodeToken) + if err != nil { + return Identity{}, fmt.Errorf("decrypt Node Token: %w", err) + } + nodeToken = string(nodeTokenRaw) + clear(nodeTokenRaw) + } + identity := Identity{ + NodeID: stored.NodeID, NodeType: stored.NodeType, NodeName: stored.NodeName, + PrivateKey: privateKey, NodeToken: nodeToken, ServerURL: stored.ServerURL, + ConfigVersion: stored.ConfigVersion, OwnedRoutes: append([]string(nil), stored.OwnedRoutes...), + } + if err := identity.Validate(false); err != nil { + return Identity{}, fmt.Errorf("validate Node identity: %w", err) + } + return identity, nil +} + +// Save protects all secrets and atomically replaces the identity artifact. +func (s *Store) Save(identity Identity) error { + if err := identity.Validate(false); err != nil { + return err + } + privateKeyRaw := []byte(identity.PrivateKey.String()) + protectedPrivateKey, err := s.protect(privateKeyRaw) + clear(privateKeyRaw) + if err != nil { + return fmt.Errorf("encrypt WireGuard private key: %w", err) + } + protectedNodeToken := "" + if identity.NodeToken != "" { + protectedNodeToken, err = s.protect([]byte(identity.NodeToken)) + if err != nil { + return fmt.Errorf("encrypt Node Token: %w", err) + } + } + stored := diskIdentity{ + Version: diskVersion, NodeID: identity.NodeID, NodeType: identity.NodeType, + NodeName: identity.NodeName, ProtectedPrivateKey: protectedPrivateKey, + ProtectedNodeToken: protectedNodeToken, ServerURL: identity.ServerURL, + ConfigVersion: identity.ConfigVersion, OwnedRoutes: identity.OwnedRoutes, + } + encoded, err := json.MarshalIndent(stored, "", " ") + if err != nil { + return fmt.Errorf("encode Node identity: %w", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create Node identity directory: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*.tmp") + if err != nil { + return fmt.Errorf("create temporary Node identity: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return fmt.Errorf("restrict temporary Node identity: %w", err) + } + if _, err := temporary.Write(encoded); err != nil { + temporary.Close() + return fmt.Errorf("write temporary Node identity: %w", err) + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return fmt.Errorf("flush temporary Node identity: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary Node identity: %w", err) + } + if err := replaceFile(temporaryPath, s.path); err != nil { + return fmt.Errorf("replace Node identity: %w", err) + } + return nil +} + +func (s *Store) protect(value []byte) (string, error) { + protected, err := s.protector.Protect(value) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(protected), nil +} + +func (s *Store) unprotect(value string) ([]byte, error) { + protected, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, err + } + return s.protector.Unprotect(protected) +} + +type diskIdentity struct { + Version int `json:"version"` + NodeID string `json:"node_id"` + NodeType model.NodeType `json:"node_type"` + NodeName string `json:"node_name"` + ProtectedPrivateKey string `json:"protected_wg_private_key"` + ProtectedNodeToken string `json:"protected_node_token,omitempty"` + ServerURL string `json:"server_url"` + ConfigVersion uint64 `json:"config_version"` + OwnedRoutes []string `json:"owned_routes,omitempty"` +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000..7385fab --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,84 @@ +package identity + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "remlink/internal/model" +) + +type testProtector struct{} + +func (testProtector) Protect(value []byte) ([]byte, error) { + result := append([]byte("protected:"), value...) + for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 { + result[left], result[right] = result[right], result[left] + } + return result, nil +} + +func (testProtector) Unprotect(value []byte) ([]byte, error) { + result := append([]byte(nil), value...) + for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 { + result[left], result[right] = result[right], result[left] + } + return bytes.TrimPrefix(result, []byte("protected:")), nil +} + +func TestIdentityRoundTripWithoutPlaintextSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), "Engineer", "identity.json") + store, err := NewStore(path, testProtector{}) + if err != nil { + t.Fatal(err) + } + value, err := New(model.NodeTypeEngineer, "Engineer-A", "http://203.0.113.1:8080/") + if err != nil { + t.Fatal(err) + } + value.NodeToken = "plain-node-token-that-must-not-appear" + value.ConfigVersion = 7 + value.OwnedRoutes = []string{"192.168.13.0/24"} + if err := store.Save(value); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte(value.NodeToken)) || bytes.Contains(raw, []byte(value.PrivateKey.String())) { + t.Fatalf("identity file contains a plaintext secret: %s", raw) + } + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + if loaded.NodeID != value.NodeID || loaded.PrivateKey != value.PrivateKey || loaded.NodeToken != value.NodeToken || + loaded.ConfigVersion != 7 || len(loaded.OwnedRoutes) != 1 { + t.Fatalf("loaded identity differs: %+v", loaded) + } + + value.NodeName = "Engineer-Renamed" + if err := store.Save(value); err != nil { + t.Fatalf("replace identity: %v", err) + } + loaded, err = store.Load() + if err != nil || loaded.NodeName != value.NodeName { + t.Fatalf("replaced identity = %+v, %v", loaded, err) + } +} + +func TestIdentityValidation(t *testing.T) { + if _, err := New(model.NodeType("server"), "x", "http://example.test"); err == nil { + t.Fatal("invalid Node type accepted") + } + if _, err := New(model.NodeTypeSite, "x", "not-a-url"); err == nil { + t.Fatal("invalid Server URL accepted") + } + for _, raw := range []string{"http://user:secret@example.test", "http://example.test/api", "http://example.test?redirect=elsewhere"} { + if _, err := New(model.NodeTypeSite, "x", raw); err == nil { + t.Errorf("unsafe Server URL %q accepted", raw) + } + } +} diff --git a/internal/identity/path_other.go b/internal/identity/path_other.go new file mode 100644 index 0000000..2c33aca --- /dev/null +++ b/internal/identity/path_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package identity + +import ( + "errors" + + "remlink/internal/model" +) + +var ErrWindowsRequired = errors.New("default Node identity path requires Windows") + +func DefaultPath(model.NodeType) (string, error) { return "", ErrWindowsRequired } diff --git a/internal/identity/path_windows.go b/internal/identity/path_windows.go new file mode 100644 index 0000000..417512d --- /dev/null +++ b/internal/identity/path_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package identity + +import ( + "errors" + "path/filepath" + + "remlink/internal/appdir" + "remlink/internal/model" +) + +// DefaultPath returns the portable identity path beside the role executable. +// Engineer and Site are shipped in different directories, so the common file +// name cannot collide across the two package bodies. +func DefaultPath(nodeType model.NodeType) (string, error) { + switch nodeType { + case model.NodeTypeEngineer, model.NodeTypeSite: + root, err := appdir.Executable() + if err != nil { + return "", err + } + return filepath.Join(root, "identity.json"), nil + default: + return "", errors.New("invalid Windows Node type") + } +} diff --git a/internal/identity/path_windows_test.go b/internal/identity/path_windows_test.go new file mode 100644 index 0000000..dcc1366 --- /dev/null +++ b/internal/identity/path_windows_test.go @@ -0,0 +1,34 @@ +//go:build windows + +package identity + +import ( + "path/filepath" + "testing" + + "remlink/internal/appdir" + "remlink/internal/model" +) + +func TestDefaultPathUsesExecutableDirectoryForPortableRoles(t *testing.T) { + root, err := appdir.Executable() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "identity.json") + for _, role := range []model.NodeType{model.NodeTypeEngineer, model.NodeTypeSite} { + got, err := DefaultPath(role) + if err != nil { + t.Fatalf("DefaultPath(%s): %v", role, err) + } + if got != want { + t.Fatalf("DefaultPath(%s) = %q, want %q", role, got, want) + } + } +} + +func TestDefaultPathRejectsUnknownRole(t *testing.T) { + if _, err := DefaultPath(model.NodeType("server")); err == nil { + t.Fatal("DefaultPath accepted a non-portable role") + } +} diff --git a/internal/identity/replace_other.go b/internal/identity/replace_other.go new file mode 100644 index 0000000..e19852c --- /dev/null +++ b/internal/identity/replace_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package identity + +import "os" + +func replaceFile(source, destination string) error { return os.Rename(source, destination) } diff --git a/internal/identity/replace_windows.go b/internal/identity/replace_windows.go new file mode 100644 index 0000000..97ab452 --- /dev/null +++ b/internal/identity/replace_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package identity + +import "golang.org/x/sys/windows" + +func replaceFile(source, destination string) error { + sourcePointer, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(sourcePointer, destinationPointer, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/ipam/ipam.go b/internal/ipam/ipam.go new file mode 100644 index 0000000..1d96cfb --- /dev/null +++ b/internal/ipam/ipam.go @@ -0,0 +1,204 @@ +// Package ipam owns authoritative IPv4 overlay allocation. +package ipam + +import ( + "context" + "errors" + "fmt" + "net/netip" + "sync" + + "remlink/internal/database" + "remlink/internal/model" +) + +var ( + // ErrAddressPoolExhausted reports that no usable node address remains. + ErrAddressPoolExhausted = errors.New("overlay address pool exhausted") + // ErrAddressUnavailable reports an invalid, reserved, or occupied manual address. + ErrAddressUnavailable = errors.New("overlay address unavailable") +) + +// Manager serializes all in-process allocation and modification operations. +// The database UNIQUE constraint remains the final consistency boundary. +type Manager struct { + mu sync.Mutex + store *database.Store + prefix netip.Prefix + serverIP netip.Addr +} + +// PlanNodeAddresses deterministically preserves usable allocations and assigns +// the lowest free addresses for Nodes that must move into a new Overlay pool. +func PlanNodeAddresses(nodes []model.Node, prefix netip.Prefix, serverIP netip.Addr) (map[string]netip.Addr, error) { + prefix = prefix.Masked() + if !prefix.Addr().Is4() || prefix.Bits() == 0 || prefix.Bits() > 30 || !serverIP.Is4() || !prefix.Contains(serverIP) || + serverIP == prefix.Addr() || serverIP == lastAddress(prefix) { + return nil, errors.New("invalid Overlay migration network") + } + usable := func(address netip.Addr) bool { + return address.Is4() && prefix.Contains(address) && address != prefix.Addr() && address != lastAddress(prefix) && address != serverIP + } + assignments := make(map[string]netip.Addr, len(nodes)) + used := make(map[netip.Addr]struct{}, len(nodes)) + for _, node := range nodes { + if node.ID == "" { + return nil, errors.New("Node ID is required for Overlay migration") + } + if usable(node.OverlayIP) { + if _, duplicate := used[node.OverlayIP]; !duplicate { + assignments[node.ID] = node.OverlayIP + used[node.OverlayIP] = struct{}{} + } + } + } + for _, node := range nodes { + if _, assigned := assignments[node.ID]; assigned { + continue + } + found := false + for address := prefix.Addr().Next(); prefix.Contains(address); address = address.Next() { + if !usable(address) { + continue + } + if _, occupied := used[address]; occupied { + continue + } + assignments[node.ID] = address + used[address] = struct{}{} + found = true + break + } + if !found { + return nil, ErrAddressPoolExhausted + } + } + return assignments, nil +} + +// Reconfigure switches subsequent allocation/manual-validation to a migrated pool. +func (m *Manager) Reconfigure(prefix netip.Prefix, serverIP netip.Addr) error { + prefix = prefix.Masked() + if _, err := New(m.store, prefix, serverIP); err != nil { + return err + } + m.mu.Lock() + m.prefix = prefix + m.serverIP = serverIP + m.mu.Unlock() + return nil +} + +// New validates an IPv4 prefix and its reserved Server address. +func New(store *database.Store, prefix netip.Prefix, serverIP netip.Addr) (*Manager, error) { + prefix = prefix.Masked() + if !prefix.Addr().Is4() || prefix.Bits() == 0 || prefix.Bits() > 30 { + return nil, errors.New("overlay prefix must be IPv4 with at least two usable addresses") + } + if !serverIP.Is4() || !prefix.Contains(serverIP) || serverIP == prefix.Addr() || serverIP == lastAddress(prefix) { + return nil, errors.New("server address must be a usable address inside overlay prefix") + } + return &Manager{store: store, prefix: prefix, serverIP: serverIP}, nil +} + +// ReserveNode creates a node using the lowest free usable address. Existing +// NodeIDs keep their allocation and return created=false. +func (m *Manager) ReserveNode(ctx context.Context, node model.Node) (reserved model.Node, created bool, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + existing, err := m.store.GetNode(ctx, node.ID) + if err == nil { + return existing, false, nil + } + if !errors.Is(err, database.ErrNodeNotFound) { + return model.Node{}, false, err + } + used, err := m.used(ctx) + if err != nil { + return model.Node{}, false, err + } + address, ok := m.firstAvailable(used) + if !ok { + return model.Node{}, false, ErrAddressPoolExhausted + } + node.OverlayIP = address + if err := m.store.CreateNode(ctx, node); err != nil { + return model.Node{}, false, err + } + createdNode, err := m.store.GetNode(ctx, node.ID) + if err != nil { + return model.Node{}, false, err + } + return createdNode, true, nil +} + +// ChangeNodeAddress performs the Web UI's authoritative manual reassignment. +func (m *Manager) ChangeNodeAddress(ctx context.Context, nodeID string, desired netip.Addr) error { + m.mu.Lock() + defer m.mu.Unlock() + if !m.usable(desired) { + return fmt.Errorf("%w: %s is reserved or outside %s", ErrAddressUnavailable, desired, m.prefix) + } + used, err := m.used(ctx) + if err != nil { + return err + } + current, err := m.store.GetNode(ctx, nodeID) + if err != nil { + return err + } + if current.OverlayIP == desired { + return nil + } + if _, occupied := used[desired]; occupied { + return fmt.Errorf("%w: %s is already assigned", ErrAddressUnavailable, desired) + } + return m.store.UpdateNodeOverlayIP(ctx, nodeID, desired) +} + +// ReleaseNode deletes the node record, token, and allocation. Peer revocation +// must succeed in the orchestration layer before this method is called. +func (m *Manager) ReleaseNode(ctx context.Context, nodeID string) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.store.DeleteNode(ctx, nodeID) +} + +func (m *Manager) used(ctx context.Context) (map[netip.Addr]struct{}, error) { + addresses, err := m.store.ListOverlayIPs(ctx) + if err != nil { + return nil, err + } + used := make(map[netip.Addr]struct{}, len(addresses)) + for _, address := range addresses { + used[address] = struct{}{} + } + return used, nil +} + +func (m *Manager) firstAvailable(used map[netip.Addr]struct{}) (netip.Addr, bool) { + for address := m.prefix.Addr().Next(); m.prefix.Contains(address); address = address.Next() { + if !m.usable(address) { + continue + } + if _, occupied := used[address]; !occupied { + return address, true + } + } + return netip.Addr{}, false +} + +func (m *Manager) usable(address netip.Addr) bool { + return address.Is4() && m.prefix.Contains(address) && address != m.prefix.Addr() && + address != lastAddress(m.prefix) && address != m.serverIP +} + +func lastAddress(prefix netip.Prefix) netip.Addr { + bits := prefix.Bits() + base := prefix.Masked().Addr().As4() + hostBits := 32 - bits + value := uint32(base[0])<<24 | uint32(base[1])<<16 | uint32(base[2])<<8 | uint32(base[3]) + value |= uint32(1)<> 24), byte(value >> 16), byte(value >> 8), byte(value)}) +} diff --git a/internal/ipam/ipam_test.go b/internal/ipam/ipam_test.go new file mode 100644 index 0000000..49206e3 --- /dev/null +++ b/internal/ipam/ipam_test.go @@ -0,0 +1,149 @@ +package ipam + +import ( + "context" + "errors" + "fmt" + "net/netip" + "path/filepath" + "sync" + "testing" + + "remlink/internal/database" + "remlink/internal/model" +) + +func TestReserveTenNodesUniqueStableAndConcurrent(t *testing.T) { + manager, store := testManager(t, "10.88.0.0/16", "10.88.0.1") + ctx := context.Background() + const count = 10 + results := make(chan model.Node, count) + errorsChannel := make(chan error, count) + var group sync.WaitGroup + for index := range count { + group.Add(1) + go func() { + defer group.Done() + node, created, err := manager.ReserveNode(ctx, newNode(index)) + if err != nil { + errorsChannel <- err + return + } + if !created { + errorsChannel <- fmt.Errorf("node %d was not newly created", index) + return + } + results <- node + }() + } + group.Wait() + close(results) + close(errorsChannel) + for err := range errorsChannel { + t.Error(err) + } + seen := make(map[netip.Addr]struct{}, count) + for result := range results { + if result.OverlayIP == netip.MustParseAddr("10.88.0.1") { + t.Fatal("Server address allocated to node") + } + if _, duplicate := seen[result.OverlayIP]; duplicate { + t.Fatalf("duplicate allocation %s", result.OverlayIP) + } + seen[result.OverlayIP] = struct{}{} + } + if len(seen) != count { + t.Fatalf("unique allocation count = %d, want %d", len(seen), count) + } + + existing, err := store.GetNode(ctx, newNode(3).ID) + if err != nil { + t.Fatal(err) + } + restarted, created, err := manager.ReserveNode(ctx, newNode(3)) + if err != nil || created || restarted.OverlayIP != existing.OverlayIP { + t.Fatalf("restart allocation = %v, created=%v, err=%v; want %s", restarted.OverlayIP, created, err, existing.OverlayIP) + } +} + +func TestManualChangeAndRelease(t *testing.T) { + manager, store := testManager(t, "10.88.0.0/24", "10.88.0.1") + ctx := context.Background() + first, _, err := manager.ReserveNode(ctx, newNode(1)) + if err != nil { + t.Fatal(err) + } + second, _, err := manager.ReserveNode(ctx, newNode(2)) + if err != nil { + t.Fatal(err) + } + if err := manager.ChangeNodeAddress(ctx, first.ID, second.OverlayIP); !errors.Is(err, ErrAddressUnavailable) { + t.Fatalf("occupied address error = %v", err) + } + for _, reserved := range []string{"10.88.0.0", "10.88.0.1", "10.88.0.255", "10.89.0.2"} { + if err := manager.ChangeNodeAddress(ctx, first.ID, netip.MustParseAddr(reserved)); !errors.Is(err, ErrAddressUnavailable) { + t.Errorf("reserved address %s error = %v", reserved, err) + } + } + desired := netip.MustParseAddr("10.88.0.100") + if err := manager.ChangeNodeAddress(ctx, first.ID, desired); err != nil { + t.Fatal(err) + } + updated, err := store.GetNode(ctx, first.ID) + if err != nil || updated.OverlayIP != desired { + t.Fatalf("updated address = %s, err=%v", updated.OverlayIP, err) + } + if err := manager.ReleaseNode(ctx, first.ID); err != nil { + t.Fatal(err) + } + if _, err := store.GetNode(ctx, first.ID); !errors.Is(err, database.ErrNodeNotFound) { + t.Fatalf("released node error = %v", err) + } +} + +func TestPoolExhaustion(t *testing.T) { + manager, _ := testManager(t, "10.88.0.0/30", "10.88.0.1") + ctx := context.Background() + if _, _, err := manager.ReserveNode(ctx, newNode(1)); err != nil { + t.Fatal(err) + } + if _, _, err := manager.ReserveNode(ctx, newNode(2)); !errors.Is(err, ErrAddressPoolExhausted) { + t.Fatalf("second /30 allocation error = %v", err) + } +} + +func TestIPAMRejectsExitNodePool(t *testing.T) { + db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := New(database.NewStore(db), netip.MustParsePrefix("0.0.0.0/0"), netip.MustParseAddr("10.88.0.1")); err == nil { + t.Fatal("IPAM accepted 0.0.0.0/0 Exit Node pool") + } +} + +func testManager(t *testing.T, prefix, server string) (*Manager, *database.Store) { + t.Helper() + db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "remlink.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + store := database.NewStore(db) + manager, err := New(store, netip.MustParsePrefix(prefix), netip.MustParseAddr(server)) + if err != nil { + t.Fatal(err) + } + return manager, store +} + +func newNode(index int) model.Node { + return model.Node{ + ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", index), + Type: model.NodeTypeEngineer, + Name: fmt.Sprintf("node-%d", index), + WGPublicKey: fmt.Sprintf("public-key-%d", index), + NodeTokenHash: []byte(fmt.Sprintf("token-hash-%d", index)), + } +} diff --git a/internal/localization/zhcn.go b/internal/localization/zhcn.go new file mode 100644 index 0000000..30d6183 --- /dev/null +++ b/internal/localization/zhcn.go @@ -0,0 +1,96 @@ +// Package localization provides Chinese operator-facing labels while keeping +// protocol codes and persisted enum values stable. +package localization + +import "strings" + +var sessionStatuses = map[string]string{ + "IDLE": "空闲", + "CREATING": "正在创建", + "PREPARING_SITE": "正在准备现场端", + "READY": "准备就绪", + "ACTIVE": "活动中", + "STOPPING": "正在停止", + "CLOSED": "已关闭", + "FAILED": "失败", +} + +var nodeStatuses = map[string]string{ + "ONLINE": "在线", + "UNSTABLE": "连接不稳定", + "OFFLINE": "离线", +} + +var routeResults = map[string]string{ + "DIRECT": "直连路由", + "ROUTED": "明确路由", + "DEFAULT_ONLY": "仅有默认路由", + "NO_ROUTE": "没有路由", + "OVERLAY_CONFLICT": "与 Overlay 网段冲突", +} + +var errorCodes = map[string]string{ + "SERVER_UNREACHABLE": "无法连接服务器", + "JOIN_TOKEN_INVALID": "Join Token 无效", + "NODE_AUTH_FAILED": "节点身份认证失败", + "OVERLAY_LOCAL_CONFLICT": "Overlay 网段与本地网络冲突", + "ENGINEER_SESSION_EXISTS": "Engineer 已存在未结束的会话", + "SITE_OFFLINE": "现场端离线", + "CIDR_INVALID": "远程网段格式无效", + "CIDR_LOCAL_CONFLICT": "远程网段与 Engineer 本地网络冲突", + "CIDR_OVERLAY_CONFLICT": "远程网段与 Overlay 网段冲突", + "SITE_NO_ROUTE": "现场端没有通往远程网段的明确路由", + "NETSTACK_UNAVAILABLE": "现场端 netstack 网关不可用", + "FLOW_LIMIT_REACHED": "现场端连接流数量已达到上限", + "SESSION_TIMEOUT": "会话建立超时", + "SESSION_INJECT_FAILED": "会话数据包注入失败", + "NODE_RUNTIME_REBUILT": "节点网络运行时已重建", + "NODE_REVOKED": "节点已被撤销", + "ADMIN_DISCONNECT": "管理员强制断开", + "ENGINEER_OPERATOR": "Engineer 操作员主动断开", +} + +func bilingual(value string, labels map[string]string) string { + value = strings.TrimSpace(value) + if label, ok := labels[value]; ok { + return label + "(" + value + ")" + } + return value +} + +// SessionStatus returns a Chinese label with the stable wire value retained. +func SessionStatus(value string) string { return bilingual(value, sessionStatuses) } + +// NodeStatus returns a Chinese label with the stable persisted value retained. +func NodeStatus(value string) string { return bilingual(value, nodeStatuses) } + +// RouteResult returns a Chinese route decision with the stable result retained. +func RouteResult(value string) string { return bilingual(value, routeResults) } + +// Reason translates a stable error/reason code without hiding its original value. +func Reason(value string) string { return bilingual(value, errorCodes) } + +// ErrorMessage translates common Windows/operator errors. Unknown errors are +// deliberately preserved verbatim so diagnostics are never lost. +func ErrorMessage(value string) string { + trimmed := strings.TrimSpace(value) + translations := []struct { + fragment string + chinese string + }{ + {"administrator privileges are required to manage the RemLink Wintun adapter", "需要以管理员身份运行,才能管理 RemLink Wintun 网卡"}, + {"decrypt WireGuard private key: unprotect secret with DPAPI", "无法解密 WireGuard 私钥:identity.json 不是由当前 Windows 系统生成,需重新注册节点"}, + {"Join Token is required for first registration", "首次注册需要在 YAML 中填写 Join Token"}, + {"configured Server URL differs from persisted Node identity", "YAML 中的 Server URL 与已保存的节点身份不一致"}, + {"Site has no route to Remote CIDR", "现场端没有通往远程网段的明确路由"}, + {"Engineer Session runtime is not ready", "Engineer 会话运行时尚未就绪"}, + {"Node Session runtime is not initialized", "节点会话运行时尚未初始化"}, + {"Site Session runtime is not ready", "Site 会话运行时尚未就绪"}, + } + for _, translation := range translations { + if strings.Contains(trimmed, translation.fragment) { + return translation.chinese + "(原始错误:" + trimmed + ")" + } + } + return Reason(trimmed) +} diff --git a/internal/localization/zhcn_test.go b/internal/localization/zhcn_test.go new file mode 100644 index 0000000..0395976 --- /dev/null +++ b/internal/localization/zhcn_test.go @@ -0,0 +1,32 @@ +package localization + +import ( + "strings" + "testing" +) + +func TestStableValuesRemainVisibleInChineseLabels(t *testing.T) { + for _, test := range []struct { + got, chinese, stable string + }{ + {SessionStatus("FAILED"), "失败", "FAILED"}, + {NodeStatus("UNSTABLE"), "连接不稳定", "UNSTABLE"}, + {RouteResult("DEFAULT_ONLY"), "仅有默认路由", "DEFAULT_ONLY"}, + {Reason("SITE_NO_ROUTE"), "没有通往远程网段", "SITE_NO_ROUTE"}, + } { + if !strings.Contains(test.got, test.chinese) || !strings.Contains(test.got, test.stable) { + t.Fatalf("label %q does not contain %q and %q", test.got, test.chinese, test.stable) + } + } +} + +func TestErrorMessageTranslatesKnownErrorsAndPreservesUnknown(t *testing.T) { + known := "decrypt WireGuard private key: unprotect secret with DPAPI: invalid state" + if got := ErrorMessage(known); !strings.Contains(got, "无法解密") || !strings.Contains(got, known) { + t.Fatalf("known error translation = %q", got) + } + unknown := "a future diagnostic with important detail" + if got := ErrorMessage(unknown); got != unknown { + t.Fatalf("unknown error translation = %q, want original", got) + } +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..05a8796 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,228 @@ +package logging + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "gopkg.in/natefinch/lumberjack.v2" +) + +const defaultPacketSampleInterval = 5 * time.Second + +var ErrInvalidModule = errors.New("invalid logging module") + +// RotationConfig controls rolling-file retention. +type RotationConfig struct { + MaxSizeMB int + MaxAgeDays int + MaxBackups int + LocalTime bool + Compress bool +} + +// PacketConfig controls opt-in, rate-limited packet-event metadata logging. +// Raw packet payloads are deliberately not accepted by the sampling API. +type PacketConfig struct { + Enabled bool + SampleInterval time.Duration +} + +// Config controls the slog handler and rolling file. +type Config struct { + File string + Level slog.Level + JSON bool + Console io.Writer + Rotation RotationConfig + Packets PacketConfig +} + +// DefaultConfig returns production-safe defaults with packet events disabled. +func DefaultConfig(file string) Config { + return Config{ + File: file, + Level: slog.LevelInfo, + JSON: true, + Console: os.Stderr, + Rotation: RotationConfig{ + MaxSizeMB: 50, + MaxAgeDays: 14, + MaxBackups: 5, + LocalTime: true, + Compress: true, + }, + Packets: PacketConfig{ + Enabled: false, + SampleInterval: defaultPacketSampleInterval, + }, + } +} + +// Logger owns the structured logger and its rolling file writer. +type Logger struct { + base *slog.Logger + file *lumberjack.Logger + packets packetSampler + security packetSampler +} + +// New constructs a structured logger writing to a rolling file and optionally a console. +func New(config Config) (*Logger, error) { + if config.File == "" { + return nil, fmt.Errorf("log file path must not be empty") + } + applyRotationDefaults(&config.Rotation) + if config.Packets.SampleInterval <= 0 { + config.Packets.SampleInterval = defaultPacketSampleInterval + } + if err := os.MkdirAll(filepath.Dir(config.File), 0o750); err != nil { + return nil, fmt.Errorf("create log directory: %w", err) + } + + rollingFile := &lumberjack.Logger{ + Filename: config.File, + MaxSize: config.Rotation.MaxSizeMB, + MaxAge: config.Rotation.MaxAgeDays, + MaxBackups: config.Rotation.MaxBackups, + LocalTime: config.Rotation.LocalTime, + Compress: config.Rotation.Compress, + } + var writer io.Writer = rollingFile + if config.Console != nil { + writer = io.MultiWriter(config.Console, rollingFile) + } + + handlerOptions := &slog.HandlerOptions{Level: config.Level} + var handler slog.Handler + if config.JSON { + handler = slog.NewJSONHandler(writer, handlerOptions) + } else { + handler = slog.NewTextHandler(writer, handlerOptions) + } + + logger := &Logger{ + base: slog.New(handler), + file: rollingFile, + } + logger.packets = packetSampler{ + enabled: config.Packets.Enabled, + interval: config.Packets.SampleInterval, + last: make(map[packetSampleKey]time.Time), + } + logger.security = packetSampler{ + enabled: true, interval: defaultPacketSampleInterval, + last: make(map[packetSampleKey]time.Time), + } + return logger, nil +} + +// For returns a logger permanently tagged with a validated module. +func (l *Logger) For(module Module) (*slog.Logger, error) { + if !module.Valid() { + return nil, fmt.Errorf("%w: %q", ErrInvalidModule, module) + } + return l.base.With(slog.String("module", string(module))), nil +} + +// SamplePacketDebug emits at most one DEBUG metadata record per module/key interval. +// It returns true only when the sample passed the limiter. Callers must never add raw +// packet bytes to attrs. +func (l *Logger) SamplePacketDebug( + ctx context.Context, + module Module, + key string, + message string, + attrs ...slog.Attr, +) (bool, error) { + if !module.Valid() { + return false, fmt.Errorf("%w: %q", ErrInvalidModule, module) + } + if !l.packets.allow(module, key, time.Now()) { + return false, nil + } + logger := l.base.With( + slog.String("module", string(module)), + slog.String("packet_sample", key), + ) + logger.LogAttrs(ctx, slog.LevelDebug, message, attrs...) + return true, nil +} + +// SampleSecurityWarning emits rate-limited metadata for rejected or dropped +// packets. It is always active because these are security/diagnostic events, +// while routine per-packet DEBUG sampling remains opt-in. Raw payload bytes are +// deliberately not accepted by this API. +func (l *Logger) SampleSecurityWarning( + ctx context.Context, + module Module, + key string, + message string, + attrs ...slog.Attr, +) (bool, error) { + if !module.Valid() { + return false, fmt.Errorf("%w: %q", ErrInvalidModule, module) + } + if !l.security.allow(module, key, time.Now()) { + return false, nil + } + logger := l.base.With( + slog.String("module", string(module)), + slog.String("security_sample", key), + ) + logger.LogAttrs(ctx, slog.LevelWarn, message, attrs...) + return true, nil +} + +// Close flushes and closes the rolling file. It is safe to call more than once. +func (l *Logger) Close() error { + if l == nil || l.file == nil { + return nil + } + return l.file.Close() +} + +func applyRotationDefaults(config *RotationConfig) { + if config.MaxSizeMB <= 0 { + config.MaxSizeMB = 50 + } + if config.MaxAgeDays <= 0 { + config.MaxAgeDays = 14 + } + if config.MaxBackups <= 0 { + config.MaxBackups = 5 + } +} + +type packetSampleKey struct { + module Module + key string +} + +type packetSampler struct { + mu sync.Mutex + enabled bool + interval time.Duration + last map[packetSampleKey]time.Time +} + +func (s *packetSampler) allow(module Module, key string, now time.Time) bool { + if !s.enabled { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + + sample := packetSampleKey{module: module, key: key} + if previous, ok := s.last[sample]; ok && now.Sub(previous) < s.interval { + return false + } + s.last[sample] = now + return true +} diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go new file mode 100644 index 0000000..4a22d09 --- /dev/null +++ b/internal/logging/logger_test.go @@ -0,0 +1,138 @@ +package logging + +import ( + "bytes" + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestModulesMatchV1Taxonomy(t *testing.T) { + t.Parallel() + if got, want := len(Modules), 11; got != want { + t.Fatalf("Modules length = %d, want %d", got, want) + } + seen := make(map[Module]struct{}, len(Modules)) + for _, module := range Modules { + if !module.Valid() { + t.Fatalf("listed module %q is invalid", module) + } + if _, duplicate := seen[module]; duplicate { + t.Fatalf("duplicate module %q", module) + } + seen[module] = struct{}{} + } +} + +func TestLoggerWritesStructuredModuleAndClosesIdempotently(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "remlink.log") + var console bytes.Buffer + config := DefaultConfig(path) + config.Console = &console + logger, err := New(config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + core, err := logger.For(ModuleCore) + if err != nil { + t.Fatalf("For() error = %v", err) + } + core.Info("started", slog.String("version", "test")) + if err := logger.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := logger.Close(); err != nil { + t.Fatalf("second Close() error = %v", err) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + text := string(contents) + if !strings.Contains(text, `"module":"CORE"`) || !strings.Contains(text, `"msg":"started"`) { + t.Fatalf("structured log missing expected fields: %s", text) + } + if console.Len() == 0 { + t.Fatal("console writer received no log record") + } +} + +func TestPacketSamplingDefaultsOffAndRateLimits(t *testing.T) { + t.Parallel() + ctx := context.Background() + disabledConfig := DefaultConfig(filepath.Join(t.TempDir(), "disabled.log")) + disabledConfig.Console = nil + disabled, err := New(disabledConfig) + if err != nil { + t.Fatalf("New(disabled) error = %v", err) + } + t.Cleanup(func() { _ = disabled.Close() }) + logged, err := disabled.SamplePacketDebug(ctx, ModuleSubnet, "invalid-header", "packet rejected") + if err != nil || logged { + t.Fatalf("disabled SamplePacketDebug() = (%v, %v), want (false, nil)", logged, err) + } + + enabledConfig := DefaultConfig(filepath.Join(t.TempDir(), "enabled.log")) + enabledConfig.Console = nil + enabledConfig.Level = slog.LevelDebug + enabledConfig.Packets.Enabled = true + enabledConfig.Packets.SampleInterval = time.Hour + enabled, err := New(enabledConfig) + if err != nil { + t.Fatalf("New(enabled) error = %v", err) + } + t.Cleanup(func() { _ = enabled.Close() }) + first, err := enabled.SamplePacketDebug(ctx, ModuleSubnet, "invalid-header", "packet rejected") + if err != nil || !first { + t.Fatalf("first SamplePacketDebug() = (%v, %v), want (true, nil)", first, err) + } + second, err := enabled.SamplePacketDebug(ctx, ModuleSubnet, "invalid-header", "packet rejected") + if err != nil || second { + t.Fatalf("second SamplePacketDebug() = (%v, %v), want (false, nil)", second, err) + } + different, err := enabled.SamplePacketDebug(ctx, ModuleSubnet, "source-mismatch", "packet rejected") + if err != nil || !different { + t.Fatalf("different-key SamplePacketDebug() = (%v, %v), want (true, nil)", different, err) + } +} + +func TestSecurityWarningIsAlwaysRateLimited(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "security.log") + config := DefaultConfig(path) + config.Console = nil + logger, err := New(config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = logger.Close() }) + first, err := logger.SampleSecurityWarning(context.Background(), ModuleSubnet, "invalid-datagram", "packet rejected") + if err != nil || !first { + t.Fatalf("first warning = (%v, %v)", first, err) + } + second, err := logger.SampleSecurityWarning(context.Background(), ModuleSubnet, "invalid-datagram", "packet rejected") + if err != nil || second { + t.Fatalf("second warning = (%v, %v)", second, err) + } +} + +func TestLoggerRejectsUnknownModule(t *testing.T) { + t.Parallel() + config := DefaultConfig(filepath.Join(t.TempDir(), "remlink.log")) + config.Console = nil + logger, err := New(config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(func() { _ = logger.Close() }) + if _, err := logger.For(Module("PACKET")); !errors.Is(err, ErrInvalidModule) { + t.Fatalf("For() error = %v, want %v", err, ErrInvalidModule) + } +} diff --git a/internal/logging/module.go b/internal/logging/module.go new file mode 100644 index 0000000..a9cacfa --- /dev/null +++ b/internal/logging/module.go @@ -0,0 +1,54 @@ +// Package logging configures structured RemLink logs and packet sampling. +package logging + +// Module identifies the subsystem that emitted a log record. +type Module string + +const ( + ModuleCore Module = "CORE" + ModuleBootstrap Module = "BOOTSTRAP" + ModuleWG Module = "WG" + ModuleIPAM Module = "IPAM" + ModuleControl Module = "CONTROL" + ModuleSession Module = "SESSION" + ModuleRoute Module = "ROUTE" + ModuleNetstack Module = "NETSTACK" + ModuleTUN Module = "TUN" + ModuleSubnet Module = "SUBNET" + ModuleSystem Module = "SYSTEM" +) + +// Modules is the complete v1 logging-module set. +var Modules = [...]Module{ + ModuleCore, + ModuleBootstrap, + ModuleWG, + ModuleIPAM, + ModuleControl, + ModuleSession, + ModuleRoute, + ModuleNetstack, + ModuleTUN, + ModuleSubnet, + ModuleSystem, +} + +// Valid reports whether the module belongs to the v1 logging taxonomy. +func (m Module) Valid() bool { + switch m { + case ModuleCore, + ModuleBootstrap, + ModuleWG, + ModuleIPAM, + ModuleControl, + ModuleSession, + ModuleRoute, + ModuleNetstack, + ModuleTUN, + ModuleSubnet, + ModuleSystem: + return true + default: + return false + } +} diff --git a/internal/model/event.go b/internal/model/event.go new file mode 100644 index 0000000..ca82147 --- /dev/null +++ b/internal/model/event.go @@ -0,0 +1,27 @@ +package model + +import ( + "encoding/json" + "time" +) + +type EventLog struct { + ID int64 `json:"id"` + Time time.Time `json:"time"` + Level string `json:"level"` + Module string `json:"module"` + NodeID string `json:"node_id,omitempty"` + SessionID uint64 `json:"session_id,omitempty,string"` + Message string `json:"message"` + FieldsJSON json.RawMessage `json:"fields"` +} + +type EventLogFilter struct { + Level string + Module string + NodeID string + SessionID uint64 + From time.Time + To time.Time + Limit int +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..ef48157 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,75 @@ +package model + +import ( + "encoding/json" + "math" + "strings" + "testing" +) + +func TestNodeTypesAreCompleteAndValid(t *testing.T) { + t.Parallel() + if got, want := len(NodeTypes), 2; got != want { + t.Fatalf("NodeTypes length = %d, want %d", got, want) + } + for _, nodeType := range NodeTypes { + if !nodeType.Valid() { + t.Fatalf("listed node type %q is invalid", nodeType) + } + } + if _, err := ParseNodeType("server"); err == nil { + t.Fatal("ParseNodeType accepted the Server, which is not a Windows Node role") + } +} + +func TestNodeStatusesAreCompleteAndValid(t *testing.T) { + t.Parallel() + for _, status := range []NodeStatus{NodeOnline, NodeUnstable, NodeOffline} { + if !status.Valid() { + t.Fatalf("listed Node status %q is invalid", status) + } + } + if NodeStatus("UNKNOWN").Valid() { + t.Fatal("unknown Node status accepted") + } +} + +func TestSessionStatusesAreCompleteAndValid(t *testing.T) { + t.Parallel() + if got, want := len(SessionStatuses), 7; got != want { + t.Fatalf("SessionStatuses length = %d, want %d", got, want) + } + seen := make(map[SessionStatus]struct{}, len(SessionStatuses)) + for _, status := range SessionStatuses { + if !status.Valid() { + t.Fatalf("listed Session status %q is invalid", status) + } + if _, duplicate := seen[status]; duplicate { + t.Fatalf("duplicate Session status %q", status) + } + seen[status] = struct{}{} + } + if _, err := ParseSessionStatus("RUNNING"); err == nil { + t.Fatal("ParseSessionStatus accepted a non-spec state") + } +} + +func TestSessionIDsUseLosslessJSONDecimalStrings(t *testing.T) { + t.Parallel() + session := Session{ID: math.MaxUint64, Status: SessionActive} + raw, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"session_id":"18446744073709551615"`) { + t.Fatalf("SessionID was not encoded losslessly for JavaScript clients: %s", raw) + } + var decoded Session + if err := json.Unmarshal(raw, &decoded); err != nil || decoded.ID != session.ID { + t.Fatalf("SessionID round trip = %d, %v", decoded.ID, err) + } + eventRaw, err := json.Marshal(EventLog{SessionID: math.MaxUint64}) + if err != nil || !strings.Contains(string(eventRaw), `"session_id":"18446744073709551615"`) { + t.Fatalf("Event SessionID was not encoded losslessly: %s, %v", eventRaw, err) + } +} diff --git a/internal/model/node.go b/internal/model/node.go new file mode 100644 index 0000000..8803a75 --- /dev/null +++ b/internal/model/node.go @@ -0,0 +1,76 @@ +// Package model contains transport-independent RemLink domain values. +package model + +import ( + "fmt" + "net/netip" + "time" +) + +// NodeType identifies the role of a RemLink Windows node. +type NodeType string + +const ( + NodeTypeEngineer NodeType = "engineer" + NodeTypeSite NodeType = "site" +) + +// NodeTypes is the complete v1 node-role set. +var NodeTypes = [...]NodeType{ + NodeTypeEngineer, + NodeTypeSite, +} + +// Valid reports whether the node type is part of the v1 protocol. +func (t NodeType) Valid() bool { + switch t { + case NodeTypeEngineer, NodeTypeSite: + return true + default: + return false + } +} + +// ParseNodeType parses and validates a wire/storage node type. +func ParseNodeType(value string) (NodeType, error) { + t := NodeType(value) + if !t.Valid() { + return "", fmt.Errorf("invalid node type %q", value) + } + return t, nil +} + +// NodeStatus is the Server-observed application status of a Windows node. +type NodeStatus string + +const ( + NodeOnline NodeStatus = "ONLINE" + NodeUnstable NodeStatus = "UNSTABLE" + NodeOffline NodeStatus = "OFFLINE" +) + +// Valid reports whether the status is part of the v1 heartbeat model. +func (s NodeStatus) Valid() bool { + switch s { + case NodeOnline, NodeUnstable, NodeOffline: + return true + default: + return false + } +} + +// Node is the persistent Server-side identity and overlay allocation. +type Node struct { + ID string `json:"node_id"` + Type NodeType `json:"type"` + Name string `json:"name"` + OverlayIP netip.Addr `json:"overlay_ip"` + WGPublicKey string `json:"wg_public_key"` + NodeTokenHash []byte `json:"-"` + Status NodeStatus `json:"status"` + Version string `json:"version"` + OSVersion string `json:"os_version"` + LastSeen *time.Time `json:"last_seen,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/model/session.go b/internal/model/session.go new file mode 100644 index 0000000..9ccdab5 --- /dev/null +++ b/internal/model/session.go @@ -0,0 +1,78 @@ +package model + +import ( + "fmt" + "net/netip" + "time" +) + +// SessionStatus is the Server-authoritative Remote Subnet Session state. +type SessionStatus string + +const ( + SessionCreating SessionStatus = "CREATING" + SessionPreparingSite SessionStatus = "PREPARING_SITE" + SessionReady SessionStatus = "READY" + SessionActive SessionStatus = "ACTIVE" + SessionStopping SessionStatus = "STOPPING" + SessionClosed SessionStatus = "CLOSED" + SessionFailed SessionStatus = "FAILED" +) + +// SessionStatuses is the complete v1 state set. +var SessionStatuses = [...]SessionStatus{ + SessionCreating, + SessionPreparingSite, + SessionReady, + SessionActive, + SessionStopping, + SessionClosed, + SessionFailed, +} + +// Valid reports whether the status belongs to the v1 state machine. +func (s SessionStatus) Valid() bool { + switch s { + case SessionCreating, + SessionPreparingSite, + SessionReady, + SessionActive, + SessionStopping, + SessionClosed, + SessionFailed: + return true + default: + return false + } +} + +// ParseSessionStatus parses and validates a wire/storage Session state. +func ParseSessionStatus(value string) (SessionStatus, error) { + status := SessionStatus(value) + if !status.Valid() { + return "", fmt.Errorf("invalid session status %q", value) + } + return status, nil +} + +// SessionCounters are cumulative and use the Engineer point of view. +type SessionCounters struct { + UploadBytes uint64 `json:"upload_bytes"` + DownloadBytes uint64 `json:"download_bytes"` + UploadPackets uint64 `json:"upload_packets"` + DownloadPackets uint64 `json:"download_packets"` +} + +// Session is the Server-authoritative persisted Remote Subnet lifecycle. +type Session struct { + ID uint64 `json:"session_id,string"` + EngineerNodeID string `json:"engineer_node_id"` + SiteNodeID string `json:"site_node_id"` + Status SessionStatus `json:"status"` + CIDRs []netip.Prefix `json:"cidrs"` + CreatedAt time.Time `json:"created_at"` + ActiveAt *time.Time `json:"active_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Counters SessionCounters `json:"counters"` +} diff --git a/internal/nodeagent/options.go b/internal/nodeagent/options.go new file mode 100644 index 0000000..68888a8 --- /dev/null +++ b/internal/nodeagent/options.go @@ -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 +} diff --git a/internal/nodeagent/run_other.go b/internal/nodeagent/run_other.go new file mode 100644 index 0000000..d9cf459 --- /dev/null +++ b/internal/nodeagent/run_other.go @@ -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 } diff --git a/internal/nodeagent/run_windows.go b/internal/nodeagent/run_windows.go new file mode 100644 index 0000000..8028663 --- /dev/null +++ b/internal/nodeagent/run_windows.go @@ -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("%s:Overlay 网段 %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): + } + } +} diff --git a/internal/nodeagent/run_windows_test.go b/internal/nodeagent/run_windows_test.go new file mode 100644 index 0000000..6209f63 --- /dev/null +++ b/internal/nodeagent/run_windows_test.go @@ -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") + } +} diff --git a/internal/overlay/clientwg/adapter.go b/internal/overlay/clientwg/adapter.go new file mode 100644 index 0000000..3dd3753 --- /dev/null +++ b/internal/overlay/clientwg/adapter.go @@ -0,0 +1,22 @@ +package clientwg + +import ( + "errors" + "log/slog" + + "golang.zx2c4.com/wireguard/tun" +) + +// Adapter supplies the one RemLink TUN device created by platform/windows. +type Adapter interface { + Device() tun.Device +} + +// NewFromAdapter transfers ownership of the Adapter's live TUN session to an +// embedded wireguard-go Device. +func NewFromAdapter(adapter Adapter, logger *slog.Logger) (*Device, error) { + if adapter == nil { + return nil, errors.New("RemLink adapter must not be nil") + } + return NewDevice(adapter.Device(), logger) +} diff --git a/internal/overlay/clientwg/config.go b/internal/overlay/clientwg/config.go new file mode 100644 index 0000000..a32241b --- /dev/null +++ b/internal/overlay/clientwg/config.go @@ -0,0 +1,92 @@ +package clientwg + +import ( + "encoding/hex" + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "time" +) + +const keySize = 32 + +// Key is one raw 32-byte WireGuard private or public key. +type Key [keySize]byte + +// Config is the one-Server-peer wireguard-go configuration used by v1 Nodes. +type Config struct { + PrivateKey Key + ServerPublicKey Key + ServerEndpoint string + OverlayAllowedIPs []netip.Prefix + ListenPort uint16 + PersistentKeepalive time.Duration +} + +func (c Config) uapi() (string, error) { + if err := c.validate(); err != nil { + return "", err + } + + var builder strings.Builder + fmt.Fprintf(&builder, "private_key=%s\n", hex.EncodeToString(c.PrivateKey[:])) + fmt.Fprintf(&builder, "listen_port=%d\n", c.ListenPort) + builder.WriteString("replace_peers=true\n") + fmt.Fprintf(&builder, "public_key=%s\n", hex.EncodeToString(c.ServerPublicKey[:])) + fmt.Fprintf(&builder, "endpoint=%s\n", c.ServerEndpoint) + fmt.Fprintf(&builder, "persistent_keepalive_interval=%d\n", int(c.PersistentKeepalive/time.Second)) + builder.WriteString("replace_allowed_ips=true\n") + for _, prefix := range c.OverlayAllowedIPs { + fmt.Fprintf(&builder, "allowed_ip=%s\n", prefix) + } + builder.WriteString("\n") + return builder.String(), nil +} + +func (c Config) validate() error { + if zeroKey(c.PrivateKey) { + return errors.New("WireGuard private key must not be zero") + } + if zeroKey(c.ServerPublicKey) { + return errors.New("Server WireGuard public key must not be zero") + } + if c.PrivateKey == c.ServerPublicKey { + return errors.New("Node private key and Server public key must differ") + } + host, portText, err := net.SplitHostPort(c.ServerEndpoint) + if err != nil { + return fmt.Errorf("Server WireGuard endpoint must be host:port: %w", err) + } + if host == "" || portText == "" { + return errors.New("Server WireGuard endpoint must include a host and port") + } + port, err := strconv.ParseUint(portText, 10, 16) + if err != nil || port == 0 { + return errors.New("Server WireGuard endpoint port must be between 1 and 65535") + } + if len(c.OverlayAllowedIPs) != 1 { + return errors.New("v1 requires exactly one Overlay AllowedIP and one Server peer") + } + for _, prefix := range c.OverlayAllowedIPs { + if !prefix.IsValid() || !prefix.Addr().Is4() || prefix.Bits() == 0 { + return fmt.Errorf("Overlay AllowedIP must be IPv4: %s", prefix) + } + if prefix != prefix.Masked() { + return fmt.Errorf("Overlay AllowedIP must use its network address: %s", prefix) + } + } + if c.PersistentKeepalive < 0 || c.PersistentKeepalive > 65535*time.Second { + return errors.New("persistent keepalive must be between 0 and 65535 seconds") + } + if c.PersistentKeepalive%time.Second != 0 { + return errors.New("persistent keepalive must be a whole number of seconds") + } + return nil +} + +func zeroKey(key Key) bool { + return key == Key{} +} diff --git a/internal/overlay/clientwg/config_test.go b/internal/overlay/clientwg/config_test.go new file mode 100644 index 0000000..de2e57e --- /dev/null +++ b/internal/overlay/clientwg/config_test.go @@ -0,0 +1,83 @@ +package clientwg + +import ( + "net/netip" + "strings" + "testing" + "time" +) + +func TestConfigUAPIGolden(t *testing.T) { + t.Parallel() + privateKey := Key{1, 2, 3} + serverKey := Key{4, 5, 6} + config := Config{ + PrivateKey: privateKey, + ServerPublicKey: serverKey, + ServerEndpoint: "203.0.113.10:51820", + OverlayAllowedIPs: []netip.Prefix{netip.MustParsePrefix("10.88.0.0/16")}, + PersistentKeepalive: 25 * time.Second, + } + got, err := config.uapi() + if err != nil { + t.Fatalf("uapi() error = %v", err) + } + checks := []string{ + "private_key=0102030000000000000000000000000000000000000000000000000000000000\n", + "listen_port=0\n", + "replace_peers=true\n", + "public_key=0405060000000000000000000000000000000000000000000000000000000000\n", + "endpoint=203.0.113.10:51820\n", + "persistent_keepalive_interval=25\n", + "replace_allowed_ips=true\n", + "allowed_ip=10.88.0.0/16\n", + } + for _, check := range checks { + if !strings.Contains(got, check) { + t.Fatalf("uapi() missing %q in:\n%s", check, got) + } + } + if !strings.HasSuffix(got, "\n\n") { + t.Fatalf("uapi() must terminate with a blank line: %q", got) + } +} + +func TestConfigRejectsNonV1Values(t *testing.T) { + t.Parallel() + valid := Config{ + PrivateKey: Key{1}, + ServerPublicKey: Key{2}, + ServerEndpoint: "203.0.113.10:51820", + OverlayAllowedIPs: []netip.Prefix{netip.MustParsePrefix("10.88.0.0/16")}, + PersistentKeepalive: 25 * time.Second, + } + tests := []struct { + name string + mutate func(*Config) + }{ + {name: "zero private key", mutate: func(c *Config) { c.PrivateKey = Key{} }}, + {name: "zero public key", mutate: func(c *Config) { c.ServerPublicKey = Key{} }}, + {name: "same keys", mutate: func(c *Config) { c.ServerPublicKey = c.PrivateKey }}, + {name: "endpoint", mutate: func(c *Config) { c.ServerEndpoint = "203.0.113.10" }}, + {name: "endpoint port", mutate: func(c *Config) { c.ServerEndpoint = "203.0.113.10:not-a-port" }}, + {name: "no AllowedIPs", mutate: func(c *Config) { c.OverlayAllowedIPs = nil }}, + {name: "multiple AllowedIPs", mutate: func(c *Config) { + c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("10.88.0.0/16"), netip.MustParsePrefix("192.168.13.0/24")} + }}, + {name: "IPv6", mutate: func(c *Config) { c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("fd00::/64")} }}, + {name: "Exit Node", mutate: func(c *Config) { c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")} }}, + {name: "unmasked", mutate: func(c *Config) { c.OverlayAllowedIPs = []netip.Prefix{netip.MustParsePrefix("10.88.0.1/16")} }}, + {name: "fractional keepalive", mutate: func(c *Config) { c.PersistentKeepalive = 1500 * time.Millisecond }}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + config := valid + test.mutate(&config) + if _, err := config.uapi(); err == nil { + t.Fatal("uapi() unexpectedly accepted invalid config") + } + }) + } +} diff --git a/internal/overlay/clientwg/device.go b/internal/overlay/clientwg/device.go new file mode 100644 index 0000000..beed22f --- /dev/null +++ b/internal/overlay/clientwg/device.go @@ -0,0 +1,84 @@ +package clientwg + +import ( + "errors" + "fmt" + "log/slog" + "sync" + + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun" +) + +// Device owns the MuxTun, wireguard-go engine, and transport bind. +type Device struct { + tun *MuxTun + wireguard *device.Device + closeOnce sync.Once +} + +// NewDevice transfers ownership of base to a new embedded wireguard-go Device. +func NewDevice(base tun.Device, logger *slog.Logger) (*Device, error) { + if base == nil { + return nil, errors.New("base TUN device must not be nil") + } + mux := NewMuxTun(base) + wireguard := device.NewDevice(mux, conn.NewDefaultBind(), wireGuardLogger(logger)) + return &Device{tun: mux, wireguard: wireguard}, nil +} + +// Configure atomically replaces the single Server peer through wireguard-go UAPI. +func (d *Device) Configure(config Config) error { + uapi, err := config.uapi() + if err != nil { + return err + } + if err := d.wireguard.IpcSet(uapi); err != nil { + return fmt.Errorf("configure embedded wireguard-go: %w", err) + } + return nil +} + +// Up starts the embedded WireGuard device after configuration. +func (d *Device) Up() error { + if err := d.wireguard.Up(); err != nil { + return fmt.Errorf("bring embedded wireguard-go up: %w", err) + } + return nil +} + +// UAPIState returns wireguard-go's current UAPI state for diagnostics. +func (d *Device) UAPIState() (string, error) { + return d.wireguard.IpcGet() +} + +// MuxTun returns the single packet boundary used by Session transport. +func (d *Device) MuxTun() *MuxTun { return d.tun } + +// Close is idempotent and stops wireguard-go, MuxTun, and the base Wintun. +func (d *Device) Close() { + if d == nil || d.wireguard == nil { + return + } + d.closeOnce.Do(func() { + d.wireguard.Close() + }) +} + +func wireGuardLogger(logger *slog.Logger) *device.Logger { + if logger == nil { + return &device.Logger{ + Verbosef: device.DiscardLogf, + Errorf: device.DiscardLogf, + } + } + return &device.Logger{ + Verbosef: func(format string, args ...any) { + logger.Debug(fmt.Sprintf(format, args...)) + }, + Errorf: func(format string, args ...any) { + logger.Error(fmt.Sprintf(format, args...)) + }, + } +} diff --git a/internal/overlay/clientwg/key.go b/internal/overlay/clientwg/key.go new file mode 100644 index 0000000..db47423 --- /dev/null +++ b/internal/overlay/clientwg/key.go @@ -0,0 +1,20 @@ +package clientwg + +import ( + "encoding/base64" + "fmt" +) + +// ParseKeyBase64 parses the standard WireGuard 32-byte base64 key format. +func ParseKeyBase64(value string) (Key, error) { + var key Key + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return key, fmt.Errorf("decode WireGuard key: %w", err) + } + if len(decoded) != keySize { + return key, fmt.Errorf("WireGuard key decoded length is %d, want %d", len(decoded), keySize) + } + copy(key[:], decoded) + return key, nil +} diff --git a/internal/overlay/clientwg/key_test.go b/internal/overlay/clientwg/key_test.go new file mode 100644 index 0000000..3e5c8a4 --- /dev/null +++ b/internal/overlay/clientwg/key_test.go @@ -0,0 +1,27 @@ +package clientwg + +import ( + "encoding/base64" + "testing" +) + +func TestParseKeyBase64(t *testing.T) { + t.Parallel() + raw := make([]byte, keySize) + for index := range raw { + raw[index] = byte(index + 1) + } + key, err := ParseKeyBase64(base64.StdEncoding.EncodeToString(raw)) + if err != nil { + t.Fatalf("ParseKeyBase64() error = %v", err) + } + if key[0] != 1 || key[31] != 32 { + t.Fatalf("ParseKeyBase64() returned unexpected bytes: first=%d last=%d", key[0], key[31]) + } + if _, err := ParseKeyBase64("not-base64"); err == nil { + t.Fatal("ParseKeyBase64() accepted invalid base64") + } + if _, err := ParseKeyBase64(base64.StdEncoding.EncodeToString([]byte{1, 2, 3})); err == nil { + t.Fatal("ParseKeyBase64() accepted a short key") + } +} diff --git a/internal/overlay/clientwg/muxtun.go b/internal/overlay/clientwg/muxtun.go new file mode 100644 index 0000000..19a2cdc --- /dev/null +++ b/internal/overlay/clientwg/muxtun.go @@ -0,0 +1,121 @@ +// Package clientwg embeds wireguard-go around the single RemLink Wintun. +package clientwg + +import ( + "errors" + "os" + "sync" + "sync/atomic" + + "golang.zx2c4.com/wireguard/tun" +) + +var _ tun.Device = (*MuxTun)(nil) + +// MuxTun is the deliberately thin Phase 1 wrapper around the real Wintun. +// Later phases add Read-side CIDR classification without changing this surface. +type MuxTun struct { + base tun.Device + router atomic.Pointer[PacketMux] + writeMu sync.Mutex + closeOnce sync.Once + closeErr error +} + +// NewMuxTun wraps a live TUN device without changing its semantics. +func NewMuxTun(base tun.Device) *MuxTun { + return &MuxTun{base: base} +} + +func (m *MuxTun) File() *os.File { + return m.base.File() +} + +func (m *MuxTun) Read(bufs [][]byte, sizes []int, offset int) (int, error) { + router := m.router.Load() + if router == nil { + return m.base.Read(bufs, sizes, offset) + } + if len(bufs) == 0 || len(sizes) < len(bufs) || offset < 0 { + return 0, errors.New("invalid MuxTun read buffers") + } + for { + count, readErr := m.base.Read(bufs, sizes, offset) + if count < 0 || count > len(bufs) { + return 0, errors.New("base TUN returned invalid batch count") + } + overlayCount := 0 + for index := 0; index < count; index++ { + size := sizes[index] + if size < 0 || offset+size > len(bufs[index]) { + return 0, errors.New("base TUN returned invalid packet size") + } + packet := bufs[index][offset : offset+size] + class, sink := router.route(packet) + switch class { + case PacketOverlay: + if overlayCount != index { + if offset+size > len(bufs[overlayCount]) { + return 0, errors.New("destination batch buffer is too small") + } + copy(bufs[overlayCount][offset:offset+size], packet) + } + sizes[overlayCount] = size + overlayCount++ + case PacketRemote: + if sink != nil { + owned := append([]byte(nil), packet...) + if !sink.Enqueue(owned) { + router.recordDrop(DropEvent{Reason: DropRemoteQueueFull}) + } + } + } + } + if overlayCount > 0 || readErr != nil { + return overlayCount, readErr + } + // A batch containing only Remote/dropped packets is consumed here. Read + // again instead of returning 0,nil to wireguard-go and spinning it. + } +} + +func (m *MuxTun) Write(bufs [][]byte, offset int) (int, error) { + m.writeMu.Lock() + defer m.writeMu.Unlock() + return m.base.Write(bufs, offset) +} + +// SetPacketMux enables or replaces Engineer outbound classification. A nil +// value restores Phase 1 passthrough behavior. +func (m *MuxTun) SetPacketMux(router *PacketMux) { m.router.Store(router) } + +// InjectInbound serializes Site Session replies with wireguard-go writes. +func (m *MuxTun) InjectInbound(packet []byte) error { + m.writeMu.Lock() + defer m.writeMu.Unlock() + _, err := m.base.Write([][]byte{packet}, 0) + return err +} + +func (m *MuxTun) MTU() (int, error) { + return m.base.MTU() +} + +func (m *MuxTun) Name() (string, error) { + return m.base.Name() +} + +func (m *MuxTun) Events() <-chan tun.Event { + return m.base.Events() +} + +func (m *MuxTun) Close() error { + m.closeOnce.Do(func() { + m.closeErr = m.base.Close() + }) + return m.closeErr +} + +func (m *MuxTun) BatchSize() int { + return m.base.BatchSize() +} diff --git a/internal/overlay/clientwg/muxtun_test.go b/internal/overlay/clientwg/muxtun_test.go new file mode 100644 index 0000000..b5af8b1 --- /dev/null +++ b/internal/overlay/clientwg/muxtun_test.go @@ -0,0 +1,96 @@ +package clientwg + +import ( + "errors" + "os" + "testing" + + "golang.zx2c4.com/wireguard/tun" +) + +func TestMuxTunProxiesBaseSemanticsAndClosesOnce(t *testing.T) { + t.Parallel() + base := newFakeTUN() + mux := NewMuxTun(base) + if name, err := mux.Name(); err != nil || name != "RemLink" { + t.Fatalf("Name() = (%q, %v)", name, err) + } + if mtu, err := mux.MTU(); err != nil || mtu != 1280 { + t.Fatalf("MTU() = (%d, %v)", mtu, err) + } + if mux.BatchSize() != 1 { + t.Fatalf("BatchSize() = %d, want 1", mux.BatchSize()) + } + if mux.Events() != base.events { + t.Fatal("Events() did not return the base channel") + } + + buffer := make([]byte, 32) + sizes := make([]int, 1) + n, err := mux.Read([][]byte{buffer}, sizes, 4) + if err != nil || n != 1 || sizes[0] != 3 || string(buffer[4:7]) != "out" { + t.Fatalf("Read() = n=%d sizes=%v data=%q err=%v", n, sizes, buffer[4:7], err) + } + n, err = mux.Write([][]byte{[]byte("xxxxin")}, 4) + if err != nil || n != 1 || string(base.written) != "in" { + t.Fatalf("Write() = n=%d written=%q err=%v", n, base.written, err) + } + + if err := mux.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := mux.Close(); err != nil { + t.Fatalf("second Close() error = %v", err) + } + if base.closeCalls != 1 { + t.Fatalf("base Close() calls = %d, want 1", base.closeCalls) + } +} + +func TestMuxTunPreservesBaseErrors(t *testing.T) { + t.Parallel() + want := errors.New("read failed") + base := newFakeTUN() + base.readErr = want + _, err := NewMuxTun(base).Read([][]byte{make([]byte, 8)}, make([]int, 1), 0) + if !errors.Is(err, want) { + t.Fatalf("Read() error = %v, want %v", err, want) + } +} + +type fakeTUN struct { + events chan tun.Event + written []byte + readErr error + closeCalls int +} + +func newFakeTUN() *fakeTUN { + return &fakeTUN{events: make(chan tun.Event, 1)} +} + +func (f *fakeTUN) File() *os.File { return nil } + +func (f *fakeTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) { + if f.readErr != nil { + return 0, f.readErr + } + sizes[0] = copy(bufs[0][offset:], []byte("out")) + return 1, nil +} + +func (f *fakeTUN) Write(bufs [][]byte, offset int) (int, error) { + f.written = append(f.written[:0], bufs[0][offset:]...) + return len(bufs), nil +} + +func (f *fakeTUN) MTU() (int, error) { return 1280, nil } +func (f *fakeTUN) Name() (string, error) { return "RemLink", nil } +func (f *fakeTUN) Events() <-chan tun.Event { + return f.events +} +func (f *fakeTUN) Close() error { + f.closeCalls++ + return nil +} +func (f *fakeTUN) BatchSize() int { return 1 } diff --git a/internal/overlay/clientwg/packetmux.go b/internal/overlay/clientwg/packetmux.go new file mode 100644 index 0000000..a8c0251 --- /dev/null +++ b/internal/overlay/clientwg/packetmux.go @@ -0,0 +1,134 @@ +package clientwg + +import ( + "net/netip" + "sync/atomic" +) + +// PacketClass is the only Engineer outbound routing decision. +type PacketClass uint8 + +const ( + PacketDrop PacketClass = iota + PacketOverlay + PacketRemote +) + +type DropReason string + +const ( + DropInvalidIPv4 DropReason = "INVALID_IPV4" + DropUnmanagedDestination DropReason = "UNMANAGED_DESTINATION" + DropRemoteQueueFull DropReason = "REMOTE_QUEUE_FULL" +) + +type DropEvent struct { + Reason DropReason + Destination netip.Addr +} + +type dropHandler struct{ callback func(DropEvent) } + +// RemoteSink accepts an owned copy without performing network I/O in Read. +type RemoteSink interface { + Enqueue(packet []byte) bool +} + +type routeSnapshot struct { + overlay netip.Prefix + remote []netip.Prefix + sink RemoteSink +} + +// PacketMux classifies raw IPv4 packets using an atomically replaced snapshot. +type PacketMux struct { + routes atomic.Pointer[routeSnapshot] + onDrop atomic.Pointer[dropHandler] + overlayPackets atomic.Uint64 + remotePackets atomic.Uint64 + remoteBytes atomic.Uint64 + droppedPackets atomic.Uint64 +} + +func NewPacketMux(overlay netip.Prefix, remote []netip.Prefix, sink RemoteSink) *PacketMux { + mux := &PacketMux{} + mux.SetRoutes(overlay, remote, sink) + return mux +} + +// SetRoutes replaces the Active Session CIDRs as one consistent snapshot. +func (m *PacketMux) SetRoutes(overlay netip.Prefix, remote []netip.Prefix, sink RemoteSink) { + copyOfRemote := append([]netip.Prefix(nil), remote...) + m.routes.Store(&routeSnapshot{overlay: overlay.Masked(), remote: copyOfRemote, sink: sink}) +} + +// SetDropHandler installs an optional metadata-only callback for rate-limited +// logging. The callback never receives packet bytes. +func (m *PacketMux) SetDropHandler(callback func(DropEvent)) { + if callback == nil { + m.onDrop.Store(nil) + return + } + m.onDrop.Store(&dropHandler{callback: callback}) +} + +// Classify validates enough of IPv4 to safely read Destination Address. +func (m *PacketMux) Classify(packet []byte) PacketClass { + routes := m.routes.Load() + class, _, _ := classifySnapshot(routes, packet) + return class +} + +func classifySnapshot(routes *routeSnapshot, packet []byte) (PacketClass, RemoteSink, DropEvent) { + if routes == nil || len(packet) < 20 || packet[0]>>4 != 4 { + return PacketDrop, nil, DropEvent{Reason: DropInvalidIPv4} + } + headerLength := int(packet[0]&0x0F) * 4 + totalLength := int(packet[2])<<8 | int(packet[3]) + if headerLength < 20 || headerLength > len(packet) || totalLength < headerLength || totalLength != len(packet) { + return PacketDrop, nil, DropEvent{Reason: DropInvalidIPv4} + } + destination := netip.AddrFrom4([4]byte{packet[16], packet[17], packet[18], packet[19]}) + if routes.overlay.IsValid() && routes.overlay.Contains(destination) { + return PacketOverlay, nil, DropEvent{} + } + for _, prefix := range routes.remote { + if prefix.Contains(destination) { + return PacketRemote, routes.sink, DropEvent{} + } + } + return PacketDrop, nil, DropEvent{Reason: DropUnmanagedDestination, Destination: destination} +} + +// Counters returns cumulative classification outcomes. +func (m *PacketMux) Counters() (overlay, remote, dropped uint64) { + return m.overlayPackets.Load(), m.remotePackets.Load(), m.droppedPackets.Load() +} + +// RemoteCounters is the Engineer-view Upload source of truth: bytes and +// packets are counted when PacketMux intercepts them, before queueing or UDP +// host I/O, exactly as required by the Session statistics contract. +func (m *PacketMux) RemoteCounters() (bytes, packets uint64) { + return m.remoteBytes.Load(), m.remotePackets.Load() +} + +func (m *PacketMux) route(packet []byte) (PacketClass, RemoteSink) { + class, sink, drop := classifySnapshot(m.routes.Load(), packet) + switch class { + case PacketOverlay: + m.overlayPackets.Add(1) + case PacketRemote: + m.remotePackets.Add(1) + m.remoteBytes.Add(uint64(len(packet))) + default: + m.recordDrop(drop) + } + return class, sink +} + +func (m *PacketMux) recordDrop(event DropEvent) { + m.droppedPackets.Add(1) + if handler := m.onDrop.Load(); handler != nil { + handler.callback(event) + } +} diff --git a/internal/overlay/clientwg/packetmux_test.go b/internal/overlay/clientwg/packetmux_test.go new file mode 100644 index 0000000..4b6edcb --- /dev/null +++ b/internal/overlay/clientwg/packetmux_test.go @@ -0,0 +1,189 @@ +package clientwg + +import ( + "net/netip" + "os" + "testing" + + "golang.zx2c4.com/wireguard/tun" +) + +type collectingSink struct{ packets [][]byte } + +func (s *collectingSink) Enqueue(packet []byte) bool { + s.packets = append(s.packets, packet) + return true +} + +type rejectingSink struct{} + +func (rejectingSink) Enqueue([]byte) bool { return false } + +func TestPacketMuxClassifiesIPv4Destination(t *testing.T) { + mux := NewPacketMux( + netip.MustParsePrefix("10.88.0.0/16"), + []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, + nil, + ) + for _, test := range []struct { + packet []byte + want PacketClass + }{ + {ipv4Packet("10.88.0.3"), PacketOverlay}, + {ipv4Packet("192.168.13.10"), PacketRemote}, + {ipv4Packet("8.8.8.8"), PacketDrop}, + {[]byte{0x60, 0, 0, 20}, PacketDrop}, + {[]byte{0x45, 0, 0, 40}, PacketDrop}, + } { + if got := mux.Classify(test.packet); got != test.want { + t.Errorf("Classify(%v) = %v, want %v", test.packet, got, test.want) + } + } +} + +func TestMuxTunConsumesRemoteOnlyBatchUntilOverlay(t *testing.T) { + base := &sequenceTUN{ + events: make(chan tun.Event), + batches: [][][]byte{ + {ipv4Packet("192.168.13.10")}, + {ipv4Packet("10.88.0.3")}, + }, + } + sink := &collectingSink{} + router := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"), + []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, sink) + mux := NewMuxTun(base) + mux.SetPacketMux(router) + buffer := make([]byte, 128) + sizes := make([]int, 1) + count, err := mux.Read([][]byte{buffer}, sizes, 4) + if err != nil { + t.Fatal(err) + } + if count != 1 || base.reads != 2 || len(sink.packets) != 1 { + t.Fatalf("count=%d reads=%d remote=%d", count, base.reads, len(sink.packets)) + } + if destination(buffer[4:4+sizes[0]]) != netip.MustParseAddr("10.88.0.3") { + t.Fatalf("returned packet destination = %s", destination(buffer[4:4+sizes[0]])) + } + if destination(sink.packets[0]) != netip.MustParseAddr("192.168.13.10") { + t.Fatalf("queued packet destination = %s", destination(sink.packets[0])) + } + base.batches[0][0][16] = 1 + if destination(sink.packets[0]) != netip.MustParseAddr("192.168.13.10") { + t.Fatal("RemoteSink packet aliases the base TUN buffer") + } +} + +func TestMuxTunCompactsMixedBatch(t *testing.T) { + base := &sequenceTUN{ + events: make(chan tun.Event), + batches: [][][]byte{{ + ipv4Packet("192.168.13.10"), ipv4Packet("10.88.0.4"), ipv4Packet("8.8.8.8"), ipv4Packet("10.88.0.5"), + }}, + } + sink := &collectingSink{} + router := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"), + []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, sink) + mux := NewMuxTun(base) + mux.SetPacketMux(router) + bufs := [][]byte{make([]byte, 64), make([]byte, 64), make([]byte, 64), make([]byte, 64)} + sizes := make([]int, len(bufs)) + count, err := mux.Read(bufs, sizes, 0) + if err != nil { + t.Fatal(err) + } + if count != 2 || destination(bufs[0][:sizes[0]]).String() != "10.88.0.4" || destination(bufs[1][:sizes[1]]).String() != "10.88.0.5" { + t.Fatalf("compacted count=%d destinations=%s,%s", count, destination(bufs[0][:sizes[0]]), destination(bufs[1][:sizes[1]])) + } + overlay, remote, dropped := router.Counters() + if overlay != 2 || remote != 1 || dropped != 1 { + t.Fatalf("counters overlay=%d remote=%d dropped=%d", overlay, remote, dropped) + } +} + +func TestPacketMuxDropCallbackContainsMetadataOnly(t *testing.T) { + mux := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"), nil, nil) + var events []DropEvent + mux.SetDropHandler(func(event DropEvent) { events = append(events, event) }) + mux.route(ipv4Packet("8.8.8.8")) + mux.route([]byte{0x60}) + if len(events) != 2 || events[0].Reason != DropUnmanagedDestination || events[0].Destination.String() != "8.8.8.8" || events[1].Reason != DropInvalidIPv4 { + t.Fatalf("drop events = %+v", events) + } +} + +func TestPacketMuxCountsInterceptedUploadAndReportsQueueDrop(t *testing.T) { + base := &sequenceTUN{ + events: make(chan tun.Event), + batches: [][][]byte{{ipv4Packet("192.168.13.10")}, {ipv4Packet("10.88.0.3")}}, + } + router := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"), + []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, rejectingSink{}) + var drops []DropEvent + router.SetDropHandler(func(event DropEvent) { drops = append(drops, event) }) + mux := NewMuxTun(base) + mux.SetPacketMux(router) + buffer := make([]byte, 64) + sizes := make([]int, 1) + if _, err := mux.Read([][]byte{buffer}, sizes, 0); err != nil { + t.Fatal(err) + } + bytes, packets := router.RemoteCounters() + _, remote, dropped := router.Counters() + if bytes != 20 || packets != 1 || remote != 1 || dropped != 1 { + t.Fatalf("Upload/drop counters bytes=%d packets=%d remote=%d dropped=%d", bytes, packets, remote, dropped) + } + if len(drops) != 1 || drops[0].Reason != DropRemoteQueueFull { + t.Fatalf("queue drop events = %+v", drops) + } +} + +func TestPacketMuxRejectsTrailingBytesBeyondIPv4TotalLength(t *testing.T) { + packet := append(ipv4Packet("192.168.13.10"), 0) + mux := NewPacketMux(netip.MustParsePrefix("10.88.0.0/16"), + []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, nil) + if got := mux.Classify(packet); got != PacketDrop { + t.Fatalf("Classify packet with trailing bytes = %v, want PacketDrop", got) + } +} + +func ipv4Packet(destinationText string) []byte { + packet := make([]byte, 20) + packet[0] = 0x45 + packet[2] = 0 + packet[3] = 20 + packet[12] = 10 + packet[13] = 88 + packet[14] = 0 + packet[15] = 2 + destination := netip.MustParseAddr(destinationText).As4() + copy(packet[16:20], destination[:]) + return packet +} + +func destination(packet []byte) netip.Addr { + return netip.AddrFrom4([4]byte{packet[16], packet[17], packet[18], packet[19]}) +} + +type sequenceTUN struct { + events chan tun.Event + batches [][][]byte + reads int +} + +func (t *sequenceTUN) File() *os.File { return nil } +func (t *sequenceTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) { + batch := t.batches[t.reads] + t.reads++ + for index, packet := range batch { + sizes[index] = copy(bufs[index][offset:], packet) + } + return len(batch), nil +} +func (t *sequenceTUN) Write(bufs [][]byte, offset int) (int, error) { return len(bufs), nil } +func (t *sequenceTUN) MTU() (int, error) { return 1280, nil } +func (t *sequenceTUN) Name() (string, error) { return "RemLink", nil } +func (t *sequenceTUN) Events() <-chan tun.Event { return t.events } +func (t *sequenceTUN) Close() error { return nil } +func (t *sequenceTUN) BatchSize() int { return 4 } diff --git a/internal/overlay/serverwg/config.go b/internal/overlay/serverwg/config.go new file mode 100644 index 0000000..4e712f3 --- /dev/null +++ b/internal/overlay/serverwg/config.go @@ -0,0 +1,45 @@ +// Package serverwg creates and configures the Linux kernel WireGuard hub. +package serverwg + +import ( + "errors" + "fmt" + "net/netip" +) + +const DefaultInterfaceName = "wg0" + +// Config contains kernel interface and host-forwarding settings. +type Config struct { + InterfaceName string + Address netip.Prefix + ListenPort int + PrivateKeyPath string + EnableForwarding bool + IPTablesPath string +} + +func (c Config) validate() error { + if c.InterfaceName == "" { + return errors.New("WireGuard interface name must not be empty") + } + if !c.Address.Addr().Is4() || c.Address.Bits() > 30 { + return errors.New("WireGuard address must be an IPv4 interface prefix") + } + if c.Address.Addr() == c.Address.Masked().Addr() { + return errors.New("WireGuard address must be a usable host address, not its network address") + } + if c.ListenPort < 1 || c.ListenPort > 65535 { + return fmt.Errorf("WireGuard listen port %d is outside 1..65535", c.ListenPort) + } + if c.PrivateKeyPath == "" { + return errors.New("Server private key path must not be empty") + } + return nil +} + +// Peer is one Node's cryptokey-routing entry. +type Peer struct { + PublicKey string + Address netip.Addr +} diff --git a/internal/overlay/serverwg/config_test.go b/internal/overlay/serverwg/config_test.go new file mode 100644 index 0000000..6863a3d --- /dev/null +++ b/internal/overlay/serverwg/config_test.go @@ -0,0 +1,85 @@ +package serverwg + +import ( + "net/netip" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConfigValidation(t *testing.T) { + valid := Config{ + InterfaceName: "wg0", Address: netip.MustParsePrefix("10.88.0.1/16"), + ListenPort: 51820, PrivateKeyPath: filepath.Join(t.TempDir(), "server.key"), + } + if err := valid.validate(); err != nil { + t.Fatal(err) + } + invalid := valid + invalid.Address = netip.MustParsePrefix("10.88.0.0/16") + if err := invalid.validate(); err == nil { + t.Fatal("network address accepted as Server address") + } +} + +func TestLoadOrCreatePrivateKeyIsConcurrentAndAtomic(t *testing.T) { + path := filepath.Join(t.TempDir(), "keys", "server.key") + const workers = 8 + keys := make(chan string, workers) + errorsSeen := make(chan error, workers) + var group sync.WaitGroup + for range workers { + group.Add(1) + go func() { + defer group.Done() + key, err := LoadOrCreatePrivateKey(path) + if err != nil { + errorsSeen <- err + return + } + keys <- key.String() + }() + } + group.Wait() + close(keys) + close(errorsSeen) + for err := range errorsSeen { + t.Fatal(err) + } + want := "" + for key := range keys { + if want == "" { + want = key + } + if key != want { + t.Fatalf("concurrent creators observed different keys %s and %s", want, key) + } + } + raw, err := os.ReadFile(path) + if err != nil || string(raw) != want+"\n" { + t.Fatalf("published key file = %q, %v", raw, err) + } +} + +func TestLoadOrCreatePrivateKeyIsStable(t *testing.T) { + path := filepath.Join(t.TempDir(), "keys", "server.key") + first, err := LoadOrCreatePrivateKey(path) + if err != nil { + t.Fatal(err) + } + second, err := LoadOrCreatePrivateKey(path) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("Server private key changed on reload") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(raw) != first.String()+"\n" { + t.Fatal("private key file has unexpected contents") + } +} diff --git a/internal/overlay/serverwg/keyfile.go b/internal/overlay/serverwg/keyfile.go new file mode 100644 index 0000000..5595558 --- /dev/null +++ b/internal/overlay/serverwg/keyfile.go @@ -0,0 +1,68 @@ +package serverwg + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// LoadOrCreatePrivateKey reads a Server key or atomically creates a 0600 file. +func LoadOrCreatePrivateKey(path string) (wgtypes.Key, error) { + if raw, err := os.ReadFile(path); err == nil { + key, err := wgtypes.ParseKey(strings.TrimSpace(string(raw))) + if err != nil { + return wgtypes.Key{}, fmt.Errorf("parse Server private key %q: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + return wgtypes.Key{}, fmt.Errorf("restrict Server private key permissions: %w", err) + } + return key, nil + } else if !errors.Is(err, os.ErrNotExist) { + return wgtypes.Key{}, fmt.Errorf("read Server private key %q: %w", path, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return wgtypes.Key{}, fmt.Errorf("create Server key directory: %w", err) + } + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return wgtypes.Key{}, fmt.Errorf("generate Server WireGuard private key: %w", err) + } + directory := filepath.Dir(path) + file, err := os.CreateTemp(directory, ".server-wg-*.tmp") + if err != nil { + return wgtypes.Key{}, fmt.Errorf("create temporary Server private key: %w", err) + } + temporaryPath := file.Name() + defer os.Remove(temporaryPath) + if err := file.Chmod(0o600); err != nil { + file.Close() + return wgtypes.Key{}, fmt.Errorf("restrict temporary Server private key: %w", err) + } + writeErr := func() error { + if _, err := file.WriteString(key.String() + "\n"); err != nil { + return err + } + return file.Sync() + }() + closeErr := file.Close() + if writeErr != nil { + return wgtypes.Key{}, fmt.Errorf("write Server private key: %w", writeErr) + } + if closeErr != nil { + return wgtypes.Key{}, fmt.Errorf("close Server private key: %w", closeErr) + } + // A hard link publishes the fully flushed inode without overwriting a key + // concurrently created by another Server process. Temporary and final files + // are guaranteed to be on the same data-directory filesystem. + if err := os.Link(temporaryPath, path); err != nil { + if errors.Is(err, os.ErrExist) { + return LoadOrCreatePrivateKey(path) + } + return wgtypes.Key{}, fmt.Errorf("publish Server private key %q: %w", path, err) + } + return key, nil +} diff --git a/internal/overlay/serverwg/manager_linux.go b/internal/overlay/serverwg/manager_linux.go new file mode 100644 index 0000000..f011395 --- /dev/null +++ b/internal/overlay/serverwg/manager_linux.go @@ -0,0 +1,302 @@ +//go:build linux + +package serverwg + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/vishvananda/netlink" + "golang.zx2c4.com/wireguard/wgctrl" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// Manager owns RemLink's wg0 and its dynamic Node peers. +type Manager struct { + mu sync.Mutex + config Config + client *wgctrl.Client + key wgtypes.Key +} + +// New creates/reuses the kernel WireGuard interface and applies hub settings. +func New(ctx context.Context, config Config) (*Manager, error) { + if err := config.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + key, err := LoadOrCreatePrivateKey(config.PrivateKeyPath) + if err != nil { + return nil, err + } + link, err := ensureLink(config.InterfaceName) + if err != nil { + return nil, err + } + if err := configureAddress(link, config.Address); err != nil { + return nil, err + } + client, err := wgctrl.New() + if err != nil { + return nil, fmt.Errorf("open wgctrl: %w", err) + } + manager := &Manager{config: config, client: client, key: key} + if err := client.ConfigureDevice(config.InterfaceName, wgtypes.Config{ + PrivateKey: &key, ListenPort: &config.ListenPort, + }); err != nil { + client.Close() + return nil, fmt.Errorf("configure kernel WireGuard interface %s: %w", config.InterfaceName, err) + } + if err := netlink.LinkSetUp(link); err != nil { + client.Close() + return nil, fmt.Errorf("bring up %s: %w", config.InterfaceName, err) + } + if config.EnableForwarding { + if err := enableIPv4Forwarding(); err != nil { + client.Close() + return nil, err + } + if err := ensureHubForwardRule(ctx, config); err != nil { + client.Close() + return nil, err + } + } + return manager, nil +} + +// PublicKey is safe to return in Bootstrap responses. +func (m *Manager) PublicKey() string { return m.key.PublicKey().String() } + +// Close releases control sockets but deliberately leaves the kernel interface. +func (m *Manager) Close() error { return m.client.Close() } + +// EnsurePeer creates or replaces one Node's only AllowedIPs entry with /32. +func (m *Manager) EnsurePeer(ctx context.Context, publicKey string, address netip.Addr) error { + if err := ctx.Err(); err != nil { + return err + } + peer, err := peerConfig(Peer{PublicKey: publicKey, Address: address}) + if err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}}); err != nil { + return fmt.Errorf("ensure WireGuard peer %s: %w", address, err) + } + // The kernel mutation has completed. A cancellation observed afterwards + // must not be reported as failure, because callers may otherwise roll back + // adjacent database state while leaving the peer applied. + return nil +} + +// RemovePeer revokes a Node's WireGuard public key. +func (m *Manager) RemovePeer(ctx context.Context, publicKey string) error { + if err := ctx.Err(); err != nil { + return err + } + key, err := wgtypes.ParseKey(publicKey) + if err != nil { + return fmt.Errorf("parse WireGuard public key: %w", err) + } + m.mu.Lock() + defer m.mu.Unlock() + if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{ + Peers: []wgtypes.PeerConfig{{PublicKey: key, Remove: true}}, + }); err != nil { + return fmt.Errorf("remove WireGuard peer: %w", err) + } + return nil +} + +// LastHandshake returns the kernel-observed handshake time for one peer. +func (m *Manager) LastHandshake(ctx context.Context, publicKey string) (*time.Time, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + key, err := wgtypes.ParseKey(publicKey) + if err != nil { + return nil, fmt.Errorf("parse WireGuard public key: %w", err) + } + m.mu.Lock() + defer m.mu.Unlock() + device, err := m.client.Device(m.config.InterfaceName) + if err != nil { + return nil, fmt.Errorf("read WireGuard device %s: %w", m.config.InterfaceName, err) + } + for _, peer := range device.Peers { + if peer.PublicKey == key { + if peer.LastHandshakeTime.IsZero() { + return nil, nil + } + handshake := peer.LastHandshakeTime.UTC() + return &handshake, nil + } + } + return nil, nil +} + +// ReconcilePeers replaces kernel peers from the authoritative Node Registry. +func (m *Manager) ReconcilePeers(ctx context.Context, peers []Peer) error { + configs := make([]wgtypes.PeerConfig, 0, len(peers)) + for _, peer := range peers { + config, err := peerConfig(peer) + if err != nil { + return err + } + configs = append(configs, config) + } + if err := ctx.Err(); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{ + ReplacePeers: true, Peers: configs, + }); err != nil { + return fmt.Errorf("reconcile WireGuard peers: %w", err) + } + return nil +} + +// Reconfigure atomically applies a new Server Overlay address/listen port and +// complete peer set as part of Admin network migration. +func (m *Manager) Reconfigure(ctx context.Context, address netip.Prefix, listenPort int, peers []Peer) error { + next := m.config + next.Address = address + next.ListenPort = listenPort + if err := next.validate(); err != nil { + return err + } + configs := make([]wgtypes.PeerConfig, 0, len(peers)) + for _, peer := range peers { + config, err := peerConfig(peer) + if err != nil { + return err + } + configs = append(configs, config) + } + link, err := netlink.LinkByName(m.config.InterfaceName) + if err != nil { + return fmt.Errorf("look up %s for reconfiguration: %w", m.config.InterfaceName, err) + } + m.mu.Lock() + defer m.mu.Unlock() + oldAddress := m.config.Address + if err := configureAddress(link, address); err != nil { + _ = configureAddress(link, oldAddress) + return err + } + if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{ + ListenPort: &listenPort, ReplacePeers: true, Peers: configs, + }); err != nil { + _ = configureAddress(link, oldAddress) + return fmt.Errorf("reconfigure kernel WireGuard: %w", err) + } + m.config = next + return nil +} + +func peerConfig(peer Peer) (wgtypes.PeerConfig, error) { + key, err := wgtypes.ParseKey(peer.PublicKey) + if err != nil { + return wgtypes.PeerConfig{}, fmt.Errorf("parse WireGuard public key: %w", err) + } + if !peer.Address.Is4() { + return wgtypes.PeerConfig{}, errors.New("WireGuard peer address must be IPv4") + } + bits := peer.Address.As4() + allowedIP := net.IPNet{IP: net.IPv4(bits[0], bits[1], bits[2], bits[3]), Mask: net.CIDRMask(32, 32)} + return wgtypes.PeerConfig{ + PublicKey: key, ReplaceAllowedIPs: true, AllowedIPs: []net.IPNet{allowedIP}, + }, nil +} + +func ensureLink(name string) (netlink.Link, error) { + link, err := netlink.LinkByName(name) + if err == nil { + if link.Type() != "wireguard" { + return nil, fmt.Errorf("interface %s exists with type %s, want wireguard", name, link.Type()) + } + return link, nil + } + if _, notFound := err.(netlink.LinkNotFoundError); !notFound { + return nil, fmt.Errorf("look up interface %s: %w", name, err) + } + link = &netlink.GenericLink{LinkAttrs: netlink.LinkAttrs{Name: name}, LinkType: "wireguard"} + if err := netlink.LinkAdd(link); err != nil { + return nil, fmt.Errorf("create kernel WireGuard interface %s: %w", name, err) + } + return netlink.LinkByName(name) +} + +func configureAddress(link netlink.Link, desired netip.Prefix) error { + addresses, err := netlink.AddrList(link, netlink.FAMILY_V4) + if err != nil { + return fmt.Errorf("list %s addresses: %w", link.Attrs().Name, err) + } + desiredText := desired.String() + for index := range addresses { + if addresses[index].IPNet.String() == desiredText { + continue + } + if err := netlink.AddrDel(link, &addresses[index]); err != nil { + return fmt.Errorf("remove stale %s address %s: %w", link.Attrs().Name, addresses[index].IPNet, err) + } + } + address, err := netlink.ParseAddr(desiredText) + if err != nil { + return fmt.Errorf("convert WireGuard address: %w", err) + } + if err := netlink.AddrReplace(link, address); err != nil { + return fmt.Errorf("configure %s address %s: %w", link.Attrs().Name, desired, err) + } + return nil +} + +func enableIPv4Forwarding() error { + const path = "/proc/sys/net/ipv4/ip_forward" + value, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read IPv4 forwarding state: %w", err) + } + if strings.TrimSpace(string(value)) == "1" { + return nil + } + if err := os.WriteFile(path, []byte("1\n"), 0o644); err != nil { + return fmt.Errorf("enable IPv4 forwarding: %w", err) + } + return nil +} + +func ensureHubForwardRule(ctx context.Context, config Config) error { + path := config.IPTablesPath + if path == "" { + var err error + path, err = exec.LookPath("iptables") + if err != nil { + return errors.New("iptables is required to allow wg0-to-wg0 forwarding") + } + } + arguments := []string{"FORWARD", "-i", config.InterfaceName, "-o", config.InterfaceName, "-j", "ACCEPT"} + check := exec.CommandContext(ctx, path, append([]string{"-C"}, arguments...)...) + if err := check.Run(); err == nil { + return nil + } + insert := exec.CommandContext(ctx, path, append([]string{"-I"}, arguments...)...) + output, err := insert.CombinedOutput() + if err != nil { + return fmt.Errorf("allow %s-to-%s forwarding: %w: %s", config.InterfaceName, config.InterfaceName, err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/internal/overlay/serverwg/manager_other.go b/internal/overlay/serverwg/manager_other.go new file mode 100644 index 0000000..7236fc8 --- /dev/null +++ b/internal/overlay/serverwg/manager_other.go @@ -0,0 +1,32 @@ +//go:build !linux + +package serverwg + +import ( + "context" + "errors" + "net/netip" + "time" +) + +var ErrLinuxRequired = errors.New("kernel WireGuard Server requires Linux") + +// Manager is unavailable on non-Linux build targets. +type Manager struct{} + +func New(context.Context, Config) (*Manager, error) { return nil, ErrLinuxRequired } +func (*Manager) PublicKey() string { return "" } +func (*Manager) Close() error { return nil } +func (*Manager) EnsurePeer(context.Context, string, netip.Addr) error { + return ErrLinuxRequired +} +func (*Manager) RemovePeer(context.Context, string) error { return ErrLinuxRequired } +func (*Manager) ReconcilePeers(context.Context, []Peer) error { + return ErrLinuxRequired +} +func (*Manager) Reconfigure(context.Context, netip.Prefix, int, []Peer) error { + return ErrLinuxRequired +} +func (*Manager) LastHandshake(context.Context, string) (*time.Time, error) { + return nil, ErrLinuxRequired +} diff --git a/internal/platform/linux/netlink/.gitkeep b/internal/platform/linux/netlink/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/platform/linux/netlink/.gitkeep @@ -0,0 +1 @@ + diff --git a/internal/platform/windows/adapter_windows.go b/internal/platform/windows/adapter_windows.go new file mode 100644 index 0000000..c4044be --- /dev/null +++ b/internal/platform/windows/adapter_windows.go @@ -0,0 +1,152 @@ +//go:build windows + +// Package windowsplatform centralizes RemLink-owned Windows networking changes. +package windowsplatform + +import ( + "errors" + "fmt" + "net/netip" + "sync" + + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/tun" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" + + "remlink/internal/platform/windows/wintunruntime" +) + +const ( + AdapterName = "RemLink" + AdapterTunnelType = "RemLink" + DefaultMTU = 1280 +) + +var ErrAdministratorRequired = errors.New("administrator privileges are required to manage the RemLink Wintun adapter") + +// AdapterConfig defines the single IPv4 Overlay address owned by RemLink. +type AdapterConfig struct { + Address netip.Prefix + MTU int +} + +// Adapter owns one live Wintun session. Closing it leaves the persistent +// Windows adapter installed so a later RemLink process can reuse it. +type Adapter struct { + device tun.Device + luid winipcfg.LUID + interfaceIndex uint32 + closeOnce sync.Once + closeErr error +} + +// OpenRemLink creates or reuses the one fixed-name RemLink Wintun, applies its +// IPv4 address and MTU through Windows APIs, and returns the live TUN device. +func OpenRemLink(config AdapterConfig) (*Adapter, error) { + if err := validateAdapterConfig(config); err != nil { + return nil, err + } + if !windows.GetCurrentProcessToken().IsElevated() { + return nil, ErrAdministratorRequired + } + if _, err := wintunruntime.PreloadDefault(); err != nil { + return nil, err + } + + tun.WintunTunnelType = AdapterTunnelType + base, err := tun.CreateTUN(AdapterName, config.MTU) + if err != nil { + return nil, fmt.Errorf("create or reuse %s Wintun: %w", AdapterName, err) + } + succeeded := false + defer func() { + if !succeeded { + _ = base.Close() + } + }() + + luidSource, ok := base.(interface{ LUID() uint64 }) + if !ok { + return nil, errors.New("wireguard-go Windows TUN does not expose its interface LUID") + } + luid := winipcfg.LUID(luidSource.LUID()) + if luid == 0 { + return nil, errors.New("RemLink Wintun returned an invalid interface LUID") + } + if err := luid.SetIPAddressesForFamily( + winipcfg.AddressFamily(windows.AF_INET), + []netip.Prefix{config.Address}, + ); err != nil { + return nil, fmt.Errorf("set RemLink IPv4 address %s: %w", config.Address, err) + } + + ipInterface, err := luid.IPInterface(winipcfg.AddressFamily(windows.AF_INET)) + if err != nil { + return nil, fmt.Errorf("read RemLink IPv4 interface: %w", err) + } + ipInterface.NLMTU = uint32(config.MTU) + if err := ipInterface.Set(); err != nil { + return nil, fmt.Errorf("set RemLink MTU %d: %w", config.MTU, err) + } + interfaceRow, err := luid.Interface() + if err != nil { + return nil, fmt.Errorf("read RemLink interface index: %w", err) + } + + succeeded = true + return &Adapter{ + device: base, + luid: luid, + interfaceIndex: interfaceRow.InterfaceIndex, + }, nil +} + +// Device returns the TUN device. Ownership transfers to wireguard-go when it is +// passed to clientwg.NewDevice; callers must then close the wireguard-go owner. +func (a *Adapter) Device() tun.Device { + return a.device +} + +// LUID returns the Windows interface locally unique identifier. +func (a *Adapter) LUID() uint64 { + return uint64(a.luid) +} + +// InterfaceIndex returns the Windows interface index used by later RouteManager work. +func (a *Adapter) InterfaceIndex() uint32 { + return a.interfaceIndex +} + +// Close stops the Wintun session without deleting the persistent adapter. +func (a *Adapter) Close() error { + if a == nil || a.device == nil { + return nil + } + a.closeOnce.Do(func() { + a.closeErr = a.device.Close() + }) + return a.closeErr +} + +func validateAdapterConfig(config AdapterConfig) error { + if !config.Address.IsValid() || !config.Address.Addr().Is4() { + return errors.New("RemLink adapter address must be a valid IPv4 prefix") + } + if config.Address.Bits() > 30 { + return errors.New("RemLink adapter prefix must leave usable host addresses") + } + if config.Address.Addr() == config.Address.Masked().Addr() || config.Address.Addr() == lastAddress(config.Address) { + return errors.New("RemLink adapter address must not be the network or broadcast address") + } + if config.MTU < 576 || config.MTU > 65535 { + return errors.New("RemLink adapter MTU must be between 576 and 65535") + } + return nil +} + +func lastAddress(prefix netip.Prefix) netip.Addr { + bytes := prefix.Masked().Addr().As4() + value := uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3]) + value |= ^uint32(0) >> prefix.Bits() + return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}) +} diff --git a/internal/platform/windows/adapter_windows_test.go b/internal/platform/windows/adapter_windows_test.go new file mode 100644 index 0000000..e1ab02f --- /dev/null +++ b/internal/platform/windows/adapter_windows_test.go @@ -0,0 +1,35 @@ +//go:build windows + +package windowsplatform + +import ( + "net/netip" + "testing" +) + +func TestValidateAdapterConfig(t *testing.T) { + t.Parallel() + tests := []struct { + name string + address string + mtu int + wantErr bool + }{ + {name: "valid", address: "10.88.0.2/16", mtu: 1280}, + {name: "IPv6", address: "fd00::2/64", mtu: 1280, wantErr: true}, + {name: "network", address: "10.88.0.0/16", mtu: 1280, wantErr: true}, + {name: "broadcast", address: "10.88.255.255/16", mtu: 1280, wantErr: true}, + {name: "small MTU", address: "10.88.0.2/16", mtu: 575, wantErr: true}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + prefix := netip.MustParsePrefix(test.address) + err := validateAdapterConfig(AdapterConfig{Address: prefix, MTU: test.mtu}) + if (err != nil) != test.wantErr { + t.Fatalf("validateAdapterConfig() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} diff --git a/internal/platform/windows/dpapi/dpapi_other.go b/internal/platform/windows/dpapi/dpapi_other.go new file mode 100644 index 0000000..fa2c4cf --- /dev/null +++ b/internal/platform/windows/dpapi/dpapi_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package dpapi + +import "errors" + +var ErrWindowsRequired = errors.New("DPAPI requires Windows") + +type Protector struct{} + +func (Protector) Protect([]byte) ([]byte, error) { return nil, ErrWindowsRequired } +func (Protector) Unprotect([]byte) ([]byte, error) { return nil, ErrWindowsRequired } diff --git a/internal/platform/windows/dpapi/dpapi_windows.go b/internal/platform/windows/dpapi/dpapi_windows.go new file mode 100644 index 0000000..7000733 --- /dev/null +++ b/internal/platform/windows/dpapi/dpapi_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +// Package dpapi protects Windows Node secrets using the operating system DPAPI. +package dpapi + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +var entropy = []byte("RemLink-v1-NodeIdentity") + +// Protector uses machine-scoped DPAPI so Console/service identities can read +// the package-local identity after Windows restart. Directory ACLs remain required. +type Protector struct{} + +func (Protector) Protect(plain []byte) ([]byte, error) { + if len(plain) == 0 { + return nil, errors.New("DPAPI plaintext must not be empty") + } + input := blob(plain) + extra := blob(entropy) + var output windows.DataBlob + name, err := windows.UTF16PtrFromString("RemLink Node Identity") + if err != nil { + return nil, err + } + flags := uint32(windows.CRYPTPROTECT_LOCAL_MACHINE | windows.CRYPTPROTECT_UI_FORBIDDEN) + if err := windows.CryptProtectData(&input, name, &extra, 0, nil, flags, &output); err != nil { + return nil, fmt.Errorf("protect secret with DPAPI: %w", err) + } + return copyAndFree(output) +} + +func (Protector) Unprotect(ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 { + return nil, errors.New("DPAPI ciphertext must not be empty") + } + input := blob(ciphertext) + extra := blob(entropy) + var output windows.DataBlob + flags := uint32(windows.CRYPTPROTECT_UI_FORBIDDEN) + if err := windows.CryptUnprotectData(&input, nil, &extra, 0, nil, flags, &output); err != nil { + return nil, fmt.Errorf("unprotect secret with DPAPI: %w", err) + } + return copyAndFree(output) +} + +func blob(value []byte) windows.DataBlob { + return windows.DataBlob{Size: uint32(len(value)), Data: &value[0]} +} + +func copyAndFree(value windows.DataBlob) ([]byte, error) { + if value.Data == nil || value.Size == 0 { + return nil, errors.New("DPAPI returned empty output") + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(value.Data)))) + result := make([]byte, int(value.Size)) + copy(result, unsafe.Slice(value.Data, int(value.Size))) + return result, nil +} diff --git a/internal/platform/windows/dpapi/dpapi_windows_test.go b/internal/platform/windows/dpapi/dpapi_windows_test.go new file mode 100644 index 0000000..e0c79e0 --- /dev/null +++ b/internal/platform/windows/dpapi/dpapi_windows_test.go @@ -0,0 +1,27 @@ +//go:build windows + +package dpapi + +import ( + "bytes" + "testing" +) + +func TestProtectUnprotect(t *testing.T) { + protector := Protector{} + plain := []byte("RemLink test secret") + protected, err := protector.Protect(plain) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(protected, plain) { + t.Fatal("DPAPI returned plaintext") + } + unprotected, err := protector.Unprotect(protected) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(unprotected, plain) { + t.Fatalf("unprotected = %q, want %q", unprotected, plain) + } +} diff --git a/internal/platform/windows/netinfo/prefixes.go b/internal/platform/windows/netinfo/prefixes.go new file mode 100644 index 0000000..a7756f5 --- /dev/null +++ b/internal/platform/windows/netinfo/prefixes.go @@ -0,0 +1,55 @@ +// Package netinfo inspects local IPv4 networks without mutating Windows state. +package netinfo + +import ( + "fmt" + "net" + "net/netip" +) + +// DirectIPv4Prefixes returns assigned non-loopback IPv4 interface prefixes, +// excluding the named RemLink adapter. +func DirectIPv4Prefixes(excludedInterface string) ([]netip.Prefix, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("list network interfaces: %w", err) + } + var prefixes []netip.Prefix + for _, networkInterface := range interfaces { + if networkInterface.Name == excludedInterface || networkInterface.Flags&net.FlagLoopback != 0 || networkInterface.Flags&net.FlagUp == 0 { + continue + } + addresses, err := networkInterface.Addrs() + if err != nil { + return nil, fmt.Errorf("list addresses for %s: %w", networkInterface.Name, err) + } + for _, address := range addresses { + prefix, err := netip.ParsePrefix(address.String()) + if err != nil || !prefix.Addr().Is4() { + continue + } + prefixes = append(prefixes, prefix.Masked()) + } + } + return prefixes, nil +} + +// PrefixesOverlap performs true containment-based IPv4 prefix overlap. +func PrefixesOverlap(left, right netip.Prefix) bool { + if !left.Addr().Is4() || !right.Addr().Is4() { + return false + } + left = left.Masked() + right = right.Masked() + return left.Contains(right.Addr()) || right.Contains(left.Addr()) +} + +// FindConflict returns the first local network overlapping desired. +func FindConflict(desired netip.Prefix, existing []netip.Prefix) (netip.Prefix, bool) { + for _, candidate := range existing { + if PrefixesOverlap(desired, candidate) { + return candidate, true + } + } + return netip.Prefix{}, false +} diff --git a/internal/platform/windows/netinfo/prefixes_test.go b/internal/platform/windows/netinfo/prefixes_test.go new file mode 100644 index 0000000..418d13c --- /dev/null +++ b/internal/platform/windows/netinfo/prefixes_test.go @@ -0,0 +1,23 @@ +package netinfo + +import ( + "net/netip" + "testing" +) + +func TestPrefixesOverlap(t *testing.T) { + for _, test := range []struct { + left, right string + want bool + }{ + {"192.168.0.0/16", "192.168.13.0/24", true}, + {"192.168.13.0/24", "192.168.13.10/32", true}, + {"10.88.0.0/16", "10.89.0.0/16", false}, + {"0.0.0.0/0", "10.88.0.0/16", true}, + } { + got := PrefixesOverlap(netip.MustParsePrefix(test.left), netip.MustParsePrefix(test.right)) + if got != test.want { + t.Errorf("PrefixesOverlap(%s, %s) = %v, want %v", test.left, test.right, got, test.want) + } + } +} diff --git a/internal/platform/windows/route/inspector.go b/internal/platform/windows/route/inspector.go new file mode 100644 index 0000000..71d5a5c --- /dev/null +++ b/internal/platform/windows/route/inspector.go @@ -0,0 +1,80 @@ +// Package route owns RemLink Remote routes and performs conflict/lookup checks. +package route + +import ( + "errors" + "net/netip" + + "remlink/internal/platform/windows/netinfo" +) + +type LookupResult string + +const ( + LookupDirect LookupResult = "DIRECT" + LookupRouted LookupResult = "ROUTED" + LookupDefaultOnly LookupResult = "DEFAULT_ONLY" + LookupNoRoute LookupResult = "NO_ROUTE" + LookupOverlayConflict LookupResult = "OVERLAY_CONFLICT" +) + +type Entry struct { + InterfaceLUID uint64 + Destination netip.Prefix + NextHop netip.Addr +} + +// ConflictsFrom applies the Engineer rule: ignore default and RemLink-owned +// routes, reject any other prefix overlap. +func ConflictsFrom(entries []Entry, desired netip.Prefix, remLinkLUID uint64) []Entry { + var conflicts []Entry + for _, entry := range entries { + if entry.InterfaceLUID == remLinkLUID || !entry.Destination.Addr().Is4() || entry.Destination.Bits() == 0 { + continue + } + if netinfo.PrefixesOverlap(desired, entry.Destination) { + conflicts = append(conflicts, entry) + } + } + return conflicts +} + +// LookupFrom returns the most-specific Windows route classification used by Site PREPARE. +func LookupFrom(entries []Entry, target netip.Addr, overlay netip.Prefix, remLinkLUID uint64) LookupResult { + if !target.Is4() { + return LookupNoRoute + } + if overlay.Contains(target) { + return LookupOverlayConflict + } + var best *Entry + for index := range entries { + entry := &entries[index] + if !entry.Destination.Contains(target) || entry.InterfaceLUID == remLinkLUID { + continue + } + if best == nil || entry.Destination.Bits() > best.Destination.Bits() { + best = entry + } + } + if best == nil { + return LookupNoRoute + } + if best.Destination.Bits() == 0 { + return LookupDefaultOnly + } + if !best.NextHop.IsValid() || best.NextHop.IsUnspecified() { + return LookupDirect + } + return LookupRouted +} + +func validateRemote(prefix, overlay netip.Prefix) error { + if !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 { + return errors.New("Remote CIDR must be canonical IPv4 and must not be 0.0.0.0/0") + } + if netinfo.PrefixesOverlap(prefix, overlay) { + return errors.New("Remote CIDR overlaps Overlay CIDR") + } + return nil +} diff --git a/internal/platform/windows/route/inspector_test.go b/internal/platform/windows/route/inspector_test.go new file mode 100644 index 0000000..07440bf --- /dev/null +++ b/internal/platform/windows/route/inspector_test.go @@ -0,0 +1,55 @@ +package route + +import ( + "net/netip" + "testing" +) + +func TestConflictsIgnoresDefaultAndRemLink(t *testing.T) { + entries := []Entry{ + {InterfaceLUID: 1, Destination: netip.MustParsePrefix("0.0.0.0/0"), NextHop: netip.MustParseAddr("192.0.2.1")}, + {InterfaceLUID: 2, Destination: netip.MustParsePrefix("192.168.0.0/16")}, + {InterfaceLUID: 99, Destination: netip.MustParsePrefix("192.168.13.0/24")}, + } + conflicts := ConflictsFrom(entries, netip.MustParsePrefix("192.168.13.0/24"), 99) + if len(conflicts) != 1 || conflicts[0].Destination.String() != "192.168.0.0/16" { + t.Fatalf("conflicts = %+v", conflicts) + } +} + +func TestLookupClassifications(t *testing.T) { + overlay := netip.MustParsePrefix("10.88.0.0/16") + entries := []Entry{ + {InterfaceLUID: 1, Destination: netip.MustParsePrefix("0.0.0.0/0"), NextHop: netip.MustParseAddr("192.0.2.1")}, + {InterfaceLUID: 2, Destination: netip.MustParsePrefix("192.168.13.0/24"), NextHop: netip.IPv4Unspecified()}, + {InterfaceLUID: 3, Destination: netip.MustParsePrefix("172.16.0.0/16"), NextHop: netip.MustParseAddr("192.0.2.254")}, + } + for _, test := range []struct { + target string + want LookupResult + }{ + {"10.88.0.5", LookupOverlayConflict}, + {"192.168.13.10", LookupDirect}, + {"172.16.4.2", LookupRouted}, + {"8.8.8.8", LookupDefaultOnly}, + } { + if got := LookupFrom(entries, netip.MustParseAddr(test.target), overlay, 99); got != test.want { + t.Errorf("LookupFrom(%s) = %s, want %s", test.target, got, test.want) + } + } + if got := LookupFrom(nil, netip.MustParseAddr("192.168.1.2"), overlay, 99); got != LookupNoRoute { + t.Fatalf("empty route lookup = %s", got) + } +} + +func TestValidateRemote(t *testing.T) { + overlay := netip.MustParsePrefix("10.88.0.0/16") + for _, invalid := range []string{"0.0.0.0/0", "10.88.5.0/24"} { + if err := validateRemote(netip.MustParsePrefix(invalid), overlay); err == nil { + t.Errorf("validateRemote accepted %s", invalid) + } + } + if err := validateRemote(netip.MustParsePrefix("192.168.13.0/24"), overlay); err != nil { + t.Fatal(err) + } +} diff --git a/internal/platform/windows/route/manager_other.go b/internal/platform/windows/route/manager_other.go new file mode 100644 index 0000000..99f0964 --- /dev/null +++ b/internal/platform/windows/route/manager_other.go @@ -0,0 +1,26 @@ +//go:build !windows + +package route + +import ( + "errors" + "net/netip" +) + +var ErrWindowsRequired = errors.New("Windows RouteManager requires Windows") + +type OwnershipStore interface { + LoadOwnedRoutes() ([]netip.Prefix, error) + SaveOwnedRoutes([]netip.Prefix) error +} + +type Manager struct{} + +func NewManager(uint64, netip.Prefix, OwnershipStore) (*Manager, error) { + return nil, ErrWindowsRequired +} +func (*Manager) AddRemote(netip.Prefix) error { return ErrWindowsRequired } +func (*Manager) RemoveRemote(netip.Prefix) error { return ErrWindowsRequired } +func (*Manager) Conflicts(netip.Prefix) ([]Entry, error) { return nil, ErrWindowsRequired } +func (*Manager) Lookup(netip.Addr) (LookupResult, error) { return LookupNoRoute, ErrWindowsRequired } +func (*Manager) Reconcile() error { return ErrWindowsRequired } diff --git a/internal/platform/windows/route/manager_windows.go b/internal/platform/windows/route/manager_windows.go new file mode 100644 index 0000000..d27130e --- /dev/null +++ b/internal/platform/windows/route/manager_windows.go @@ -0,0 +1,151 @@ +//go:build windows + +package route + +import ( + "errors" + "fmt" + "net/netip" + "sync" + + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" +) + +const remoteRouteMetric = 0 + +type OwnershipStore interface { + LoadOwnedRoutes() ([]netip.Prefix, error) + SaveOwnedRoutes([]netip.Prefix) error +} + +// Manager is the sole writer for Engineer Remote CIDR routes. +type Manager struct { + mu sync.Mutex + luid winipcfg.LUID + overlay netip.Prefix + store OwnershipStore + owned map[netip.Prefix]struct{} +} + +func NewManager(luid uint64, overlay netip.Prefix, store OwnershipStore) (*Manager, error) { + if luid == 0 || !overlay.Addr().Is4() || store == nil { + return nil, errors.New("RouteManager requires RemLink LUID, IPv4 Overlay, and ownership store") + } + ownedRoutes, err := store.LoadOwnedRoutes() + if err != nil { + return nil, err + } + owned := make(map[netip.Prefix]struct{}, len(ownedRoutes)) + for _, prefix := range ownedRoutes { + owned[prefix.Masked()] = struct{}{} + } + return &Manager{luid: winipcfg.LUID(luid), overlay: overlay.Masked(), store: store, owned: owned}, nil +} + +func (m *Manager) AddRemote(prefix netip.Prefix) error { + prefix = prefix.Masked() + if err := validateRemote(prefix, m.overlay); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.owned[prefix]; exists { + return nil + } + conflicts, err := m.conflictsLocked(prefix) + if err != nil { + return err + } + if len(conflicts) != 0 { + return fmt.Errorf("Remote CIDR %s conflicts with existing route %s", prefix, conflicts[0].Destination) + } + if err := m.luid.AddRoute(prefix, netip.IPv4Unspecified(), remoteRouteMetric); err != nil { + return fmt.Errorf("add RemLink Remote route %s: %w", prefix, err) + } + m.owned[prefix] = struct{}{} + if err := m.persistLocked(); err != nil { + delete(m.owned, prefix) + _ = m.luid.DeleteRoute(prefix, netip.IPv4Unspecified()) + return err + } + return nil +} + +func (m *Manager) RemoveRemote(prefix netip.Prefix) error { + prefix = prefix.Masked() + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.owned[prefix]; !exists { + return nil + } + if err := m.luid.DeleteRoute(prefix, netip.IPv4Unspecified()); err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) { + return fmt.Errorf("remove RemLink Remote route %s: %w", prefix, err) + } + delete(m.owned, prefix) + return m.persistLocked() +} + +func (m *Manager) Conflicts(prefix netip.Prefix) ([]Entry, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.conflictsLocked(prefix.Masked()) +} + +func (m *Manager) Lookup(target netip.Addr) (LookupResult, error) { + entries, err := windowsEntries() + if err != nil { + return LookupNoRoute, err + } + return LookupFrom(entries, target, m.overlay, uint64(m.luid)), nil +} + +// Reconcile removes every route recorded by a previous non-Active Session. +func (m *Manager) Reconcile() error { + m.mu.Lock() + defer m.mu.Unlock() + for prefix := range m.owned { + if err := m.luid.DeleteRoute(prefix, netip.IPv4Unspecified()); err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) { + return fmt.Errorf("reconcile stale Remote route %s: %w", prefix, err) + } + delete(m.owned, prefix) + } + return m.persistLocked() +} + +func (m *Manager) conflictsLocked(prefix netip.Prefix) ([]Entry, error) { + entries, err := windowsEntries() + if err != nil { + return nil, err + } + return ConflictsFrom(entries, prefix, uint64(m.luid)), nil +} + +func (m *Manager) persistLocked() error { + prefixes := make([]netip.Prefix, 0, len(m.owned)) + for prefix := range m.owned { + prefixes = append(prefixes, prefix) + } + if err := m.store.SaveOwnedRoutes(prefixes); err != nil { + return fmt.Errorf("persist RemLink route ownership: %w", err) + } + return nil +} + +func windowsEntries() ([]Entry, error) { + rows, err := winipcfg.GetIPForwardTable2(winipcfg.AddressFamily(windows.AF_INET)) + if err != nil { + return nil, fmt.Errorf("read Windows IPv4 route table: %w", err) + } + entries := make([]Entry, 0, len(rows)) + for index := range rows { + prefix := rows[index].DestinationPrefix.Prefix() + if !prefix.Addr().Is4() { + continue + } + entries = append(entries, Entry{ + InterfaceLUID: uint64(rows[index].InterfaceLUID), Destination: prefix.Masked(), NextHop: rows[index].NextHop.Addr(), + }) + } + return entries, nil +} diff --git a/internal/platform/windows/socket/.gitkeep b/internal/platform/windows/socket/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/platform/windows/socket/.gitkeep @@ -0,0 +1 @@ + diff --git a/internal/platform/windows/wintunruntime/asset_windows_amd64.go b/internal/platform/windows/wintunruntime/asset_windows_amd64.go new file mode 100644 index 0000000..ea0bcf2 --- /dev/null +++ b/internal/platform/windows/wintunruntime/asset_windows_amd64.go @@ -0,0 +1,22 @@ +//go:build windows && amd64 + +package wintunruntime + +import ( + "crypto/sha256" + _ "embed" + "encoding/hex" +) + +//go:embed assets/amd64/wintun.dll +var embeddedDLL []byte + +func assetBytes() ([]byte, [sha256.Size]byte, error) { + var expected [sha256.Size]byte + decoded, err := hex.DecodeString(DLLSHA256AMD64) + if err != nil { + return nil, expected, err + } + copy(expected[:], decoded) + return embeddedDLL, expected, nil +} diff --git a/internal/platform/windows/wintunruntime/asset_windows_other.go b/internal/platform/windows/wintunruntime/asset_windows_other.go new file mode 100644 index 0000000..154c5b1 --- /dev/null +++ b/internal/platform/windows/wintunruntime/asset_windows_other.go @@ -0,0 +1,13 @@ +//go:build windows && !amd64 + +package wintunruntime + +import ( + "crypto/sha256" + "fmt" + "runtime" +) + +func assetBytes() ([]byte, [sha256.Size]byte, error) { + return nil, [sha256.Size]byte{}, fmt.Errorf("Phase 1 Wintun asset is not bundled for windows/%s", runtime.GOARCH) +} diff --git a/internal/platform/windows/wintunruntime/assets/amd64/wintun.dll b/internal/platform/windows/wintunruntime/assets/amd64/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/internal/platform/windows/wintunruntime/assets/amd64/wintun.dll differ diff --git a/internal/platform/windows/wintunruntime/runtime_windows.go b/internal/platform/windows/wintunruntime/runtime_windows.go new file mode 100644 index 0000000..ef7faa3 --- /dev/null +++ b/internal/platform/windows/wintunruntime/runtime_windows.go @@ -0,0 +1,193 @@ +//go:build windows + +// Package wintunruntime installs and preloads the pinned embedded Wintun DLL. +package wintunruntime + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "golang.org/x/sys/windows" + "golang.zx2c4.com/wintun" + + "remlink/internal/appdir" +) + +const ( + Version = "0.14.1" + DLLName = "wintun.dll" + DLLSHA256AMD64 = "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce" +) + +var ( + preloadOnce sync.Once + preloadPath string + preloadErr error + // Keep the module referenced for the process lifetime. The upstream Go + // binding subsequently resolves the already-loaded DLL by base name. + preloadHandle windows.Handle +) + +// Install writes the embedded signed DLL into one portable package directory. +// An existing verified file is reused; an unexpected file is replaced. +func Install(packageDirectory string) (string, error) { + if packageDirectory == "" { + return "", errors.New("package directory must not be empty") + } + data, expectedHash, err := assetBytes() + if err != nil { + return "", err + } + if err := os.MkdirAll(packageDirectory, 0o750); err != nil { + return "", fmt.Errorf("create Wintun runtime directory: %w", err) + } + target := filepath.Join(packageDirectory, DLLName) + + verified, err := fileHasSHA256Stable(target, expectedHash) + if err != nil { + return "", err + } + if verified { + return target, nil + } + if err := writeAtomically(target, data); err != nil { + return "", err + } + verified, err = fileHasSHA256Stable(target, expectedHash) + if err != nil { + return "", err + } + if !verified { + return "", errors.New("installed Wintun DLL failed SHA-256 verification") + } + return target, nil +} + +// PreloadDefault installs and loads Wintun beside the running executable before +// the upstream lazy binding attempts to resolve wintun.dll by its base name. +func PreloadDefault() (string, error) { + root, err := appdir.Executable() + if err != nil { + return "", err + } + return Preload(root) +} + +// Preload installs and loads Wintun exactly once for the process. +func Preload(packageDirectory string) (string, error) { + preloadOnce.Do(func() { + preloadPath, preloadErr = Install(packageDirectory) + if preloadErr != nil { + return + } + preloadHandle, preloadErr = windows.LoadLibraryEx( + preloadPath, + 0, + windows.LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|windows.LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + if preloadErr != nil { + preloadErr = fmt.Errorf("preload Wintun DLL %q: %w", preloadPath, preloadErr) + } + }) + return preloadPath, preloadErr +} + +// Probe installs/preloads the DLL and asks the official binding for its version. +func Probe() (path string, version string, err error) { + path, err = PreloadDefault() + if err != nil { + return "", "", err + } + version = wintun.Version() + if version == "unknown" { + return "", "", errors.New("official Wintun binding could not resolve the preloaded DLL") + } + return path, version, nil +} + +func fileHasSHA256(path string, expected [sha256.Size]byte) (bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read existing Wintun DLL: %w", err) + } + return sha256.Sum256(data) == expected, nil +} + +func fileHasSHA256Stable(path string, expected [sha256.Size]byte) (bool, error) { + var lastErr error + for attempt := 0; attempt < 16; attempt++ { + verified, err := fileHasSHA256(path, expected) + if err == nil { + return verified, nil + } + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) && !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + return false, err + } + lastErr = err + time.Sleep(time.Duration(attempt+1) * time.Millisecond) + } + return false, lastErr +} + +func writeAtomically(target string, data []byte) error { + temporary, err := os.CreateTemp(filepath.Dir(target), ".wintun-*.tmp") + if err != nil { + return fmt.Errorf("create temporary Wintun DLL: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return fmt.Errorf("write temporary Wintun DLL: %w", err) + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return fmt.Errorf("sync temporary Wintun DLL: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary Wintun DLL: %w", err) + } + if err := os.Chmod(temporaryPath, 0o644); err != nil { + return fmt.Errorf("set Wintun DLL permissions: %w", err) + } + + from, err := windows.UTF16PtrFromString(temporaryPath) + if err != nil { + return fmt.Errorf("encode temporary Wintun DLL path: %w", err) + } + to, err := windows.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("encode Wintun DLL target path: %w", err) + } + // MoveFileExW publishes the verified bytes in one replace operation. This + // avoids a remove/rename gap where another launch from the same package could + // observe the DLL path as missing. Concurrent publishers + // may briefly hold a Windows file handle; accept their verified result or + // retry only the documented sharing/access failures. + for attempt := 0; attempt < 8; attempt++ { + if verified, verifyErr := fileHasSHA256(target, sha256.Sum256(data)); verifyErr == nil && verified { + return nil + } + err = windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) + if err == nil { + return nil + } + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) && !errors.Is(err, windows.ERROR_SHARING_VIOLATION) { + return fmt.Errorf("install Wintun DLL: %w", err) + } + time.Sleep(time.Duration(attempt+1) * time.Millisecond) + } + if verified, verifyErr := fileHasSHA256(target, sha256.Sum256(data)); verifyErr == nil && verified { + return nil + } + return fmt.Errorf("install Wintun DLL after concurrent publish retries: %w", err) +} diff --git a/internal/platform/windows/wintunruntime/runtime_windows_test.go b/internal/platform/windows/wintunruntime/runtime_windows_test.go new file mode 100644 index 0000000..03f1f82 --- /dev/null +++ b/internal/platform/windows/wintunruntime/runtime_windows_test.go @@ -0,0 +1,86 @@ +//go:build windows && amd64 + +package wintunruntime + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestInstallIsVerifiedIdempotentAndRepairsUnexpectedFile(t *testing.T) { + root := t.TempDir() + path, err := Install(root) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if got, want := path, filepath.Join(root, DLLName); got != want { + t.Fatalf("Install() path = %q, want %q", got, want) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + secondPath, err := Install(root) + if err != nil { + t.Fatalf("second Install() error = %v", err) + } + if secondPath != path { + t.Fatalf("second Install() path = %q, want %q", secondPath, path) + } + + if err := os.WriteFile(path, []byte("tampered"), 0o644); err != nil { + t.Fatalf("tamper DLL: %v", err) + } + if _, err := Install(root); err != nil { + t.Fatalf("repair Install() error = %v", err) + } + repaired, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read repaired DLL: %v", err) + } + if string(repaired) != string(first) { + t.Fatal("Install() did not repair the unexpected DLL") + } +} + +func TestInstallPublishesAtomicallyAcrossConcurrentProcesses(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, DLLName) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("corrupt"), 0o644); err != nil { + t.Fatal(err) + } + const workers = 16 + errorsByWorker := make([]error, workers) + paths := make([]string, workers) + var wait sync.WaitGroup + for index := 0; index < workers; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + paths[index], errorsByWorker[index] = Install(root) + }(index) + } + wait.Wait() + for index := range errorsByWorker { + if errorsByWorker[index] != nil || paths[index] != target { + t.Fatalf("worker %d path=%q error=%v", index, paths[index], errorsByWorker[index]) + } + } + data, expected, err := assetBytes() + if err != nil { + t.Fatal(err) + } + installed, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(installed) != string(data) { + t.Fatalf("concurrent install produced %d bytes, want %d (expected hash %x)", len(installed), len(data), expected) + } +} diff --git a/internal/protocol/constants_test.go b/internal/protocol/constants_test.go new file mode 100644 index 0000000..36adee8 --- /dev/null +++ b/internal/protocol/constants_test.go @@ -0,0 +1,66 @@ +package protocol + +import ( + "testing" + "time" +) + +func TestControlMessageTypesMatchV1Spec(t *testing.T) { + t.Parallel() + if got, want := len(ControlMessageTypes), 13; got != want { + t.Fatalf("ControlMessageTypes length = %d, want %d", got, want) + } + seen := make(map[ControlMessageType]struct{}, len(ControlMessageTypes)) + for _, messageType := range ControlMessageTypes { + if !messageType.Valid() { + t.Fatalf("listed Control message type %q is invalid", messageType) + } + if _, duplicate := seen[messageType]; duplicate { + t.Fatalf("duplicate Control message type %q", messageType) + } + seen[messageType] = struct{}{} + } + if ControlMessageType("SESSION_RESUME").Valid() { + t.Fatal("non-v1 SESSION_RESUME unexpectedly accepted") + } +} + +func TestControlEnvelopeRoundTripAndStrictPayload(t *testing.T) { + envelope, err := NewControlEnvelope(ControlHeartbeat, "request-1", HeartbeatPayload{ + Timestamp: time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC), Status: "OK", + }) + if err != nil { + t.Fatal(err) + } + var payload HeartbeatPayload + if err := envelope.DecodePayload(&payload); err != nil { + t.Fatal(err) + } + if payload.Status != "OK" || envelope.RequestID != "request-1" { + t.Fatalf("unexpected envelope: %+v payload=%+v", envelope, payload) + } + envelope.Payload = []byte(`{"timestamp":"2026-08-25T00:00:00Z","status":"OK","unknown":true}`) + if err := envelope.DecodePayload(&payload); err == nil { + t.Fatal("unknown Control payload field was accepted") + } +} + +func TestErrorCodesMatchAuthoritativeV1List(t *testing.T) { + t.Parallel() + if got, want := len(ErrorCodes), 14; got != want { + t.Fatalf("ErrorCodes length = %d, want %d", got, want) + } + seen := make(map[ErrorCode]struct{}, len(ErrorCodes)) + for _, code := range ErrorCodes { + if !code.Valid() { + t.Fatalf("listed error code %q is invalid", code) + } + if _, duplicate := seen[code]; duplicate { + t.Fatalf("duplicate error code %q", code) + } + seen[code] = struct{}{} + } + if ErrorCode("UNKNOWN").Valid() { + t.Fatal("unknown error code unexpectedly accepted") + } +} diff --git a/internal/protocol/control.go b/internal/protocol/control.go new file mode 100644 index 0000000..a6ae075 --- /dev/null +++ b/internal/protocol/control.go @@ -0,0 +1,208 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "remlink/internal/model" +) + +// ErrRebootstrapRequired is returned by a Node runtime when all state derived +// from the public Bootstrap API must be rebuilt. Callers must tear down the +// current adapter, routes, WireGuard device, and Session runtime before retrying. +var ErrRebootstrapRequired = errors.New("Node rebootstrap is required") + +// ControlMessageType identifies a Control WebSocket message. +type ControlMessageType string + +const ( + ControlHello ControlMessageType = "HELLO" + ControlWelcome ControlMessageType = "WELCOME" + ControlNodeList ControlMessageType = "NODE_LIST" + ControlCreateSession ControlMessageType = "CREATE_SESSION" + ControlPrepareSession ControlMessageType = "PREPARE_SESSION" + ControlPrepareResult ControlMessageType = "PREPARE_RESULT" + ControlSessionConfig ControlMessageType = "SESSION_CONFIG" + ControlRoutesReady ControlMessageType = "ROUTES_READY" + ControlSessionActive ControlMessageType = "SESSION_ACTIVE" + ControlStopSession ControlMessageType = "STOP_SESSION" + ControlSessionStats ControlMessageType = "SESSION_STATS" + ControlHeartbeat ControlMessageType = "HEARTBEAT" + ControlRebootstrapRequired ControlMessageType = "REBOOTSTRAP_REQUIRED" +) + +// ControlMessageTypes is the complete v1 Control message set. +var ControlMessageTypes = [...]ControlMessageType{ + ControlHello, + ControlWelcome, + ControlNodeList, + ControlCreateSession, + ControlPrepareSession, + ControlPrepareResult, + ControlSessionConfig, + ControlRoutesReady, + ControlSessionActive, + ControlStopSession, + ControlSessionStats, + ControlHeartbeat, + ControlRebootstrapRequired, +} + +// Valid reports whether the message type is part of the v1 Control protocol. +func (t ControlMessageType) Valid() bool { + switch t { + case ControlHello, + ControlWelcome, + ControlNodeList, + ControlCreateSession, + ControlPrepareSession, + ControlPrepareResult, + ControlSessionConfig, + ControlRoutesReady, + ControlSessionActive, + ControlStopSession, + ControlSessionStats, + ControlHeartbeat, + ControlRebootstrapRequired: + return true + default: + return false + } +} + +// ControlEnvelope is the stable v1 WebSocket framing shared by all messages. +type ControlEnvelope struct { + Type ControlMessageType `json:"type"` + RequestID string `json:"request_id,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +// NewControlEnvelope serializes a typed payload. +func NewControlEnvelope(messageType ControlMessageType, requestID string, payload any) (ControlEnvelope, error) { + if !messageType.Valid() { + return ControlEnvelope{}, fmt.Errorf("invalid Control message type %q", messageType) + } + raw, err := json.Marshal(payload) + if err != nil { + return ControlEnvelope{}, fmt.Errorf("encode %s payload: %w", messageType, err) + } + return ControlEnvelope{Type: messageType, RequestID: requestID, Payload: raw}, nil +} + +// DecodePayload strictly decodes exactly one JSON object. +func (e ControlEnvelope) DecodePayload(destination any) error { + if !e.Type.Valid() { + return fmt.Errorf("invalid Control message type %q", e.Type) + } + decoder := json.NewDecoder(bytes.NewReader(e.Payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return fmt.Errorf("decode %s payload: %w", e.Type, err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return fmt.Errorf("%s payload must contain one JSON value", e.Type) + } + return nil +} + +type NodeCapabilities struct { + RemoteSubnet bool `json:"remote_subnet"` + NetstackStatus string `json:"netstack_status,omitempty"` + TCPCapacity int `json:"tcp_capacity,omitempty"` + UDPCapacity int `json:"udp_capacity,omitempty"` +} + +type HelloPayload struct { + NodeID string `json:"node_id"` + NodeToken string `json:"node_token"` + ConfigVersion uint64 `json:"config_version"` + Capabilities NodeCapabilities `json:"capabilities"` + OSVersion string `json:"os_version"` + Version string `json:"version"` +} + +type WelcomePayload struct { + ServerTime time.Time `json:"server_time"` + NetworkConfigVersion uint64 `json:"network_config_version"` +} + +type HeartbeatPayload struct { + Timestamp time.Time `json:"timestamp"` + Status string `json:"status"` +} + +type SiteSummary struct { + NodeID string `json:"node_id"` + Name string `json:"name"` + OverlayIP string `json:"overlay_ip"` + Online bool `json:"online"` + RemoteSubnetCapability bool `json:"remote_subnet_capability"` + LastSeen time.Time `json:"last_seen,omitempty"` +} + +type NodeListPayload struct { + Sites []SiteSummary `json:"sites"` +} + +// CreateSessionPayload is accepted only from an authenticated Engineer. +type CreateSessionPayload struct { + SiteNodeID string `json:"site_node_id"` + TargetCIDRs []string `json:"target_cidrs"` +} + +type PrepareSessionPayload struct { + SessionID uint64 `json:"session_id"` + EngineerOverlayIP string `json:"engineer_overlay_ip"` + TargetCIDRs []string `json:"target_cidrs"` +} + +type RouteResult struct { + CIDR string `json:"cidr"` + Result string `json:"result"` +} + +type PrepareResultPayload struct { + SessionID uint64 `json:"session_id"` + OK bool `json:"ok"` + RouteResults []RouteResult `json:"route_results"` + SubnetGatewayStatus string `json:"subnet_gateway_status"` + TCPCapacity int `json:"tcp_capacity"` + UDPCapacity int `json:"udp_capacity"` + ErrorCode ErrorCode `json:"error_code,omitempty"` + Error string `json:"error,omitempty"` +} + +type SessionConfigPayload struct { + SessionID uint64 `json:"session_id"` + PeerOverlayIP string `json:"peer_overlay_ip"` + CIDRs []string `json:"cidrs"` + MTU int `json:"mtu"` + UDPPort int `json:"udp_port"` +} + +type RoutesReadyPayload struct { + SessionID uint64 `json:"session_id"` +} + +type SessionActivePayload struct { + SessionID uint64 `json:"session_id"` +} + +type StopSessionPayload struct { + SessionID uint64 `json:"session_id"` + Reason string `json:"reason"` +} + +type SessionStatsPayload struct { + SessionID uint64 `json:"session_id"` + Counters model.SessionCounters `json:"counters"` +} + +type RebootstrapRequiredPayload struct { + ConfigVersion uint64 `json:"config_version"` + Reason string `json:"reason"` +} diff --git a/internal/protocol/error_code.go b/internal/protocol/error_code.go new file mode 100644 index 0000000..f8a146f --- /dev/null +++ b/internal/protocol/error_code.go @@ -0,0 +1,62 @@ +package protocol + +// ErrorCode is a stable machine-readable RemLink failure identifier. +type ErrorCode string + +const ( + ErrorServerUnreachable ErrorCode = "SERVER_UNREACHABLE" + ErrorJoinTokenInvalid ErrorCode = "JOIN_TOKEN_INVALID" + ErrorNodeAuthFailed ErrorCode = "NODE_AUTH_FAILED" + ErrorOverlayLocalConflict ErrorCode = "OVERLAY_LOCAL_CONFLICT" + ErrorEngineerSessionExists ErrorCode = "ENGINEER_SESSION_EXISTS" + ErrorSiteOffline ErrorCode = "SITE_OFFLINE" + ErrorCIDRInvalid ErrorCode = "CIDR_INVALID" + ErrorCIDRLocalConflict ErrorCode = "CIDR_LOCAL_CONFLICT" + ErrorCIDROverlayConflict ErrorCode = "CIDR_OVERLAY_CONFLICT" + ErrorSiteNoRoute ErrorCode = "SITE_NO_ROUTE" + ErrorNetstackUnavailable ErrorCode = "NETSTACK_UNAVAILABLE" + ErrorFlowLimitReached ErrorCode = "FLOW_LIMIT_REACHED" + ErrorSessionTimeout ErrorCode = "SESSION_TIMEOUT" + ErrorSessionInjectFailed ErrorCode = "SESSION_INJECT_FAILED" +) + +// ErrorCodes is the authoritative 14-code v1 baseline. +var ErrorCodes = [...]ErrorCode{ + ErrorServerUnreachable, + ErrorJoinTokenInvalid, + ErrorNodeAuthFailed, + ErrorOverlayLocalConflict, + ErrorEngineerSessionExists, + ErrorSiteOffline, + ErrorCIDRInvalid, + ErrorCIDRLocalConflict, + ErrorCIDROverlayConflict, + ErrorSiteNoRoute, + ErrorNetstackUnavailable, + ErrorFlowLimitReached, + ErrorSessionTimeout, + ErrorSessionInjectFailed, +} + +// Valid reports whether the code belongs to the v1 baseline. +func (c ErrorCode) Valid() bool { + switch c { + case ErrorServerUnreachable, + ErrorJoinTokenInvalid, + ErrorNodeAuthFailed, + ErrorOverlayLocalConflict, + ErrorEngineerSessionExists, + ErrorSiteOffline, + ErrorCIDRInvalid, + ErrorCIDRLocalConflict, + ErrorCIDROverlayConflict, + ErrorSiteNoRoute, + ErrorNetstackUnavailable, + ErrorFlowLimitReached, + ErrorSessionTimeout, + ErrorSessionInjectFailed: + return true + default: + return false + } +} diff --git a/internal/protocol/session_header.go b/internal/protocol/session_header.go new file mode 100644 index 0000000..df1a2e0 --- /dev/null +++ b/internal/protocol/session_header.go @@ -0,0 +1,144 @@ +// Package protocol defines RemLink wire-level constants and codecs. +package protocol + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" +) + +const ( + // SessionHeaderSize is the fixed v1 Session header length in bytes. + SessionHeaderSize = 20 + // SessionVersion is the only Session protocol version supported by v1. + SessionVersion uint8 = 1 + // SessionTypeIPv4 marks a payload as one complete raw IPv4 packet. + SessionTypeIPv4 uint8 = 0x01 +) + +var ( + sessionMagic = [4]byte{'R', 'M', 'L', 'K'} + + ErrSessionHeaderTooShort = errors.New("session packet is shorter than the 20-byte header") + ErrSessionMagic = errors.New("invalid session magic") + ErrSessionVersion = errors.New("unsupported session version") + ErrSessionType = errors.New("unsupported session payload type") + ErrSessionFlags = errors.New("session flags must be zero in v1") + ErrSessionReserved = errors.New("session reserved field must be zero in v1") + ErrSessionPayloadLength = errors.New("session payload length mismatch") + ErrSessionPayloadTooLong = errors.New("session payload exceeds uint16 length") +) + +// SessionHeader is the fixed 20-byte prefix of every Remote Subnet datagram. +// Multi-byte fields are encoded in network byte order (big-endian). +type SessionHeader struct { + Version uint8 + Type uint8 + Flags uint16 + SessionID uint64 + PayloadLen uint16 + Reserved uint16 +} + +// NewIPv4SessionHeader constructs the canonical v1 header for a raw IPv4 payload. +func NewIPv4SessionHeader(sessionID uint64, payloadLen uint16) SessionHeader { + return SessionHeader{ + Version: SessionVersion, + Type: SessionTypeIPv4, + SessionID: sessionID, + PayloadLen: payloadLen, + } +} + +// Validate checks all v1 constants and reserved fields. +func (h SessionHeader) Validate() error { + switch { + case h.Version != SessionVersion: + return fmt.Errorf("%w: got %d", ErrSessionVersion, h.Version) + case h.Type != SessionTypeIPv4: + return fmt.Errorf("%w: got 0x%02x", ErrSessionType, h.Type) + case h.Flags != 0: + return fmt.Errorf("%w: got 0x%04x", ErrSessionFlags, h.Flags) + case h.Reserved != 0: + return fmt.Errorf("%w: got 0x%04x", ErrSessionReserved, h.Reserved) + default: + return nil + } +} + +// MarshalBinary serializes only the fixed header. +func (h SessionHeader) MarshalBinary() ([]byte, error) { + if err := h.Validate(); err != nil { + return nil, err + } + + out := make([]byte, SessionHeaderSize) + copy(out[0:4], sessionMagic[:]) + out[4] = h.Version + out[5] = h.Type + binary.BigEndian.PutUint16(out[6:8], h.Flags) + binary.BigEndian.PutUint64(out[8:16], h.SessionID) + binary.BigEndian.PutUint16(out[16:18], h.PayloadLen) + binary.BigEndian.PutUint16(out[18:20], h.Reserved) + return out, nil +} + +// ParseSessionHeader parses and validates a fixed header from the start of data. +func ParseSessionHeader(data []byte) (SessionHeader, error) { + if len(data) < SessionHeaderSize { + return SessionHeader{}, ErrSessionHeaderTooShort + } + if !bytes.Equal(data[0:4], sessionMagic[:]) { + return SessionHeader{}, ErrSessionMagic + } + + header := SessionHeader{ + Version: data[4], + Type: data[5], + Flags: binary.BigEndian.Uint16(data[6:8]), + SessionID: binary.BigEndian.Uint64(data[8:16]), + PayloadLen: binary.BigEndian.Uint16(data[16:18]), + Reserved: binary.BigEndian.Uint16(data[18:20]), + } + if err := header.Validate(); err != nil { + return SessionHeader{}, err + } + return header, nil +} + +// EncodeIPv4Session serializes a header and one complete raw IPv4 packet. +func EncodeIPv4Session(sessionID uint64, packet []byte) ([]byte, error) { + if len(packet) > int(^uint16(0)) { + return nil, ErrSessionPayloadTooLong + } + header := NewIPv4SessionHeader(sessionID, uint16(len(packet))) + headerBytes, err := header.MarshalBinary() + if err != nil { + return nil, err + } + + out := make([]byte, SessionHeaderSize+len(packet)) + copy(out, headerBytes) + copy(out[SessionHeaderSize:], packet) + return out, nil +} + +// DecodeIPv4Session validates framing and returns the header and raw IPv4 payload. +// The returned payload aliases data and must be copied before data is reused. +func DecodeIPv4Session(data []byte) (SessionHeader, []byte, error) { + header, err := ParseSessionHeader(data) + if err != nil { + return SessionHeader{}, nil, err + } + payload := data[SessionHeaderSize:] + if len(payload) != int(header.PayloadLen) { + return SessionHeader{}, nil, fmt.Errorf( + "%w: header=%d actual=%d", + ErrSessionPayloadLength, + header.PayloadLen, + len(payload), + ) + } + return header, payload, nil +} diff --git a/internal/protocol/session_header_test.go b/internal/protocol/session_header_test.go new file mode 100644 index 0000000..d95a11c --- /dev/null +++ b/internal/protocol/session_header_test.go @@ -0,0 +1,88 @@ +package protocol + +import ( + "bytes" + "errors" + "testing" +) + +func TestSessionHeaderGoldenBytes(t *testing.T) { + t.Parallel() + header := NewIPv4SessionHeader(0x0102030405060708, 0x0014) + got, err := header.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary() error = %v", err) + } + want := []byte{ + 'R', 'M', 'L', 'K', + 0x01, 0x01, + 0x00, 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x00, 0x14, + 0x00, 0x00, + } + if !bytes.Equal(got, want) { + t.Fatalf("MarshalBinary() = %x, want %x", got, want) + } +} + +func TestIPv4SessionRoundTrip(t *testing.T) { + t.Parallel() + packet := []byte{0x45, 0x00, 0x00, 0x04} + encoded, err := EncodeIPv4Session(42, packet) + if err != nil { + t.Fatalf("EncodeIPv4Session() error = %v", err) + } + header, decoded, err := DecodeIPv4Session(encoded) + if err != nil { + t.Fatalf("DecodeIPv4Session() error = %v", err) + } + if header.SessionID != 42 { + t.Fatalf("SessionID = %d, want 42", header.SessionID) + } + if !bytes.Equal(decoded, packet) { + t.Fatalf("decoded payload = %x, want %x", decoded, packet) + } +} + +func TestDecodeIPv4SessionRejectsInvalidFraming(t *testing.T) { + t.Parallel() + valid, err := EncodeIPv4Session(7, []byte{0x45}) + if err != nil { + t.Fatalf("EncodeIPv4Session() error = %v", err) + } + + tests := []struct { + name string + mutate func([]byte) []byte + want error + }{ + {name: "short", mutate: func(data []byte) []byte { return data[:19] }, want: ErrSessionHeaderTooShort}, + {name: "magic", mutate: func(data []byte) []byte { data[0] = 'X'; return data }, want: ErrSessionMagic}, + {name: "version", mutate: func(data []byte) []byte { data[4] = 2; return data }, want: ErrSessionVersion}, + {name: "type", mutate: func(data []byte) []byte { data[5] = 2; return data }, want: ErrSessionType}, + {name: "flags", mutate: func(data []byte) []byte { data[7] = 1; return data }, want: ErrSessionFlags}, + {name: "reserved", mutate: func(data []byte) []byte { data[19] = 1; return data }, want: ErrSessionReserved}, + {name: "payload length", mutate: func(data []byte) []byte { data[17] = 2; return data }, want: ErrSessionPayloadLength}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + input := append([]byte(nil), valid...) + _, _, gotErr := DecodeIPv4Session(test.mutate(input)) + if !errors.Is(gotErr, test.want) { + t.Fatalf("DecodeIPv4Session() error = %v, want %v", gotErr, test.want) + } + }) + } +} + +func TestEncodeIPv4SessionRejectsOversizedPayload(t *testing.T) { + t.Parallel() + _, err := EncodeIPv4Session(1, make([]byte, 1<<16)) + if !errors.Is(err, ErrSessionPayloadTooLong) { + t.Fatalf("EncodeIPv4Session() error = %v, want %v", err, ErrSessionPayloadTooLong) + } +} diff --git a/internal/session/.gitkeep b/internal/session/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/session/.gitkeep @@ -0,0 +1 @@ + diff --git a/internal/session/engineer.go b/internal/session/engineer.go new file mode 100644 index 0000000..28eaee9 --- /dev/null +++ b/internal/session/engineer.go @@ -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 +} diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 0000000..8d41ffd --- /dev/null +++ b/internal/session/manager.go @@ -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 +} diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go new file mode 100644 index 0000000..1e06d8d --- /dev/null +++ b/internal/session/manager_test.go @@ -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 +} diff --git a/internal/session/runtime_test.go b/internal/session/runtime_test.go new file mode 100644 index 0000000..2e1e060 --- /dev/null +++ b/internal/session/runtime_test.go @@ -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 +} diff --git a/internal/session/site.go b/internal/session/site.go new file mode 100644 index 0000000..f06f1ee --- /dev/null +++ b/internal/session/site.go @@ -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 +} diff --git a/internal/siteprofile/replace_other.go b/internal/siteprofile/replace_other.go new file mode 100644 index 0000000..1b3b2b0 --- /dev/null +++ b/internal/siteprofile/replace_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package siteprofile + +import "os" + +func replaceFile(source, destination string) error { return os.Rename(source, destination) } diff --git a/internal/siteprofile/replace_windows.go b/internal/siteprofile/replace_windows.go new file mode 100644 index 0000000..6487b94 --- /dev/null +++ b/internal/siteprofile/replace_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package siteprofile + +import "golang.org/x/sys/windows" + +func replaceFile(source, destination string) error { + sourcePointer, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPointer, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx(sourcePointer, destinationPointer, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/siteprofile/store.go b/internal/siteprofile/store.go new file mode 100644 index 0000000..dcfa237 --- /dev/null +++ b/internal/siteprofile/store.go @@ -0,0 +1,179 @@ +// Package siteprofile persists non-secret Engineer Remote CIDR preferences per Site. +package siteprofile + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/netip" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const diskVersion = 1 + +type Profile struct { + SiteName string `json:"site_name"` + CIDRs []string `json:"remote_cidrs"` + UpdatedAt time.Time `json:"updated_at"` +} + +type diskProfiles struct { + Version int `json:"version"` + Sites map[string]Profile `json:"sites"` +} + +type Store struct { + mu sync.Mutex + path string +} + +func NewStore(path string) (*Store, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("Site profile path is required") + } + return &Store{path: path}, nil +} + +func (s *Store) Path() string { return s.path } + +func (s *Store) Load() (map[string]Profile, error) { + s.mu.Lock() + defer s.mu.Unlock() + profiles, err := s.loadLocked() + if err != nil { + return nil, err + } + return cloneProfiles(profiles), nil +} + +func (s *Store) Save(siteID, siteName string, cidrs []string) error { + s.mu.Lock() + defer s.mu.Unlock() + siteID = strings.TrimSpace(siteID) + siteName = strings.TrimSpace(siteName) + if siteID == "" || siteName == "" { + return errors.New("Site ID and name are required") + } + canonical, err := canonicalCIDRs(cidrs) + if err != nil { + return err + } + profiles, err := s.loadLocked() + if err != nil { + return err + } + if len(canonical) == 0 { + delete(profiles, siteID) + } else { + profiles[siteID] = Profile{SiteName: siteName, CIDRs: canonical, UpdatedAt: time.Now().UTC()} + } + return s.saveLocked(profiles) +} + +func (s *Store) loadLocked() (map[string]Profile, error) { + raw, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return map[string]Profile{}, nil + } + if err != nil { + return nil, fmt.Errorf("read Site profiles: %w", err) + } + var stored diskProfiles + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&stored); err != nil { + return nil, fmt.Errorf("decode Site profiles: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, errors.New("Site profiles must contain one JSON object") + } + if stored.Version != diskVersion { + return nil, fmt.Errorf("unsupported Site profile version %d", stored.Version) + } + if stored.Sites == nil { + stored.Sites = map[string]Profile{} + } + for siteID, profile := range stored.Sites { + if strings.TrimSpace(siteID) == "" || strings.TrimSpace(profile.SiteName) == "" { + return nil, errors.New("stored Site profile has an empty Site ID or name") + } + canonical, err := canonicalCIDRs(profile.CIDRs) + if err != nil { + return nil, fmt.Errorf("validate Site profile %q: %w", siteID, err) + } + if len(canonical) == 0 { + return nil, fmt.Errorf("validate Site profile %q: Remote CIDRs are empty", siteID) + } + profile.CIDRs = canonical + stored.Sites[siteID] = profile + } + return stored.Sites, nil +} + +func (s *Store) saveLocked(profiles map[string]Profile) error { + encoded, err := json.MarshalIndent(diskProfiles{Version: diskVersion, Sites: profiles}, "", " ") + if err != nil { + return fmt.Errorf("encode Site profiles: %w", err) + } + encoded = append(encoded, '\n') + directory := filepath.Dir(s.path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("create Site profile directory: %w", err) + } + temporary, err := os.CreateTemp(directory, ".site-profiles-*.tmp") + if err != nil { + return fmt.Errorf("create temporary Site profiles: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return fmt.Errorf("restrict temporary Site profiles: %w", err) + } + if _, err := temporary.Write(encoded); err != nil { + temporary.Close() + return fmt.Errorf("write temporary Site profiles: %w", err) + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return fmt.Errorf("flush temporary Site profiles: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary Site profiles: %w", err) + } + if err := replaceFile(temporaryPath, s.path); err != nil { + return fmt.Errorf("replace Site profiles: %w", err) + } + return nil +} + +func canonicalCIDRs(cidrs []string) ([]string, error) { + result := make([]string, 0, len(cidrs)) + seen := make(map[netip.Prefix]struct{}, len(cidrs)) + for _, raw := range cidrs { + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err != nil || !prefix.Addr().Is4() || prefix.Bits() == 0 || prefix != prefix.Masked() { + return nil, fmt.Errorf("Remote CIDR must be canonical IPv4 and cannot be /0: %q", raw) + } + if _, exists := seen[prefix]; exists { + return nil, fmt.Errorf("duplicate Remote CIDR %q", prefix) + } + seen[prefix] = struct{}{} + result = append(result, prefix.String()) + } + return result, nil +} + +func cloneProfiles(source map[string]Profile) map[string]Profile { + result := make(map[string]Profile, len(source)) + for siteID, profile := range source { + profile.CIDRs = append([]string(nil), profile.CIDRs...) + result[siteID] = profile + } + return result +} diff --git a/internal/siteprofile/store_test.go b/internal/siteprofile/store_test.go new file mode 100644 index 0000000..c432782 --- /dev/null +++ b/internal/siteprofile/store_test.go @@ -0,0 +1,58 @@ +package siteprofile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStoreKeepsIndependentCIDRsPerSite(t *testing.T) { + path := filepath.Join(t.TempDir(), "site-profiles.json") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + profiles, err := store.Load() + if err != nil || len(profiles) != 0 { + t.Fatalf("initial profiles = %+v, %v", profiles, err) + } + if err := store.Save("site-a", "现场 A", []string{"192.168.17.0/24"}); err != nil { + t.Fatal(err) + } + if err := store.Save("site-b", "现场 B", []string{"192.168.107.0/24"}); err != nil { + t.Fatal(err) + } + profiles, err = store.Load() + if err != nil { + t.Fatal(err) + } + if len(profiles) != 2 || profiles["site-a"].CIDRs[0] != "192.168.17.0/24" || profiles["site-b"].CIDRs[0] != "192.168.107.0/24" { + t.Fatalf("saved profiles = %+v", profiles) + } + if err := store.Save("site-a", "现场 A", nil); err != nil { + t.Fatal(err) + } + profiles, _ = store.Load() + if len(profiles) != 1 || profiles["site-b"].CIDRs[0] != "192.168.107.0/24" { + t.Fatalf("profiles after clearing Site A = %+v", profiles) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} + +func TestStoreRejectsInvalidOrCorruptProfiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "site-profiles.json") + store, _ := NewStore(path) + for _, invalid := range [][]string{{"192.168.17.1/24"}, {"0.0.0.0/0"}, {"192.168.17.0/24", "192.168.17.0/24"}} { + if err := store.Save("site", "现场", invalid); err == nil { + t.Fatalf("accepted invalid CIDRs: %v", invalid) + } + } + if err := os.WriteFile(path, []byte(`{"version":1,"sites":{},"unknown":true}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := store.Load(); err == nil { + t.Fatal("accepted unknown Site profile field") + } +} diff --git a/internal/stats/.gitkeep b/internal/stats/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/stats/.gitkeep @@ -0,0 +1 @@ + diff --git a/internal/subnet/header.go b/internal/subnet/header.go new file mode 100644 index 0000000..00830df --- /dev/null +++ b/internal/subnet/header.go @@ -0,0 +1,9 @@ +// Package subnet transports complete raw IPv4 packets inside Overlay UDP. +package subnet + +import "remlink/internal/protocol" + +// EncodeDatagram uses the one authoritative v1 SessionHeader codec. +func EncodeDatagram(sessionID uint64, packet []byte) ([]byte, error) { + return protocol.EncodeIPv4Session(sessionID, packet) +} diff --git a/internal/subnet/listener.go b/internal/subnet/listener.go new file mode 100644 index 0000000..eb3f4c5 --- /dev/null +++ b/internal/subnet/listener.go @@ -0,0 +1,115 @@ +package subnet + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sync" + "sync/atomic" +) + +type PacketHandler func(context.Context, uint64, []byte) error +type RejectHandler func(error, netip.Addr) + +// Listener binds only the configured Overlay IP and validates before dispatch. +type Listener struct { + conn *net.UDPConn + registry *Registry + handler PacketHandler + onReject RejectHandler + closeOnce sync.Once + counterMu sync.RWMutex + bySession map[uint64]listenerCounters + accepted atomic.Uint64 + acceptedBytes atomic.Uint64 + rejected atomic.Uint64 +} + +type listenerCounters struct { + bytes uint64 + packets uint64 +} + +func NewListener(localIP netip.Addr, port int, registry *Registry, handler PacketHandler, onReject RejectHandler) (*Listener, error) { + if !localIP.Is4() || localIP.IsUnspecified() || port < 0 || port > 65535 || registry == nil || handler == nil { + return nil, errors.New("Listener requires exact IPv4 local address, valid port, Registry, and handler") + } + connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(localIP.AsSlice()), Port: port}) + if err != nil { + return nil, fmt.Errorf("bind Session Listener to %s:%d: %w", localIP, port, err) + } + return &Listener{conn: connection, registry: registry, handler: handler, onReject: onReject, bySession: make(map[uint64]listenerCounters)}, nil +} + +func (l *Listener) Port() int { return l.conn.LocalAddr().(*net.UDPAddr).Port } + +func (l *Listener) Close() error { + var err error + l.closeOnce.Do(func() { err = l.conn.Close() }) + return err +} + +func (l *Listener) Counters() (accepted, rejected uint64) { + return l.accepted.Load(), l.rejected.Load() +} + +// DetailedCounters returns accepted payload bytes/packets and rejected datagrams. +func (l *Listener) DetailedCounters() (bytes, packets, rejected uint64) { + return l.acceptedBytes.Load(), l.accepted.Load(), l.rejected.Load() +} + +// SessionCounters returns accepted raw IPv4 payload bytes and packets for one SessionID. +func (l *Listener) SessionCounters(sessionID uint64) (bytes, packets uint64) { + l.counterMu.RLock() + counters := l.bySession[sessionID] + l.counterMu.RUnlock() + return counters.bytes, counters.packets +} + +func (l *Listener) Run(ctx context.Context) error { + stopClose := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = l.Close() + case <-stopClose: + } + }() + defer close(stopClose) + buffer := make([]byte, 65535) + for { + count, source, err := l.conn.ReadFromUDPAddrPort(buffer) + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return ctx.Err() + } + return fmt.Errorf("read Session UDP: %w", err) + } + sessionID, packet, err := ValidateDatagram(buffer[:count], source.Addr(), l.registry) + if err != nil { + l.rejected.Add(1) + if l.onReject != nil { + l.onReject(err, source.Addr()) + } + continue + } + owned := append([]byte(nil), packet...) + if err := l.handler(ctx, sessionID, owned); err != nil { + l.rejected.Add(1) + if l.onReject != nil { + l.onReject(fmt.Errorf("handle Session packet: %w", err), source.Addr()) + } + continue + } + l.acceptedBytes.Add(uint64(len(owned))) + l.accepted.Add(1) + l.counterMu.Lock() + counters := l.bySession[sessionID] + counters.bytes += uint64(len(owned)) + counters.packets++ + l.bySession[sessionID] = counters + l.counterMu.Unlock() + } +} diff --git a/internal/subnet/listener_test.go b/internal/subnet/listener_test.go new file mode 100644 index 0000000..201eed0 --- /dev/null +++ b/internal/subnet/listener_test.go @@ -0,0 +1,75 @@ +package subnet + +import ( + "context" + "errors" + "net" + "net/netip" + "sync/atomic" + "testing" + "time" +) + +func TestListenerContinuesAfterPacketHandlerFailure(t *testing.T) { + registry := NewRegistry() + peer := netip.MustParseAddr("127.0.0.1") + if err := registry.Upsert(SessionBinding{ + SessionID: 41, PeerOverlayIP: peer, EngineerOverlayIP: peer, + RemoteCIDRs: []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, + Direction: EngineerToSite, Active: true, + }); err != nil { + t.Fatal(err) + } + var calls, rejects atomic.Int32 + listener, err := NewListener(peer, 0, registry, func(context.Context, uint64, []byte) error { + if calls.Add(1) == 1 { + return errors.New("injection failed") + } + return nil + }, func(error, netip.Addr) { rejects.Add(1) }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- listener.Run(ctx) }() + connection, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IP(peer.AsSlice()), Port: listener.Port()}) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + packet := testListenerIPv4("127.0.0.1", "192.168.13.10") + datagram, err := EncodeDatagram(41, packet) + if err != nil { + t.Fatal(err) + } + if _, err := connection.Write(datagram); err != nil { + t.Fatal(err) + } + if _, err := connection.Write(datagram); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + accepted, rejected := listener.Counters() + if accepted == 1 && rejected == 1 && rejects.Load() == 1 { + cancel() + <-done + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("listener counters accepted=%d rejected=%d callbacks=%d", func() uint64 { a, _ := listener.Counters(); return a }(), func() uint64 { _, r := listener.Counters(); return r }(), rejects.Load()) +} + +func testListenerIPv4(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 +} diff --git a/internal/subnet/registry.go b/internal/subnet/registry.go new file mode 100644 index 0000000..4ca4633 --- /dev/null +++ b/internal/subnet/registry.go @@ -0,0 +1,71 @@ +package subnet + +import ( + "errors" + "net/netip" + "sync" +) + +type Direction uint8 + +const ( + EngineerToSite Direction = iota + 1 + SiteToEngineer +) + +// SessionBinding contains the exact identities used by receive validation. +type SessionBinding struct { + SessionID uint64 + PeerOverlayIP netip.Addr + EngineerOverlayIP netip.Addr + RemoteCIDRs []netip.Prefix + Direction Direction + Active bool +} + +// Registry is the listener's concurrency-safe Active Session lookup. +type Registry struct { + mu sync.RWMutex + sessions map[uint64]SessionBinding +} + +func NewRegistry() *Registry { return &Registry{sessions: make(map[uint64]SessionBinding)} } + +func (r *Registry) Upsert(binding SessionBinding) error { + if err := validateBinding(binding); err != nil { + return err + } + binding.RemoteCIDRs = append([]netip.Prefix(nil), binding.RemoteCIDRs...) + r.mu.Lock() + r.sessions[binding.SessionID] = binding + r.mu.Unlock() + return nil +} + +func (r *Registry) Remove(sessionID uint64) { + r.mu.Lock() + delete(r.sessions, sessionID) + r.mu.Unlock() +} + +func (r *Registry) Lookup(sessionID uint64) (SessionBinding, bool) { + r.mu.RLock() + binding, found := r.sessions[sessionID] + r.mu.RUnlock() + return binding, found +} + +func validateBinding(binding SessionBinding) error { + if binding.SessionID == 0 || !binding.PeerOverlayIP.Is4() || !binding.EngineerOverlayIP.Is4() || len(binding.RemoteCIDRs) == 0 { + return errors.New("Session binding requires nonzero ID, IPv4 peers, and Remote CIDRs") + } + if binding.Direction != EngineerToSite && binding.Direction != SiteToEngineer { + return errors.New("Session binding direction is invalid") + } + for _, prefix := range binding.RemoteCIDRs { + if !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 { + return errors.New("Session Remote CIDRs must be canonical non-default IPv4 prefixes") + } + } + return nil +} diff --git a/internal/subnet/sender.go b/internal/subnet/sender.go new file mode 100644 index 0000000..e31299f --- /dev/null +++ b/internal/subnet/sender.go @@ -0,0 +1,138 @@ +package subnet + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sync" + "sync/atomic" +) + +const DefaultQueueCapacity = 1024 + +type SenderConfig struct { + SessionID uint64 + LocalIP netip.Addr + PeerIP netip.Addr + PeerPort int + QueueCapacity int +} + +// Sender performs ordinary UDP socket I/O on a dedicated goroutine. +type Sender struct { + conn *net.UDPConn + queue chan []byte + enqueueMu sync.RWMutex + closed bool + cancel context.CancelFunc + done chan struct{} + closeOnce sync.Once + sessionID uint64 + sentBytes atomic.Uint64 + sentPackets atomic.Uint64 + dropped atomic.Uint64 + lastError atomic.Value +} + +type errorBox struct{ err error } + +func NewSender(parent context.Context, config SenderConfig) (*Sender, error) { + if err := validateSenderConfig(config); err != nil { + return nil, err + } + if config.QueueCapacity <= 0 { + config.QueueCapacity = DefaultQueueCapacity + } + connection, err := net.DialUDP("udp4", + &net.UDPAddr{IP: net.IP(config.LocalIP.AsSlice())}, + &net.UDPAddr{IP: net.IP(config.PeerIP.AsSlice()), Port: config.PeerPort}, + ) + if err != nil { + return nil, fmt.Errorf("bind Session Sender to %s and dial %s:%d: %w", config.LocalIP, config.PeerIP, config.PeerPort, err) + } + ctx, cancel := context.WithCancel(parent) + sender := &Sender{ + conn: connection, queue: make(chan []byte, config.QueueCapacity), cancel: cancel, + done: make(chan struct{}), sessionID: config.SessionID, + } + go sender.run(ctx) + return sender, nil +} + +func validateSenderConfig(config SenderConfig) error { + if config.SessionID == 0 || !config.LocalIP.Is4() || !config.PeerIP.Is4() || config.PeerPort < 1 || config.PeerPort > 65535 { + return errors.New("Sender requires SessionID, IPv4 local/peer addresses, and valid peer port") + } + if config.LocalIP.IsUnspecified() || config.PeerIP.IsUnspecified() || config.LocalIP.IsMulticast() || config.PeerIP.IsMulticast() || config.LocalIP == config.PeerIP { + return errors.New("Sender local/peer addresses must be distinct unicast addresses") + } + return nil +} + +// Enqueue never blocks PacketMux. packet ownership transfers to Sender on true. +func (s *Sender) Enqueue(packet []byte) bool { + s.enqueueMu.RLock() + defer s.enqueueMu.RUnlock() + if s.closed { + return false + } + select { + case s.queue <- packet: + return true + default: + s.dropped.Add(1) + return false + } +} + +func (s *Sender) Close() error { + s.closeOnce.Do(func() { + s.enqueueMu.Lock() + s.closed = true + s.enqueueMu.Unlock() + s.cancel() + _ = s.conn.Close() + <-s.done + }) + return nil +} + +func (s *Sender) Counters() (bytes, packets, dropped uint64) { + return s.sentBytes.Load(), s.sentPackets.Load(), s.dropped.Load() +} + +func (s *Sender) Err() error { + value := s.lastError.Load() + if value == nil { + return nil + } + return value.(*errorBox).err +} + +func (s *Sender) run(ctx context.Context) { + defer close(s.done) + for { + select { + case <-ctx.Done(): + return + case packet := <-s.queue: + datagram, err := EncodeDatagram(s.sessionID, packet) + if err == nil { + var written int + written, err = s.conn.Write(datagram) + if err == nil && written != len(datagram) { + err = errors.New("short Session UDP write") + } + } + if err != nil { + s.lastError.Store(&errorBox{err: err}) + s.dropped.Add(1) + continue + } + s.sentBytes.Add(uint64(len(packet))) + s.sentPackets.Add(1) + } + } +} diff --git a/internal/subnet/sender_test.go b/internal/subnet/sender_test.go new file mode 100644 index 0000000..00cd313 --- /dev/null +++ b/internal/subnet/sender_test.go @@ -0,0 +1,42 @@ +package subnet + +import ( + "context" + "net/netip" + "testing" +) + +func TestSenderRejectsEnqueueAfterClose(t *testing.T) { + sender, err := NewSender(context.Background(), SenderConfig{ + SessionID: 55, + LocalIP: netip.MustParseAddr("127.0.0.1"), + PeerIP: netip.MustParseAddr("127.0.0.2"), + PeerPort: 6200, + }) + if err != nil { + t.Fatal(err) + } + if err := sender.Close(); err != nil { + t.Fatal(err) + } + if sender.Enqueue([]byte{1, 2, 3}) { + t.Fatal("closed Sender accepted a packet with no running consumer") + } +} + +func TestValidateSenderConfigRejectsInvalidAddressRoles(t *testing.T) { + valid := SenderConfig{SessionID: 1, LocalIP: netip.MustParseAddr("127.0.0.1"), PeerIP: netip.MustParseAddr("127.0.0.2"), PeerPort: 6200} + if err := validateSenderConfig(valid); err != nil { + t.Fatalf("valid Sender config error = %v", err) + } + invalid := []SenderConfig{ + {SessionID: 1, LocalIP: netip.IPv4Unspecified(), PeerIP: valid.PeerIP, PeerPort: 6200}, + {SessionID: 1, LocalIP: valid.LocalIP, PeerIP: netip.MustParseAddr("224.0.0.1"), PeerPort: 6200}, + {SessionID: 1, LocalIP: valid.LocalIP, PeerIP: valid.LocalIP, PeerPort: 6200}, + } + for _, current := range invalid { + if err := validateSenderConfig(current); err == nil { + t.Errorf("invalid Sender config accepted: %+v", current) + } + } +} diff --git a/internal/subnet/validator.go b/internal/subnet/validator.go new file mode 100644 index 0000000..9dad9c5 --- /dev/null +++ b/internal/subnet/validator.go @@ -0,0 +1,82 @@ +package subnet + +import ( + "errors" + "fmt" + "net/netip" + + "remlink/internal/protocol" +) + +var ( + ErrUnknownSession = errors.New("unknown or inactive Session") + ErrOuterSourceMismatch = errors.New("Session outer source does not match peer Overlay IP") + ErrInnerIPv4Invalid = errors.New("Session payload is not one complete IPv4 packet") + ErrInnerSourceMismatch = errors.New("Session inner source is not allowed") + ErrInnerTargetMismatch = errors.New("Session inner destination is not allowed") +) + +// ValidateDatagram verifies framing, Active Session identity, outer source, +// IPv4 total length, and direction-specific inner addresses. +func ValidateDatagram(data []byte, outerSource netip.Addr, registry *Registry) (uint64, []byte, error) { + if registry == nil { + return 0, nil, ErrUnknownSession + } + header, packet, err := protocol.DecodeIPv4Session(data) + if err != nil { + return 0, nil, err + } + binding, found := registry.Lookup(header.SessionID) + if !found || !binding.Active { + return 0, nil, ErrUnknownSession + } + if outerSource.Unmap() != binding.PeerOverlayIP.Unmap() { + return 0, nil, ErrOuterSourceMismatch + } + source, destination, err := ipv4Addresses(packet) + if err != nil { + return 0, nil, err + } + switch binding.Direction { + case EngineerToSite: + if source != binding.EngineerOverlayIP { + return 0, nil, fmt.Errorf("%w: got %s want %s", ErrInnerSourceMismatch, source, binding.EngineerOverlayIP) + } + if !containedBy(destination, binding.RemoteCIDRs) { + return 0, nil, fmt.Errorf("%w: %s is outside Remote CIDRs", ErrInnerTargetMismatch, destination) + } + case SiteToEngineer: + if !containedBy(source, binding.RemoteCIDRs) { + return 0, nil, fmt.Errorf("%w: %s is outside Remote CIDRs", ErrInnerSourceMismatch, source) + } + if destination != binding.EngineerOverlayIP { + return 0, nil, fmt.Errorf("%w: got %s want %s", ErrInnerTargetMismatch, destination, binding.EngineerOverlayIP) + } + default: + return 0, nil, ErrUnknownSession + } + return header.SessionID, packet, nil +} + +func ipv4Addresses(packet []byte) (source, destination netip.Addr, err error) { + if len(packet) < 20 || packet[0]>>4 != 4 { + return netip.Addr{}, netip.Addr{}, ErrInnerIPv4Invalid + } + headerLength := int(packet[0]&0x0F) * 4 + totalLength := int(packet[2])<<8 | int(packet[3]) + if headerLength < 20 || headerLength > len(packet) || totalLength != len(packet) || totalLength < headerLength { + return netip.Addr{}, netip.Addr{}, ErrInnerIPv4Invalid + } + source = netip.AddrFrom4([4]byte{packet[12], packet[13], packet[14], packet[15]}) + destination = netip.AddrFrom4([4]byte{packet[16], packet[17], packet[18], packet[19]}) + return source, destination, nil +} + +func containedBy(address netip.Addr, prefixes []netip.Prefix) bool { + for _, prefix := range prefixes { + if prefix.Contains(address) { + return true + } + } + return false +} diff --git a/internal/subnet/validator_test.go b/internal/subnet/validator_test.go new file mode 100644 index 0000000..09bcfbe --- /dev/null +++ b/internal/subnet/validator_test.go @@ -0,0 +1,111 @@ +package subnet + +import ( + "context" + "errors" + "net/netip" + "testing" + "time" +) + +func TestValidateDatagramBothDirections(t *testing.T) { + remoteCIDR := netip.MustParsePrefix("192.168.13.0/24") + engineerIP := netip.MustParseAddr("10.88.0.2") + siteIP := netip.MustParseAddr("10.88.0.3") + + siteRegistry := NewRegistry() + if err := siteRegistry.Upsert(SessionBinding{ + SessionID: 7, PeerOverlayIP: engineerIP, EngineerOverlayIP: engineerIP, + RemoteCIDRs: []netip.Prefix{remoteCIDR}, Direction: EngineerToSite, Active: true, + }); err != nil { + t.Fatal(err) + } + requestDatagram, _ := EncodeDatagram(7, testIPv4("10.88.0.2", "192.168.13.10")) + if _, _, err := ValidateDatagram(requestDatagram, engineerIP, siteRegistry); err != nil { + t.Fatalf("Engineer-to-Site validation: %v", err) + } + + engineerRegistry := NewRegistry() + if err := engineerRegistry.Upsert(SessionBinding{ + SessionID: 7, PeerOverlayIP: siteIP, EngineerOverlayIP: engineerIP, + RemoteCIDRs: []netip.Prefix{remoteCIDR}, Direction: SiteToEngineer, Active: true, + }); err != nil { + t.Fatal(err) + } + replyDatagram, _ := EncodeDatagram(7, testIPv4("192.168.13.10", "10.88.0.2")) + if _, _, err := ValidateDatagram(replyDatagram, siteIP, engineerRegistry); err != nil { + t.Fatalf("Site-to-Engineer validation: %v", err) + } + if _, _, err := ValidateDatagram(replyDatagram, netip.MustParseAddr("10.88.0.4"), engineerRegistry); !errors.Is(err, ErrOuterSourceMismatch) { + t.Fatalf("wrong outer source error = %v", err) + } + wrongDatagram, _ := EncodeDatagram(7, testIPv4("192.168.14.10", "10.88.0.2")) + if _, _, err := ValidateDatagram(wrongDatagram, siteIP, engineerRegistry); !errors.Is(err, ErrInnerSourceMismatch) { + t.Fatalf("wrong inner source error = %v", err) + } +} + +func TestSenderListenerRoundTripOverOrdinaryUDP(t *testing.T) { + listenerIP := netip.MustParseAddr("127.0.0.1") + senderIP := netip.MustParseAddr("127.0.0.2") + engineerIP := netip.MustParseAddr("10.88.0.2") + registry := NewRegistry() + if err := registry.Upsert(SessionBinding{ + SessionID: 42, PeerOverlayIP: senderIP, EngineerOverlayIP: engineerIP, + RemoteCIDRs: []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, + Direction: EngineerToSite, Active: true, + }); err != nil { + t.Fatal(err) + } + received := make(chan []byte, 1) + listener, err := NewListener(listenerIP, 0, registry, func(_ context.Context, sessionID uint64, packet []byte) error { + if sessionID != 42 { + return errors.New("unexpected SessionID") + } + received <- packet + return nil + }, nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- listener.Run(ctx) }() + sender, err := NewSender(ctx, SenderConfig{ + SessionID: 42, LocalIP: senderIP, PeerIP: listenerIP, PeerPort: listener.Port(), QueueCapacity: 4, + }) + if err != nil { + cancel() + t.Fatal(err) + } + packet := testIPv4("10.88.0.2", "192.168.13.10") + if !sender.Enqueue(append([]byte(nil), packet...)) { + t.Fatal("Sender queue unexpectedly full") + } + select { + case got := <-received: + if string(got) != string(packet) { + t.Fatal("received packet differs") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Session UDP") + } + _ = sender.Close() + cancel() + <-done + accepted, rejected := listener.Counters() + if accepted != 1 || rejected != 0 { + t.Fatalf("listener counters accepted=%d rejected=%d", accepted, rejected) + } +} + +func testIPv4(sourceText, destinationText string) []byte { + packet := make([]byte, 20) + packet[0] = 0x45 + packet[3] = byte(len(packet)) + source := netip.MustParseAddr(sourceText).As4() + destination := netip.MustParseAddr(destinationText).As4() + copy(packet[12:16], source[:]) + copy(packet[16:20], destination[:]) + return packet +} diff --git a/internal/subnetgateway/gateway.go b/internal/subnetgateway/gateway.go new file mode 100644 index 0000000..1fbe041 --- /dev/null +++ b/internal/subnetgateway/gateway.go @@ -0,0 +1,19 @@ +// Package subnetgateway defines the Site userspace subnet backend boundary. +package subnetgateway + +import ( + "context" + "net/netip" +) + +type SessionConfig struct { + SessionID uint64 + EngineerOverlayIP netip.Addr + RemoteCIDRs []netip.Prefix +} + +type SubnetGateway interface { + Prepare(context.Context, SessionConfig) error + InjectIPv4(context.Context, uint64, []byte) error + CloseSession(context.Context, uint64) error +} diff --git a/internal/subnetgateway/netstack/backend.go b/internal/subnetgateway/netstack/backend.go new file mode 100644 index 0000000..d6ebd1b --- /dev/null +++ b/internal/subnetgateway/netstack/backend.go @@ -0,0 +1,538 @@ +// Package netstack implements the Site SubnetGateway with pinned gVisor netstack. +package netstack + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "sync" + "sync/atomic" + "time" + + "golang.org/x/net/icmp" + xipv4 "golang.org/x/net/ipv4" + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/link/channel" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" + "gvisor.dev/gvisor/pkg/waiter" + + "remlink/internal/subnetgateway" + "remlink/internal/subnetgateway/pingrelay" + "remlink/internal/subnetgateway/tcprelay" + "remlink/internal/subnetgateway/udprelay" +) + +const ( + DefaultTCPFlowLimit = 2048 + DefaultUDPFlowLimit = 4096 + DefaultUDPIdleTimeout = 60 * time.Second + defaultQueueSize = 1024 + defaultMTU = 1280 + nicID tcpip.NICID = 1 +) + +type Dialer interface { + DialContext(context.Context, string, string) (net.Conn, error) +} + +type EgressHandler func(context.Context, uint64, []byte) error + +type EchoProber interface { + Echo(context.Context, netip.Addr, int, int, []byte) error +} + +type Config struct { + MTU int + TCPFlowLimit int + UDPFlowLimit int + UDPIdleTimeout time.Duration + Dialer Dialer + Egress EgressHandler + PingProber EchoProber +} + +type flowProtocol uint8 + +const ( + flowTCP flowProtocol = iota + 1 + flowUDP +) + +type flowKey struct { + SessionID uint64 + Protocol flowProtocol + EngineerIP netip.Addr + EngineerPort uint16 + TargetIP netip.Addr + TargetPort uint16 +} + +type sessionState struct { + config subnetgateway.SessionConfig + ctx context.Context + cancel context.CancelFunc +} + +// Backend is one process-wide IPv4 stack shared by all Site Sessions. +type Backend struct { + mu sync.RWMutex + stack *stack.Stack + endpoint *channel.Endpoint + dialer Dialer + egress EgressHandler + ping EchoProber + udpIdle time.Duration + sessions map[uint64]*sessionState + byEngineer map[netip.Addr]uint64 + flows map[flowKey]context.CancelFunc + tcpSlots chan struct{} + udpSlots chan struct{} + ctx context.Context + cancel context.CancelFunc + closeOnce sync.Once + egressError atomic.Value +} + +type errorBox struct{ err error } + +func New(config Config) (*Backend, error) { + if config.MTU == 0 { + config.MTU = defaultMTU + } + if config.MTU < 576 || config.MTU > 65535 { + return nil, errors.New("netstack MTU must be between 576 and 65535") + } + if config.TCPFlowLimit <= 0 { + config.TCPFlowLimit = DefaultTCPFlowLimit + } + if config.UDPFlowLimit <= 0 { + config.UDPFlowLimit = DefaultUDPFlowLimit + } + if config.UDPIdleTimeout <= 0 { + config.UDPIdleTimeout = DefaultUDPIdleTimeout + } + if config.Dialer == nil { + config.Dialer = &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + } + if config.Egress == nil { + return nil, errors.New("netstack Egress handler is required") + } + if config.PingProber == nil { + config.PingProber = pingrelay.Relay{} + } + ctx, cancel := context.WithCancel(context.Background()) + backend := &Backend{ + dialer: config.Dialer, egress: config.Egress, ping: config.PingProber, udpIdle: config.UDPIdleTimeout, + sessions: make(map[uint64]*sessionState), byEngineer: make(map[netip.Addr]uint64), + flows: make(map[flowKey]context.CancelFunc), tcpSlots: make(chan struct{}, config.TCPFlowLimit), + udpSlots: make(chan struct{}, config.UDPFlowLimit), ctx: ctx, cancel: cancel, + } + backend.stack = stack.New(stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol}, + }) + backend.endpoint = channel.New(defaultQueueSize, uint32(config.MTU), "") + if err := tcpipError("create netstack NIC", backend.stack.CreateNIC(nicID, backend.endpoint)); err != nil { + cancel() + return nil, err + } + if err := tcpipError("enable netstack promiscuous mode", backend.stack.SetPromiscuousMode(nicID, true)); err != nil { + backend.Close() + return nil, err + } + if err := tcpipError("enable netstack spoofing", backend.stack.SetSpoofing(nicID, true)); err != nil { + backend.Close() + return nil, err + } + backend.stack.SetRouteTable([]tcpip.Route{{Destination: header.IPv4EmptySubnet, NIC: nicID}}) + tcpForwarder := tcp.NewForwarder(backend.stack, 0, config.TCPFlowLimit, backend.handleTCP) + udpForwarder := udp.NewForwarder(backend.stack, func(request *udp.ForwarderRequest) { go backend.handleUDP(request) }) + backend.stack.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) + backend.stack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + go backend.runEgress() + return backend, nil +} + +func (b *Backend) Prepare(_ context.Context, config subnetgateway.SessionConfig) error { + if err := validateSession(config); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if existing := b.sessions[config.SessionID]; existing != nil { + if existing.config.EngineerOverlayIP != config.EngineerOverlayIP || !samePrefixes(existing.config.RemoteCIDRs, config.RemoteCIDRs) { + return errors.New("SessionID is already bound to another configuration") + } + // An exact retry is idempotent. Never mutate a published session config: + // packet injection and flow lookup intentionally read it without holding + // the map lock on their host-I/O paths. + return nil + } + if existingID, exists := b.byEngineer[config.EngineerOverlayIP]; exists && existingID != config.SessionID { + return errors.New("Engineer already has a prepared Site Session") + } + sessionContext, cancel := context.WithCancel(b.ctx) + config.RemoteCIDRs = append([]netip.Prefix(nil), config.RemoteCIDRs...) + b.sessions[config.SessionID] = &sessionState{config: config, ctx: sessionContext, cancel: cancel} + b.byEngineer[config.EngineerOverlayIP] = config.SessionID + return nil +} + +func (b *Backend) InjectIPv4(ctx context.Context, sessionID uint64, packet []byte) error { + if err := ctx.Err(); err != nil { + return err + } + session := b.sessionByID(sessionID) + if session == nil { + return errors.New("inject into unknown Session") + } + source, destination, err := rawIPv4Addresses(packet) + if err != nil { + return err + } + if source != session.config.EngineerOverlayIP || !contains(session.config.RemoteCIDRs, destination) { + return errors.New("injected IPv4 addresses do not match Session") + } + if packet[9] == uint8(header.ICMPv4ProtocolNumber) { + return b.handleICMPEcho(session, packet, source, destination) + } + payload := append([]byte(nil), packet...) + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(payload)}) + b.endpoint.InjectInbound(ipv4.ProtocolNumber, packetBuffer) + packetBuffer.DecRef() + return nil +} + +func (b *Backend) handleICMPEcho(session *sessionState, packet []byte, source, destination netip.Addr) error { + headerLength := int(packet[0]&0x0F) * 4 + if len(packet) < headerLength+header.ICMPv4MinimumSize { + return errors.New("ICMPv4 packet is too short") + } + message, err := icmp.ParseMessage(1, packet[headerLength:]) + if err != nil || message.Type != xipv4.ICMPTypeEcho || message.Code != 0 { + return errors.New("only ICMPv4 Echo Request is supported") + } + echo, ok := message.Body.(*icmp.Echo) + if !ok { + return errors.New("ICMPv4 Echo body is invalid") + } + data := append([]byte(nil), echo.Data...) + go func() { + if err := b.ping.Echo(session.ctx, destination, echo.ID, echo.Seq, data); err != nil { + return + } + replyMessage := icmp.Message{ + Type: xipv4.ICMPTypeEchoReply, Code: 0, + Body: &icmp.Echo{ID: echo.ID, Seq: echo.Seq, Data: data}, + } + encoded, err := replyMessage.Marshal(nil) + if err != nil { + return + } + reply := buildIPv4Packet(destination, source, uint16(packet[4])<<8|uint16(packet[5]), uint8(header.ICMPv4ProtocolNumber), encoded) + if err := b.egress(session.ctx, session.config.SessionID, reply); err != nil { + b.egressError.Store(&errorBox{err: err}) + } + }() + return nil +} + +func (b *Backend) CloseSession(_ context.Context, sessionID uint64) error { + b.mu.Lock() + session := b.sessions[sessionID] + if session == nil { + b.mu.Unlock() + return nil + } + delete(b.sessions, sessionID) + delete(b.byEngineer, session.config.EngineerOverlayIP) + session.cancel() + for key, cancel := range b.flows { + if key.SessionID == sessionID { + cancel() + } + } + b.mu.Unlock() + return nil +} + +func (b *Backend) Close() error { + b.closeOnce.Do(func() { + b.cancel() + b.mu.Lock() + for _, session := range b.sessions { + session.cancel() + } + b.sessions = make(map[uint64]*sessionState) + b.byEngineer = make(map[netip.Addr]uint64) + b.mu.Unlock() + if b.endpoint != nil { + b.endpoint.Close() + } + if b.stack != nil { + b.stack.Close() + } + }) + return nil +} + +func (b *Backend) Err() error { + value := b.egressError.Load() + if value == nil { + return nil + } + return value.(*errorBox).err +} + +func (b *Backend) FlowCounts() (tcpCount, udpCount int) { + return len(b.tcpSlots), len(b.udpSlots) +} + +func (b *Backend) handleTCP(request *tcp.ForwarderRequest) { + id := request.ID() + key, session, ok := b.flowFromID(flowTCP, id) + if !ok { + request.Complete(true) + return + } + flowContext, finish, ok := b.beginFlow(key, session, b.tcpSlots) + if !ok { + request.Complete(true) + return + } + defer finish() + hostConnection, err := b.dialer.DialContext(flowContext, "tcp4", targetAddress(key.TargetIP, key.TargetPort)) + if err != nil { + request.Complete(true) + return + } + var queue waiter.Queue + endpoint, endpointErr := request.CreateEndpoint(&queue) + if endpointErr != nil { + request.Complete(true) + _ = hostConnection.Close() + return + } + request.Complete(false) + engineerConnection := gonet.NewTCPConn(&queue, endpoint) + _ = tcprelay.Relay(flowContext, engineerConnection, hostConnection) +} + +func (b *Backend) handleUDP(request *udp.ForwarderRequest) { + id := request.ID() + var queue waiter.Queue + endpoint, endpointErr := request.CreateEndpoint(&queue) + if endpointErr != nil { + return + } + engineerConnection := gonet.NewUDPConn(&queue, endpoint) + key, session, ok := b.flowFromID(flowUDP, id) + if !ok { + _ = engineerConnection.Close() + return + } + flowContext, finish, ok := b.beginFlow(key, session, b.udpSlots) + if !ok { + _ = engineerConnection.Close() + return + } + defer finish() + hostConnection, err := b.dialer.DialContext(flowContext, "udp4", targetAddress(key.TargetIP, key.TargetPort)) + if err != nil { + _ = engineerConnection.Close() + return + } + _ = udprelay.Relay(flowContext, engineerConnection, hostConnection, b.udpIdle) +} + +func (b *Backend) runEgress() { + for { + packetBuffer := b.endpoint.ReadContext(b.ctx) + if packetBuffer == nil { + return + } + view := packetBuffer.ToView() + packet := append([]byte(nil), view.AsSlice()...) + view.Release() + packetBuffer.DecRef() + source, destination, err := rawIPv4Addresses(packet) + if err != nil { + continue + } + session := b.sessionByEngineer(destination) + if session == nil || !contains(session.config.RemoteCIDRs, source) { + continue + } + if err := b.egress(session.ctx, session.config.SessionID, packet); err != nil { + b.egressError.Store(&errorBox{err: err}) + } + } +} + +func (b *Backend) flowFromID(protocol flowProtocol, id stack.TransportEndpointID) (flowKey, *sessionState, bool) { + engineerIP, ok := tcpipAddress(id.RemoteAddress) + if !ok { + return flowKey{}, nil, false + } + targetIP, ok := tcpipAddress(id.LocalAddress) + if !ok { + return flowKey{}, nil, false + } + session := b.sessionByEngineer(engineerIP) + if session == nil || !contains(session.config.RemoteCIDRs, targetIP) { + return flowKey{}, nil, false + } + return flowKey{ + SessionID: session.config.SessionID, Protocol: protocol, + EngineerIP: engineerIP, EngineerPort: id.RemotePort, TargetIP: targetIP, TargetPort: id.LocalPort, + }, session, true +} + +func (b *Backend) beginFlow(key flowKey, session *sessionState, slots chan struct{}) (context.Context, func(), bool) { + select { + case slots <- struct{}{}: + default: + return nil, nil, false + } + b.mu.Lock() + if b.sessions[key.SessionID] != session { + b.mu.Unlock() + <-slots + return nil, nil, false + } + if _, duplicate := b.flows[key]; duplicate { + b.mu.Unlock() + <-slots + return nil, nil, false + } + ctx, cancel := context.WithCancel(session.ctx) + b.flows[key] = cancel + b.mu.Unlock() + var once sync.Once + finish := func() { + once.Do(func() { + cancel() + b.mu.Lock() + delete(b.flows, key) + b.mu.Unlock() + <-slots + }) + } + return ctx, finish, true +} + +func (b *Backend) sessionByID(sessionID uint64) *sessionState { + b.mu.RLock() + session := b.sessions[sessionID] + b.mu.RUnlock() + return session +} + +func (b *Backend) sessionByEngineer(address netip.Addr) *sessionState { + b.mu.RLock() + session := b.sessions[b.byEngineer[address]] + b.mu.RUnlock() + return session +} + +func validateSession(config subnetgateway.SessionConfig) error { + if config.SessionID == 0 || !config.EngineerOverlayIP.Is4() || len(config.RemoteCIDRs) == 0 { + return errors.New("netstack Session requires ID, Engineer IPv4, and Remote CIDRs") + } + for _, prefix := range config.RemoteCIDRs { + if !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 || prefix.Contains(config.EngineerOverlayIP) { + return errors.New("netstack Remote CIDRs must be canonical non-default IPv4 and exclude Engineer") + } + } + return nil +} + +func rawIPv4Addresses(packet []byte) (netip.Addr, netip.Addr, error) { + if len(packet) < header.IPv4MinimumSize || packet[0]>>4 != 4 { + return netip.Addr{}, netip.Addr{}, errors.New("invalid raw IPv4 packet") + } + headerLength := int(packet[0]&0x0F) * 4 + totalLength := int(packet[2])<<8 | int(packet[3]) + if headerLength < header.IPv4MinimumSize || totalLength != len(packet) || totalLength < headerLength { + return netip.Addr{}, netip.Addr{}, errors.New("invalid raw IPv4 lengths") + } + return netip.AddrFrom4([4]byte{packet[12], packet[13], packet[14], packet[15]}), + netip.AddrFrom4([4]byte{packet[16], packet[17], packet[18], packet[19]}), nil +} + +func contains(prefixes []netip.Prefix, address netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(address) { + return true + } + } + return false +} + +func samePrefixes(left, right []netip.Prefix) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func tcpipAddress(address tcpip.Address) (netip.Addr, bool) { + if address.Len() != 4 { + return netip.Addr{}, false + } + return netip.AddrFrom4(address.As4()), true +} + +func targetAddress(address netip.Addr, port uint16) string { + return net.JoinHostPort(address.String(), strconv.Itoa(int(port))) +} + +func tcpipError(operation string, err tcpip.Error) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %s", operation, err.String()) +} + +func buildIPv4Packet(source, destination netip.Addr, identification uint16, protocol uint8, payload []byte) []byte { + packet := make([]byte, header.IPv4MinimumSize+len(payload)) + packet[0] = 0x45 + totalLength := len(packet) + packet[2], packet[3] = byte(totalLength>>8), byte(totalLength) + packet[4], packet[5] = byte(identification>>8), byte(identification) + packet[8] = 64 + packet[9] = protocol + sourceBytes := source.As4() + destinationBytes := destination.As4() + copy(packet[12:16], sourceBytes[:]) + copy(packet[16:20], destinationBytes[:]) + checksum := ipv4HeaderChecksum(packet[:header.IPv4MinimumSize]) + packet[10], packet[11] = byte(checksum>>8), byte(checksum) + copy(packet[header.IPv4MinimumSize:], payload) + return packet +} + +func ipv4HeaderChecksum(headerBytes []byte) uint16 { + var sum uint32 + for index := 0; index+1 < len(headerBytes); index += 2 { + sum += uint32(headerBytes[index])<<8 | uint32(headerBytes[index+1]) + } + for sum>>16 != 0 { + sum = sum&0xFFFF + sum>>16 + } + return ^uint16(sum) +} diff --git a/internal/subnetgateway/netstack/backend_test.go b/internal/subnetgateway/netstack/backend_test.go new file mode 100644 index 0000000..4b81e49 --- /dev/null +++ b/internal/subnetgateway/netstack/backend_test.go @@ -0,0 +1,355 @@ +package netstack + +import ( + "context" + "io" + "net" + "net/netip" + "testing" + "time" + + "golang.org/x/net/icmp" + xipv4 "golang.org/x/net/ipv4" + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/link/channel" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" + + "remlink/internal/subnetgateway" +) + +func TestTCPForwarderHostDialAndRoundTrip(t *testing.T) { + targetIP := localTestIPv4(t) + hostListener, err := net.Listen("tcp4", net.JoinHostPort(targetIP.String(), "0")) + if err != nil { + t.Fatal(err) + } + defer hostListener.Close() + go func() { + connection, err := hostListener.Accept() + if err != nil { + return + } + defer connection.Close() + _, _ = io.Copy(connection, connection) + }() + + testNetwork := newTestNetwork(t, targetIP) + defer testNetwork.close() + port := uint16(hostListener.Addr().(*net.TCPAddr).Port) + connection, err := gonet.DialTCP(testNetwork.clientStack, tcpip.FullAddress{ + Addr: tcpip.AddrFrom4(targetIP.As4()), Port: port, + }, ipv4.ProtocolNumber) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + want := []byte("gVisor TCP forwarder") + if _, err := connection.Write(want); err != nil { + t.Fatal(err) + } + got := make([]byte, len(want)) + if _, err := io.ReadFull(connection, got); err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("TCP echo = %q, want %q", got, want) + } +} + +func TestUDPForwarderHostSocketAndRoundTrip(t *testing.T) { + targetIP := localTestIPv4(t) + hostConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(targetIP.AsSlice())}) + if err != nil { + t.Fatal(err) + } + defer hostConnection.Close() + go func() { + buffer := make([]byte, 2048) + count, source, err := hostConnection.ReadFromUDP(buffer) + if err == nil { + _, _ = hostConnection.WriteToUDP(buffer[:count], source) + } + }() + + testNetwork := newTestNetwork(t, targetIP) + defer testNetwork.close() + port := uint16(hostConnection.LocalAddr().(*net.UDPAddr).Port) + connection, err := gonet.DialUDP(testNetwork.clientStack, nil, &tcpip.FullAddress{ + Addr: tcpip.AddrFrom4(targetIP.As4()), Port: port, + }, ipv4.ProtocolNumber) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + want := []byte("gVisor UDP forwarder") + if _, err := connection.Write(want); err != nil { + t.Fatal(err) + } + got := make([]byte, len(want)) + count, err := connection.Read(got) + if err != nil { + t.Fatal(err) + } + if string(got[:count]) != string(want) { + t.Fatalf("UDP echo = %q, want %q", got[:count], want) + } +} + +func TestICMPEchoRelayPreservesIdentityAndBuildsRawReply(t *testing.T) { + replies := make(chan []byte, 1) + prober := &fakeEchoProber{} + backend, err := New(Config{ + PingProber: prober, + Egress: func(_ context.Context, sessionID uint64, packet []byte) error { + if sessionID != 99 { + t.Errorf("reply SessionID = %d", sessionID) + } + replies <- packet + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + defer backend.Close() + engineer := netip.MustParseAddr("10.88.0.2") + target := netip.MustParseAddr("192.168.13.10") + if err := backend.Prepare(context.Background(), subnetgateway.SessionConfig{ + SessionID: 99, EngineerOverlayIP: engineer, + RemoteCIDRs: []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, + }); err != nil { + t.Fatal(err) + } + echoRequest, err := (&icmp.Message{ + Type: xipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: 0x1234, Seq: 77, Data: []byte("ping-data")}, + }).Marshal(nil) + if err != nil { + t.Fatal(err) + } + request := buildIPv4Packet(engineer, target, 0xABCD, uint8(header.ICMPv4ProtocolNumber), echoRequest) + if err := backend.InjectIPv4(context.Background(), 99, request); err != nil { + t.Fatal(err) + } + select { + case reply := <-replies: + source, destination, err := rawIPv4Addresses(reply) + if err != nil { + t.Fatal(err) + } + if source != target || destination != engineer || uint16(reply[4])<<8|uint16(reply[5]) != 0xABCD { + t.Fatalf("reply addresses/ID source=%s destination=%s id=%x", source, destination, reply[4:6]) + } + message, err := icmp.ParseMessage(1, reply[header.IPv4MinimumSize:]) + if err != nil || message.Type != xipv4.ICMPTypeEchoReply { + t.Fatalf("reply ICMP = %+v, %v", message, err) + } + echo := message.Body.(*icmp.Echo) + if echo.ID != 0x1234 || echo.Seq != 77 || string(echo.Data) != "ping-data" { + t.Fatalf("reply Echo = %+v", echo) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ICMP Echo Reply") + } + if prober.target != target || prober.id != 0x1234 || prober.sequence != 77 { + t.Fatalf("prober call = %+v", prober) + } +} + +func TestDuplicateRemoteCIDRsAreIsolatedBySessionAndEngineer(t *testing.T) { + backend, err := New(Config{TCPFlowLimit: 4, UDPFlowLimit: 4, Egress: func(context.Context, uint64, []byte) error { return nil }}) + if err != nil { + t.Fatal(err) + } + defer backend.Close() + remote := netip.MustParsePrefix("192.168.13.0/24") + engineerA := netip.MustParseAddr("10.88.0.10") + engineerB := netip.MustParseAddr("10.88.0.11") + for _, session := range []subnetgateway.SessionConfig{ + {SessionID: 101, EngineerOverlayIP: engineerA, RemoteCIDRs: []netip.Prefix{remote}}, + {SessionID: 202, EngineerOverlayIP: engineerB, RemoteCIDRs: []netip.Prefix{remote}}, + } { + if err := backend.Prepare(context.Background(), session); err != nil { + t.Fatal(err) + } + } + target := tcpip.AddrFrom4([4]byte{192, 168, 13, 50}) + makeID := func(engineer netip.Addr) stack.TransportEndpointID { + return stack.TransportEndpointID{ + RemoteAddress: tcpip.AddrFrom4(engineer.As4()), RemotePort: 41000, + LocalAddress: target, LocalPort: 502, + } + } + keyA, sessionA, okA := backend.flowFromID(flowTCP, makeID(engineerA)) + keyB, sessionB, okB := backend.flowFromID(flowTCP, makeID(engineerB)) + if !okA || !okB || keyA.SessionID != 101 || keyB.SessionID != 202 || sessionA == sessionB || keyA == keyB { + t.Fatalf("flow isolation A=%+v/%p/%v B=%+v/%p/%v", keyA, sessionA, okA, keyB, sessionB, okB) + } + _, finishA, startedA := backend.beginFlow(keyA, sessionA, backend.tcpSlots) + _, finishB, startedB := backend.beginFlow(keyB, sessionB, backend.tcpSlots) + if !startedA || !startedB || len(backend.tcpSlots) != 2 { + t.Fatalf("parallel flows started A=%v B=%v count=%d", startedA, startedB, len(backend.tcpSlots)) + } + finishA() + finishB() + if len(backend.tcpSlots) != 0 { + t.Fatalf("flow slots leaked: %d", len(backend.tcpSlots)) + } +} + +func TestPrepareRetryIsIdempotentButCannotMutatePublishedSession(t *testing.T) { + backend, err := New(Config{Egress: func(context.Context, uint64, []byte) error { return nil }}) + if err != nil { + t.Fatal(err) + } + defer backend.Close() + original := subnetgateway.SessionConfig{ + SessionID: 303, EngineerOverlayIP: netip.MustParseAddr("10.88.0.30"), + RemoteCIDRs: []netip.Prefix{netip.MustParsePrefix("192.168.13.0/24")}, + } + if err := backend.Prepare(context.Background(), original); err != nil { + t.Fatal(err) + } + if err := backend.Prepare(context.Background(), original); err != nil { + t.Fatalf("exact PREPARE retry failed: %v", err) + } + changed := original + changed.RemoteCIDRs = []netip.Prefix{netip.MustParsePrefix("192.168.21.0/24")} + if err := backend.Prepare(context.Background(), changed); err == nil { + t.Fatal("PREPARE retry mutated an already published Session configuration") + } + if err := backend.InjectIPv4(context.Background(), original.SessionID, + buildIPv4Packet(original.EngineerOverlayIP, netip.MustParseAddr("192.168.21.10"), 1, uint8(header.UDPProtocolNumber), []byte{0, 1})); err == nil { + t.Fatal("changed Remote CIDR became visible after rejected PREPARE retry") + } +} + +type fakeEchoProber struct { + target netip.Addr + id int + sequence int +} + +func (p *fakeEchoProber) Echo(_ context.Context, target netip.Addr, id, sequence int, _ []byte) error { + p.target, p.id, p.sequence = target, id, sequence + return nil +} + +type testNetwork struct { + ctx context.Context + cancel context.CancelFunc + clientStack *stack.Stack + clientEndpoint *channel.Endpoint + backend *Backend +} + +func newTestNetwork(t *testing.T, targetIP netip.Addr) *testNetwork { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + network := &testNetwork{ctx: ctx, cancel: cancel} + network.clientStack = stack.New(stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol}, + }) + network.clientEndpoint = channel.New(1024, 1280, "") + if err := network.clientStack.CreateNIC(nicID, network.clientEndpoint); err != nil { + cancel() + t.Fatal(err.String()) + } + engineer := tcpip.AddrFrom4([4]byte{10, 88, 0, 2}) + if err := network.clientStack.AddProtocolAddress(nicID, tcpip.ProtocolAddress{ + Protocol: ipv4.ProtocolNumber, AddressWithPrefix: engineer.WithPrefix(), + }, stack.AddressProperties{}); err != nil { + cancel() + t.Fatal(err.String()) + } + network.clientStack.SetRouteTable([]tcpip.Route{{Destination: header.IPv4EmptySubnet, NIC: nicID}}) + + var backend *Backend + var err error + backend, err = New(Config{ + TCPFlowLimit: 8, UDPFlowLimit: 8, UDPIdleTimeout: time.Second, + Egress: func(_ context.Context, sessionID uint64, packet []byte) error { + if sessionID != 7 { + t.Errorf("egress SessionID = %d", sessionID) + } + packetBuffer := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(append([]byte(nil), packet...)), + }) + network.clientEndpoint.InjectInbound(ipv4.ProtocolNumber, packetBuffer) + packetBuffer.DecRef() + return nil + }, + }) + if err != nil { + cancel() + t.Fatal(err) + } + network.backend = backend + if err := backend.Prepare(ctx, subnetgateway.SessionConfig{ + SessionID: 7, EngineerOverlayIP: netip.MustParseAddr("10.88.0.2"), + RemoteCIDRs: []netip.Prefix{netip.PrefixFrom(targetIP, 32)}, + }); err != nil { + network.close() + t.Fatal(err) + } + go func() { + for { + packetBuffer := network.clientEndpoint.ReadContext(ctx) + if packetBuffer == nil { + return + } + view := packetBuffer.ToView() + packet := append([]byte(nil), view.AsSlice()...) + view.Release() + packetBuffer.DecRef() + _ = backend.InjectIPv4(ctx, 7, packet) + } + }() + return network +} + +func localTestIPv4(t *testing.T) netip.Addr { + t.Helper() + interfaces, err := net.Interfaces() + if err != nil { + t.Fatal(err) + } + for _, networkInterface := range interfaces { + if networkInterface.Flags&net.FlagUp == 0 || networkInterface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := networkInterface.Addrs() + if err != nil { + continue + } + for _, raw := range addresses { + prefix, err := netip.ParsePrefix(raw.String()) + if err == nil && prefix.Addr().Is4() && !prefix.Addr().IsLoopback() { + return prefix.Addr() + } + } + } + t.Skip("no non-loopback IPv4 address available for host relay test") + return netip.Addr{} +} + +func (n *testNetwork) close() { + n.cancel() + if n.backend != nil { + _ = n.backend.Close() + } + if n.clientEndpoint != nil { + n.clientEndpoint.Close() + } + if n.clientStack != nil { + n.clientStack.Close() + } +} diff --git a/internal/subnetgateway/pingrelay/relay.go b/internal/subnetgateway/pingrelay/relay.go new file mode 100644 index 0000000..4610998 --- /dev/null +++ b/internal/subnetgateway/pingrelay/relay.go @@ -0,0 +1,85 @@ +// Package pingrelay probes a Site target and preserves Echo ID/Sequence. +package pingrelay + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +const DefaultTimeout = 3 * time.Second + +type Relay struct { + Timeout time.Duration +} + +// Echo sends one host ICMP Echo and waits for the matching target reply. +func (r Relay) Echo(ctx context.Context, target netip.Addr, id, sequence int, data []byte) error { + if !target.Is4() || id < 0 || id > 65535 || sequence < 0 || sequence > 65535 { + return errors.New("PingRelay target, ID, or Sequence is invalid") + } + timeout := r.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + connection, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + return fmt.Errorf("open host ICMP socket: %w", err) + } + defer connection.Close() + deadline := time.Now().Add(timeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if err := connection.SetDeadline(deadline); err != nil { + return err + } + stopClose := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = connection.Close() + case <-stopClose: + } + }() + defer close(stopClose) + message := icmp.Message{ + Type: ipv4.ICMPTypeEcho, Code: 0, + Body: &icmp.Echo{ID: id, Seq: sequence, Data: append([]byte(nil), data...)}, + } + encoded, err := message.Marshal(nil) + if err != nil { + return fmt.Errorf("marshal ICMP Echo: %w", err) + } + if _, err := connection.WriteTo(encoded, &net.IPAddr{IP: net.IP(target.AsSlice())}); err != nil { + return fmt.Errorf("send ICMP Echo to %s: %w", target, err) + } + buffer := make([]byte, 1500) + for { + count, peer, err := connection.ReadFrom(buffer) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("wait for ICMP Echo Reply from %s: %w", target, err) + } + peerIP, ok := netip.AddrFromSlice(peer.(*net.IPAddr).IP) + if !ok || peerIP.Unmap() != target { + continue + } + parsed, err := icmp.ParseMessage(1, buffer[:count]) + if err != nil || parsed.Type != ipv4.ICMPTypeEchoReply { + continue + } + echo, ok := parsed.Body.(*icmp.Echo) + if ok && echo.ID == id && echo.Seq == sequence { + return nil + } + } +} diff --git a/internal/subnetgateway/tcprelay/relay.go b/internal/subnetgateway/tcprelay/relay.go new file mode 100644 index 0000000..738981f --- /dev/null +++ b/internal/subnetgateway/tcprelay/relay.go @@ -0,0 +1,49 @@ +// Package tcprelay performs protocol-agnostic TCP byte forwarding. +package tcprelay + +import ( + "context" + "errors" + "io" + "net" + "sync" +) + +const bufferSize = 32 * 1024 + +var bufferPool = sync.Pool{New: func() any { return make([]byte, bufferSize) }} + +// Relay copies both directions until EOF, cancellation, or an I/O failure. +func Relay(ctx context.Context, left, right net.Conn) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + results := make(chan error, 2) + copyDirection := func(destination, source net.Conn) { + buffer := bufferPool.Get().([]byte) + _, err := io.CopyBuffer(destination, source, buffer) + bufferPool.Put(buffer) + if closeWriter, ok := destination.(interface{ CloseWrite() error }); ok { + _ = closeWriter.CloseWrite() + } + results <- err + } + go copyDirection(left, right) + go copyDirection(right, left) + go func() { + <-ctx.Done() + _ = left.Close() + _ = right.Close() + }() + first := <-results + cancel() + _ = left.Close() + _ = right.Close() + second := <-results + if first != nil && !errors.Is(first, net.ErrClosed) { + return first + } + if second != nil && !errors.Is(second, net.ErrClosed) { + return second + } + return nil +} diff --git a/internal/subnetgateway/udprelay/relay.go b/internal/subnetgateway/udprelay/relay.go new file mode 100644 index 0000000..3168c4a --- /dev/null +++ b/internal/subnetgateway/udprelay/relay.go @@ -0,0 +1,68 @@ +// Package udprelay preserves UDP datagram boundaries across a host socket flow. +package udprelay + +import ( + "context" + "errors" + "net" + "sync/atomic" + "time" +) + +// Relay copies connected UDP datagrams until idle timeout or cancellation. +func Relay(ctx context.Context, left, right net.Conn, idleTimeout time.Duration) error { + parent := ctx + ctx, cancel := context.WithCancel(parent) + defer cancel() + if idleTimeout <= 0 { + idleTimeout = 60 * time.Second + } + var lastActivity atomic.Int64 + lastActivity.Store(time.Now().UnixNano()) + results := make(chan error, 2) + copyDatagrams := func(destination, source net.Conn) { + buffer := make([]byte, 65535) + for { + deadline := time.Now().Add(min(idleTimeout/2, time.Second)) + _ = source.SetReadDeadline(deadline) + count, err := source.Read(buffer) + if err != nil { + if timeout, ok := err.(net.Error); ok && timeout.Timeout() { + last := time.Unix(0, lastActivity.Load()) + if time.Since(last) < idleTimeout { + continue + } + } + results <- err + return + } + if _, err := destination.Write(buffer[:count]); err != nil { + results <- err + return + } + lastActivity.Store(time.Now().UnixNano()) + } + } + go copyDatagrams(left, right) + go copyDatagrams(right, left) + go func() { + <-ctx.Done() + _ = left.Close() + _ = right.Close() + }() + err := <-results + cancel() + _ = left.Close() + _ = right.Close() + <-results + if parent.Err() != nil { + return parent.Err() + } + if timeout, ok := err.(net.Error); ok && timeout.Timeout() { + return nil + } + if errors.Is(err, net.ErrClosed) { + return nil + } + return err +} diff --git a/internal/subnetgateway/udprelay/relay_test.go b/internal/subnetgateway/udprelay/relay_test.go new file mode 100644 index 0000000..de3983e --- /dev/null +++ b/internal/subnetgateway/udprelay/relay_test.go @@ -0,0 +1,44 @@ +package udprelay + +import ( + "context" + "net" + "testing" + "time" +) + +func TestRelayIdleTimeoutIsNormalFlowCompletion(t *testing.T) { + leftRelay, leftPeer := net.Pipe() + rightRelay, rightPeer := net.Pipe() + defer leftPeer.Close() + defer rightPeer.Close() + done := make(chan error, 1) + go func() { done <- Relay(context.Background(), leftRelay, rightRelay, 20*time.Millisecond) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("idle Relay returned %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("idle Relay did not reclaim the flow") + } +} + +func TestRelayReturnsParentCancellation(t *testing.T) { + leftRelay, leftPeer := net.Pipe() + rightRelay, rightPeer := net.Pipe() + defer leftPeer.Close() + defer rightPeer.Close() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Relay(ctx, leftRelay, rightRelay, time.Hour) }() + cancel() + select { + case err := <-done: + if err != context.Canceled { + t.Fatalf("Relay cancellation = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled Relay did not stop") + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..0f10bb0 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,17 @@ +// Package version exposes build metadata shared by all RemLink binaries. +package version + +var ( + // Version is overridden by release builds with -ldflags. + Version = "dev" + // Commit is overridden by release builds with -ldflags. + Commit = "unknown" +) + +// String returns a compact version suitable for logs and command output. +func String() string { + if Commit == "" || Commit == "unknown" { + return Version + } + return Version + "+" + Commit +} diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 new file mode 100644 index 0000000..ae7b5b3 --- /dev/null +++ b/scripts/build-release.ps1 @@ -0,0 +1,165 @@ +[CmdletBinding()] +param( + [string]$Version = "1.0.0", + [string]$OutputDirectory = "dist" +) + +$ErrorActionPreference = "Stop" +if ($Version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') { + throw "Version must be a semantic version without path characters" +} + +$repository = Split-Path -Parent $PSScriptRoot +$releaseRoot = Join-Path $repository $OutputDirectory +$engineerName = "RemLink-Engineer-v$Version-windows-amd64" +$siteName = "RemLink-Site-v$Version-windows-amd64" +$serverName = "RemLink-Server-v$Version-linux-amd64" +$engineerRoot = Join-Path $releaseRoot $engineerName +$siteRoot = Join-Path $releaseRoot $siteName +$serverRoot = Join-Path $releaseRoot $serverName +$linuxRoot = Join-Path $serverRoot "linux-amd64" +$validationScriptsRoot = Join-Path $serverRoot "scripts/validation" +$validationDocsRoot = Join-Path $serverRoot "docs/validation" +$commit = "unknown" +try { $commit = (git -C $repository rev-parse --short HEAD 2>$null).Trim() } catch {} +if (-not $commit) { $commit = "unknown" } +$linkerFlags = "-s -w -X remlink/internal/version.Version=$Version -X remlink/internal/version.Commit=$commit" + +function Write-PackageMetadata { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Role, + [Parameter(Mandatory = $true)][string]$Target + ) + [ordered]@{ + version = $Version + role = $Role + commit = $commit + built_at = (Get-Date).ToUniversalTime().ToString("o") + go = (go version) + node = (node --version) + target = @($Target) + portable_data_root = if ($Role -in @("Engineer", "Site")) { "executable_directory" } else { $null } + wails_build_tags = if ($Role -eq "Engineer") { @("desktop", "production") } else { $null } + } | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 (Join-Path $Root "BUILD-INFO.json") + + $checksumLines = Get-ChildItem -LiteralPath $Root -Recurse -File | Sort-Object FullName | ForEach-Object { + $relativePath = [System.IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() + "$hash $relativePath" + } + $checksumLines | Set-Content -Encoding ascii (Join-Path $Root "SHA256SUMS.txt") +} + +function New-PackageArchive { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Role + ) + $archivePath = Join-Path $releaseRoot "$Name.zip" + if (Test-Path -LiteralPath $archivePath) { + Remove-Item -LiteralPath $archivePath -Force + } + Compress-Archive -Path $Root -DestinationPath $archivePath + & (Join-Path $repository "scripts/validation/Test-ReleasePackage.ps1") -PackagePath $archivePath -Role $Role + return $archivePath +} + +New-Item -ItemType Directory -Force -Path $releaseRoot | Out-Null +$generatedTargets = @( + $engineerRoot, $siteRoot, $serverRoot, + (Join-Path $releaseRoot "RemLink-v$Version"), + (Join-Path $releaseRoot "RemLink-v$Version.zip") +) +foreach ($target in $generatedTargets) { + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Recurse -Force + } +} +New-Item -ItemType Directory -Force -Path ( + $engineerRoot, + $siteRoot, + $linuxRoot, + (Join-Path $engineerRoot "docs"), + (Join-Path $siteRoot "docs"), + (Join-Path $engineerRoot "scripts/validation"), + (Join-Path $siteRoot "scripts/validation"), + (Join-Path $serverRoot "docs"), + $validationScriptsRoot, + $validationDocsRoot +) | Out-Null + +Push-Location $repository +try { + & (Join-Path $repository "scripts/maintenance/Test-RepositoryHygiene.ps1") + npm ci --prefix frontend + npm run typecheck --prefix frontend + npm run build --prefix frontend + & (Join-Path $repository "scripts/validation/Test-FrontendProduction.ps1") + go mod verify + go test -count=1 ./... + go vet ./... + & (Join-Path $repository "scripts/validation/Test-Architecture.ps1") + & (Join-Path $repository "scripts/validation/Test-AcceptanceTools.ps1") + + $savedGOOS = $env:GOOS + $savedGOARCH = $env:GOARCH + $savedCGO = $env:CGO_ENABLED + try { + $env:GOOS = "windows" + $env:GOARCH = "amd64" + $env:CGO_ENABLED = "0" + # Wails desktop production tags are mandatory. Without them Wails + # intentionally compiles app_default_windows.go, which only shows an + # error dialog instructing the operator to use `wails build`. + go build -trimpath -tags "desktop,production" -ldflags "$linkerFlags -H windowsgui" -o (Join-Path $engineerRoot "RemLinkEngineer.exe") ./cmd/engineer + go build -trimpath -ldflags $linkerFlags -o (Join-Path $siteRoot "RemLinkSite.exe") ./cmd/site + + $env:GOOS = "linux" + $env:GOARCH = "amd64" + $env:CGO_ENABLED = "0" + go build -trimpath -ldflags $linkerFlags -o (Join-Path $linuxRoot "remlink-server") ./cmd/server + } finally { + $env:GOOS = $savedGOOS + $env:GOARCH = $savedGOARCH + $env:CGO_ENABLED = $savedCGO + } + + # Engineer and Site are intentionally self-contained portable packages. + Copy-Item config/engineer.example.yaml (Join-Path $engineerRoot "engineer.yaml") + Copy-Item config/site.example.yaml (Join-Path $siteRoot "site.yaml") + Copy-Item THIRD_PARTY_NOTICES.md $engineerRoot + Copy-Item THIRD_PARTY_NOTICES.md $siteRoot + Copy-Item docs/packages/engineer-readme.md (Join-Path $engineerRoot "README.md") + Copy-Item docs/packages/site-readme.md (Join-Path $siteRoot "README.md") + Copy-Item docs/deployment-and-usage.md (Join-Path $engineerRoot "docs/deployment-and-usage.md") + Copy-Item docs/deployment-and-usage.md (Join-Path $siteRoot "docs/deployment-and-usage.md") + Copy-Item scripts/validation/Test-ReleasePackage.ps1 (Join-Path $engineerRoot "scripts/validation/Test-ReleasePackage.ps1") + Copy-Item scripts/validation/Test-ReleasePackage.ps1 (Join-Path $siteRoot "scripts/validation/Test-ReleasePackage.ps1") + + # Server has its own native binary and Docker deployment context; it never + # contains either Windows client executable. + Copy-Item config/server.example.yaml (Join-Path $linuxRoot "server.yaml") + Copy-Item THIRD_PARTY_NOTICES.md $linuxRoot + Copy-Item README.md $serverRoot + Copy-Item -Recurse -Force deploy/docker (Join-Path $serverRoot "docker") + Copy-Item docs/deployment-and-usage.md (Join-Path $serverRoot "docs/deployment-and-usage.md") + Copy-Item docs/implementation-status.md (Join-Path $serverRoot "docs/implementation-status.md") + Copy-Item scripts/validation/* $validationScriptsRoot + Copy-Item docs/validation/* $validationDocsRoot + + Write-PackageMetadata -Root $engineerRoot -Role Engineer -Target "windows/amd64" + Write-PackageMetadata -Root $siteRoot -Role Site -Target "windows/amd64" + Write-PackageMetadata -Root $serverRoot -Role Server -Target "linux/amd64" + + $engineerArchive = New-PackageArchive -Root $engineerRoot -Name $engineerName -Role Engineer + $siteArchive = New-PackageArchive -Root $siteRoot -Name $siteName -Role Site + $serverArchive = New-PackageArchive -Root $serverRoot -Name $serverName -Role Server +} finally { + Pop-Location +} + +Write-Host "Engineer package: $engineerArchive" +Write-Host "Site package: $siteArchive" +Write-Host "Server package: $serverArchive" diff --git a/scripts/maintenance/Test-RepositoryHygiene.ps1 b/scripts/maintenance/Test-RepositoryHygiene.ps1 new file mode 100644 index 0000000..0cb4728 --- /dev/null +++ b/scripts/maintenance/Test-RepositoryHygiene.ps1 @@ -0,0 +1,128 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$violations = [System.Collections.Generic.List[string]]::new() + +Push-Location $repository +try { + $gitRoot = (& git rev-parse --show-toplevel 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $gitRoot) { + throw "Repository hygiene check must run inside a Git worktree" + } + # Disable Git's C-style quoting so paths containing Chinese characters are + # inspected as real filesystem paths instead of skipped quoted strings. + $candidateFiles = @(& git -c core.quotePath=false ls-files --cached --others --exclude-standard) + if ($LASTEXITCODE -ne 0) { + throw "Unable to enumerate Git candidate files" + } +} finally { + Pop-Location +} + +$allowedDLLs = @( + "internal/platform/windows/wintunruntime/assets/amd64/wintun.dll" +) +$forbiddenDirectoryPattern = '(^|/)(build|dist|node_modules|data|runtime|evidence|\.codex-qa)(/|$)' +$forbiddenRuntimeNamePattern = '(^|/)(identity\.json|site-profiles\.json)$' +$forbiddenExtensions = @(".exe", ".zip", ".db", ".sqlite", ".log", ".jsonl", ".key", ".pem", ".p12", ".pfx") +$textExtensions = @(".go", ".ts", ".vue", ".css", ".html", ".md", ".yaml", ".yml", ".json", ".ps1", ".sh", ".txt", ".mod", ".sum") +$maximumSourceFileBytes = 10MB + +foreach ($candidate in $candidateFiles) { + $relative = $candidate.Replace('\', '/') + $absolute = Join-Path $repository $candidate + if (-not (Test-Path -LiteralPath $absolute -PathType Leaf)) { + continue + } + + $name = [System.IO.Path]::GetFileName($relative) + $extension = [System.IO.Path]::GetExtension($relative).ToLowerInvariant() + if ($relative -match $forbiddenDirectoryPattern) { + $violations.Add("generated/runtime directory is a Git candidate: $relative") + } + if ($relative -match $forbiddenRuntimeNamePattern) { + $violations.Add("runtime identity/profile is a Git candidate: $relative") + } + if ($name -match '^\.env(?:\..+)?$' -and $name -notmatch '^\.env(?:\.[A-Za-z0-9_-]+)?\.example$') { + $violations.Add("real environment file is a Git candidate: $relative") + } + if ($relative -match '^config/.*\.yaml$' -and $relative -notmatch '\.example\.yaml$') { + $violations.Add("real client/server YAML is a Git candidate: $relative") + } + if ($extension -eq ".dll" -and $relative -notin $allowedDLLs) { + $violations.Add("unapproved DLL is a Git candidate: $relative") + } + if ($extension -in $forbiddenExtensions) { + $violations.Add("binary, runtime data, or private-key file is a Git candidate: $relative") + } + + $file = Get-Item -LiteralPath $absolute + if ($file.Length -gt $maximumSourceFileBytes) { + $violations.Add("source candidate exceeds 10 MiB: $relative ($($file.Length) bytes)") + } + + $isText = $extension -in $textExtensions -or $name -like ".env*" -or $name -in @("Dockerfile", "go.mod", "go.sum", ".gitignore", ".gitattributes", ".dockerignore") + if (-not $isText -or $file.Length -gt 2MB) { + continue + } + + $content = Get-Content -Raw -LiteralPath $absolute + if ($content -match '(?m)[ \t]+$') { + $violations.Add("trailing whitespace found in: $relative") + } + if ($content -match '(?m)^(?:<<<<<<< .+|=======|>>>>>>> .+)$') { + $violations.Add("unresolved merge marker found in: $relative") + } + if ($content -match '-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----') { + $violations.Add("private-key material found in: $relative") + } + + if ($extension -in @(".yaml", ".yml") -or $name -like ".env*") { + $secretAssignments = [regex]::Matches( + $content, + '(?im)^\s*(?(?:REMLINK_)?(?:JOIN_TOKEN|NODE_TOKEN|ADMIN_TOKEN|PASSWORD|PRIVATE_KEY|API_KEY))\s*[:=]\s*["'']?(?[^\s"''#]+)' + ) + foreach ($assignment in $secretAssignments) { + $value = $assignment.Groups["value"].Value + $isSafeExample = $value.StartsWith('${') -or $value -match '(?i)replace|change|example|placeholder|random|your|替换|粘贴' + if (-not $isSafeExample) { + $violations.Add("non-placeholder $($assignment.Groups['key'].Value) found in: $relative") + } + } + } +} + +$markdownFiles = $candidateFiles | Where-Object { $_ -match '\.md$' } +foreach ($markdownFile in $markdownFiles) { + $absolute = Join-Path $repository $markdownFile + if (-not (Test-Path -LiteralPath $absolute -PathType Leaf)) { + continue + } + $content = Get-Content -Raw -LiteralPath $absolute + $links = [regex]::Matches($content, '!?(?:\[[^\]]*\])\((?[^)]+)\)') + foreach ($link in $links) { + $target = $link.Groups["target"].Value.Trim().Trim('<', '>') + if (-not $target -or $target.StartsWith('#') -or $target -match '^[A-Za-z][A-Za-z0-9+.-]*:') { + continue + } + $pathPart = ($target -split '[?#]', 2)[0] + if ($pathPart -match '\s+["'']') { + $pathPart = ($pathPart -split '\s+["'']', 2)[0] + } + $decodedPath = [Uri]::UnescapeDataString($pathPart) + $resolved = Join-Path (Split-Path -Parent $absolute) $decodedPath + if (-not (Test-Path -LiteralPath $resolved)) { + $relativeMarkdown = $markdownFile.Replace('\', '/') + $violations.Add("broken relative Markdown link in ${relativeMarkdown}: $target") + } + } +} + +if ($violations.Count -gt 0) { + $violations | Sort-Object -Unique | ForEach-Object { Write-Error $_ } + exit 1 +} + +Write-Host "Repository hygiene checks passed ($($candidateFiles.Count) Git candidate files inspected)." diff --git a/scripts/validation/Collect-ServerEvidence.sh b/scripts/validation/Collect-ServerEvidence.sh new file mode 100644 index 0000000..8af18a1 --- /dev/null +++ b/scripts/validation/Collect-ServerEvidence.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +output_directory="${1:?usage: Collect-ServerEvidence.sh OUTPUT_DIRECTORY}" +mkdir -p "$output_directory" +stamp="$(date -u +%Y%m%dT%H%M%SZ)" + +uname -a >"$output_directory/$stamp-uname.txt" +ip -details address show >"$output_directory/$stamp-ip-address.txt" +ip -4 route show table all >"$output_directory/$stamp-ipv4-routes.txt" +{ + echo "[public-keys]" + wg show all public-key + echo "[listen-ports]" + wg show all listen-port + echo "[peers]" + wg show all peers + echo "[allowed-ips]" + wg show all allowed-ips + echo "[endpoints]" + wg show all endpoints + echo "[latest-handshakes]" + wg show all latest-handshakes + echo "[transfer]" + wg show all transfer +} >"$output_directory/$stamp-wireguard-public.txt" +iptables-save >"$output_directory/$stamp-iptables.txt" +cat /proc/sys/net/ipv4/ip_forward >"$output_directory/$stamp-ip-forward.txt" + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$output_directory/$stamp-"* >"$output_directory/$stamp-SHA256SUMS" +fi + +echo "Server evidence captured under $output_directory" diff --git a/scripts/validation/Collect-WindowsEvidence.ps1 b/scripts/validation/Collect-WindowsEvidence.ps1 new file mode 100644 index 0000000..d9befee --- /dev/null +++ b/scripts/validation/Collect-WindowsEvidence.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("Engineer", "Site")] + [string]$Role, + [Parameter(Mandatory = $true)] + [string]$OutputDirectory +) + +$ErrorActionPreference = "Stop" +$resolved = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $resolved | Out-Null +$stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ") +$prefix = "$stamp-$($Role.ToLowerInvariant())" + +$adapters = Get-NetAdapter -IncludeHidden | Select-Object Name, InterfaceDescription, ifIndex, Status, MacAddress, LinkSpeed +$remlinkAdapters = @($adapters | Where-Object Name -eq "RemLink") +$ipConfiguration = Get-NetIPConfiguration -All | Select-Object InterfaceAlias, InterfaceIndex, IPv4Address, IPv4DefaultGateway, DNSServer +$routes = Get-NetRoute -AddressFamily IPv4 | Select-Object DestinationPrefix, NextHop, InterfaceAlias, InterfaceIndex, RouteMetric, Protocol, PolicyStore +$nat = @(Get-NetNat -ErrorAction SilentlyContinue | Select-Object Name, InternalIPInterfaceAddressPrefix, ExternalIPInterfaceAddressPrefix, Active) +$processes = Get-Process | Where-Object ProcessName -Match "RemLink|wireguard" | Select-Object ProcessName, Id, Path, StartTime + +$inventory = [ordered]@{ + captured_at = (Get-Date).ToUniversalTime().ToString("o") + computer = $env:COMPUTERNAME + role = $Role + elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + remlink_adapter_count = $remlinkAdapters.Count + remlink_adapters = $remlinkAdapters + adapters = $adapters + ip_configuration = $ipConfiguration + ipv4_routes = $routes + existing_nat_read_only_snapshot = $nat + processes = $processes +} +$path = Join-Path $resolved "$prefix-network.json" +$inventory | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $path +Get-FileHash -Algorithm SHA256 $path | Format-List | Out-File -Encoding utf8 (Join-Path $resolved "$prefix-network.sha256.txt") + +if ($remlinkAdapters.Count -ne 1) { + throw "Expected exactly one adapter named RemLink; found $($remlinkAdapters.Count). Evidence was saved to $path" +} +if (@($adapters | Where-Object { $_.Name -Match "WireGuard" -or $_.InterfaceDescription -Match "WireGuardNT" }).Count -ne 0) { + throw "Unexpected independent WireGuard/WireGuardNT adapter detected. Evidence was saved to $path" +} +Write-Host "Windows evidence captured: $path" diff --git a/scripts/validation/New-AcceptanceRun.ps1 b/scripts/validation/New-AcceptanceRun.ps1 new file mode 100644 index 0000000..444ffff --- /dev/null +++ b/scripts/validation/New-AcceptanceRun.ps1 @@ -0,0 +1,45 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + [string]$EngineerA = "Engineer-A", + [string]$SiteA = "Site-A", + [string]$EngineerB = "Engineer-B", + [string]$SiteB = "Site-B" +) + +$ErrorActionPreference = "Stop" +$resolved = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $resolved | Out-Null +$scenarios = 1..18 | ForEach-Object { + [ordered]@{ + id = "T{0:D2}" -f $_ + status = "NOT_RUN" + started_at = $null + completed_at = $null + operator = $env:USERNAME + evidence = @() + notes = "" + } +} +$manifest = [ordered]@{ + schema_version = 2 + created_at = (Get-Date).ToUniversalTime().ToString("o") + topology = [ordered]@{ + engineer_a = $EngineerA + site_a = $SiteA + engineer_b = $EngineerB + site_b = $SiteB + } + gates = @( + [ordered]@{ id = "Gate A"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" }, + [ordered]@{ id = "Gate B"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" }, + [ordered]@{ id = "Gate C"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" }, + [ordered]@{ id = "Gate D"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" } + ) + scenarios = $scenarios +} +$manifestPath = Join-Path $resolved "acceptance-run.json" +$manifest | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $manifestPath +Copy-Item (Join-Path $PSScriptRoot "..\..\docs\validation\T01-T18-runbook.md") (Join-Path $resolved "T01-T18-runbook.md") +Write-Host "Acceptance run initialized: $manifestPath" diff --git a/scripts/validation/Set-AcceptanceResult.ps1 b/scripts/validation/Set-AcceptanceResult.ps1 new file mode 100644 index 0000000..961b5dc --- /dev/null +++ b/scripts/validation/Set-AcceptanceResult.ps1 @@ -0,0 +1,108 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$RunDirectory, + [Parameter(Mandatory = $true)] + [ValidatePattern('^(T(0[1-9]|1[0-8])|Gate [A-D])$')] + [string]$ID, + [Parameter(Mandatory = $true)] + [ValidateSet("PASS", "FAIL", "NOT_RUN")] + [string]$Status, + [string[]]$EvidencePath = @(), + [string]$Notes = "", + [string]$Operator = $env:USERNAME +) + +$ErrorActionPreference = "Stop" +$runRoot = [System.IO.Path]::GetFullPath($RunDirectory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +$manifestPath = Join-Path $runRoot "acceptance-run.json" +if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Acceptance manifest not found: $manifestPath" +} +$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json +if ($manifest.schema_version -ne 2) { + throw "Unsupported acceptance manifest schema version: $($manifest.schema_version)" +} +$entry = if ($ID.StartsWith("Gate ")) { + @($manifest.gates | Where-Object id -eq $ID) +} else { + @($manifest.scenarios | Where-Object id -eq $ID) +} +if ($entry.Count -ne 1) { + throw "Manifest must contain exactly one entry for $ID" +} +$entry = $entry[0] + +$records = @() +foreach ($rawPath in $EvidencePath) { + $candidate = if ([System.IO.Path]::IsPathRooted($rawPath)) { $rawPath } else { Join-Path $runRoot $rawPath } + $fullPath = [System.IO.Path]::GetFullPath($candidate) + $rootPrefix = $runRoot + [System.IO.Path]::DirectorySeparatorChar + if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Evidence must be stored inside the acceptance run directory: $fullPath" + } + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "Evidence file not found: $fullPath" + } + if ($fullPath -eq $manifestPath) { + throw "The acceptance manifest cannot be used as its own evidence" + } + $item = Get-Item -LiteralPath $fullPath + $records += [ordered]@{ + path = [System.IO.Path]::GetRelativePath($runRoot, $fullPath).Replace('\', '/') + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant() + bytes = $item.Length + } +} +if (($Status -eq "PASS" -or $Status -eq "FAIL") -and $records.Count -eq 0) { + throw "$Status requires at least one evidence file" +} +if ($Status -eq "NOT_RUN" -and $records.Count -ne 0) { + throw "NOT_RUN must not carry evidence; use PASS or FAIL after execution" +} + +if ($Status -eq "PASS" -and $ID.StartsWith("Gate ")) { + $dependencies = switch ($ID) { + "Gate A" { @("T01", "T02") } + "Gate B" { @("Gate A") } + "Gate C" { @("Gate B", "T08", "T09") } + "Gate D" { @("Gate C", "T07", "T08", "T09") } + } + foreach ($dependency in $dependencies) { + $dependencyEntry = if ($dependency.StartsWith("Gate ")) { + @($manifest.gates | Where-Object id -eq $dependency) + } else { + @($manifest.scenarios | Where-Object id -eq $dependency) + } + if ($dependencyEntry.Count -ne 1 -or $dependencyEntry[0].status -ne "PASS") { + throw "$ID cannot be marked PASS until $dependency is PASS" + } + } +} + +foreach ($property in @("started_at", "completed_at", "operator", "notes")) { + if (-not $entry.PSObject.Properties[$property]) { + $entry | Add-Member -NotePropertyName $property -NotePropertyValue $null + } +} +$now = (Get-Date).ToUniversalTime().ToString("o") +$entry.status = $Status +$entry.evidence = $records +$entry.notes = $Notes +$entry.operator = $Operator +if ($Status -eq "NOT_RUN") { + $entry.started_at = $null + $entry.completed_at = $null +} else { + if (-not $entry.started_at) { $entry.started_at = $now } + $entry.completed_at = $now +} + +$temporary = Join-Path $runRoot (".acceptance-run-" + [guid]::NewGuid().ToString("N") + ".tmp") +try { + $manifest | ConvertTo-Json -Depth 12 | Set-Content -Encoding utf8 -LiteralPath $temporary + [System.IO.File]::Move($temporary, $manifestPath, $true) +} finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue +} +Write-Host "Acceptance result recorded: $ID=$Status" diff --git a/scripts/validation/Test-AcceptanceRun.ps1 b/scripts/validation/Test-AcceptanceRun.ps1 new file mode 100644 index 0000000..45bb44f --- /dev/null +++ b/scripts/validation/Test-AcceptanceRun.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$RunDirectory +) + +$ErrorActionPreference = "Stop" +$runRoot = [System.IO.Path]::GetFullPath($RunDirectory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +$manifestPath = Join-Path $runRoot "acceptance-run.json" +if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Acceptance manifest not found: $manifestPath" +} +$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json +if ($manifest.schema_version -ne 2) { + throw "Unsupported acceptance manifest schema version: $($manifest.schema_version)" +} +$wantedGates = @("Gate A", "Gate B", "Gate C", "Gate D") +$wantedScenarios = 1..18 | ForEach-Object { "T{0:D2}" -f $_ } +if (@($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) { + throw "Manifest must contain four Gates and eighteen scenarios" +} +$actualGates = @($manifest.gates | ForEach-Object id | Sort-Object) +$actualScenarios = @($manifest.scenarios | ForEach-Object id | Sort-Object) +if (Compare-Object $wantedGates $actualGates -SyncWindow 0) { throw "Manifest Gate IDs are incomplete or duplicated" } +if (Compare-Object $wantedScenarios $actualScenarios -SyncWindow 0) { throw "Manifest scenario IDs are incomplete or duplicated" } + +$allEntries = @($manifest.gates) + @($manifest.scenarios) +foreach ($entry in $allEntries) { + if ($entry.status -notin @("PASS", "FAIL", "NOT_RUN")) { + throw "$($entry.id) has invalid status $($entry.status)" + } + $evidence = @($entry.evidence) + if ($entry.status -eq "NOT_RUN") { + if ($evidence.Count -ne 0 -or $entry.completed_at) { + throw "$($entry.id) is NOT_RUN but carries evidence or a completion time" + } + continue + } + if ($evidence.Count -eq 0 -or -not $entry.started_at -or -not $entry.completed_at) { + throw "$($entry.id) requires evidence plus start/completion timestamps" + } + foreach ($record in $evidence) { + if (-not $record.path -or $record.sha256 -notmatch '^[0-9a-f]{64}$' -or $record.bytes -lt 0) { + throw "$($entry.id) contains an invalid evidence record" + } + $fullPath = [System.IO.Path]::GetFullPath((Join-Path $runRoot $record.path)) + $rootPrefix = $runRoot + [System.IO.Path]::DirectorySeparatorChar + if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "$($entry.id) evidence is missing or outside the run directory: $($record.path)" + } + $item = Get-Item -LiteralPath $fullPath + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant() + if ($actualHash -ne $record.sha256 -or $item.Length -ne $record.bytes) { + throw "$($entry.id) evidence hash/size mismatch: $($record.path)" + } + } +} + +$dependenciesByGate = [ordered]@{ + "Gate A" = @("T01", "T02") + "Gate B" = @("Gate A") + "Gate C" = @("Gate B", "T08", "T09") + "Gate D" = @("Gate C", "T07", "T08", "T09") +} +foreach ($gateID in $dependenciesByGate.Keys) { + $gate = @($manifest.gates | Where-Object id -eq $gateID)[0] + if ($gate.status -ne "PASS") { continue } + foreach ($dependency in $dependenciesByGate[$gateID]) { + $dependencyEntry = if ($dependency.StartsWith("Gate ")) { + @($manifest.gates | Where-Object id -eq $dependency)[0] + } else { + @($manifest.scenarios | Where-Object id -eq $dependency)[0] + } + if ($dependencyEntry.status -ne "PASS") { + throw "$gateID is PASS while dependency $dependency is not PASS" + } + } +} +Write-Host "Acceptance manifest and evidence hashes verified: $manifestPath" diff --git a/scripts/validation/Test-AcceptanceTools.ps1 b/scripts/validation/Test-AcceptanceTools.ps1 new file mode 100644 index 0000000..b0611a1 --- /dev/null +++ b/scripts/validation/Test-AcceptanceTools.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$root = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-tools-" + [guid]::NewGuid().ToString("N")) +try { + & (Join-Path $PSScriptRoot "New-AcceptanceRun.ps1") -OutputDirectory $root | Out-Null + $missingEvidenceRejected = $false + try { + & (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T01 -Status PASS | Out-Null + } catch { + $missingEvidenceRejected = $true + } + if (-not $missingEvidenceRejected) { throw "PASS without evidence was accepted" } + + "T01 physical evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "t01.txt") + "T02 physical evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "t02.txt") + "Gate A evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "gate-a.txt") + & (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T01 -Status PASS -EvidencePath t01.txt | Out-Null + & (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T02 -Status PASS -EvidencePath t02.txt | Out-Null + & (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID "Gate A" -Status PASS -EvidencePath gate-a.txt | Out-Null + & (Join-Path $PSScriptRoot "Test-AcceptanceRun.ps1") -RunDirectory $root | Out-Null + + "tampered" | Add-Content -Encoding utf8 -LiteralPath (Join-Path $root "t01.txt") + $tamperRejected = $false + try { + & (Join-Path $PSScriptRoot "Test-AcceptanceRun.ps1") -RunDirectory $root | Out-Null + } catch { + $tamperRejected = $true + } + if (-not $tamperRejected) { throw "Tampered acceptance evidence was accepted" } + Write-Host "Acceptance tooling self-test passed" +} finally { + $resolvedTemp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + $resolvedRoot = [System.IO.Path]::GetFullPath($root) + if ($resolvedRoot.StartsWith($resolvedTemp + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -and + [System.IO.Path]::GetFileName($resolvedRoot).StartsWith("remlink-acceptance-tools-")) { + Remove-Item -LiteralPath $resolvedRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/validation/Test-Architecture.ps1 b/scripts/validation/Test-Architecture.ps1 new file mode 100644 index 0000000..e52e3dc --- /dev/null +++ b/scripts/validation/Test-Architecture.ps1 @@ -0,0 +1,109 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$goFiles = Get-ChildItem -Path $repository -Recurse -File -Filter *.go | Where-Object { + $_.FullName -notmatch '[\\/]frontend[\\/]node_modules[\\/]' -and $_.Name -notlike '*_test.go' +} +$violations = @() +foreach ($file in $goFiles) { + $relative = [System.IO.Path]::GetRelativePath($repository, $file.FullName) + $content = Get-Content -Raw $file.FullName + if ($relative -notlike 'internal\platform\windows\*' -and ($content -match 'powershell\.exe|Set-NetIPInterface|New-NetRoute|Remove-NetRoute')) { + $violations += "$relative contains a Windows network command outside internal/platform/windows" + } + if ($content -match 'New-NetNat|Get-NetNat|Remove-NetNat|Set-NetNat|IPEnableRouter|Disable-NetFirewall|Set-NetFirewallProfile|netsh\s+advfirewall') { + $violations += "$relative contains forbidden WinNAT, Windows forwarding, or firewall-disabling code" + } + if ($content -match 'S7Proxy|ModbusProxy|HTTPProxy|RDPProxy|WireGuardNT|TransitCIDR|TransitPrefix|SecondSubnetAdapter|SubnetAdapterName|\bP2P\b|\bSTUN\b|\bTURN\b|TAPDevice|EthernetFrame') { + $violations += "$relative contains a forbidden architecture symbol" + } +} +$siteConfig = Get-Content -Raw (Join-Path $repository 'internal/config/client.go') +if ($siteConfig -match '(?i)remote[_ ]?(cidr|subnet)|transit[_ ]?(cidr|prefix)') { + $violations += 'Site YAML configuration contains a forbidden Remote/Transit CIDR field' +} +$packetMux = Get-Content -Raw (Join-Path $repository 'internal/overlay/clientwg/packetmux.go') +if ($packetMux -match 'gopacket|tcpip/header|tcprelay|udprelay|pingrelay') { + $violations += 'PacketMux contains protocol parsing beyond IPv4 destination CIDR classification' +} +$serverSources = (Get-Content -Raw (Join-Path $repository 'cmd/server/main.go')) + (Get-Content -Raw (Join-Path $repository 'internal/overlay/serverwg/manager_linux.go')) +if ($serverSources -match 'wireguard/device|internal/overlay/clientwg|NewPacketMux|NewMuxTun') { + $violations += 'Server data plane imports client wireguard-go or PacketMux code' +} +$composePaths = @('deploy/docker/compose.yaml', 'deploy/docker/compose.release.yaml') +$composes = @{} +foreach ($composePath in $composePaths) { + $compose = Get-Content -Raw (Join-Path $repository $composePath) + $composes[$composePath] = $compose + if ($compose -match '(?m)^\s*privileged\s*:') { $violations += "$composePath enables privileged mode" } + if ($compose -notmatch 'NET_ADMIN') { $violations += "$composePath does not grant NET_ADMIN" } + if ($compose -notmatch '/dev/net/tun') { $violations += "$composePath does not map /dev/net/tun" } + if ($compose -match '(?m)^\s*-\s*"?(7001|6200):') { $violations += "$composePath publicly publishes an Overlay-only Control or Session port" } + if ($compose -match '(?i)MASQUERADE|\bSNAT\b') { $violations += "$composePath configures forbidden Overlay NAT" } + if ($compose -notmatch '\./data:/app/data') { $violations += "$composePath does not use the required ./data:/app/data persistence mount" } + if ($compose -notmatch '\$\{REMLINK_WG_PORT:-51820\}:\$\{REMLINK_WG_PORT:-51820\}/udp') { + $violations += "$composePath WireGuard host/container port mapping does not follow REMLINK_WG_PORT" + } +} +$compose = $composes['deploy/docker/compose.yaml'] +$dockerServerConfig = Get-Content -Raw (Join-Path $repository 'deploy/docker/server.yaml') +$dockerfile = Get-Content -Raw (Join-Path $repository 'deploy/docker/Dockerfile') +if ($dockerServerConfig -notmatch '(?m)^\s*directory:\s*["'']?/app/data["'']?\s*$' -or $dockerfile -notmatch 'VOLUME \["/app/data"\]') { + $violations += 'Docker Server data directory does not target the required ./data:/app/data persistence mount' +} +$releaseDockerfile = Get-Content -Raw (Join-Path $repository 'deploy/docker/Dockerfile.release') +if ($releaseDockerfile -notmatch 'COPY linux-amd64/remlink-server' -or $releaseDockerfile -notmatch 'VOLUME \["/app/data"\]') { + $violations += 'Release Dockerfile does not package the released Server binary with persistent data' +} +$identityPathSource = Get-Content -Raw (Join-Path $repository 'internal/identity/path_windows.go') +$wintunRuntimeSource = Get-Content -Raw (Join-Path $repository 'internal/platform/windows/wintunruntime/runtime_windows.go') +if (($identityPathSource + $wintunRuntimeSource) -match 'ProgramData') { + $violations += 'Portable Windows identity or Wintun runtime still depends on ProgramData' +} +$releaseScript = Get-Content -Raw (Join-Path $repository 'scripts/build-release.ps1') +foreach ($packageName in @('RemLink-Engineer-v', 'RemLink-Site-v', 'RemLink-Server-v')) { + if ($releaseScript -notmatch [regex]::Escape($packageName)) { + $violations += "Release build does not define the independent $packageName package" + } +} +if ($releaseScript -match '\$windowsRoot') { + $violations += 'Release build still combines Engineer and Site under one Windows package root' +} +if ($releaseScript -notmatch 'go build[^\r\n]+-tags\s+"desktop,production"[^\r\n]+RemLinkEngineer\.exe') { + $violations += 'Engineer release build does not use the mandatory Wails desktop,production tags' +} +foreach ($dockerfileEntry in @{ + 'deploy/docker/Dockerfile' = $dockerfile + 'deploy/docker/Dockerfile.release' = $releaseDockerfile +}.GetEnumerator()) { + if ($dockerfileEntry.Value -notmatch 'Acquire::Retries=5' -or + $dockerfileEntry.Value -notmatch 'Acquire::http::Timeout=30' -or + $dockerfileEntry.Value -notmatch 'APT_FORCE_IPV4' -or + $dockerfileEntry.Value -notmatch 'APT_DEBIAN_MIRROR' -or + $dockerfileEntry.Value -notmatch 'APT_SECURITY_MIRROR') { + $violations += "$($dockerfileEntry.Key) does not bound apt network waits and expose the IPv4 fallback" + } +} +$moduleText = Get-Content -Raw (Join-Path $repository 'go.mod') +$packageText = Get-Content -Raw (Join-Path $repository 'frontend/package.json') +if ($moduleText -match '@latest' -or $packageText -match '"(latest|\*)"') { $violations += 'A floating dependency version was found' } +$serverCollector = Get-Content -Raw (Join-Path $repository 'scripts/validation/Collect-ServerEvidence.sh') +if ($serverCollector -match '(?im)wg\s+show[^\r\n]*\bdump\b|show[^\r\n]*(private-key|preshared-key)') { + $violations += 'Server evidence collector may export WireGuard private or preshared keys' +} +$preflight = Get-Content -Raw (Join-Path $repository 'deploy/docker/preflight.sh') +if ($preflight -notmatch 'probe_interface="([^"]+)"') { + $violations += 'Docker preflight does not define a fixed WireGuard probe interface' +} elseif ($Matches[1].Length -gt 15) { + $violations += "Docker preflight interface '$($Matches[1])' exceeds the Linux 15-character interface-name limit" +} +if ($preflight -notmatch 'ip link error') { + $violations += 'Docker preflight suppresses the underlying ip link diagnostic' +} +if ($violations.Count -gt 0) { + $violations | ForEach-Object { Write-Error $_ } + exit 1 +} +Write-Host "Architecture policy checks passed ($($goFiles.Count) production Go files inspected)." diff --git a/scripts/validation/Test-FrontendProduction.ps1 b/scripts/validation/Test-FrontendProduction.ps1 new file mode 100644 index 0000000..f56bde5 --- /dev/null +++ b/scripts/validation/Test-FrontendProduction.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$engineerSource = Get-Content -Raw -LiteralPath (Join-Path $repository "frontend/engineer/src/api.ts") +if ($engineerSource -notmatch 'import\.meta\.env\.DEV' -or $engineerSource -notmatch 'production Demo fallback is disabled') { + throw "Engineer API does not fail closed when the production Native binding is absent" +} +if ($engineerSource -match '\\d\{1,3\}.*CIDR_INVALID') { + throw "Engineer frontend duplicates CIDR validation instead of calling the Go Core" +} +$serverAppSource = Get-Content -Raw -LiteralPath (Join-Path $repository "frontend/server/src/App.vue") +if ($serverAppSource -notmatch 'Array\.isArray' -or $serverAppSource -notmatch 'logs\.value=arrayOrEmpty') { + throw "Server frontend does not normalize nullable legacy Admin list responses" +} + +$checks = @( + [ordered]@{ + name = "Engineer" + root = Join-Path $repository "frontend/engineer/dist" + required = @("GetState", "CreateSession", "CheckCIDRs", "production Demo fallback is disabled") + forbidden = @("4488624737516445881", "site-qingdao", "dev-request", "青岛现场 01") + }, + [ordered]@{ + name = "Server" + root = Join-Path $repository "frontend/server/dist" + required = @("/api/v1/admin/nodes", "/api/v1/admin/sessions", "/api/v1/admin/network", "/api/v1/admin/logs") + forbidden = @("8648912340291133", "demo-join-token-after-rotation", "节点上线,Overlay") + } +) +foreach ($check in $checks) { + if (-not (Test-Path -LiteralPath $check.root -PathType Container)) { + throw "$($check.name) production bundle is missing; run npm build first" + } + $bundle = (Get-ChildItem -LiteralPath $check.root -Recurse -File | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n" + foreach ($required in $check.required) { + if (-not $bundle.Contains($required)) { + throw "$($check.name) production bundle is missing required runtime marker: $required" + } + } + foreach ($forbidden in $check.forbidden) { + if ($bundle.Contains($forbidden)) { + throw "$($check.name) production bundle contains development fixture data: $forbidden" + } + } +} +Write-Host "Frontend production bundles contain live adapters and no Demo fixtures" diff --git a/scripts/validation/Test-ReleasePackage.ps1 b/scripts/validation/Test-ReleasePackage.ps1 new file mode 100644 index 0000000..8d94b00 --- /dev/null +++ b/scripts/validation/Test-ReleasePackage.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackagePath, + + [ValidateSet("Engineer", "Site", "Server")] + [string]$Role +) + +$ErrorActionPreference = "Stop" +$resolvedPackage = [System.IO.Path]::GetFullPath($PackagePath) +$temporaryRoot = $null +$packageRoot = $resolvedPackage +try { + if (Test-Path -LiteralPath $resolvedPackage -PathType Leaf) { + if ([System.IO.Path]::GetExtension($resolvedPackage) -ne ".zip") { + throw "Release package file must be a ZIP: $resolvedPackage" + } + $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-release-verify-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + Expand-Archive -LiteralPath $resolvedPackage -DestinationPath $temporaryRoot + $roots = @(Get-ChildItem -LiteralPath $temporaryRoot -Directory) + $looseFiles = @(Get-ChildItem -LiteralPath $temporaryRoot -File) + if ($roots.Count -ne 1 -or $looseFiles.Count -ne 0) { + throw "Release ZIP must contain exactly one top-level package directory" + } + $packageRoot = $roots[0].FullName + } elseif (-not (Test-Path -LiteralPath $resolvedPackage -PathType Container)) { + throw "Release package not found: $resolvedPackage" + } + + $roleMarkers = [ordered]@{ + Engineer = "RemLinkEngineer.exe" + Site = "RemLinkSite.exe" + Server = "linux-amd64/remlink-server" + } + $detectedRoles = @($roleMarkers.GetEnumerator() | Where-Object { + Test-Path -LiteralPath (Join-Path $packageRoot $_.Value) -PathType Leaf + } | ForEach-Object Key) + if (-not $Role) { + if ($detectedRoles.Count -ne 1) { + throw "Unable to infer one package role; found markers for: $($detectedRoles -join ', ')" + } + $Role = $detectedRoles[0] + } + if ($detectedRoles.Count -ne 1 -or $detectedRoles[0] -ne $Role) { + throw "$Role package must contain only its own executable; found markers for: $($detectedRoles -join ', ')" + } + $forbiddenExecutableNames = switch ($Role) { + Engineer { @("RemLinkSite.exe", "remlink-server") } + Site { @("RemLinkEngineer.exe", "remlink-server") } + Server { @("RemLinkEngineer.exe", "RemLinkSite.exe") } + } + $foreignExecutables = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | Where-Object { $_.Name -in $forbiddenExecutableNames }) + if ($foreignExecutables.Count -ne 0) { + throw "$Role package contains a foreign executable: $($foreignExecutables[0].FullName)" + } + + $required = @( + "README.md", "BUILD-INFO.json", "SHA256SUMS.txt", "docs/deployment-and-usage.md", + "scripts/validation/Test-ReleasePackage.ps1" + ) + switch ($Role) { + Engineer { + $required += @("RemLinkEngineer.exe", "engineer.yaml", "THIRD_PARTY_NOTICES.md") + } + Site { + $required += @("RemLinkSite.exe", "site.yaml", "THIRD_PARTY_NOTICES.md") + } + Server { + $required += @( + "linux-amd64/remlink-server", "linux-amd64/server.yaml", "linux-amd64/THIRD_PARTY_NOTICES.md", + "docker/compose.yaml", "docker/Dockerfile", "docker/compose.release.yaml", "docker/Dockerfile.release", + "docker/.env.example", "docker/.env.china.example", "docker/preflight.sh", "docker/server.yaml", "docker/README.md", + "docs/implementation-status.md", + "scripts/validation/New-AcceptanceRun.ps1", "scripts/validation/Set-AcceptanceResult.ps1", + "scripts/validation/Test-AcceptanceRun.ps1", "scripts/validation/Test-AcceptanceTools.ps1", + "scripts/validation/Test-ReleasePackage.ps1", "scripts/validation/Collect-WindowsEvidence.ps1", + "scripts/validation/Collect-ServerEvidence.sh", "scripts/validation/Test-RemoteTargets.ps1", + "docs/validation/T01-T18-runbook.md", "docs/validation/requirements-evidence.md" + ) + } + } + foreach ($relative in $required) { + $target = Join-Path $packageRoot $relative + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + throw "Required $Role release entry is missing: $relative" + } + if ((Get-Item -LiteralPath $target).Length -eq 0) { + throw "Required $Role release entry is empty: $relative" + } + } + if ($Role -in @("Engineer", "Site")) { + $clientConfigName = if ($Role -eq "Engineer") { "engineer.yaml" } else { "site.yaml" } + $clientConfig = Get-Content -Raw -LiteralPath (Join-Path $packageRoot $clientConfigName) + if ($clientConfig -notmatch '(?m)^join_token:\s*') { + throw "$Role package YAML does not expose the join_token convenience field" + } + if ($clientConfig -match '(?im)^\s*(node_token|wg_private_key)\s*:') { + throw "$Role package YAML contains a durable Node secret field" + } + } + + $checksumPath = Join-Path $packageRoot "SHA256SUMS.txt" + $checksums = @{} + foreach ($line in Get-Content -LiteralPath $checksumPath) { + if ($line -notmatch '^([0-9a-f]{64}) (.+)$') { + throw "Malformed checksum line: $line" + } + $relative = $Matches[2].Replace('\', '/') + if ([System.IO.Path]::IsPathRooted($relative) -or $relative -match '(^|/)\.\.(/|$)' -or $relative -eq "SHA256SUMS.txt") { + throw "Unsafe or self-referential checksum path: $relative" + } + if ($checksums.ContainsKey($relative)) { + throw "Duplicate checksum path: $relative" + } + $checksums[$relative] = $Matches[1] + } + $actualFiles = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | ForEach-Object { + [System.IO.Path]::GetRelativePath($packageRoot, $_.FullName).Replace('\', '/') + } | Where-Object { $_ -ne "SHA256SUMS.txt" }) + if ($checksums.Count -ne $actualFiles.Count) { + throw "Checksum count $($checksums.Count) does not match package file count $($actualFiles.Count)" + } + foreach ($relative in $actualFiles) { + if (-not $checksums.ContainsKey($relative)) { + throw "Package file is not covered by SHA256SUMS.txt: $relative" + } + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $packageRoot $relative)).Hash.ToLowerInvariant() + if ($actualHash -ne $checksums[$relative]) { + throw "Checksum mismatch: $relative" + } + } + + $buildInfo = Get-Content -Raw -LiteralPath (Join-Path $packageRoot "BUILD-INFO.json") | ConvertFrom-Json + if (-not $buildInfo.version -or $buildInfo.role -ne $Role -or @($buildInfo.target).Count -ne 1) { + throw "BUILD-INFO.json does not describe one $Role target" + } + if ($Role -in @("Engineer", "Site") -and $buildInfo.portable_data_root -ne "executable_directory") { + throw "$Role BUILD-INFO.json does not declare its portable executable data root" + } + if ($Role -eq "Engineer" -and (@($buildInfo.wails_build_tags) -join ',') -ne 'desktop,production') { + throw "Engineer BUILD-INFO.json does not declare the mandatory Wails desktop,production tags" + } + + if ($Role -eq "Server") { + $acceptanceRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-smoke-" + [guid]::NewGuid().ToString("N")) + try { + & (Join-Path $packageRoot "scripts/validation/New-AcceptanceRun.ps1") -OutputDirectory $acceptanceRoot + $manifest = Get-Content -Raw -LiteralPath (Join-Path $acceptanceRoot "acceptance-run.json") | ConvertFrom-Json + if ($manifest.schema_version -ne 2 -or @($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) { + throw "Packaged acceptance initializer produced an invalid manifest" + } + if (@($manifest.gates + $manifest.scenarios | Where-Object status -ne "NOT_RUN").Count -ne 0) { + throw "Packaged acceptance initializer fabricated a completed result" + } + if (-not (Test-Path -LiteralPath (Join-Path $acceptanceRoot "T01-T18-runbook.md") -PathType Leaf)) { + throw "Packaged acceptance initializer did not copy its runbook" + } + } finally { + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + if ($acceptanceRoot -and $acceptanceRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and + [System.IO.Path]::GetFileName($acceptanceRoot).StartsWith("remlink-acceptance-smoke-")) { + Remove-Item -LiteralPath $acceptanceRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + Write-Host "$Role release package verified: $packageRoot ($($checksums.Count) checksums)" +} finally { + if ($temporaryRoot) { + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + if ($temporaryRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and + [System.IO.Path]::GetFileName($temporaryRoot).StartsWith("remlink-release-verify-")) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/scripts/validation/Test-RemoteTargets.ps1 b/scripts/validation/Test-RemoteTargets.ps1 new file mode 100644 index 0000000..d95ca37 --- /dev/null +++ b/scripts/validation/Test-RemoteTargets.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TargetIPv4, + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + [int[]]$TCPPorts = @(102, 502, 80, 3389), + [int]$UDPPort = 0, + [string]$UDPPayload = "RemLink-T09", + [int]$TimeoutMilliseconds = 3000 +) + +$ErrorActionPreference = "Stop" +$address = [System.Net.IPAddress]::Parse($TargetIPv4) +if ($address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + throw "TargetIPv4 must be IPv4" +} +$resolved = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $resolved | Out-Null +$results = [ordered]@{ + captured_at = (Get-Date).ToUniversalTime().ToString("o") + target = $TargetIPv4 + ping = $null + tcp = @() + udp = $null +} + +$ping = Test-Connection -TargetName $TargetIPv4 -Count 4 -ErrorAction SilentlyContinue +$results.ping = @($ping | Select-Object Address, Latency, Status) +foreach ($port in $TCPPorts) { + $probe = Test-NetConnection -ComputerName $TargetIPv4 -Port $port -InformationLevel Detailed -WarningAction SilentlyContinue + $results.tcp += [ordered]@{ port = $port; success = [bool]$probe.TcpTestSucceeded; remote_address = "$($probe.RemoteAddress)" } +} +if ($UDPPort -gt 0) { + $client = [System.Net.Sockets.UdpClient]::new([System.Net.Sockets.AddressFamily]::InterNetwork) + try { + $client.Client.ReceiveTimeout = $TimeoutMilliseconds + $payload = [Text.Encoding]::UTF8.GetBytes($UDPPayload) + [void]$client.Send($payload, $payload.Length, $TargetIPv4, $UDPPort) + $remote = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0) + $reply = $client.Receive([ref]$remote) + $replyText = [Text.Encoding]::UTF8.GetString($reply) + $results.udp = [ordered]@{ port = $UDPPort; success = ($replyText -eq $UDPPayload); reply = $replyText; remote = "$remote" } + } catch { + $results.udp = [ordered]@{ port = $UDPPort; success = $false; error = $_.Exception.Message } + } finally { + $client.Dispose() + } +} +$path = Join-Path $resolved ((Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ") + "-target-$TargetIPv4.json") +$results | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $path +Get-FileHash -Algorithm SHA256 -LiteralPath $path | Format-List | Out-File -Encoding utf8 "$path.sha256.txt" +Write-Host "Remote target evidence: $path" diff --git a/specs/checklist.md b/specs/checklist.md new file mode 100644 index 0000000..d5ede77 --- /dev/null +++ b/specs/checklist.md @@ -0,0 +1,84 @@ +# 检查清单 + +> 对应规格书第 18–20 章。每项检查须在对应实现完成后逐条核验;任何一项不通过都视为偏离规格。 +> +> `[x]` 表示代码/静态策略/自动化验证已经完成;Gate 与 T01–T18 仍只接受真实主机证据,保持 `[ ]` 不代表自动化测试失败。 + +## 关键技术 Gate + +- [ ] Gate A:wireguard-go 能通过 MuxTun 使用唯一 Wintun,正常 Overlay 通信 +- [ ] Gate B:Remote Packet 被 MuxTun 截获后,普通 UDP Socket 的外层 Overlay Packet 能通过同一 Wintun 再进入 WireGuard,不死锁、不无限循环 +- [ ] Gate C:Site 收到 Session Raw IPv4 后能注入 gVisor netstack;TCP/UDP Forwarder 能建立 host socket 到现场目标并完成双向数据搬运 +- [ ] Gate D:gVisor netstack 能把返回数据重新构造成 Engineer 看到的原始目标 IP 流,并通过 Session UDP 反向封装回 Engineer;Ping Relay 能正确返回 ICMP Echo Reply + +## 架构不可变约束(规格书第 0/20 章) + +- [x] 每个 Engineer/Site 进程只创建一块名为 RemLink 的 Wintun;无第二块 Subnet Adapter、无 Transit CIDR +- [x] Windows WireGuard 使用嵌入进程的 wireguard-go;无 WireGuardNT 独立接口 +- [x] wireguard-go 必须通过 MuxTun 包装唯一 Wintun +- [x] v1 不调用 WinNAT、不启用 Windows IP Forwarding,也不修改第三方 NAT +- [x] Server 使用 Linux Kernel WireGuard;Go Server 不逐包中转 Overlay +- [x] Server Peer AllowedIPs 只放 Node Overlay /32;绝不把现场 Remote CIDR 加进 Server WireGuard AllowedIPs +- [x] 所有 Engineer/Site 的 WireGuard 公网 Peer 都只有 Server;无 P2P 代码 +- [x] 无二层代码(ARP/以太网帧/TAP/Bridge/DCP/LLDP) +- [x] PacketMux 只做 IPv4 CIDR 分类,不解析 TCP/UDP/ICMP 或应用层协议 +- [x] Remote Packet 外层传输使用普通 UDP Socket over Overlay;未手写外层 IP/UDP 协议栈 +- [x] Remote CIDR 由 Engineer Session 动态下发;Site 配置文件不保存现场网段 +- [x] 不同 Site 的 Remote CIDR 可完全相同(实现未引入人为限制) +- [x] 一个 Engineer 同时只能有一个 Site Session +- [x] Site Subnet Gateway 复用 gVisor netstack(固定 commit),无自研 TCP/IP 栈 +- [x] 无 S7Proxy/ModbusProxy/HTTPProxy/RDPProxy 等应用协议专用模块;PingRelay 是唯一 ICMP 模块 +- [x] 所有 Windows 网络修改集中在 platform/windows;GUI/业务层无散落 PowerShell +- [x] 所有依赖固定版本(Go module/NPM/Wintun DLL/gVisor commit);生产构建无 latest 浮动依赖 +- [ ] 先完成 Phase 1–7 网络 Gate,再投入完整 GUI +- [x] 敏感字段(WG PrivateKey/NodeToken)使用 DPAPI 保护落盘,无明文 YAML 私钥 +- [x] Engineer/Site YAML 可保存首次注册 Join Token;CLI/环境变量可覆盖 YAML +- [x] Wintun DLL go:embed 打入 EXE,Engineer/Site 各自释放到本端 EXE 目录,不使用共享 ProgramData +- [x] Engineer、Site、Server 使用三个独立目录和 ZIP,互不包含其他端可执行文件 +- [x] THIRD_PARTY_NOTICES 随发布包提供(WireGuard/Wintun/gVisor/参考代码许可证) +- [x] 绝不自动关闭 Windows Firewall;防火墙规则仅创建带 RemLink 前缀、可识别且可回滚的规则 +- [x] v1 无 0.0.0.0/0 Exit Node 模式 + +## 验收测试(规格书第 19 章) + +- [ ] T01:Engineer 与 Site 都在线,仅有一个 RemLink 虚拟网卡 +- [ ] T02:Engineer ping Site Overlay IP,通过 Server 中转成功 +- [ ] T03:Engineer 输入 192.168.13.0/24,本机无冲突,Session 可建立 +- [ ] T04:Engineer 本地本身是 192.168.13.0/24,建立前拒绝 CIDR_LOCAL_CONFLICT +- [ ] T05:Site 对 192.168.13.0/24 无路由,拒绝 SITE_NO_ROUTE +- [ ] T06:Site 同时运行 Hyper-V/Docker/已有 WinNAT,Remote Session 仍可正常建立;不读取/修改第三方 NAT +- [ ] T07:Engineer ping 现场 PLC,ICMP Echo Relay 成功;延迟可比直接链路略高 +- [ ] T08:TCP 102 / 502 / HTTP / RDP 通过通用 TCP Relay 正常连接;无应用协议专用代码 +- [ ] T09:普通单播 UDP 流量通过 UDP flow relay 往返成功;idle flow 可回收 +- [ ] T10:Session 同时下发 192.168.13.0/24 与 192.168.21.0/24,两网段均可访问 +- [ ] T11:Engineer-A→Site-A、Engineer-B→Site-B;两 Site 都是 192.168.13.0/24,同时访问成功互不串流 +- [ ] T12:同 Site 两个 Engineer 访问不同/相同目标设备,SessionID + Overlay IP 隔离正确 +- [ ] T13:Engineer 尝试再连接第二个 Site,拒绝 ENGINEER_SESSION_EXISTS +- [ ] T14:Engineer Wi-Fi 切手机热点,WireGuard/Control 自动恢复 +- [ ] T15:Server 重启,Node 自动重连;旧 Session CLOSED,用户可重新连接 +- [ ] T16:Engineer 崩溃后重启,清理残留 Remote Route +- [ ] T17:Server 修改某 Node Overlay IP,Node 自动 Re-bootstrap 后使用新 IP +- [ ] T18:Server 修改整个 Overlay CIDR,所有 Session 关闭、Nodes 重新分配/配置;Site 无需重建系统 NAT + +## 功能完整性检查 + +- [x] Session 状态机七状态(CREATING/PREPARING_SITE/READY/ACTIVE/STOPPING/CLOSED/FAILED)按规格流转 +- [x] SessionHeader 20 字节头(RMLK/Version/Type/Flags/SessionID/PayloadLen/Reserved)双向对称封装 +- [x] Session 双向收包校验完整(SessionID/外层源/内层源/内层目 CIDR/IPv4 Total Length) +- [x] Session UDP Listener 仅绑定 Overlay 地址,不监听公网或 0.0.0.0 +- [x] Bootstrap API 三接口 + Admin Web API 八接口按规格实现 +- [x] Control WebSocket 13 种消息按规格实现 +- [x] 14 个错误码全量落地(以权威规格实际枚举为准) +- [x] SQLite 六张表 + migration 工具化(无散落 CREATE TABLE) +- [x] Overlay CIDR 修改七步流程完整实现(含 REBOOTSTRAP_REQUIRED) +- [x] Heartbeat 参数按默认值实现(5s 间隔;ONLINE ≤15s / UNSTABLE 15–30s / OFFLINE >30s;退避 1/2/5/10/30s) +- [x] Flow limits 默认值(TCP 2048 / UDP 4096 / UDP idle 60s)且可配置 +- [x] Site PREPARE 路由检查覆盖 DIRECT/ROUTED/DEFAULT_ONLY/NO_ROUTE/OVERLAY_CONFLICT +- [x] Engineer 本地冲突检查使用 net/netip prefix overlap(非字符串比较) +- [x] Session 统计每 5 秒上报;Upload/Download 按 Engineer 视角定义 +- [x] 11 个日志模块分类正确;高频 packet 日志默认关闭、DEBUG 禁止逐包打印 Payload +- [x] Web UI 五页面(Dashboard/Nodes/Sessions/Network/Logs)功能齐备 +- [x] Engineer GUI 功能齐备(Site 列表/多 CIDR 输入/冲突预检/Session 详情/断开) +- [x] 端口占用符合基线:51820/udp(公网 WG)、8080/tcp(Web+Bootstrap)、7001/tcp(仅 Overlay Control)、6200/udp(仅 Overlay Session) +- [x] Docker Compose 只授予 NET_ADMIN(非 privileged);启动 Preflight 检查内核 WireGuard +- [x] MuxTun Close 幂等且使全部 goroutine 退出 diff --git a/specs/spec.md b/specs/spec.md new file mode 100644 index 0000000..8b6238c --- /dev/null +++ b/specs/spec.md @@ -0,0 +1,414 @@ +# RemLink v1.0(Userspace Netstack 版)实施规格 + +> 权威设计来源:仓库根目录《RemLink_v1.0_技术设计与AI开发规格书_Netstack版.docx》(版本日期 2026-08-19)。本文件将其转化为 spec-driven 开发格式;若实现中发现本文件与 docx 原文有冲突,以 docx 为准并回改本文件。禁止为了让代码跑通而绕开核心约束。 + +## 背景与目标 + +工业自动化远程调试中,工程师需要在异地直接访问现场设备 IP(如 192.168.13.10 的 PLC、HMI HTTP、RDP),而端口代理需逐个配置、协议适配无法泛化。RemLink 通过中心式 IPv4 L3 虚拟组网 + Engineer 动态下发远程 CIDR + Site 端 gVisor netstack userspace 子网网关,让 Windows 普通 IP 应用无需任何协议适配即可访问现场地址,且允许多个现场使用完全相同的网段。 + +## 变更内容 + +- 全新构建 RemLink v1.0 三端产品(绿地项目,全部为新增能力): + - **Server**:Ubuntu / Docker / Linux Kernel WireGuard + Go(wgctrl 编排)+ SQLite + Vue 3 Web UI + - **Engineer**:Windows Wails GUI + wireguard-go(嵌入进程)+ 单一 RemLink Wintun + PacketMux + - **Site**:Windows Console + wireguard-go(嵌入进程)+ 单一 RemLink Wintun + gVisor netstack SubnetGateway +- 架构基线:**单 Wintun + wireguard-go + PacketMux + gVisor netstack**;不使用 WinNAT、Windows IP Forwarding、Transit CIDR、WireGuardNT +- 按 Phase 0–10 分阶段实施,Gate A–D 为关键验收关卡(见 tasks.md) +- 仅 IPv4;Remote Subnet v1 支持 TCP / 单播 UDP / ICMP Echo,不实现二层 + +## 影响范围 + +- 受影响规格:无(首个 change) +- 受影响代码:全新 Go monorepo `remlink/`(目录结构见 R15) +- 固定版本外部依赖:Wintun DLL、wireguard-go(锁定 commit)、gVisor netstack(锁定 commit)、wgctrl、SQLite driver、Wails、Vue 3 + Vite、golang.org/x/net/icmp + +## 新增需求 + +### 需求: R1 总体架构与不可变约束 + +系统 必须 采用中心式 Hub-and-Spoke 架构:所有 Engineer/Site 节点只与 Server 建立 WireGuard 连接,数据始终经 Server Hub 中转;节点只配置 Server 公网 IP/端口,不配置彼此公网地址。 + +系统 必须 遵守以下不可变决策: + +| 决策项 | v1.0 最终选择 | +|---|---| +| 虚拟组网 | 中心式 Hub-and-Spoke,无 P2P | +| Windows 虚拟网卡 | 每个 Engineer/Site 只有 1 块名为 RemLink 的 Wintun | +| Windows WireGuard | wireguard-go 嵌入进程,不用 WireGuardNT | +| Server WireGuard | Linux Kernel WireGuard | +| 数据层 | IPv4 L3 捕获;Remote Subnet v1 = TCP/UDP/ICMP Echo | +| 远程子网 | Engineer 建立 Session 时动态下发;Site 配置不保存现场 CIDR | +| Site Subnet Gateway | gVisor netstack userspace relay,不依赖 WinNAT/IP Forwarding | +| Engineer 并发 | 一个 Engineer 同时最多连接一个 Site | +| Site 并发 | 底层允许多个 Engineer 同时连接同一个 Site | +| 现场网段重复 | 允许不同 Site 使用完全相同的 192.168.x.0/24 | +| 二层 | 明确不做 ARP/以太网帧/DCP/LLDP/TAP/Bridge | +| 服务端网络地址 | Server Web UI 配置;Node IP 由 Server IPAM 自动分配 | + +明确不在 v1 范围:二层协议、P2P/STUN/TURN/UDP 打洞、IPv6、IP 广播/组播、SCTP/GRE/ESP、用户注册/多租户/RBAC/OAuth、HA/Cluster/K8s、自研 TCP/IP 栈/VPN/网卡驱动/NAT、一个 Engineer 同时连多个 Site。 + +#### 场景: 两个 Site 使用相同现场网段 + +- **当** Site-A 与 Site-B 都使用 192.168.13.0/24 作为现场网段,且分别被 Engineer-A、Engineer-B 通过 Session 访问 +- **则** 两条 Session 同时工作互不串流;Server WireGuard 不出现路由冲突(Server 只按 Overlay /32 选路,现场 CIDR 位于 Session UDP Payload 内) + +#### 场景: 尝试 P2P 或二层能力 + +- **当** 任何实现引入 P2P 直连、打洞、ARP/TAP/Bridge 代码 +- **则** 违反不可变约束,不被接受 + +### 需求: R2 Overlay 虚拟组网与 Server IPAM + +系统 必须 提供 Overlay 虚拟组网与集中式 IPAM: + +- 默认 Overlay CIDR 为 10.88.0.0/16(仅初始值,Server Web UI 必须允许修改);Server 地址默认取该网段第一个可用地址(如 10.88.0.1);Engineer 与 Site 使用统一 Node Pool。 +- 每个 Node 的地址在 Server WireGuard Peer 上以 /32 AllowedIPs 登记;客户端到 Server 的唯一 Peer 使用整个 Overlay CIDR 作为 AllowedIPs。 +- Server 是 Overlay 地址唯一权威来源,客户端不得自行填写虚拟 IP。 +- Node 首次注册时由 IPAM 分配地址,后续重启保持同一地址;地址不得为网络地址、广播地址、Server 地址或已占用地址。 +- Web UI 可手动修改 Node IP;修改后触发该 Node 重新 Bootstrap/重建 WireGuard。 +- 删除 Node 后撤销 WireGuard Peer、Node Token,并释放地址。 + +修改 Overlay CIDR 时系统 必须 按 7 步流程执行:Web UI 提交并校验新 CIDR → 所有 Active Session 进入 STOPPING/CLOSED → IPAM 为现有 Node 重新分配/迁移地址 → 重建 wg0 地址和 Peer AllowedIPs → 在线 Node 经旧 Control 通道收 REBOOTSTRAP_REQUIRED(通道断则自动走公网 Bootstrap API)→ Windows Node 更新 Adapter IP/路由并重启 wireguard-go → Site 重建 NetworkConfig 与 Session Gateway(不修改 Windows NAT/Forwarding)。 + +Node 必须 在应用新 Overlay CIDR 前检查其与本机现有直连网络是否重叠;发现冲突时不得强行启用,向 Server 报告 OVERLAY_LOCAL_CONFLICT。 + +#### 场景: Node 地址稳定 + +- **当** 已注册 Node 重启后再次 Bootstrap +- **则** 获得与之前相同的 Overlay IP + +#### 场景: Node 本地网络与 Overlay 冲突 + +- **当** Node 本机直连网络与 Overlay CIDR 重叠 +- **则** 拒绝启用新配置并上报 OVERLAY_LOCAL_CONFLICT + +### 需求: R3 Windows 单虚拟网卡数据面(MuxTun / PacketMux) + +Engineer 与 Site 必须 都只创建一块名称固定为 RemLink 的 Wintun L3 Adapter(默认 MTU 1280),同时承载 Overlay 节点通信和 Engineer 的 Remote Subnet 路由。不存在第二块 Subnet Adapter,也不存在 Transit CIDR。 + +系统 必须 将真实 Wintun 包装为 MuxTun(实现 wireguard-go 的 tun.Device 接口,Read/Write 代理给真实 Wintun)后交给 wireguard-go Device,使 RemLink 能在"Windows 网络栈 ↔ WireGuard 引擎"边界做 L3 分流。MuxTun 必须尽量薄。 + +Engineer 出方向 PacketMux 必须 仅根据目标 IPv4 地址分类: + +- 目标属于 Overlay CIDR:原包不修改,直接交给 wireguard-go。 +- 目标属于当前 Active Session 的任一 Remote CIDR:不交给 wireguard-go,将原始 IPv4 bytes 放入 SubnetSender 的有界队列。 +- 其他目标:丢弃并做限速日志。 + +SubnetSender 必须 使用普通 Go UDP Socket(绑定 Engineer Overlay IP,目标为 Site OverlayIP:6200)发送 `RemLinkHeader + 原始 IPv4 Packet`,由 Windows TCP/IP 生成外层 IP/UDP Header;该外层 UDP Packet 因目标属于 Overlay CIDR 再次进入同一 Wintun,被 PacketMux 判定为 Overlay 流量交给 wireguard-go(防循环:外层目标为 Site Overlay IP,不属于 Remote CIDR,只进入一次 WireGuard)。 + +MuxTun 并发要求: + +- PacketMux Read 不得在发送 Remote Packet 时直接阻塞等待 UDP 网络 I/O;使用有界 channel + 独立 Sender goroutine。 +- 当一次 Wintun batch 全部为 Remote Packet 时,Read 应继续读取,直到能向 wireguard-go 返回至少一个 Overlay Packet 或收到关闭/错误事件,避免返回 0,nil 造成 busy loop。 +- Wintun Write 由 wireguard-go 入方向和 Site Session 注入共用时,通过统一 Writer/锁序列化。 +- packet buffer 尽量复用池;先保证正确性,再做 batch 优化。 + +#### 场景: Remote 包外层再入 Wintun + +- **当** Engineer 应用访问 192.168.13.10,PacketMux 拦截后由 SubnetSender 以 UDP 发往 Site Overlay IP:6200 +- **则** 外层 UDP 再次进入同一 Wintun 后被识别为 Overlay 流量交给 wireguard-go,无死锁、无无限循环 + +### 需求: R4 Remote Subnet Session + +Session 业务规则: + +- Remote CIDR 由 Engineer 用户在连接 Site 时输入并下发;Site 本地配置不预存现场网段。 +- 一个 Engineer 同时最多一个处于 CREATING/PREPARING_SITE/ACTIVE 的 Site Session。 +- 一个 Session 可携带多个 IPv4 CIDR,数据结构从第一天就使用数组。 +- Site 底层允许同时服务多个 Engineer;通过 SessionID + Engineer Overlay IP 区分。 +- 两个不同 Site 的 Remote CIDR 可以完全相同。 +- 目标 CIDR 不得与 RemLink Overlay CIDR 重叠,不得与 Engineer 本地非 RemLink 网络发生任何前缀重叠。 +- v1 禁止 0.0.0.0/0 Exit Node 模式。 + +Session 状态机 必须 为:CREATING → PREPARING_SITE → READY → ACTIVE → STOPPING → CLOSED,以及 FAILED(任一关键检查失败)。 + +Session 建立顺序 必须 为:Engineer GUI 选择 Site 并输入 CIDR → Engineer 本地冲突检查(失败不请求 Server)→ CREATE_SESSION → Server 校验(已有 Session/Site 在线/CIDR 合法性)→ Server 生成随机 64-bit SessionID、状态 PREPARING_SITE、向 Site 下发 PREPARE_SESSION → Site 对每个 CIDR 做 Windows Route Lookup 并检查 SubnetGateway 能力与 flow capacity → Site 返回 PREPARE_RESULT(含 subnet_gateway=netstack)→ Server 向 Engineer 返回 SESSION_CONFIG(SessionID、Site Overlay IP、Remote CIDRs、MTU、Session UDP Port)→ Engineer 添加 Windows Remote Routes 并启动 SessionTransport → Engineer 发送 ROUTES_READY → Server 置 ACTIVE 并通知 Site。 + +Session 数据包格式(两个方向统一)SHALL 为: + +| 字段 | 长度 | 说明 | +|---|---|---| +| Magic | 4 bytes | ASCII: RMLK | +| Version | 1 byte | v1 = 1 | +| Type | 1 byte | v1 固定 0x01 = IPv4 | +| Flags | 2 bytes | v1 置 0 | +| SessionID | 8 bytes | Server 生成的 uint64 | +| PayloadLen | 2 bytes | 原始 IPv4 Packet 长度 | +| Reserved | 2 bytes | 置 0 | +| Payload | N bytes | 完整原始 IPv4 Packet | + +不加入 TCP 风格序号/ACK/重传/拥塞控制;不做 TCP-over-TCP。WireGuard 已提供外层完整性和加密,不再增加自研加密或可靠 UDP。 + +Session 双向收包校验 必须 满足: + +- Engineer 与 Site 都只在自己的 OverlayIP:6200 上监听 Session UDP,不监听公网地址或 0.0.0.0。 +- 根据 SessionID 查找 Active Session。 +- UDP 外层源 Overlay IP 必须等于该 Session 对端绑定的 Overlay IP(双向都校验)。 +- Engineer→Site 方向内层 IPv4 Source 默认必须等于 Engineer Overlay IP(应用显式绑定其他物理源地址的流量不保证可用)。 +- Engineer→Site:内层 Destination 必须属于 Session Remote CIDR;Site→Engineer:内层 Source 必须属于 Session Remote CIDR,Destination 必须等于 Engineer Overlay IP。 +- IPv4 Total Length、版本和实际 Payload 长度必须一致。 +- 校验失败直接丢弃并做限速安全日志。 + +#### 场景: Engineer 已有 Active Session 再连第二个 Site + +- **当** Engineer 在 ACTIVE Session 期间请求连接另一个 Site +- **则** 拒绝并返回 ENGINEER_SESSION_EXISTS + +#### 场景: 目标 CIDR 与本地网络冲突 + +- **当** Engineer 本机存在 192.168.0.0/16,用户输入 192.168.13.0/24 +- **则** 本地冲突检查失败(前缀重叠),不向 Server 发起请求 + +### 需求: R5 Site Userspace Subnet Gateway(gVisor netstack) + +Site 收到 Session UDP 后 必须 取出原始 IPv4 Packet,直接交给 gVisor netstack SubnetGateway(通过 channel.Endpoint.InjectInbound),不注入 Windows Wintun 做内核转发。gVisor netstack 负责 TCP/UDP 连接状态和报文重建;RemLink 不实现 TCP 状态机。现场侧实际出站使用 Windows 普通 host socket(net.Dial / UDPConn),目标设备看到的源地址是 Site 的现场可达地址。 + +- v1 不创建 Transit CIDR,不创建 RemLinkNAT,不要求 PLC/网关认识 Overlay 网段。 +- 能力边界:v1 明确支持 TCP、单播 UDP 和 ICMP Echo(Ping);IP 广播/组播、SCTP、GRE、ESP 及其他非 TCP/UDP IP 协议不在保证范围。 +- SubnetGateway 接口: + +```go +type SubnetGateway interface { + Prepare(ctx context.Context, cfg SessionConfig) error + InjectIPv4(ctx context.Context, sessionID uint64, packet []byte) error + CloseSession(ctx context.Context, sessionID uint64) error +} +// v1 default / only backend: GVisorNetstackBackend +``` + +- 优先直接依赖 upstream gVisor netstack 并固定 commit;可参考 Tailscale wgengine/netstack(BSD-3-Clause)实现模式,但不得引入整个 Tailscale 控制面。未来 Kernel/NAT Backend 必须保持接口不变。 +- TCP Relay:gVisor netstack 接收 Engineer 的 SYN,创建 Engineer-facing TCP endpoint;RemLink 使用 host net.Dial("tcp", target) 建立 Site→PLC 连接,以 io.CopyBuffer 双向搬运字节;不做应用协议识别。 +- UDP Relay:按 SessionID + Engineer 源/目标五元组维护轻量 flow mapping;每个 flow 使用 host UDPConn 与现场目标通信,收到回复后写回 gVisor UDP endpoint;flow 使用可配置 idle timeout 做 GC。 +- 返回包必须使用 Session 封装(对称封装,v1 强制设计):gVisor netstack 生成面向 Engineer 的 IPv4 响应包(SRC=现场 IP → DST=Engineer Overlay IP)后,Site 必须将其再封装成 Session UDP(Outer SRC=Site Overlay IP,Outer DST=Engineer Overlay IP,Payload=SessionHeader+RawIPv4);不得把该 Raw Packet 直接交给 WireGuard(Server 对 Site Peer 的 AllowedIPs 只有 /32,源地址会被 cryptokey routing 拒绝)。Engineer SessionListener 校验后将 Raw IPv4 写入本机 Wintun 入站方向;Windows 最终看到 SRC=现场 IP、DST=Engineer Overlay IP。 +- ICMP Echo Relay:v1 只支持 Echo Request/Reply;收到 Echo Request 后由 PingRelay 使用成熟 ICMP 库或受控系统 ping 从 Site 探测目标;目标响应后构造与原请求 ID/Sequence 对应的 Echo Reply Raw IPv4 经 SessionTransport 返回 Engineer;其他 ICMP 类型不支持。 +- Site PREPARE 时 必须 对每个目标 CIDR 做 Windows Route Lookup:DIRECT(直连)允许;ROUTED(明确静态/动态路由)允许;DEFAULT_ONLY(仅默认路由)默认拒绝;NO_ROUTE 拒绝(SITE_NO_ROUTE);OVERLAY_CONFLICT 拒绝。 +- 建议初始 flow 上限(均可配置):TCP 2048 flows、UDP 4096 flows、UDP idle timeout 60s。 + +#### 场景: 现场设备返回路径 + +- **当** PLC 192.168.13.10 响应 Site host socket 的连接 +- **则** Site 经 gVisor netstack 生成面向 Engineer 的 Raw IPv4,再以 SessionHeader 封装经 UDP 发往 Engineer Overlay IP;PLC 无需任何 Overlay 返回路由 + +### 需求: R6 控制面与节点协议 + +系统 必须 分离 Bootstrap 与正常 Control: + +- Bootstrap(公网 HTTP):`http://SERVER_IP:8080/api/v1/bootstrap/...` +- WireGuard:`SERVER_IP:51820/udp` +- Control(仅 Overlay):`ws://10.88.0.1:7001/control` + +节点第一次启动无 WireGuard 时使用公网 Bootstrap API 获取 Overlay IP、Server WireGuard PublicKey 和 Endpoint;建立 WireGuard 后所有控制消息走 Overlay 内 Control WebSocket。 + +Node 身份 必须 包含:NodeID(UUID,首次运行生成并持久化)、NodeType(engineer/site)、NodeName、WireGuard PrivateKey(本地生成,仅本机保存)、WireGuard PublicKey(注册时提交 Server)、NodeToken(Server 注册成功后生成,用于后续身份验证;不是用户系统)。 + +Windows 本地敏感字段(WG PrivateKey、NodeToken)SHALL 优先使用 Windows DPAPI 保护后落盘,不以明文 YAML 保存私钥;为简化自用部署,首次注册 Join Token 可以明文保存在 Engineer/Site YAML 中。Server PrivateKey 保存到数据目录并设置严格文件权限。 + +Server 必须 维护可轮换 Join Token:新 Node 注册必须提供 Join Token;注册成功后改用 NodeToken。v1 不实现 HTTPS,公网 Bootstrap/管理 HTTP 的机密性不由 TLS 提供,作为部署边界在文档中说明。 + +Heartbeat 参数(默认值):interval 5s;ONLINE=最近心跳 ≤15s;UNSTABLE=15–30s;OFFLINE=>30s;Reconnect backoff=1s,2s,5s,10s,30s 上限。 + +### 需求: R7 Server 设计 + +Server 必须 承担:Bootstrap API 和 Web UI、IPAM/Node Registry/Node Token、Linux Kernel WireGuard interface 和 Peer 编排(wgctrl)、Control WebSocket Hub、Session Manager、节点/Session 统计聚合、SQLite 持久化和日志查询;不逐包处理 WireGuard Overlay 数据。 + +Server wg0 必须 配置 Address 10.88.0.1/16(随 Overlay CIDR)、ListenPort 51820;每个 Node Peer 的 AllowedIPs 为其 Overlay /32。Linux 必须启用 IPv4 forwarding 并允许 wg0→wg0 转发;Server 不对 Overlay 做 SNAT。v1 不同时实现 kernel WireGuard 与 wireguard-go 两套 Server Backend。 + +Docker Compose 基线 必须 只授予 NET_ADMIN(不用 privileged: true),映射 /dev/net/tun、51820/udp、8080/tcp,挂载 ./data:/app/data,sysctls net.ipv4.ip_forward=1。镜像启动 Preflight 必须检查内核 WireGuard 能否创建接口;失败时明确报错并退出,不静默切换。 + +Web UI 必须 提供五个页面: + +| 页面 | 核心内容 | +|---|---| +| Dashboard | Uptime、Overlay CIDR、在线 Engineer/Site、Active Session、流量 | +| Nodes | 名称、类型、Overlay IP、WG PublicKey 摘要、Handshake、App 状态、版本、LastSeen | +| Sessions | Engineer、Site、Remote CIDRs、状态、上下行流量、持续时间、强制断开 | +| Network | Overlay CIDR、Server IP、WG Port、Join Token 轮换 | +| Logs | 按时间/级别/模块/Node/Session 过滤事件 | + +### 需求: R8 Engineer 设计 + +Engineer 进程(RemLinkEngineer.exe)SHALL 包含:Wails GUI、Bootstrap/Control Client、Node Identity Store、Wintun Adapter Manager、MuxTun/PacketMux、wireguard-go Device、Session Manager、SessionTransport(UDP Sender + Listener)、RouteManager、Stats、Logging。 + +GUI 必须 提供:Server 公网地址/Overlay 状态/本机虚拟 IP/Control 状态/版本显示;全部 Site 列表(Name、Overlay IP、Online、RemoteSubnetCapability、LastSeen);选择 Site 后输入一个或多个 CIDR;连接前执行本地 CIDR 冲突检查;连接后显示 SessionID、目标 Site、CIDR、上传/下载、包数、时延和日志;Active Session 时禁止再选择第二个 Site。 + +Session READY 后 Engineer 必须 为每个 Remote CIDR 增加指向 RemLink Adapter 的 Windows 路由,由 RouteManager 统一创建并带 RemLink ownership metadata/本地状态记录以便异常恢复。不得通过降低 Metric 强抢冲突路由;发现 Remote CIDR 与本机现有非 RemLink 直连/静态/VPN 前缀重叠时直接拒绝建立 Session。 + +应用显式绑定网卡的情况:普通 Socket 由 Windows 根据 Remote Route 选择 RemLink Adapter;显式绑定物理网卡或固定源 IP 的工业软件不完全透明,应在其网络接口选择中选择 RemLink Adapter;v1 不增加源地址改写。 + +### 需求: R9 Site 设计 + +Site 进程(RemLinkSite.exe,Console)SHALL 包含:Bootstrap/Control Client、Node Identity Store、Wintun Adapter Manager、MuxTun/PacketMux、wireguard-go Device、UDP Session Listener :6200(Overlay only)、gVisor Netstack SubnetGateway、TCP/UDP/Ping Relay、Route Inspector、Flow Manager/Stats、Logging。 + +Site 启动能力检测 必须 按 7 步执行:加载 Node Identity 并获取 Server NetworkConfig → 检查 Overlay CIDR 与本地直连网络冲突 → 创建/复用 RemLink Wintun 并配置 Overlay IP/Prefix/MTU → 启动 wireguard-go 并等待 Server Overlay 可达 → 初始化 gVisor netstack SubnetGateway(TCP/UDP forwarder 与 flow limits;无需检查或修改 WinNAT)→ 启动仅绑定 OverlayIP:6200 的 Session UDP Listener(同一端口承载双向)→ 连接 Control WebSocket 并报告能力、OS、版本、netstack 状态与 flow capacity。 + +Console 输出 必须 展示 Server、Node、Overlay IP、WireGuard/Control/Remote Subnet/SubnetGateway 状态及 SESSION/ROUTE 事件。 + +### 需求: R10 数据库与数据模型 + +Server 必须 使用单 SQLite 数据库文件(如 /app/data/remlink.db),database/sql + 稳定 SQLite Driver,迁移采用成熟 migration 工具或嵌入 SQL migration 文件,不在代码中散落 CREATE TABLE。 + +核心表 必须 包含: + +| 表 | 关键字段 | +|---|---| +| settings | key, value, updated_at | +| nodes | node_id, type, name, overlay_ip, wg_public_key, node_token_hash, status, version, os_version, last_seen | +| sessions | session_id, engineer_node_id, site_node_id, status, created_at, active_at, closed_at, error_code | +| session_cidrs | session_id, cidr | +| session_stats | session_id, tx_bytes, rx_bytes, tx_packets, rx_packets, updated_at | +| event_logs | time, level, module, node_id, session_id, message, fields_json | + +Windows Node 不使用 Server SQLite;Engineer 与 Site 必须作为完全独立的便携式包发布,持久状态放在各自 EXE 所在目录,且不得共用运行文件。敏感字段用 DPAPI,至少保存 NodeID、NodeToken、WireGuard PrivateKey、Server URL、NodeName、最后一次 NetworkConfig 版本,以及 RemLink 创建过的路由状态用于 Reconcile。 + +### 需求: R11 API 与消息定义 + +Public Bootstrap API 必须 提供:GET /api/v1/server/info(Server ID、版本、WG Endpoint、Bootstrap 信息)、POST /api/v1/bootstrap/register(首次 Node 注册)、POST /api/v1/bootstrap/config(已注册 Node 用 NodeID+NodeToken 拉取最新 NetworkConfig)。 + +Admin Web API 必须 提供:GET /api/v1/admin/nodes;PATCH /api/v1/admin/nodes/{id}(改名/改 Overlay IP);DELETE /api/v1/admin/nodes/{id}(撤销 Node);GET /api/v1/admin/sessions;POST /api/v1/admin/sessions/{id}/disconnect(强制断开);GET /api/v1/admin/network;PUT /api/v1/admin/network;GET /api/v1/admin/logs。v1 不做用户系统;如需保护仅实现可选的单一 Admin Token,不设计账户/角色体系。 + +Control WebSocket 消息 必须 包含: + +| Type | 方向 | 关键字段 | +|---|---|---| +| HELLO | Node→Server | node_id, node_token, config_version, capabilities | +| WELCOME | Server→Node | server_time, network_config_version | +| NODE_LIST | Server→Engineer | sites[] | +| CREATE_SESSION | Engineer→Server | site_node_id, target_cidrs[] | +| PREPARE_SESSION | Server→Site | session_id, engineer_overlay_ip, target_cidrs[] | +| PREPARE_RESULT | Site→Server | ok, route_results[], subnet_gateway_status, tcp_capacity, udp_capacity, error | +| SESSION_CONFIG | Server→Engineer | session_id, peer_overlay_ip, cidrs[], mtu, udp_port | +| ROUTES_READY | Engineer→Server | session_id | +| SESSION_ACTIVE | Server→Engineer/Site | session_id | +| STOP_SESSION | 任意→Server / Server→Node | session_id, reason | +| SESSION_STATS | Engineer/Site→Server | cumulative counters | +| HEARTBEAT | 双向 | timestamp/status | +| REBOOTSTRAP_REQUIRED | Server→Node | config_version, reason | + +错误码基线 必须 包含:SERVER_UNREACHABLE、JOIN_TOKEN_INVALID、NODE_AUTH_FAILED、OVERLAY_LOCAL_CONFLICT、ENGINEER_SESSION_EXISTS、SITE_OFFLINE、CIDR_INVALID、CIDR_LOCAL_CONFLICT、CIDR_OVERLAY_CONFLICT、SITE_NO_ROUTE、NETSTACK_UNAVAILABLE、FLOW_LIMIT_REACHED、SESSION_TIMEOUT、SESSION_INJECT_FAILED。 + +### 需求: R12 路由冲突、异常与恢复 + +Engineer 本地 CIDR 冲突算法 必须 使用 net/netip 做真正的 Prefix overlap(不使用字符串比较):收集 Windows 有效路由和所有非 RemLink 直连前缀;默认路由 0.0.0.0/0 不作为冲突依据;任何更具体的现有本地/VPN 路由只要与 Remote CIDR 有重叠即拒绝。 + +Reconcile 规则: + +- Engineer 启动时检查 RemLink Adapter 上由自身记录创建但没有对应 Active Session 的 Remote Routes,删除残留。 +- Site 启动时初始化/重建 userspace SubnetGateway;不检查、不创建、不删除任何 Windows NAT。 +- Site 清理上次异常退出遗留的 userspace flow/session 状态;Windows 系统路由、NAT、Forwarding 不需要恢复。 +- Wintun Adapter 可复用,不要求每次退出删除;卸载/显式清理时再删除。 + +Server 重启后所有 Remote Session 必须 直接标记 CLOSED,不实现透明 Session Resume;Node 的 wireguard-go/Control 自动重连,Engineer 用户重新点击连接,Site userspace flow 随 Session 关闭并清理。 + +网络切换(Wi-Fi→网线/热点)时由 WireGuard 正常 roaming/重新握手处理;客户端只配置 Server 固定 Endpoint,Server Peer Endpoint 由 WireGuard 根据握手学习;Control WebSocket 断线使用指数退避重连。 + +### 需求: R13 日志、统计与可观测性 + +日志模块 必须 分为:CORE、BOOTSTRAP、WG、IPAM、CONTROL、SESSION、ROUTE、NETSTACK、TUN、SUBNET、SYSTEM。高频 packet 日志默认关闭;DEBUG 也禁止逐包打印 Payload,只允许限速采样(防止 PLC 下载/RDP 时写满磁盘)。 + +Session 流量统计 必须 以 Engineer 视角定义:Upload=Engineer→Site LAN,Download=Site LAN→Engineer;Engineer PacketMux 在拦截 Remote 出包时累计上传,Engineer SessionListener 在校验并注入 Remote Reply 前累计下载;每 5 秒向 Server 上报累计值。Server 不为了统计进入 WireGuard packet path;节点层 WireGuard 总流量可由 wgctrl 读取作为辅助指标。 + +### 需求: R14 安全边界 + +- WireGuard 负责公网数据通道加密、认证和完整性;RemLink 不重复实现。 +- 每个 Node 独立 WireGuard KeyPair;PrivateKey 不上传 Server。 +- Server WireGuard Peer 只允许该 Node 的 /32 Overlay IP,防止节点伪造其他 Overlay Source。 +- Engineer 与 Site 的 Session UDP Listener 都只绑定 Overlay 地址,并按方向校验 SessionID、Outer Source、Inner Source/Target CIDR、Inner Destination。 +- Join Token 仅用于首次加入;NodeToken 用于应用层身份。 +- v1 按需求不实现 HTTPS;公网暴露时可由外部反向代理/防火墙补充,但不是 v1 内核功能。 +- 绝不自动关闭 Windows Firewall;如需要规则,只创建带 RemLink 前缀、可识别且可回滚的规则。 + +### 需求: R15 项目结构、关键接口与依赖管理 + +Monorepo 结构 必须 为: + +``` +remlink/ +├─ cmd/ +│ ├─ server/ +│ ├─ engineer/ +│ └─ site/ +├─ internal/ +│ ├─ model/ +│ ├─ protocol/ +│ ├─ config/ +│ ├─ identity/ +│ ├─ ipam/ +│ ├─ control/ +│ ├─ session/ +│ ├─ subnet/ +│ │ ├─ header.go +│ │ ├─ sender.go +│ │ ├─ listener.go +│ │ └─ validator.go +│ ├─ overlay/ +│ │ ├─ clientwg/ +│ │ │ ├─ device.go +│ │ │ ├─ muxtun.go +│ │ │ └─ adapter.go +│ │ └─ serverwg/ +│ ├─ platform/ +│ │ ├─ windows/ +│ │ │ ├─ route/ +│ │ │ ├─ netinfo/ +│ │ │ ├─ socket/ +│ │ │ └─ dpapi/ +│ │ └─ linux/ +│ │ └─ netlink/ +│ ├─ subnetgateway/ +│ │ ├─ netstack/ +│ │ ├─ tcprelay/ +│ │ ├─ udprelay/ +│ │ └─ pingrelay/ +│ ├─ database/ +│ ├─ logging/ +│ └─ stats/ +├─ frontend/ +│ ├─ server/ +│ └─ engineer/ +├─ deploy/docker/ +└─ docs/ +``` + +关键接口 必须 为: + +```go +type RouteManager interface { + AddRemote(prefix netip.Prefix, ifIndex uint32) error + RemoveRemote(prefix netip.Prefix) error + Conflicts(prefix netip.Prefix) ([]RouteConflict, error) + Lookup(dst netip.Addr) (RouteInfo, error) +} + +type SubnetGateway interface { + Prepare(ctx context.Context, cfg SessionConfig) error + InjectIPv4(ctx context.Context, sessionID uint64, packet []byte) error + CloseSession(ctx context.Context, sessionID uint64) error +} + +type SessionTransport interface { + SendIPv4(sessionID uint64, peer netip.Addr, packet []byte) error +} +``` + +MuxTun 约束:必须实现所固定 wireguard-go 版本的 tun.Device 接口,go.mod 锁定 wireguard-go commit/version,升级前先跑 MuxTun Windows POC;base Device 的 Name/MTU/Events/BatchSize 按原语义代理;Read 方向负责 OS→WireGuard 分类;Write 方向负责 WireGuard→Windows 原样写入(Engineer SessionReceiver 的 Remote Reply 也通过统一 Wintun inbound writer 注入;Site netstack 数据不注入 Windows Wintun);Close 必须可幂等,并使所有 goroutine 退出。 + +依赖管理:所有 Go module、NPM package、Wintun DLL、gVisor commit 均固定版本/commit,禁止生产构建使用 latest 浮动依赖(gVisor netstack API 不保证稳定,升级必须经过 POC);Wintun DLL 使用 go:embed 打入 EXE,首次运行释放到当前角色 EXE 所在目录,不得通过 `%ProgramData%` 在 Engineer 与 Site 之间共享;发布包提供 THIRD_PARTY_NOTICES(WireGuard/Wintun/gVisor 及参考复用代码许可证,如直接采用 Tailscale 代码片段遵循其 BSD-3-Clause);Windows Engineer 与 Site 都要求管理员权限(创建 Wintun、配置接口/路由;Site v1 不修改 WinNAT 或 IP Forwarding)。 + +### 需求: R16 阶段化开发与验收 + +开发 必须 按 Phase 0–10 顺序执行(详见 tasks.md),每阶段通过明确验收后才进入下一阶段;先完成 Phase 1–7 网络 Gate,再投入完整 GUI。Gate A–D 为关键关卡: + +- Gate A:wireguard-go 能通过 MuxTun 使用唯一 Wintun,正常 Overlay 通信。 +- Gate B:Remote Packet 被 MuxTun 截获后,普通 UDP Socket 的外层 Overlay Packet 能通过同一 Wintun 再进入 WireGuard,不死锁、不无限循环。 +- Gate C:Site 收到 Session Raw IPv4 后能注入 gVisor netstack;TCP/UDP Forwarder 能建立 host socket 到现场目标并完成双向数据搬运。 +- Gate D:gVisor netstack 能把返回数据重新构造成 Engineer 看到的原始目标 IP 流,并通过 Session UDP 反向封装回 Engineer;Ping Relay 能正确返回 ICMP Echo Reply。 + +最终验收 必须 通过规格书第 19 章 T01–T18 全部场景(详见 checklist.md)。测试 TCP/102、TCP/502、HTTP、RDP、ICMP Echo、UDP 的目的是证明通用 TCP/UDP/Ping userspace gateway 成立;生产代码中禁止出现 S7Proxy、ModbusProxy、HTTPProxy 等应用协议专用模块;PingRelay 是唯一明确的 ICMP Echo 诊断模块。 + +## 修改需求 + +无(绿地项目)。 + +## 删除需求 + +无(绿地项目)。 diff --git a/specs/tasks.md b/specs/tasks.md new file mode 100644 index 0000000..5ebca84 --- /dev/null +++ b/specs/tasks.md @@ -0,0 +1,90 @@ +# 开发任务 + +> 对应规格书第 18 章分阶段开发计划。执行方式:不要一次性写完整个项目;网络数据面 POC 先于完整 GUI;每一阶段通过明确验收后再进入下一阶段。任何"为了能跑"而改变核心路由模型的实现必须先修改 spec,不允许隐藏偏离。 + +- [x] Phase 0: 仓库与基础模型 + - [x] 建立 Go monorepo 目录结构(cmd/server、cmd/engineer、cmd/site、internal/*、frontend/、deploy/docker/、docs/) + - [x] internal/logging:slog + 成熟滚动文件库,11 个模块分类(CORE/BOOTSTRAP/WG/IPAM/CONTROL/SESSION/ROUTE/NETSTACK/TUN/SUBNET/SYSTEM),高频 packet 日志默认关闭 + - [x] internal/config:YAML 配置加载(Engineer/Site 支持首次注册 Join Token 便捷配置,参考规格书附录 B) + - [x] internal/protocol:SessionHeader(RMLK,20 字节头)编解码、Control WebSocket 消息类型定义、14 个错误码常量(以权威规格实际枚举为准) + - [x] CI 基础:go build / go test / go vet;不写 GUI + - [x] 验收:monorepo 可构建,协议模型单元测试通过 + +- [ ] Phase 1: 单 Wintun + wireguard-go POC(Gate A) + - [x] internal/platform/windows:Wintun Adapter Manager(创建/复用名为 RemLink 的 Wintun,配置 Overlay IP/Prefix/MTU 1280) + - [x] internal/overlay/clientwg/muxtun.go:MuxTun 实现 tun.Device(本阶段为直通模式,Read/Write 代理真实 Wintun,Name/MTU/Events/BatchSize 按原语义代理,Close 幂等) + - [x] internal/overlay/clientwg/device.go + adapter.go:wireguard-go Device 集成(go.mod 锁定 wireguard-go commit) + - [x] Wintun DLL go:embed 并首次运行释放到 Engineer/Site 各自 EXE 所在目录 + - [x] 搭建手动配置的测试 Server wg0(POC 配置与操作手册已提供;真实主机执行计入 Gate A) + - [ ] 验收(Gate A):两个 Windows 节点通过 Server ping 对方 Overlay IP;系统中只有一块 RemLink 虚拟网卡 + +- [x] Phase 2: Server IPAM + Bootstrap + - [x] internal/database:SQLite + migration 工具化(settings/nodes/sessions/session_cidrs/session_stats/event_logs 六张表) + - [x] Bootstrap API:GET /api/v1/server/info、POST /api/v1/bootstrap/register、POST /api/v1/bootstrap/config + - [x] Join Token 管理(可轮换,存 settings) + - [x] internal/ipam:地址分配/持久化/释放/手动修改(Server 地址唯一权威) + - [x] internal/overlay/serverwg:wgctrl 创建 wg0(10.88.0.1/16,ListenPort 51820)、Peer 动态编排(/32 AllowedIPs)、IPv4 forwarding + wg0→wg0 放行、不做 SNAT + - [x] Windows 端 internal/identity:Node Identity Store(NodeID/NodeName/WG KeyPair/NodeToken),敏感字段 DPAPI 落盘 + - [x] 验收:10 个模拟 Node 注册,地址唯一且重启不变;Peer 动态创建/删除测试通过 + +- [x] Phase 3: Control Plane + - [x] internal/control:Overlay Control WebSocket Hub(ws://10.88.0.1:7001/control,仅 Overlay 监听) + - [x] HELLO/WELCOME 握手、HEARTBEAT(5s 间隔;ONLINE ≤15s / UNSTABLE 15–30s / OFFLINE >30s) + - [x] NODE_LIST 下发 Engineer(Site 在线列表:Name、Overlay IP、Online、RemoteSubnetCapability、LastSeen) + - [x] 能力上报(capabilities、OS、版本、netstack 状态、flow capacity) + - [x] 断线指数退避重连(1s, 2s, 5s, 10s, 30s 上限) + - [x] 验收:Control Hub 集成测试验证节点上下线状态传播与 Engineer Site 列表 + +- [ ] Phase 4: Engineer PacketMux POC + - [x] MuxTun Read 出方向分类:Overlay CIDR → wireguard-go;Remote CIDR → SubnetSender 有界队列;其他 → 丢弃 + 限速日志 + - [x] internal/platform/windows/route:RouteManager(AddRemote/RemoveRemote/Conflicts/Lookup,带 ownership metadata) + - [x] Engineer 本地 CIDR 冲突检查(net/netip prefix overlap,收集非 RemLink 直连/静态/VPN 前缀;0.0.0.0/0 不作为冲突依据) + - [ ] 验收:给 Wintun 添加测试 Remote CIDR 路由后能截获完整 IPv4 Packet,Overlay 普通流量不受影响 + +- [ ] Phase 5: 同网卡 UDP 再入路径(Gate B) + - [x] internal/subnet/sender.go:SubnetSender(普通 UDP Socket 绑定 Engineer Overlay IP → Site OverlayIP:6200,不手写外层 IP/UDP Header) + - [x] internal/subnet/header.go + validator.go:SessionHeader 编解码与双向收包校验(Magic/Version/SessionID/PayloadLen/外层源/内层源目 CIDR) + - [x] internal/subnet/listener.go:Engineer 端 Session UDP Listener(仅绑定 OverlayIP:6200) + - [x] MuxTun 并发模型:有界 channel + 独立 Sender goroutine、Read 不阻塞、全 Remote batch 时继续读取防 busy loop、统一 Wintun writer 序列化、buffer pool + - [ ] 验收(Gate B):外层 UDP 再入同一 Wintun 无死锁/无限循环,经 wireguard-go 发往 Server + +- [ ] Phase 6: Site gVisor netstack POC(Gate C) + - [x] internal/subnetgateway/netstack:gVisor netstack 集成(pinned commit,channel.Endpoint,InjectInbound 入 / Read 出) + - [x] internal/subnetgateway/tcprelay:TCP Forwarder(接收 SYN → host net.Dial("tcp", target) → io.CopyBuffer 双向搬运) + - [x] internal/subnetgateway/udprelay:UDP flow mapping(SessionID + 五元组,host UDPConn,idle timeout GC,默认 60s 可配置) + - [x] Site 端 Session UDP Listener(仅 OverlayIP:6200)+ SubnetGateway.InjectIPv4 接入 + - [ ] 验收(Gate C):TCP Forwarder 通过 host net.Dial 连接测试目标;UDP Forwarder 双向收发 + +- [ ] Phase 7: 双向 Session 回传 + Ping Gate(Gate D,关键 Gate) + - [x] netstack egress Raw IPv4 → SessionTransport 对称 SessionHeader 封装(Outer SRC=Site Overlay IP → DST=Engineer Overlay IP)回传 Engineer + - [x] Engineer SessionListener 校验(SessionID/Outer Source/Inner Source CIDR/Inner Destination)后经统一 Wintun inbound writer 注入 + - [x] internal/subnetgateway/pingrelay:ICMP Echo Relay(x/net/icmp 或受控系统 ping;保留原 ID/Sequence 构造 Echo Reply) + - [ ] 验收(Gate D):TCP/UDP 往返闭环;Engineer ping 现场 IP 得到正确 Echo Reply(T07/T08/T09 场景) + +- [ ] Phase 8: 完整 Session 状态机 + - [x] Server Session Manager:CREATING/PREPARING_SITE/READY/ACTIVE/STOPPING/CLOSED/FAILED 全状态流转 + - [x] 全链路消息:CREATE_SESSION → PREPARE_SESSION → PREPARE_RESULT → SESSION_CONFIG → ROUTES_READY → SESSION_ACTIVE;STOP_SESSION 双向 + - [x] Site PREPARE:每 CIDR Windows Route Lookup(DIRECT/ROUTED/DEFAULT_ONLY/NO_ROUTE/OVERLAY_CONFLICT)+ netstack capability + flow capacity(TCP 2048/UDP 4096 可配置)检查 + - [x] 错误码全量落地(含 SESSION_TIMEOUT/FLOW_LIMIT_REACHED/NETSTACK_UNAVAILABLE/SESSION_INJECT_FAILED) + - [x] SESSION_STATS:Engineer 视角 Upload/Download 累计,每 5 秒上报 Server 并持久化 + - [x] Reconcile:Engineer 启动清理残留 Remote Route;Site 启动重建 SubnetGateway 并清理遗留 flow;Server 重启 Session 全部 CLOSED + - [ ] 验收:T03/T04/T05/T13/T16 场景通过 + +- [ ] Phase 9: Engineer GUI + Server Web UI + - [x] Engineer Wails GUI(frontend/engineer + Vue 3):Server/Overlay/Control 状态、Site 列表、多 CIDR 输入、本地冲突预检、Session 详情(SessionID/流量/包数/时延/日志)、断开按钮;Active 时禁止选择第二个 Site + - [x] Server Web UI(frontend/server + Vue 3 + Vite + Go embed):Dashboard/Nodes/Sessions/Network/Logs 五页面 + - [x] Admin Web API 全量实现(nodes/sessions/network/logs 的 CRUD 与强制断开;可选单一 Admin Token) + - [ ] 验收:UI 操作可驱动完整业务流程;UI 中不复制网络业务逻辑 + +- [ ] Phase 10: 多节点与重复现场网段 + - [x] 多 Engineer 多 Site 并发在线与会话(自动化状态机测试完成;真实四节点证据仍归 T11/T12) + - [x] 重复现场网段隔离验证:SessionID + Engineer Overlay IP 区分(实现级隔离测试完成;T11/T12 仍待真实环境) + - [ ] 端到端验收测试 T01–T18 全量执行(见 checklist.md) + - [x] 打包与部署:deploy/docker Compose 基线(仅 NET_ADMIN、/dev/net/tun、Preflight 检查)、THIRD_PARTY_NOTICES + - [ ] 验收:Engineer-A→Site-A 与 Engineer-B→Site-B 同时访问相同 192.168.13.0/24 互不串流 + +# 任务依赖 + +- Phase 0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10(规格书要求每阶段通过验收后进入下一阶段) +- 例外:Phase 1 的 Windows 侧 POC 与 Phase 2 的 Server 侧可部分并行(Phase 1 验收用手动配置的 kernel WireGuard Server) +- Gate A=Phase 1 验收、Gate B=Phase 5 验收、Gate C=Phase 6 验收、Gate D=Phase 7 验收;Gate D 是全项目最重要关卡,未通过前不投入 Phase 9 GUI diff --git a/third_party/wintun/0.14.1/LICENSE.txt b/third_party/wintun/0.14.1/LICENSE.txt new file mode 100644 index 0000000..9ed5018 --- /dev/null +++ b/third_party/wintun/0.14.1/LICENSE.txt @@ -0,0 +1,84 @@ +Prebuilt Binaries License +------------------------- + +1. DEFINITIONS. "Software" means the precise contents of the "wintun.dll" + files that are included in the .zip file that contains this document as + downloaded from wintun.net/builds. + +2. LICENSE GRANT. WireGuard LLC grants to you a non-exclusive and + non-transferable right to use Software for lawful purposes under certain + obligations and limited rights as set forth in this agreement. + +3. RESTRICTIONS. Software is owned and copyrighted by WireGuard LLC. It is + licensed, not sold. Title to Software and all associated intellectual + property rights are retained by WireGuard. You must not: + a. reverse engineer, decompile, disassemble, extract from, or otherwise + modify the Software; + b. modify or create derivative work based upon Software in whole or in + parts, except insofar as only the API interfaces of the "wintun.h" file + distributed alongside the Software (the "Permitted API") are used; + c. remove any proprietary notices, labels, or copyrights from the Software; + d. resell, redistribute, lease, rent, transfer, sublicense, or otherwise + transfer rights of the Software without the prior written consent of + WireGuard LLC, except insofar as the Software is distributed alongside + other software that uses the Software only via the Permitted API; + e. use the name of WireGuard LLC, the WireGuard project, the Wintun + project, or the names of its contributors to endorse or promote products + derived from the Software without specific prior written consent. + +4. LIMITED WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTY OF + ANY KIND. WIREGUARD LLC HEREBY EXCLUDES AND DISCLAIMS ALL IMPLIED OR + STATUTORY WARRANTIES, INCLUDING ANY WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE, QUALITY, NON-INFRINGEMENT, TITLE, RESULTS, + EFFORTS, OR QUIET ENJOYMENT. THERE IS NO WARRANTY THAT THE PRODUCT WILL BE + ERROR-FREE OR WILL FUNCTION WITHOUT INTERRUPTION. YOU ASSUME THE ENTIRE + RISK FOR THE RESULTS OBTAINED USING THE PRODUCT. TO THE EXTENT THAT + WIREGUARD LLC MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, + THE SCOPE AND DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER + SUCH LAW. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND + WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR + A PARTICULAR PURPOSE OR NON-INFRINGEMENT ARE DISCLAIMED, EXCEPT TO THE + EXTENT THAT THESE DISCLAIMERS ARE HELD TO BE LEGALLY INVALID. + +5. LIMITATION OF LIABILITY. To the extent not prohibited by law, in no event + WireGuard LLC or any third-party-developer will be liable for any lost + revenue, profit or data or for special, indirect, consequential, incidental + or punitive damages, however caused regardless of the theory of liability, + arising out of or related to the use of or inability to use Software, even + if WireGuard LLC has been advised of the possibility of such damages. + Solely you are responsible for determining the appropriateness of using + Software and accept full responsibility for all risks associated with its + exercise of rights under this agreement, including but not limited to the + risks and costs of program errors, compliance with applicable laws, damage + to or loss of data, programs or equipment, and unavailability or + interruption of operations. The foregoing limitations will apply even if + the above stated warranty fails of its essential purpose. You acknowledge, + that it is in the nature of software that software is complex and not + completely free of errors. In no event shall WireGuard LLC or any + third-party-developer be liable to you under any theory for any damages + suffered by you or any user of Software or for any special, incidental, + indirect, consequential or similar damages (including without limitation + damages for loss of business profits, business interruption, loss of + business information or any other pecuniary loss) arising out of the use or + inability to use Software, even if WireGuard LLC has been advised of the + possibility of such damages and regardless of the legal or quitable theory + (contract, tort, or otherwise) upon which the claim is based. + +6. TERMINATION. This agreement is affected until terminated. You may + terminate this agreement at any time. This agreement will terminate + immediately without notice from WireGuard LLC if you fail to comply with + the terms and conditions of this agreement. Upon termination, you must + delete Software and all copies of Software and cease all forms of + distribution of Software. + +7. SEVERABILITY. If any provision of this agreement is held to be + unenforceable, this agreement will remain in effect with the provision + omitted, unless omission would frustrate the intent of the parties, in + which case this agreement will immediately terminate. + +8. RESERVATION OF RIGHTS. All rights not expressly granted in this agreement + are reserved by WireGuard LLC. For example, WireGuard LLC reserves the + right at any time to cease development of Software, to alter distribution + details, features, specifications, capabilities, functions, licensing + terms, release dates, APIs, ABIs, general availability, or other + characteristics of the Software.