platform: modularize api/gui, add docs-tests-web foundation, and refresh root config

This commit is contained in:
beckline
2026-03-26 22:40:54 +03:00
parent 0e2d7f61ea
commit 6a56d734c2
562 changed files with 70151 additions and 16423 deletions

View File

@@ -0,0 +1,410 @@
package transportcfg
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
func ResolvePrimaryExecStart(client Client, singBoxConfigPath, phoenixDefaultConfigPath string) (string, string, error) {
if manual := strings.TrimSpace(ConfigString(client.Config, "exec_start")); manual != "" {
return manual, "manual", nil
}
switch strings.ToLower(strings.TrimSpace(client.Kind)) {
case KindSingBox:
cmd, err := BuildSingBoxCommand(client, singBoxConfigPath)
if err != nil {
return "", "", err
}
return cmd, "template:singbox", nil
case KindDNSTT:
cmd, err := BuildDNSTTClientCommand(client)
if err != nil {
return "", "", err
}
return cmd, "template:dnstt", nil
case KindPhoenix:
cmd, err := BuildPhoenixClientCommand(client, phoenixDefaultConfigPath)
if err != nil {
return "", "", err
}
return cmd, "template:phoenix", nil
default:
return "", "", fmt.Errorf("no command template for transport kind %q", client.Kind)
}
}
func BuildSingBoxCommand(client Client, configPath string) (string, error) {
bin := strings.TrimSpace(ConfigString(client.Config, "singbox_bin"))
if bin == "" {
bin = strings.TrimSpace(ConfigString(client.Config, "bin"))
}
if bin == "" {
var err error
bin, err = ResolveBinary(client.Config, "singbox", "/usr/bin/sing-box", "/usr/local/bin/sing-box", "sing-box")
if err != nil {
return "", err
}
} else if err := ValidateRequiredBinary(client.Config, "singbox", bin); err != nil {
return "", err
}
args := []string{bin, "run", "-c", strings.TrimSpace(configPath)}
if extra := strings.TrimSpace(ConfigString(client.Config, "singbox_extra_args")); extra != "" {
args = append(args, strings.Fields(extra)...)
}
return ShellJoinArgs(args, ShellQuoteArg), nil
}
func BuildPhoenixClientCommand(client Client, defaultConfigPath string) (string, error) {
bin := strings.TrimSpace(ConfigString(client.Config, "phoenix_bin"))
if bin == "" {
bin = strings.TrimSpace(ConfigString(client.Config, "bin"))
}
if bin == "" {
var err error
bin, err = ResolveBinary(client.Config, "phoenix", "/usr/local/bin/phoenix-client", "/usr/bin/phoenix-client", "phoenix-client")
if err != nil {
return "", err
}
} else if err := ValidateRequiredBinary(client.Config, "phoenix", bin); err != nil {
return "", err
}
configPath := strings.TrimSpace(ConfigString(client.Config, "phoenix_config_path"))
if configPath == "" {
configPath = strings.TrimSpace(ConfigString(client.Config, "config_path"))
}
if configPath == "" {
configPath = strings.TrimSpace(defaultConfigPath)
}
args := []string{bin, "-config", configPath}
if extra := strings.TrimSpace(ConfigString(client.Config, "phoenix_extra_args")); extra != "" {
args = append(args, strings.Fields(extra)...)
}
return ShellJoinArgs(args, ShellQuoteArg), nil
}
func BuildDNSTTClientCommand(client Client) (string, error) {
bin := strings.TrimSpace(ConfigString(client.Config, "dnstt_bin"))
if bin == "" {
bin = strings.TrimSpace(ConfigString(client.Config, "bin"))
}
if bin == "" {
var err error
bin, err = ResolveBinary(client.Config, "dnstt", "/usr/local/bin/dnstt-client", "/usr/bin/dnstt-client", "dnstt-client")
if err != nil {
return "", err
}
} else if err := ValidateRequiredBinary(client.Config, "dnstt", bin); err != nil {
return "", err
}
if rawArgs := strings.TrimSpace(ConfigString(client.Config, "dnstt_args")); rawArgs != "" {
args := append([]string{bin}, strings.Fields(rawArgs)...)
return ShellJoinArgs(args, ShellQuoteArg), nil
}
resolverMode := strings.ToLower(strings.TrimSpace(ConfigString(client.Config, "resolver_mode")))
dohURL := strings.TrimSpace(ConfigString(client.Config, "doh_url"))
dotAddr := strings.TrimSpace(ConfigString(client.Config, "dot_addr"))
udpAddr := strings.TrimSpace(ConfigString(client.Config, "udp_addr"))
if dohURL == "" {
dohURL = strings.TrimSpace(ConfigString(client.Config, "resolver_url"))
}
if dotAddr == "" {
dotAddr = strings.TrimSpace(ConfigString(client.Config, "resolver_addr"))
}
if udpAddr == "" {
udpAddr = strings.TrimSpace(ConfigString(client.Config, "resolver_addr"))
}
if resolverMode == "" {
switch {
case dohURL != "":
resolverMode = "doh"
case dotAddr != "":
resolverMode = "dot"
case udpAddr != "":
resolverMode = "udp"
default:
resolverMode = "doh"
}
}
args := []string{bin}
switch resolverMode {
case "doh":
if dohURL == "" {
return "", fmt.Errorf("dnstt template requires config.doh_url for resolver_mode=doh")
}
args = append(args, "-doh", dohURL)
case "dot":
if dotAddr == "" {
return "", fmt.Errorf("dnstt template requires config.dot_addr or config.resolver_addr for resolver_mode=dot")
}
args = append(args, "-dot", dotAddr)
case "udp":
if udpAddr == "" {
return "", fmt.Errorf("dnstt template requires config.udp_addr or config.resolver_addr for resolver_mode=udp")
}
args = append(args, "-udp", udpAddr)
default:
return "", fmt.Errorf("dnstt template resolver_mode must be doh|dot|udp")
}
pubkey := strings.TrimSpace(ConfigString(client.Config, "pubkey"))
if pubkey == "" {
pubkey = strings.TrimSpace(ConfigString(client.Config, "pubkey_hex"))
}
pubkeyFile := strings.TrimSpace(ConfigString(client.Config, "pubkey_file"))
if pubkey == "" && pubkeyFile == "" {
return "", fmt.Errorf("dnstt template requires config.pubkey or config.pubkey_file")
}
if pubkeyFile != "" {
args = append(args, "-pubkey-file", pubkeyFile)
} else {
args = append(args, "-pubkey", pubkey)
}
utls := strings.TrimSpace(ConfigString(client.Config, "utls"))
if utls != "" {
args = append(args, "-utls", utls)
}
if extra := strings.TrimSpace(ConfigString(client.Config, "dnstt_extra_args")); extra != "" {
args = append(args, strings.Fields(extra)...)
}
domain := strings.TrimSpace(ConfigString(client.Config, "domain"))
if domain == "" {
return "", fmt.Errorf("dnstt template requires config.domain")
}
localAddr := strings.TrimSpace(ConfigString(client.Config, "local_addr"))
if localAddr == "" {
localAddr = "127.0.0.1:7000"
}
args = append(args, domain, localAddr)
return ShellJoinArgs(args, ShellQuoteArg), nil
}
func DefaultConfigPath(clientID, fileName string, sanitizeID func(string) string) string {
id := strings.TrimSpace(clientID)
if sanitizeID != nil {
id = sanitizeID(id)
}
if strings.TrimSpace(id) == "" {
id = "client"
}
return filepath.Join("/etc/selective-vpn/transports", id, fileName)
}
func ResolveBinary(cfg map[string]any, kind string, systemCandidates ...string) (string, error) {
candidates := make([]string, 0, len(systemCandidates)+2)
profile := PackagingProfile(cfg)
switch profile {
case "bundled":
root := strings.TrimSpace(ConfigString(cfg, "bin_root"))
if root == "" {
root = "/opt/selective-vpn/bin"
}
if name := BinaryName(kind); name != "" {
candidates = append(candidates, filepath.Join(root, name))
}
if ConfigBool(cfg, "packaging_system_fallback") || !ConfigHasKey(cfg, "packaging_system_fallback") {
candidates = append(candidates, systemCandidates...)
}
default:
candidates = append(candidates, systemCandidates...)
}
bin, found := FirstExistingBinaryCandidate(candidates...)
if bin == "" {
return "", fmt.Errorf("no binary candidates configured for transport kind %q", kind)
}
if ConfigBool(cfg, "require_binary") && !found {
return "", fmt.Errorf("required %s binary not found (profile=%s)", kind, profile)
}
return bin, nil
}
func ValidateRequiredBinary(cfg map[string]any, kind, bin string) error {
if !ConfigBool(cfg, "require_binary") {
return nil
}
if BinaryExists(bin) {
return nil
}
return fmt.Errorf("required %s binary not found: %s", kind, strings.TrimSpace(bin))
}
func PackagingProfile(cfg map[string]any) string {
profile := strings.ToLower(strings.TrimSpace(ConfigString(cfg, "packaging_profile")))
switch profile {
case "", "system":
return "system"
case "bundled", "bundle":
return "bundled"
default:
return "system"
}
}
func BinaryName(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "singbox":
return "sing-box"
case "dnstt":
return "dnstt-client"
case "phoenix":
return "phoenix-client"
default:
return ""
}
}
func FirstExistingBinaryCandidate(candidates ...string) (string, bool) {
firstNonEmpty := ""
for _, candidate := range candidates {
c := strings.TrimSpace(candidate)
if c == "" {
continue
}
if firstNonEmpty == "" {
firstNonEmpty = c
}
if resolved, ok := FindBinaryPath(c); ok {
return resolved, true
}
}
return firstNonEmpty, false
}
func FindBinaryPath(candidate string) (string, bool) {
c := strings.TrimSpace(candidate)
if c == "" {
return "", false
}
if strings.ContainsRune(c, '/') {
if _, err := os.Stat(c); err == nil {
return c, true
}
return "", false
}
path, err := exec.LookPath(c)
if err != nil {
return "", false
}
return path, true
}
func BinaryExists(candidate string) bool {
_, ok := FindBinaryPath(candidate)
return ok
}
func ShellJoinArgs(args []string, quoteArg func(string) string) string {
q := quoteArg
if q == nil {
q = ShellQuoteArg
}
out := make([]string, 0, len(args))
for _, arg := range args {
arg = strings.TrimSpace(arg)
if arg == "" {
continue
}
out = append(out, q(arg))
}
return strings.Join(out, " ")
}
func ShellQuoteArg(in string) string {
s := strings.ReplaceAll(in, "'", "'\"'\"'")
return "'" + s + "'"
}
func BuildSSHOverlayCommand(cfg map[string]any, configInt func(map[string]any, string, int) int, quoteArg func(string) string) (string, error) {
intGetter := configInt
if intGetter == nil {
intGetter = ConfigInt
}
q := quoteArg
if q == nil {
q = ShellQuoteArg
}
host := strings.TrimSpace(ConfigString(cfg, "ssh_host"))
if host == "" {
return "", fmt.Errorf("config.ssh_host is required for ssh overlay")
}
user := strings.TrimSpace(ConfigString(cfg, "ssh_user"))
if user == "" {
user = "root"
}
socksHost := strings.TrimSpace(ConfigString(cfg, "socks_host"))
if socksHost == "" {
socksHost = "127.0.0.1"
}
socksPort := intGetter(cfg, "socks_port", 1080)
if socksPort <= 0 || socksPort > 65535 {
return "", fmt.Errorf("config.socks_port must be in 1..65535")
}
sshPort := intGetter(cfg, "ssh_port", 22)
if sshPort <= 0 || sshPort > 65535 {
return "", fmt.Errorf("config.ssh_port must be in 1..65535")
}
sshBin := strings.TrimSpace(ConfigString(cfg, "ssh_bin"))
if sshBin == "" {
sshBin = "/usr/bin/ssh"
}
args := []string{
sshBin,
"-N",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-D", fmt.Sprintf("%s:%d", socksHost, socksPort),
"-p", strconv.Itoa(sshPort),
}
sshKey := strings.TrimSpace(ConfigString(cfg, "ssh_key"))
if sshKey != "" {
args = append(args, "-i", sshKey)
}
extra := strings.TrimSpace(ConfigString(cfg, "ssh_extra_args"))
if extra != "" {
args = append(args, strings.Fields(extra)...)
}
args = append(args, user+"@"+host)
quoted := make([]string, 0, len(args))
for _, arg := range args {
quoted = append(quoted, q(arg))
}
return strings.Join(quoted, " "), nil
}
func ConfigInt(cfg map[string]any, key string, defaultVal int) int {
if cfg == nil {
return defaultVal
}
raw, ok := cfg[key]
if !ok || raw == nil {
return defaultVal
}
switch v := raw.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return n
}
}
return defaultVal
}

View File

@@ -0,0 +1,86 @@
package transportcfg
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
)
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}
func ReadJSONFiles(rootDir string) ([][]byte, error) {
entries, err := os.ReadDir(rootDir)
if err != nil {
if os.IsNotExist(err) {
return [][]byte{}, nil
}
return nil, err
}
out := make([][]byte, 0, len(entries))
for _, ent := range entries {
if ent.IsDir() {
continue
}
name := strings.ToLower(strings.TrimSpace(ent.Name()))
if !strings.HasSuffix(name, ".json") {
continue
}
path := filepath.Join(rootDir, ent.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
out = append(out, data)
}
return out, nil
}
func SelectRecordCandidate[T any](
records []T,
historyID string,
recordID func(T) string,
eligible func(T) bool,
) (T, bool) {
id := strings.TrimSpace(historyID)
if id != "" {
for _, rec := range records {
if strings.TrimSpace(recordID(rec)) == id {
return rec, eligible(rec)
}
}
var zero T
return zero, false
}
for _, rec := range records {
if eligible(rec) {
return rec, true
}
}
var zero T
return zero, false
}
func DecodeBase64Optional(raw string, exists bool) ([]byte, bool, error) {
if !exists {
return nil, false, nil
}
s := strings.TrimSpace(raw)
if s == "" {
return nil, true, nil
}
data, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, false, err
}
return data, true, nil
}

View File

@@ -0,0 +1,402 @@
package transportcfg
import (
"context"
"encoding/json"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const (
DefaultHealthTimeout = 5 * time.Second
DefaultProbeTimeout = 900 * time.Millisecond
)
type DialRunner func(ctx context.Context, network, address string) (net.Conn, error)
type Endpoint struct {
Host string
Port int
}
func (ep Endpoint) Address() string {
return net.JoinHostPort(ep.Host, strconv.Itoa(ep.Port))
}
type ProbeDeps struct {
Dial DialRunner
HealthTimeout time.Duration
ProbeTimeout time.Duration
NetnsEnabled func(Client) bool
NetnsName func(Client) string
NetnsExecCommand func(Client, string, ...string) (string, []string, error)
RunCommand func(time.Duration, string, ...string) (string, string, int, error)
CommandError func(string, string, string, int, error) error
ShellJoinArgs func([]string) string
ReadFile func(string) ([]byte, error)
ConfigInt func(map[string]any, string, int) int
}
func ProbeClientLatency(client Client, deps ProbeDeps) (int, error) {
healthTimeout := deps.HealthTimeout
if healthTimeout <= 0 {
healthTimeout = DefaultHealthTimeout
}
probeTimeout := deps.ProbeTimeout
if probeTimeout <= 0 {
probeTimeout = DefaultProbeTimeout
}
endpoints := CollectProbeEndpoints(client, deps.ReadFile, deps.ConfigInt)
if len(endpoints) == 0 {
return 0, nil
}
deadline := time.Now().Add(healthTimeout)
var firstErr error
for _, ep := range endpoints {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
if remaining > probeTimeout {
remaining = probeTimeout
}
ms, err := ProbeDialEndpoint(client, ep, remaining, deps)
if err == nil && ms >= 0 {
return ms, nil
}
if firstErr == nil && err != nil {
firstErr = err
}
}
if firstErr != nil {
return 0, firstErr
}
return 0, nil
}
func ProbeDialEndpoint(client Client, ep Endpoint, timeout time.Duration, deps ProbeDeps) (int, error) {
probeTimeout := deps.ProbeTimeout
if probeTimeout <= 0 {
probeTimeout = DefaultProbeTimeout
}
if timeout <= 0 {
timeout = probeTimeout
}
if deps.NetnsEnabled != nil && deps.NetnsEnabled(client) {
if ms, err := ProbeDialEndpointInNetns(client, ep, timeout, deps); err == nil {
return ms, nil
}
// Fall back to host probe for compatibility.
}
return ProbeDialEndpointHost(ep, timeout, deps.Dial)
}
func ProbeDialEndpointHost(ep Endpoint, timeout time.Duration, dial DialRunner) (int, error) {
host := strings.TrimSpace(ep.Host)
if addr, err := netip.ParseAddr(strings.TrimSpace(ep.Host)); err == nil {
host = addr.Unmap().String()
}
if host == "" || ep.Port <= 0 || ep.Port > 65535 {
return 0, fmt.Errorf("invalid endpoint")
}
if timeout <= 0 {
timeout = DefaultProbeTimeout
}
d := dial
if d == nil {
d = func(ctx context.Context, network, address string) (net.Conn, error) {
var nd net.Dialer
return nd.DialContext(ctx, network, address)
}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
start := time.Now()
conn, err := d(ctx, "tcp4", net.JoinHostPort(host, strconv.Itoa(ep.Port)))
if err != nil {
return 0, err
}
_ = conn.Close()
ms := int(time.Since(start).Milliseconds())
if ms < 1 {
ms = 1
}
return ms, nil
}
func ProbeDialEndpointInNetns(client Client, ep Endpoint, timeout time.Duration, deps ProbeDeps) (int, error) {
probeTimeout := deps.ProbeTimeout
if probeTimeout <= 0 {
probeTimeout = DefaultProbeTimeout
}
if timeout <= 0 {
timeout = probeTimeout
}
if deps.NetnsName == nil || deps.NetnsExecCommand == nil || deps.RunCommand == nil {
return 0, fmt.Errorf("netns probe dependencies are not configured")
}
ns := strings.TrimSpace(deps.NetnsName(client))
if ns == "" {
return 0, fmt.Errorf("netns name is empty")
}
script := fmt.Sprintf(
"set -e; t0=$(date +%%s%%3N); exec 3<>/dev/tcp/%s/%d; exec 3>&-; t1=$(date +%%s%%3N); echo $((t1-t0))",
strings.TrimSpace(ep.Host),
ep.Port,
)
name, args, err := deps.NetnsExecCommand(client, ns, "bash", "-lc", script)
if err != nil {
return 0, err
}
start := time.Now()
stdout, stderr, code, runErr := deps.RunCommand(timeout+500*time.Millisecond, name, args...)
if runErr != nil || code != 0 {
cmdErr := deps.CommandError
if cmdErr == nil {
cmdErr = defaultCommandError
}
join := deps.ShellJoinArgs
if join == nil {
join = func(in []string) string { return ShellJoinArgs(in, ShellQuoteArg) }
}
return 0, cmdErr(join(append([]string{name}, args...)), stdout, stderr, code, runErr)
}
val := strings.TrimSpace(stdout)
ms, err := strconv.Atoi(val)
if err != nil || ms <= 0 {
ms = int(time.Since(start).Milliseconds())
}
if ms < 1 {
ms = 1
}
return ms, nil
}
func CollectProbeEndpoints(client Client, readFile func(string) ([]byte, error), configInt func(map[string]any, string, int) int) []Endpoint {
combined := make([]Endpoint, 0, 12)
if strings.ToLower(strings.TrimSpace(client.Kind)) == KindSingBox {
combined = append(combined, CollectSingBoxConfigProbeEndpoints(client, readFile)...)
}
combined = append(combined, CollectConfigProbeEndpoints(client.Config, configInt)...)
return DedupeProbeEndpoints(combined)
}
func CollectConfigProbeEndpoints(cfg map[string]any, configInt func(map[string]any, string, int) int) []Endpoint {
intGetter := configInt
if intGetter == nil {
intGetter = ConfigInt
}
if cfg == nil {
return nil
}
rawHosts := SplitCSV(ConfigString(cfg, "probe_endpoints"))
if len(rawHosts) == 0 {
host := strings.TrimSpace(ConfigString(cfg, "endpoint_host"))
port := intGetter(cfg, "endpoint_port", 443)
if host != "" && port > 0 {
rawHosts = []string{fmt.Sprintf("%s:%d", host, port)}
}
}
fallbackPort := intGetter(cfg, "probe_port", 443)
out := make([]Endpoint, 0, len(rawHosts))
for _, raw := range rawHosts {
if ep, ok := ParseDialEndpoint(raw, fallbackPort); ok {
out = append(out, ep)
}
}
return out
}
func CollectSingBoxConfigProbeEndpoints(client Client, readFile func(string) ([]byte, error)) []Endpoint {
reader := readFile
if reader == nil {
reader = os.ReadFile
}
path := strings.TrimSpace(ConfigString(client.Config, "config_path"))
if path == "" {
path = strings.TrimSpace(ConfigString(client.Config, "singbox_config_path"))
}
if path == "" {
return nil
}
data, err := reader(path)
if err != nil {
return nil
}
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return nil
}
out := make([]Endpoint, 0, 8)
if raw != nil {
CollectProbeEndpointsRecursive(raw, &out)
}
return out
}
func CollectProbeEndpointsRecursive(node any, out *[]Endpoint) {
switch v := node.(type) {
case map[string]any:
fallbackPort := 443
for _, key := range []string{"server_port", "port", "listen_port"} {
if p, ok := ParseInt(v[key]); ok && p > 0 && p <= 65535 {
fallbackPort = p
break
}
}
for _, key := range []string{"server", "address", "host"} {
raw, ok := v[key]
if !ok || raw == nil {
continue
}
if vv, ok := raw.(string); ok {
if ep, ok := ParseDialEndpoint(vv, fallbackPort); ok {
*out = append(*out, ep)
}
}
}
for _, child := range v {
CollectProbeEndpointsRecursive(child, out)
}
case []any:
for _, child := range v {
CollectProbeEndpointsRecursive(child, out)
}
}
}
func ParseDialEndpoint(raw string, fallbackPort int) (Endpoint, bool) {
s := strings.TrimSpace(raw)
if s == "" {
return Endpoint{}, false
}
if strings.Contains(s, "://") {
if u, err := url.Parse(s); err == nil {
host := strings.TrimSpace(u.Hostname())
if host != "" {
port := fallbackPort
if p := u.Port(); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
port = parsed
}
}
if port <= 0 {
port = fallbackPort
}
if port > 0 && port <= 65535 {
return Endpoint{Host: strings.ToLower(host), Port: port}, true
}
}
}
return Endpoint{}, false
}
host := s
port := fallbackPort
if h, p, err := net.SplitHostPort(s); err == nil {
host = strings.TrimSpace(h)
if parsed, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
port = parsed
}
} else if idx := strings.LastIndex(s, ":"); idx > 0 && idx+1 < len(s) && !strings.Contains(s[idx+1:], ":") {
candidateHost := strings.TrimSpace(s[:idx])
candidatePort := strings.TrimSpace(s[idx+1:])
if parsed, err := strconv.Atoi(candidatePort); err == nil {
host = candidateHost
port = parsed
}
}
host = strings.TrimSpace(host)
if host == "" {
return Endpoint{}, false
}
if addr, err := netip.ParseAddr(host); err == nil {
host = addr.Unmap().String()
}
if port <= 0 || port > 65535 {
return Endpoint{}, false
}
return Endpoint{Host: strings.ToLower(host), Port: port}, true
}
func DedupeProbeEndpoints(in []Endpoint) []Endpoint {
if len(in) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]Endpoint, 0, len(in))
for _, ep := range in {
host := strings.ToLower(strings.TrimSpace(ep.Host))
if host == "" || ep.Port <= 0 || ep.Port > 65535 {
continue
}
key := fmt.Sprintf("%s:%d", host, ep.Port)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, Endpoint{Host: host, Port: ep.Port})
}
return out
}
func ParseInt(raw any) (int, bool) {
switch v := raw.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float64:
return int(v), true
case string:
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
func SplitCSV(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
out = append(out, v)
}
return out
}
func defaultCommandError(cmd, stdout, stderr string, code int, err error) error {
if err == nil {
err = fmt.Errorf("exit code %d", code)
}
cmd = strings.TrimSpace(cmd)
stderr = strings.TrimSpace(stderr)
if stderr != "" {
return fmt.Errorf("%s: %w: %s", cmd, err, stderr)
}
stdout = strings.TrimSpace(stdout)
if stdout != "" {
return fmt.Errorf("%s: %w: %s", cmd, err, stdout)
}
return fmt.Errorf("%s: %w", cmd, err)
}

View File

@@ -0,0 +1,210 @@
package transportcfg
import (
"fmt"
"strings"
)
const (
KindSingBox = "singbox"
KindDNSTT = "dnstt"
KindPhoenix = "phoenix"
defaultSingBoxUnitTemplate = "singbox@.service"
)
type Client struct {
ID string
Kind string
Config map[string]any
}
func BackendUnit(client Client) string {
kind := strings.ToLower(strings.TrimSpace(client.Kind))
unit := strings.TrimSpace(ConfigString(client.Config, "unit"))
if kind == KindSingBox {
return resolveSingBoxBackendUnit(client.ID, unit)
}
if unit != "" {
return unit
}
return DefaultBackendUnit(kind)
}
func DefaultBackendUnit(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case KindSingBox:
return defaultSingBoxUnitTemplate
case KindDNSTT:
return "dnstt-client.service"
case KindPhoenix:
return "phoenix.service"
default:
return ""
}
}
func resolveSingBoxBackendUnit(clientID, configuredUnit string) string {
unit := strings.TrimSpace(configuredUnit)
if unit == "" {
unit = defaultSingBoxUnitTemplate
}
if strings.HasSuffix(unit, "@.service") {
instance := sanitizeSystemdInstanceID(clientID)
if instance == "" {
instance = "client"
}
return strings.TrimSuffix(unit, "@.service") + "@" + instance + ".service"
}
return unit
}
func sanitizeSystemdInstanceID(in string) string {
s := strings.ToLower(strings.TrimSpace(in))
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s))
lastDash := false
for i := 0; i < len(s); i++ {
ch := s[i]
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.' {
b.WriteByte(ch)
lastDash = false
continue
}
if ch == '-' {
if lastDash {
continue
}
b.WriteByte('-')
lastDash = true
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func DNSTTSSHTunnelEnabled(client Client) bool {
if strings.ToLower(strings.TrimSpace(client.Kind)) != KindDNSTT {
return false
}
return ConfigBool(client.Config, "ssh_tunnel") || ConfigBool(client.Config, "ssh_overlay")
}
func DNSTTSSHUnit(client Client) string {
unit := strings.TrimSpace(ConfigString(client.Config, "ssh_unit"))
if unit != "" {
return unit
}
return "dnstt-ssh-tunnel.service"
}
func RuntimeMode(cfg map[string]any) string {
mode := strings.ToLower(strings.TrimSpace(ConfigString(cfg, "runtime_mode")))
switch mode {
case "", "exec", "external", "companion":
return "exec"
case "embedded":
return "embedded"
case "sidecar":
return "sidecar"
default:
return mode
}
}
func SystemdActionUnits(client Client, action string) ([]string, string, string) {
unit := BackendUnit(client)
if unit == "" {
return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "systemd unit is required"
}
if !DNSTTSSHTunnelEnabled(client) {
return []string{unit}, "", ""
}
sshUnit := DNSTTSSHUnit(client)
if strings.TrimSpace(sshUnit) == "" {
return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "dnstt ssh tunnel unit is required"
}
switch action {
case "stop":
return []string{unit, sshUnit}, "", ""
default:
return []string{sshUnit, unit}, "", ""
}
}
func SystemdHealthUnits(client Client) ([]string, string, string) {
unit := BackendUnit(client)
if unit == "" {
return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "systemd unit is required"
}
units := []string{unit}
if DNSTTSSHTunnelEnabled(client) {
sshUnit := DNSTTSSHUnit(client)
if strings.TrimSpace(sshUnit) == "" {
return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "dnstt ssh tunnel unit is required"
}
units = append(units, sshUnit)
}
return units, "", ""
}
func ConfigString(cfg map[string]any, key string) string {
if cfg == nil {
return ""
}
raw, ok := cfg[key]
if !ok || raw == nil {
return ""
}
switch v := raw.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func ConfigBool(cfg map[string]any, key string) bool {
if cfg == nil {
return false
}
raw, ok := cfg[key]
if !ok || raw == nil {
return false
}
switch v := raw.(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "1" || s == "true" || s == "yes" || s == "on"
case float64:
return v != 0
case int:
return v != 0
default:
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
return s == "1" || s == "true" || s == "yes" || s == "on"
}
}
func ConfigHasKey(cfg map[string]any, key string) bool {
if cfg == nil {
return false
}
raw, ok := cfg[key]
if !ok || raw == nil {
return false
}
if s, ok := raw.(string); ok {
return strings.TrimSpace(s) != ""
}
return true
}

View File

@@ -0,0 +1,67 @@
package transportcfg
import "testing"
func TestBackendUnitSingBoxUsesInstanceTemplateByDefault(t *testing.T) {
client := Client{
ID: "sg-realnetns",
Kind: KindSingBox,
}
got := BackendUnit(client)
if got != "singbox@sg-realnetns.service" {
t.Fatalf("unexpected singbox unit: %q", got)
}
}
func TestBackendUnitSingBoxTemplateConfigExpandsInstance(t *testing.T) {
client := Client{
ID: "sg-1",
Kind: KindSingBox,
Config: map[string]any{
"unit": "singbox@.service",
},
}
got := BackendUnit(client)
if got != "singbox@sg-1.service" {
t.Fatalf("unexpected template unit: %q", got)
}
}
func TestBackendUnitSingBoxKeepsExplicitNonTemplateUnit(t *testing.T) {
client := Client{
ID: "sg-realnetns",
Kind: KindSingBox,
Config: map[string]any{
"unit": "custom-singbox.service",
},
}
got := BackendUnit(client)
if got != "custom-singbox.service" {
t.Fatalf("unexpected custom unit: %q", got)
}
}
func TestBackendUnitNonSingBoxUnchanged(t *testing.T) {
client := Client{
ID: "dn-1",
Kind: KindDNSTT,
Config: map[string]any{
"unit": "dnstt-custom.service",
},
}
got := BackendUnit(client)
if got != "dnstt-custom.service" {
t.Fatalf("unexpected non-singbox unit: %q", got)
}
}
func TestBackendUnitSingBoxSanitizesInstanceID(t *testing.T) {
client := Client{
ID: " SG Real/NetNS ",
Kind: KindSingBox,
}
got := BackendUnit(client)
if got != "singbox@sg-real-netns.service" {
t.Fatalf("unexpected sanitized singbox unit: %q", got)
}
}

View File

@@ -0,0 +1,100 @@
package transportcfg
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
)
func NormalizeSecretUpdates(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
key := strings.TrimSpace(k)
if key == "" {
continue
}
out[key] = v
}
return out
}
func CloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func EqualStringMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, av := range a {
if bv, ok := b[k]; !ok || av != bv {
return false
}
}
return true
}
func MaskStringMap(in map[string]string, mask string) map[string]string {
if len(in) == 0 {
return nil
}
keys := make([]string, 0, len(in))
for k := range in {
keys = append(keys, k)
}
sort.Strings(keys)
out := make(map[string]string, len(keys))
for _, k := range keys {
out[k] = mask
}
return out
}
func ReadStringMapJSON(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
var raw map[string]string
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
return NormalizeSecretUpdates(raw), nil
}
func WriteStringMapJSON(path string, values map[string]string, dirPerm, filePerm os.FileMode) error {
normalized := NormalizeSecretUpdates(values)
if len(normalized) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil {
return err
}
data, err := json.MarshalIndent(normalized, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, filePerm); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@@ -0,0 +1,82 @@
package transportcfg
import (
"os"
"strings"
"time"
)
type RunCommandTimeoutFunc func(timeout time.Duration, name string, args ...string) (stdout string, stderr string, exitCode int, err error)
func ValidateRenderedConfigWithBinary(
config map[string]any,
run RunCommandTimeoutFunc,
timeout time.Duration,
binaryCandidates ...string,
) *SingBoxIssue {
if len(config) == 0 {
return &SingBoxIssue{
Field: "config",
Severity: "error",
Code: "SINGBOX_RENDER_EMPTY",
Message: "rendered config is empty",
}
}
bin, _ := FirstExistingBinaryCandidate(binaryCandidates...)
if strings.TrimSpace(bin) == "" {
return &SingBoxIssue{
Field: "binary",
Severity: "warning",
Code: "SINGBOX_BINARY_MISSING",
Message: "sing-box binary not found; binary check skipped",
}
}
if run == nil {
return &SingBoxIssue{
Field: "binary",
Severity: "warning",
Code: "SINGBOX_CHECK_RUNNER_MISSING",
Message: "command runner is not configured; binary check skipped",
}
}
tmp, err := os.CreateTemp("", "svpn-singbox-check-*.json")
if err != nil {
return &SingBoxIssue{
Field: "binary",
Severity: "warning",
Code: "SINGBOX_CHECK_TEMPFILE_FAILED",
Message: err.Error(),
}
}
tmpPath := tmp.Name()
_ = tmp.Close()
defer os.Remove(tmpPath)
if err := WriteJSONConfigFile(tmpPath, config); err != nil {
return &SingBoxIssue{
Field: "binary",
Severity: "warning",
Code: "SINGBOX_CHECK_TEMPFILE_FAILED",
Message: err.Error(),
}
}
stdout, _, code, runErr := run(timeout, bin, "check", "-c", tmpPath)
if runErr == nil && code == 0 {
return nil
}
msg := strings.TrimSpace(stdout)
if msg == "" && runErr != nil {
msg = runErr.Error()
}
if msg == "" {
msg = "sing-box check failed"
}
return &SingBoxIssue{
Field: "config",
Severity: "error",
Code: "SINGBOX_CHECK_FAILED",
Message: msg,
}
}

View File

@@ -0,0 +1,199 @@
package transportcfg
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"time"
)
type ConfigDiff struct {
Added int
Removed int
Changed int
}
func SingBoxTypedProtocolSupported(proto string) bool {
switch strings.ToLower(strings.TrimSpace(proto)) {
case "vless", "trojan", "shadowsocks", "wireguard", "hysteria2", "tuic":
return true
default:
return false
}
}
func ParsePort(v any) (int, bool) {
switch x := v.(type) {
case int:
return x, true
case int64:
return int(x), true
case float64:
return int(x), true
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, false
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
func DigestJSONMap(config map[string]any) string {
if config == nil {
return ""
}
b, err := json.Marshal(config)
if err != nil {
return ""
}
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func DiffConfigMaps(prev, next map[string]any) (ConfigDiff, bool) {
diff := ConfigDiff{}
changed := false
if prev == nil {
diff.Added = len(next)
return diff, diff.Added > 0
}
seen := map[string]struct{}{}
for k, pv := range prev {
seen[k] = struct{}{}
nv, ok := next[k]
if !ok {
diff.Removed++
changed = true
continue
}
if !reflect.DeepEqual(pv, nv) {
diff.Changed++
changed = true
}
}
for k := range next {
if _, ok := seen[k]; ok {
continue
}
diff.Added++
changed = true
}
return diff, changed
}
func ProfileConfigPath(rootDir, profileID string, sanitizeID func(string) string) string {
id := profileID
if sanitizeID != nil {
id = sanitizeID(id)
}
if strings.TrimSpace(id) == "" {
id = "profile"
}
return filepath.Join(strings.TrimSpace(rootDir), id+".json")
}
func WriteJSONConfigFile(path string, config map[string]any) error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func ReadJSONMapFile(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
return nil
}
return out
}
func ReadFileOptional(path string) ([]byte, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
return data, true, nil
}
func RestoreFileOptional(path string, data []byte, exists bool) error {
if !exists {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func SanitizeHistoryStamp(ts string, now time.Time) string {
s := strings.TrimSpace(ts)
if s == "" {
s = now.UTC().Format(time.RFC3339Nano)
}
repl := strings.NewReplacer(":", "", "-", "", "T", "_", "Z", "", ".", "")
out := repl.Replace(s)
out = strings.Trim(out, "_")
if out == "" {
out = strconv.FormatInt(now.UTC().UnixNano(), 10)
}
return out
}
func JoinMessages(messages []string) string {
if len(messages) == 0 {
return ""
}
parts := make([]string, 0, len(messages))
for _, msg := range messages {
v := strings.TrimSpace(msg)
if v == "" {
continue
}
parts = append(parts, v)
}
return strings.Join(parts, "; ")
}
func SortRecordsDescByAt[T any](items []T, extractAt func(T) string) {
if len(items) < 2 || extractAt == nil {
return
}
sort.Slice(items, func(i, j int) bool {
return extractAt(items[i]) > extractAt(items[j])
})
}

View File

@@ -0,0 +1,280 @@
package transportcfg
import (
"fmt"
"strings"
)
type SingBoxProfileInput struct {
ID string
Mode string
Protocol string
RawConfig map[string]any
Typed map[string]any
}
type SingBoxIssue struct {
Field string
Severity string
Code string
Message string
}
type SingBoxProfileEvalDeps struct {
NormalizeMode func(mode string) (normalized string, ok bool)
CloneMapDeep func(in map[string]any) map[string]any
AsString func(v any) string
ProtocolSupported func(proto string) bool
ParsePort func(v any) (int, bool)
}
func ValidateSingBoxProfile(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) ([]SingBoxIssue, []SingBoxIssue) {
errs := make([]SingBoxIssue, 0)
warns := make([]SingBoxIssue, 0)
if strings.TrimSpace(profile.ID) == "" {
errs = append(errs, SingBoxIssue{
Field: "id",
Severity: "error",
Code: "SINGBOX_PROFILE_ID_EMPTY",
Message: "profile id is required",
})
}
mode, ok := normalizeProfileMode(profile.Mode, deps)
if !ok {
errs = append(errs, SingBoxIssue{
Field: "mode",
Severity: "error",
Code: "SINGBOX_PROFILE_MODE_INVALID",
Message: "mode must be typed|raw",
})
return errs, warns
}
if mode == "raw" {
if len(profile.RawConfig) == 0 {
errs = append(errs, SingBoxIssue{
Field: "raw_config",
Severity: "error",
Code: "SINGBOX_PROFILE_RAW_EMPTY",
Message: "raw_config is required for raw mode",
})
}
if len(profile.Typed) > 0 {
warns = append(warns, SingBoxIssue{
Field: "typed",
Severity: "warning",
Code: "SINGBOX_PROFILE_TYPED_IGNORED",
Message: "typed fields are ignored in raw mode",
})
}
return errs, warns
}
proto := strings.ToLower(strings.TrimSpace(profile.Protocol))
if !isProtocolSupported(proto, deps) {
errs = append(errs, SingBoxIssue{
Field: "protocol",
Severity: "error",
Code: "SINGBOX_PROFILE_PROTOCOL_UNSUPPORTED",
Message: "typed mode supports: vless,trojan,shadowsocks,wireguard,hysteria2,tuic",
})
}
if len(profile.Typed) == 0 {
errs = append(errs, SingBoxIssue{
Field: "typed",
Severity: "error",
Code: "SINGBOX_PROFILE_TYPED_EMPTY",
Message: "typed config is required in typed mode",
})
return errs, warns
}
server := strings.TrimSpace(asString(deps, profile.Typed["server"]))
if server == "" {
addr := strings.TrimSpace(asString(deps, profile.Typed["address"]))
if addr == "" && profile.Typed["config"] == nil {
errs = append(errs, SingBoxIssue{
Field: "typed.server",
Severity: "error",
Code: "SINGBOX_PROFILE_SERVER_REQUIRED",
Message: "typed.server (or typed.address) is required",
})
}
}
if p, ok := parsePort(deps, profile.Typed["port"]); ok {
if p <= 0 || p > 65535 {
errs = append(errs, SingBoxIssue{
Field: "typed.port",
Severity: "error",
Code: "SINGBOX_PROFILE_PORT_INVALID",
Message: "typed.port must be in range 1..65535",
})
}
}
if len(profile.RawConfig) > 0 {
warns = append(warns, SingBoxIssue{
Field: "raw_config",
Severity: "warning",
Code: "SINGBOX_PROFILE_RAW_IGNORED",
Message: "raw_config is ignored in typed mode",
})
}
return errs, warns
}
func RenderSingBoxProfileConfig(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) (map[string]any, error) {
mode, ok := normalizeProfileMode(profile.Mode, deps)
if !ok {
return nil, fmt.Errorf("unsupported profile mode %q", profile.Mode)
}
if mode == "raw" {
cfg := cloneMap(deps, profile.RawConfig)
if len(cfg) == 0 {
return nil, fmt.Errorf("raw_config is empty")
}
return cfg, nil
}
typed := cloneMap(deps, profile.Typed)
if typed == nil {
return nil, fmt.Errorf("typed config is empty")
}
if embedded, ok := typed["config"].(map[string]any); ok && len(embedded) > 0 {
return cloneMap(deps, embedded), nil
}
outbound := cloneMap(deps, typed)
if outbound == nil {
outbound = map[string]any{}
}
proto := strings.ToLower(strings.TrimSpace(profile.Protocol))
if proto == "" {
return nil, fmt.Errorf("protocol is required in typed mode")
}
outbound["type"] = proto
tag := strings.TrimSpace(asString(deps, outbound["tag"]))
if tag == "" {
tag = "proxy"
outbound["tag"] = tag
}
cfg := map[string]any{
"log": map[string]any{
"level": "warn",
},
"outbounds": []any{
outbound,
map[string]any{"type": "direct", "tag": "direct"},
},
"route": map[string]any{
"final": tag,
"auto_detect_interface": true,
},
"dns": map[string]any{
"servers": []any{
map[string]any{"type": "local", "tag": "local"},
},
"final": "local",
},
}
if v, ok := typed["dns"]; ok {
if dnsMap, ok := v.(map[string]any); ok && len(dnsMap) > 0 {
cfg["dns"] = cloneMap(deps, dnsMap)
}
}
if v, ok := typed["route"]; ok {
if routeMap, ok := v.(map[string]any); ok && len(routeMap) > 0 {
cfg["route"] = cloneMap(deps, routeMap)
}
}
if v, ok := typed["inbounds"]; ok {
switch vv := v.(type) {
case []any:
cfg["inbounds"] = vv
case map[string]any:
cfg["inbounds"] = []any{vv}
}
}
return cfg, nil
}
func NormalizeSingBoxRenderedConfig(config map[string]any, asStringFn func(v any) string) map[string]any {
if config == nil {
return nil
}
outboundsRaw, ok := config["outbounds"].([]any)
if !ok || len(outboundsRaw) == 0 {
return config
}
asString := asStringFn
if asString == nil {
asString = func(v any) string { return strings.TrimSpace(fmt.Sprint(v)) }
}
for i := range outboundsRaw {
outbound, ok := outboundsRaw[i].(map[string]any)
if !ok {
continue
}
if strings.ToLower(strings.TrimSpace(asString(outbound["type"]))) != "vless" {
continue
}
packetEncoding := strings.ToLower(strings.TrimSpace(asString(outbound["packet_encoding"])))
if packetEncoding == "" || packetEncoding == "none" {
delete(outbound, "packet_encoding")
}
flow := strings.ToLower(strings.TrimSpace(asString(outbound["flow"])))
if flow == "" || flow == "none" {
delete(outbound, "flow")
}
}
config["outbounds"] = outboundsRaw
return config
}
func normalizeProfileMode(mode string, deps SingBoxProfileEvalDeps) (string, bool) {
if deps.NormalizeMode == nil {
return "", false
}
out, ok := deps.NormalizeMode(mode)
return strings.ToLower(strings.TrimSpace(out)), ok
}
func asString(deps SingBoxProfileEvalDeps, v any) string {
if deps.AsString == nil {
return ""
}
return deps.AsString(v)
}
func cloneMap(deps SingBoxProfileEvalDeps, in map[string]any) map[string]any {
if deps.CloneMapDeep == nil {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
return deps.CloneMapDeep(in)
}
func isProtocolSupported(proto string, deps SingBoxProfileEvalDeps) bool {
if deps.ProtocolSupported == nil {
return false
}
return deps.ProtocolSupported(proto)
}
func parsePort(deps SingBoxProfileEvalDeps, v any) (int, bool) {
if deps.ParsePort == nil {
return 0, false
}
return deps.ParsePort(v)
}

View File

@@ -0,0 +1,412 @@
package transportcfg
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type SystemdServiceTuning struct {
RestartPolicy string
RestartSec int
StartLimitIntervalSec int
StartLimitBurst int
TimeoutStartSec int
TimeoutStopSec int
WatchdogSec int
}
type SystemdHardening struct {
Enabled bool
NoNewPrivileges bool
PrivateTmp bool
ProtectSystem string
ProtectHome string
ProtectControlGroups bool
ProtectKernelModules bool
ProtectKernelTunables bool
RestrictSUIDSGID bool
LockPersonality bool
PrivateDevices bool
UMask string
}
func ValidSystemdUnitName(unit string) bool {
u := strings.TrimSpace(unit)
if u == "" || !strings.HasSuffix(u, ".service") {
return false
}
if strings.Contains(u, "/") || strings.Contains(u, "\\") || strings.Contains(u, "..") {
return false
}
for _, ch := range u {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
continue
}
if ch == '-' || ch == '_' || ch == '.' || ch == '@' {
continue
}
return false
}
return true
}
func SystemdUnitPath(unitsDir, unit string) string {
return filepath.Join(unitsDir, unit)
}
func SystemdUnitOwnedByClient(path, clientID string) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
marker := "Environment=SVPN_TRANSPORT_ID=" + strings.TrimSpace(clientID)
return strings.Contains(string(data), marker), nil
}
func WriteSystemdUnitFile(unitsDir, unit, content string) error {
path := SystemdUnitPath(unitsDir, unit)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func RenderSystemdUnit(
client Client,
stateDir string,
unit string,
execStart string,
requiresSSH bool,
sshUnit string,
tuning SystemdServiceTuning,
hardening SystemdHardening,
shellQuoteArg func(string) string,
) string {
desc := fmt.Sprintf("Selective VPN transport %s (%s)", client.ID, client.Kind)
b := strings.Builder{}
b.WriteString("[Unit]\n")
b.WriteString("Description=" + desc + "\n")
b.WriteString("After=network-online.target\n")
b.WriteString("Wants=network-online.target\n")
b.WriteString("StartLimitIntervalSec=" + strconv.Itoa(tuning.StartLimitIntervalSec) + "\n")
b.WriteString("StartLimitBurst=" + strconv.Itoa(tuning.StartLimitBurst) + "\n")
if requiresSSH && strings.TrimSpace(sshUnit) != "" {
b.WriteString("Requires=" + sshUnit + "\n")
b.WriteString("After=" + sshUnit + "\n")
}
b.WriteString("\n[Service]\n")
b.WriteString("Type=simple\n")
b.WriteString("WorkingDirectory=" + stateDir + "\n")
b.WriteString("Restart=" + tuning.RestartPolicy + "\n")
b.WriteString("RestartSec=" + strconv.Itoa(tuning.RestartSec) + "\n")
b.WriteString("Environment=SVPN_TRANSPORT_ID=" + client.ID + "\n")
b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + client.Kind + "\n")
b.WriteString("ExecStart=" + SystemdShellExec(execStart, shellQuoteArg) + "\n")
b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n")
b.WriteString("TimeoutStartSec=" + strconv.Itoa(tuning.TimeoutStartSec) + "\n")
b.WriteString("TimeoutStopSec=" + strconv.Itoa(tuning.TimeoutStopSec) + "\n")
if tuning.WatchdogSec > 0 {
b.WriteString("WatchdogSec=" + strconv.Itoa(tuning.WatchdogSec) + "\n")
b.WriteString("NotifyAccess=main\n")
}
renderSystemdHardening(&b, hardening)
b.WriteString("\n[Install]\n")
b.WriteString("WantedBy=multi-user.target\n")
return b.String()
}
func RenderSSHOverlayUnit(
client Client,
stateDir string,
unit string,
execStart string,
tuning SystemdServiceTuning,
hardening SystemdHardening,
shellQuoteArg func(string) string,
) string {
desc := fmt.Sprintf("Selective VPN DNSTT SSH overlay (%s)", client.ID)
b := strings.Builder{}
b.WriteString("[Unit]\n")
b.WriteString("Description=" + desc + "\n")
b.WriteString("After=network-online.target\n")
b.WriteString("Wants=network-online.target\n")
b.WriteString("StartLimitIntervalSec=" + strconv.Itoa(tuning.StartLimitIntervalSec) + "\n")
b.WriteString("StartLimitBurst=" + strconv.Itoa(tuning.StartLimitBurst) + "\n")
b.WriteString("\n[Service]\n")
b.WriteString("Type=simple\n")
b.WriteString("WorkingDirectory=" + stateDir + "\n")
b.WriteString("Restart=" + tuning.RestartPolicy + "\n")
b.WriteString("RestartSec=" + strconv.Itoa(tuning.RestartSec) + "\n")
b.WriteString("Environment=SVPN_TRANSPORT_ID=" + client.ID + "\n")
b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + client.Kind + "\n")
b.WriteString("ExecStart=" + SystemdShellExec(execStart, shellQuoteArg) + "\n")
b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n")
b.WriteString("TimeoutStartSec=" + strconv.Itoa(tuning.TimeoutStartSec) + "\n")
b.WriteString("TimeoutStopSec=" + strconv.Itoa(tuning.TimeoutStopSec) + "\n")
if tuning.WatchdogSec > 0 {
b.WriteString("WatchdogSec=" + strconv.Itoa(tuning.WatchdogSec) + "\n")
b.WriteString("NotifyAccess=main\n")
}
renderSystemdHardening(&b, hardening)
b.WriteString("\n[Install]\n")
b.WriteString("WantedBy=multi-user.target\n")
return b.String()
}
func SystemdServiceTuningFromConfig(cfg map[string]any, prefix string, configInt func(map[string]any, string, int) int) SystemdServiceTuning {
intGetter := configInt
if intGetter == nil {
intGetter = defaultConfigInt
}
t := SystemdServiceTuning{
RestartPolicy: "always",
RestartSec: 2,
StartLimitIntervalSec: 300,
StartLimitBurst: 30,
TimeoutStartSec: 90,
TimeoutStopSec: 20,
WatchdogSec: 0,
}
if policy := configStringWithPrefixFallback(cfg, prefix, "restart_policy"); policy != "" {
t.RestartPolicy = normalizeSystemdRestartPolicy(policy)
}
t.RestartSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "restart_sec", t.RestartSec, intGetter), 0, 3600)
t.StartLimitIntervalSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "start_limit_interval_sec", t.StartLimitIntervalSec, intGetter), 0, 86400)
t.StartLimitBurst = clampInt(configIntWithPrefixFallback(cfg, prefix, "start_limit_burst", t.StartLimitBurst, intGetter), 1, 1000)
t.TimeoutStartSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "timeout_start_sec", t.TimeoutStartSec, intGetter), 1, 3600)
t.TimeoutStopSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "timeout_stop_sec", t.TimeoutStopSec, intGetter), 1, 3600)
t.WatchdogSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "watchdog_sec", t.WatchdogSec, intGetter), 0, 3600)
return t
}
func SystemdHardeningFromConfig(cfg map[string]any, prefix string) SystemdHardening {
h := SystemdHardening{
Enabled: true,
NoNewPrivileges: true,
PrivateTmp: true,
ProtectSystem: "full",
ProtectHome: "read-only",
ProtectControlGroups: true,
ProtectKernelModules: true,
ProtectKernelTunables: true,
RestrictSUIDSGID: true,
LockPersonality: true,
PrivateDevices: false,
UMask: "0077",
}
profile := strings.ToLower(strings.TrimSpace(configStringWithPrefixFallback(cfg, prefix, "hardening_profile")))
switch profile {
case "off", "none", "disabled", "false", "0":
h.Enabled = false
case "strict":
h.ProtectSystem = "strict"
h.PrivateDevices = true
case "", "baseline", "default":
// baseline defaults
default:
// unknown profile -> keep baseline defaults
}
if ConfigHasKey(cfg, "hardening_enabled") {
h.Enabled = ConfigBool(cfg, "hardening_enabled")
}
if prefix != "" && ConfigHasKey(cfg, prefix+"hardening_enabled") {
h.Enabled = ConfigBool(cfg, prefix+"hardening_enabled")
}
if !h.Enabled {
return h
}
h.NoNewPrivileges = configBoolWithPrefixFallback(cfg, prefix, "no_new_privileges", h.NoNewPrivileges)
h.PrivateTmp = configBoolWithPrefixFallback(cfg, prefix, "private_tmp", h.PrivateTmp)
h.ProtectControlGroups = configBoolWithPrefixFallback(cfg, prefix, "protect_control_groups", h.ProtectControlGroups)
h.ProtectKernelModules = configBoolWithPrefixFallback(cfg, prefix, "protect_kernel_modules", h.ProtectKernelModules)
h.ProtectKernelTunables = configBoolWithPrefixFallback(cfg, prefix, "protect_kernel_tunables", h.ProtectKernelTunables)
h.RestrictSUIDSGID = configBoolWithPrefixFallback(cfg, prefix, "restrict_suid_sgid", h.RestrictSUIDSGID)
h.LockPersonality = configBoolWithPrefixFallback(cfg, prefix, "lock_personality", h.LockPersonality)
h.PrivateDevices = configBoolWithPrefixFallback(cfg, prefix, "private_devices", h.PrivateDevices)
h.ProtectSystem = normalizeSystemdProtectSystem(configStringWithPrefixFallback(cfg, prefix, "protect_system"), h.ProtectSystem)
h.ProtectHome = normalizeSystemdProtectHome(configStringWithPrefixFallback(cfg, prefix, "protect_home"), h.ProtectHome)
h.UMask = normalizeSystemdUMask(configStringWithPrefixFallback(cfg, prefix, "umask"), h.UMask)
return h
}
func SystemdShellExec(command string, shellQuoteArg func(string) string) string {
cmd := strings.TrimSpace(command)
if cmd == "" {
return "/bin/true"
}
quote := shellQuoteArg
if quote == nil {
quote = defaultShellQuoteArg
}
return "/bin/sh -lc " + quote(cmd)
}
func configStringWithPrefixFallback(cfg map[string]any, prefix, key string) string {
if prefix != "" {
if v := strings.TrimSpace(ConfigString(cfg, prefix+key)); v != "" {
return v
}
}
return strings.TrimSpace(ConfigString(cfg, key))
}
func configBoolWithPrefixFallback(cfg map[string]any, prefix, key string, defaultVal bool) bool {
base := defaultVal
if ConfigHasKey(cfg, key) {
base = ConfigBool(cfg, key)
}
if prefix == "" {
return base
}
if !ConfigHasKey(cfg, prefix+key) {
return base
}
return ConfigBool(cfg, prefix+key)
}
func configIntWithPrefixFallback(cfg map[string]any, prefix, key string, defaultVal int, configInt func(map[string]any, string, int) int) int {
base := configInt(cfg, key, defaultVal)
if prefix == "" {
return base
}
if !ConfigHasKey(cfg, prefix+key) {
return base
}
return configInt(cfg, prefix+key, base)
}
func systemdBool(v bool) string {
if v {
return "yes"
}
return "no"
}
func normalizeSystemdProtectSystem(raw, defaultVal string) string {
v := strings.ToLower(strings.TrimSpace(raw))
switch v {
case "":
return defaultVal
case "yes", "true", "1":
return "yes"
case "no", "false", "0":
return "no"
case "full", "strict":
return v
default:
return defaultVal
}
}
func normalizeSystemdProtectHome(raw, defaultVal string) string {
v := strings.ToLower(strings.TrimSpace(raw))
switch v {
case "":
return defaultVal
case "yes", "true", "1":
return "yes"
case "no", "false", "0":
return "no"
case "read-only", "tmpfs":
return v
default:
return defaultVal
}
}
func normalizeSystemdUMask(raw, defaultVal string) string {
v := strings.TrimSpace(raw)
if v == "" {
return defaultVal
}
if len(v) == 3 {
v = "0" + v
}
if len(v) != 4 {
return defaultVal
}
for _, ch := range v {
if ch < '0' || ch > '7' {
return defaultVal
}
}
return v
}
func normalizeSystemdRestartPolicy(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "no", "on-success", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always":
return s
default:
return "always"
}
}
func clampInt(v, minV, maxV int) int {
if v < minV {
return minV
}
if v > maxV {
return maxV
}
return v
}
func defaultConfigInt(cfg map[string]any, key string, defaultVal int) int {
if cfg == nil {
return defaultVal
}
raw, ok := cfg[key]
if !ok || raw == nil {
return defaultVal
}
switch v := raw.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return n
}
}
return defaultVal
}
func defaultShellQuoteArg(in string) string {
s := strings.ReplaceAll(in, "'", "'\"'\"'")
return "'" + s + "'"
}
func renderSystemdHardening(b *strings.Builder, h SystemdHardening) {
if !h.Enabled {
return
}
b.WriteString("NoNewPrivileges=" + systemdBool(h.NoNewPrivileges) + "\n")
b.WriteString("PrivateTmp=" + systemdBool(h.PrivateTmp) + "\n")
b.WriteString("ProtectSystem=" + h.ProtectSystem + "\n")
b.WriteString("ProtectHome=" + h.ProtectHome + "\n")
b.WriteString("ProtectControlGroups=" + systemdBool(h.ProtectControlGroups) + "\n")
b.WriteString("ProtectKernelModules=" + systemdBool(h.ProtectKernelModules) + "\n")
b.WriteString("ProtectKernelTunables=" + systemdBool(h.ProtectKernelTunables) + "\n")
b.WriteString("RestrictSUIDSGID=" + systemdBool(h.RestrictSUIDSGID) + "\n")
b.WriteString("LockPersonality=" + systemdBool(h.LockPersonality) + "\n")
if h.PrivateDevices {
b.WriteString("PrivateDevices=yes\n")
}
b.WriteString("UMask=" + h.UMask + "\n")
}