初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+106
View File
@@ -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
}
+132
View File
@@ -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
}
+37
View File
@@ -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
}
+162
View File
@@ -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)})
}