platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
410
selective-vpn-api/app/transportcfg/exec_helpers.go
Normal file
410
selective-vpn-api/app/transportcfg/exec_helpers.go
Normal 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
|
||||
}
|
||||
86
selective-vpn-api/app/transportcfg/history_helpers.go
Normal file
86
selective-vpn-api/app/transportcfg/history_helpers.go
Normal 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
|
||||
}
|
||||
402
selective-vpn-api/app/transportcfg/probe_helpers.go
Normal file
402
selective-vpn-api/app/transportcfg/probe_helpers.go
Normal 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)
|
||||
}
|
||||
210
selective-vpn-api/app/transportcfg/runtime_helpers.go
Normal file
210
selective-vpn-api/app/transportcfg/runtime_helpers.go
Normal 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
|
||||
}
|
||||
67
selective-vpn-api/app/transportcfg/runtime_helpers_test.go
Normal file
67
selective-vpn-api/app/transportcfg/runtime_helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
100
selective-vpn-api/app/transportcfg/secrets_helpers.go
Normal file
100
selective-vpn-api/app/transportcfg/secrets_helpers.go
Normal 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)
|
||||
}
|
||||
82
selective-vpn-api/app/transportcfg/singbox_binary_check.go
Normal file
82
selective-vpn-api/app/transportcfg/singbox_binary_check.go
Normal 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,
|
||||
}
|
||||
}
|
||||
199
selective-vpn-api/app/transportcfg/singbox_helpers.go
Normal file
199
selective-vpn-api/app/transportcfg/singbox_helpers.go
Normal 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])
|
||||
})
|
||||
}
|
||||
280
selective-vpn-api/app/transportcfg/singbox_profile_eval.go
Normal file
280
selective-vpn-api/app/transportcfg/singbox_profile_eval.go
Normal 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)
|
||||
}
|
||||
412
selective-vpn-api/app/transportcfg/systemd_helpers.go
Normal file
412
selective-vpn-api/app/transportcfg/systemd_helpers.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user