Files

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
}