初版功能完成
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
Reference in New Issue
Block a user