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 }