411 lines
11 KiB
Go
411 lines
11 KiB
Go
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
|
|
}
|