diff --git a/docs/TESTING.md b/docs/TESTING.md index 4a09875..6f2e447 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -182,12 +182,17 @@ cp scripts/e2e-config.json.example scripts/e2e-config.json "shadowsocks": { "multi": { "method": "aes-256-gcm", "password": "..." }, "single": { "method": "chacha20-ietf-poly1305", "password": "..." } + }, + "socks_auth": { + "user": "your-socks-user", + "password": "your-socks-password" } } ``` - `ssh_target`: SSH config alias or `user@host` (required) - `dns_resolver`: public DNS resolver for client connections (default: `8.8.8.8`) +- `socks_auth`: SOCKS5 authentication credentials for auth tests (optional, enables auth tests in `single` and `config-reload` phases) ### Usage @@ -206,11 +211,11 @@ cp scripts/e2e-config.json.example scripts/e2e-config.json | Phase | What it tests | |-------|---------------| -| `single` | Fresh install, `tunnel add` for all 5 types, each tested individually in single mode | +| `single` | Fresh install, `tunnel add` for all 5 types, each tested individually in single mode, SOCKS auth enable/disable with both transports | | `multi` | Setup multi-mode state via `config load`, test 3 tunnels simultaneously | | `mode-switch` | Switch multi→single→multi, verify tunnels work after each switch | | `config-load` | Clean reinstall, `config load` with multi config, verify tunnels work | -| `config-reload` | `config load` single config over existing multi, validate old resources cleaned up | +| `config-reload` | `config load` single config (with SOCKS auth) over existing multi, validate cleanup and auth enforcement | Each phase is standalone — it sets up its own prerequisite state and can be run independently via `--phase`. diff --git a/internal/actions/backend.go b/internal/actions/backend.go index 239d4b3..0c76436 100644 --- a/internal/actions/backend.go +++ b/internal/actions/backend.go @@ -130,6 +130,52 @@ func init() { }, }) + // Register backend.auth action + Register(&Action{ + ID: ActionBackendAuth, + Parent: ActionBackend, + Use: "auth", + Short: "Configure SOCKS5 authentication", + Long: "Enable, disable, or change SOCKS5 proxy authentication credentials", + MenuLabel: "Authentication", + RequiresRoot: true, + RequiresInstalled: true, + Args: &ArgsSpec{ + Name: "tag", + Description: "Backend tag", + Required: true, + PickerFunc: SocksBackendPicker, + }, + Inputs: []InputField{ + { + Name: "disable", + Label: "Disable authentication", + Type: InputTypeBool, + Description: "Disable SOCKS5 authentication", + }, + { + Name: "user", + Label: "Username", + ShortFlag: 'u', + Type: InputTypeText, + Description: "SOCKS5 username", + ShowIf: func(ctx *Context) bool { + return !ctx.GetBool("disable") + }, + }, + { + Name: "password", + Label: "Password", + ShortFlag: 'p', + Type: InputTypePassword, + Description: "SOCKS5 password", + ShowIf: func(ctx *Context) bool { + return !ctx.GetBool("disable") + }, + }, + }, + }) + // Register backend.remove action Register(&Action{ ID: ActionBackendRemove, @@ -182,6 +228,33 @@ func BackendPicker(ctx *Context) (string, error) { return "", nil } +// SocksBackendPicker provides interactive selection filtered to SOCKS backends only. +func SocksBackendPicker(ctx *Context) (string, error) { + cfg, err := config.Load() + if err != nil { + return "", err + } + + var options []SelectOption + for _, b := range cfg.Backends { + if b.Type != config.BackendSOCKS { + continue + } + label := fmt.Sprintf("%s (SOCKS5)", b.Tag) + options = append(options, SelectOption{ + Label: label, + Value: b.Tag, + }) + } + + if len(options) == 0 { + return "", fmt.Errorf("no SOCKS backends configured") + } + + ctx.Set("_picker_options", options) + return "", nil +} + // BackendTypeOptions returns the available backend type options for adding new backends. // Note: SOCKS and SSH are built-in backends and cannot be added manually. func BackendTypeOptions() []SelectOption { diff --git a/internal/actions/ids.go b/internal/actions/ids.go index 2f72719..cefc2d8 100644 --- a/internal/actions/ids.go +++ b/internal/actions/ids.go @@ -9,6 +9,7 @@ const ( ActionBackendAdd = "backend.add" ActionBackendRemove = "backend.remove" ActionBackendStatus = "backend.status" + ActionBackendAuth = "backend.auth" // Tunnel actions ActionTunnel = "tunnel" diff --git a/internal/clientcfg/generate.go b/internal/clientcfg/generate.go index 29407f2..c17b725 100644 --- a/internal/clientcfg/generate.go +++ b/internal/clientcfg/generate.go @@ -61,7 +61,10 @@ func Generate(tunnel *config.TunnelConfig, backend *config.BackendConfig, opts G switch backend.Type { case config.BackendSOCKS: - // No extra fields needed + if backend.HasSocksAuth() { + cfg.Backend.User = backend.Socks.User + cfg.Backend.Password = backend.Socks.Password + } case config.BackendSSH: cfg.Backend.User = opts.User diff --git a/internal/config/backend.go b/internal/config/backend.go index e6a84a0..28d0ae3 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -18,6 +18,13 @@ type BackendConfig struct { Type BackendType `json:"type"` Address string `json:"address,omitempty"` Shadowsocks *ShadowsocksConfig `json:"shadowsocks,omitempty"` + Socks *SocksConfig `json:"socks,omitempty"` +} + +// SocksConfig holds SOCKS5 authentication configuration. +type SocksConfig struct { + User string `json:"user"` + Password string `json:"password"` } // ShadowsocksConfig holds Shadowsocks-specific configuration. @@ -26,6 +33,11 @@ type ShadowsocksConfig struct { Password string `json:"password"` } +// HasSocksAuth returns true if SOCKS5 authentication is configured. +func (b *BackendConfig) HasSocksAuth() bool { + return b.Socks != nil && b.Socks.User != "" && b.Socks.Password != "" +} + // IsManaged returns true if dnstm manages this backend type. func (b *BackendConfig) IsManaged() bool { switch b.Type { diff --git a/internal/config/validation.go b/internal/config/validation.go index e8a4819..dfb83c1 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -75,6 +75,11 @@ func (c *Config) validateBackends() error { if b.Address == "" { return fmt.Errorf("backend '%s': address is required for type %s", b.Tag, b.Type) } + if b.Type == BackendSOCKS && b.Socks != nil { + if b.Socks.User == "" || b.Socks.Password == "" { + return fmt.Errorf("backend '%s': socks auth requires both user and password", b.Tag) + } + } case BackendShadowsocks: if b.Shadowsocks == nil { return fmt.Errorf("backend '%s': shadowsocks config is required for type %s", b.Tag, b.Type) diff --git a/internal/handlers/backend_auth.go b/internal/handlers/backend_auth.go new file mode 100644 index 0000000..d435d98 --- /dev/null +++ b/internal/handlers/backend_auth.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "fmt" + + "github.com/net2share/dnstm/internal/actions" + "github.com/net2share/dnstm/internal/config" + "github.com/net2share/dnstm/internal/proxy" +) + +func init() { + actions.SetBackendHandler(actions.ActionBackendAuth, HandleBackendAuth) +} + +// HandleBackendAuth enables, disables, or changes SOCKS5 authentication. +func HandleBackendAuth(ctx *actions.Context) error { + cfg, err := RequireConfig(ctx) + if err != nil { + return err + } + + tag, err := RequireTag(ctx, "backend") + if err != nil { + return err + } + + backend := cfg.GetBackendByTag(tag) + if backend == nil { + return actions.BackendNotFoundError(tag) + } + + if backend.Type != config.BackendSOCKS { + return fmt.Errorf("backend '%s' is not a SOCKS backend", tag) + } + + disable := ctx.GetBool("disable") + + if disable { + backend.Socks = nil + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + if err := proxy.ReconfigureMicrosocks(cfg.Proxy.Port, "", ""); err != nil { + return fmt.Errorf("failed to reconfigure microsocks: %w", err) + } + + ctx.Output.Success("SOCKS5 authentication disabled") + return nil + } + + user := ctx.GetString("user") + password := ctx.GetString("password") + + if user == "" || password == "" { + return fmt.Errorf("both user and password are required to enable authentication") + } + + backend.Socks = &config.SocksConfig{ + User: user, + Password: password, + } + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + if err := proxy.ReconfigureMicrosocks(cfg.Proxy.Port, user, password); err != nil { + return fmt.Errorf("failed to reconfigure microsocks: %w", err) + } + + ctx.Output.Success(fmt.Sprintf("SOCKS5 authentication enabled (user: %s)", user)) + return nil +} diff --git a/internal/handlers/backend_status.go b/internal/handlers/backend_status.go index 42c9ee8..c0312bd 100644 --- a/internal/handlers/backend_status.go +++ b/internal/handlers/backend_status.go @@ -49,6 +49,25 @@ func HandleBackendStatus(ctx *actions.Context) error { } infoCfg.Sections = append(infoCfg.Sections, mainSection) + // Show SOCKS5 auth config if applicable + if backend.Type == config.BackendSOCKS { + authSection := actions.InfoSection{ + Title: "Authentication", + } + if backend.HasSocksAuth() { + authSection.Rows = []actions.InfoRow{ + {Key: "Status", Value: "Enabled"}, + {Key: "User", Value: backend.Socks.User}, + {Key: "Password", Value: backend.Socks.Password}, + } + } else { + authSection.Rows = []actions.InfoRow{ + {Key: "Status", Value: "Disabled"}, + } + } + infoCfg.Sections = append(infoCfg.Sections, authSection) + } + // Show shadowsocks config if applicable if backend.Shadowsocks != nil { ssSection := actions.InfoSection{ @@ -94,6 +113,18 @@ func HandleBackendStatus(ctx *actions.Context) error { ctx.Output.KV("Removable", fmt.Sprintf("%v", !backend.IsBuiltIn() || (tag != "socks" && tag != "ssh"))), }) + if backend.Type == config.BackendSOCKS { + ctx.Output.Println() + ctx.Output.Println("Authentication:") + if backend.HasSocksAuth() { + ctx.Output.Printf(" Status: Enabled\n") + ctx.Output.Printf(" User: %s\n", backend.Socks.User) + ctx.Output.Printf(" Password: %s\n", backend.Socks.Password) + } else { + ctx.Output.Printf(" Status: Disabled\n") + } + } + if backend.Shadowsocks != nil { ctx.Output.Println() ctx.Output.Println("Shadowsocks Configuration:") diff --git a/internal/handlers/config_load.go b/internal/handlers/config_load.go index 432ca96..ab5443b 100644 --- a/internal/handlers/config_load.go +++ b/internal/handlers/config_load.go @@ -117,15 +117,24 @@ func HandleConfigLoad(ctx *actions.Context) error { ctx.Output.Status("Configuration saved to " + config.GetConfigPath()) - // Reconfigure microsocks if the proxy port was explicitly specified - if newCfg.Proxy.Port != 0 && proxy.IsMicrosocksInstalled() { - if err := proxy.ConfigureMicrosocks(newCfg.Proxy.Port); err != nil { + // Reconfigure microsocks with port and auth from loaded config + if proxy.IsMicrosocksInstalled() { + port := newCfg.Proxy.Port + if port == 0 { + port = 1080 + } + var socksUser, socksPass string + if socksBackend := newCfg.GetBackendByTag("socks"); socksBackend != nil && socksBackend.HasSocksAuth() { + socksUser = socksBackend.Socks.User + socksPass = socksBackend.Socks.Password + } + if err := proxy.ConfigureMicrosocksWithAuth(port, socksUser, socksPass); err != nil { ctx.Output.Warning(fmt.Sprintf("Failed to reconfigure microsocks: %v", err)) } else { if err := proxy.RestartMicrosocks(); err != nil { ctx.Output.Warning(fmt.Sprintf("Failed to restart microsocks: %v", err)) } else { - ctx.Output.Status(fmt.Sprintf("Microsocks reconfigured on port %d", newCfg.Proxy.Port)) + ctx.Output.Status(fmt.Sprintf("Microsocks reconfigured on port %d", port)) } } } diff --git a/internal/handlers/system_install.go b/internal/handlers/system_install.go index 69b677d..774f6bc 100644 --- a/internal/handlers/system_install.go +++ b/internal/handlers/system_install.go @@ -130,7 +130,13 @@ func HandleInstall(ctx *actions.Context) error { if err := cfg.Save(); err != nil { ctx.Output.Warning("Failed to save proxy port: " + err.Error()) } - if err := proxy.ConfigureMicrosocks(port); err != nil { + // Preserve existing auth config on reinstall + var socksUser, socksPass string + if socksBackend := cfg.GetBackendByTag("socks"); socksBackend != nil && socksBackend.HasSocksAuth() { + socksUser = socksBackend.Socks.User + socksPass = socksBackend.Socks.Password + } + if err := proxy.ConfigureMicrosocksWithAuth(port, socksUser, socksPass); err != nil { ctx.Output.Warning("microsocks service config: " + err.Error()) } else { if err := proxy.StartMicrosocks(); err != nil { diff --git a/internal/handlers/tunnel_status.go b/internal/handlers/tunnel_status.go index 1a7a1d5..2e27275 100644 --- a/internal/handlers/tunnel_status.go +++ b/internal/handlers/tunnel_status.go @@ -103,6 +103,19 @@ func HandleTunnelStatus(ctx *actions.Context) error { Key: "Address", Value: backend.Address, }) } + if backend.Type == config.BackendSOCKS { + if backend.HasSocksAuth() { + backendSection.Rows = append(backendSection.Rows, + actions.InfoRow{Key: "Auth", Value: "Enabled"}, + actions.InfoRow{Key: "User", Value: backend.Socks.User}, + actions.InfoRow{Key: "Password", Value: backend.Socks.Password}, + ) + } else { + backendSection.Rows = append(backendSection.Rows, + actions.InfoRow{Key: "Auth", Value: "Disabled"}, + ) + } + } infoCfg.Sections = append(infoCfg.Sections, backendSection) } } @@ -145,6 +158,15 @@ func HandleTunnelStatus(ctx *actions.Context) error { if backend.Address != "" { ctx.Output.Printf(" Address: %s\n", backend.Address) } + if backend.Type == config.BackendSOCKS { + if backend.HasSocksAuth() { + ctx.Output.Printf(" Auth: Enabled\n") + ctx.Output.Printf(" User: %s\n", backend.Socks.User) + ctx.Output.Printf(" Password: %s\n", backend.Socks.Password) + } else { + ctx.Output.Printf(" Auth: Disabled\n") + } + } ctx.Output.Println() } } diff --git a/internal/menu/main.go b/internal/menu/main.go index ea4d767..8e385c3 100644 --- a/internal/menu/main.go +++ b/internal/menu/main.go @@ -600,6 +600,15 @@ func runBackendManageMenu(tag string) error { {Label: "Status", Value: "status"}, } + // Show Authentication option for SOCKS backends + if backend.Type == config.BackendSOCKS { + authLabel := "Authentication: Disabled" + if backend.HasSocksAuth() { + authLabel = fmt.Sprintf("Authentication: %s", backend.Socks.User) + } + options = append(options, tui.MenuOption{Label: authLabel, Value: "auth"}) + } + // Only show Remove for non-built-in backends if !backend.IsBuiltIn() { options = append(options, tui.MenuOption{Label: "Remove", Value: "remove"}) @@ -616,6 +625,13 @@ func runBackendManageMenu(tag string) error { return errCancelled } + if choice == "auth" { + if err := runBackendAuthMenu(tag, backend); err != nil && err != errCancelled { + _ = tui.ShowMessage(tui.AppMessage{Type: "error", Message: err.Error()}) + } + continue + } + // Execute the action with the backend tag as argument actionID := "backend." + choice if err := runBackendAction(actionID, tag); err != nil { @@ -647,10 +663,51 @@ func getBackendDescription(b *config.BackendConfig) string { return "" } +// runBackendAuthMenu shows the authentication sub-menu for a SOCKS backend. +func runBackendAuthMenu(tag string, backend *config.BackendConfig) error { + var options []tui.MenuOption + if backend.HasSocksAuth() { + options = []tui.MenuOption{ + {Label: "Change credentials", Value: "change"}, + {Label: "Disable", Value: "disable"}, + {Label: "Back", Value: "back"}, + } + } else { + options = []tui.MenuOption{ + {Label: "Enable", Value: "enable"}, + {Label: "Back", Value: "back"}, + } + } + + choice, err := tui.RunMenu(tui.MenuConfig{ + Title: "Authentication", + Options: options, + }) + if err != nil || choice == "" || choice == "back" { + return errCancelled + } + + switch choice { + case "disable": + ctx := newActionContext() + ctx.Values["tag"] = tag + ctx.Values["disable"] = true + action := actions.Get(actions.ActionBackendAuth) + if action == nil || action.Handler == nil { + return fmt.Errorf("backend auth handler not found") + } + return action.Handler(ctx) + case "enable", "change": + return runBackendAction(actions.ActionBackendAuth, tag) + } + + return nil +} + // runBackendAction runs a backend action with the given tag as argument. func runBackendAction(actionID, backendTag string) error { switch actionID { - case actions.ActionBackendStatus, actions.ActionBackendRemove: + case actions.ActionBackendStatus, actions.ActionBackendRemove, actions.ActionBackendAuth: return runActionWithArgs(actionID, []string{backendTag}) default: return RunAction(actionID) diff --git a/internal/proxy/microsocks.go b/internal/proxy/microsocks.go index 6aa054a..7eefefd 100644 --- a/internal/proxy/microsocks.go +++ b/internal/proxy/microsocks.go @@ -23,25 +23,43 @@ func InstallMicrosocks(progressFn func(downloaded, total int64)) error { return err } -// ConfigureMicrosocks creates the systemd service for microsocks with the specified port. +// ConfigureMicrosocks creates the systemd service for microsocks with the specified port (no auth). func ConfigureMicrosocks(port int) error { + return ConfigureMicrosocksWithAuth(port, "", "") +} + +// ConfigureMicrosocksWithAuth creates the systemd service for microsocks with optional authentication. +func ConfigureMicrosocksWithAuth(port int, user, password string) error { mgr := binary.NewDefaultManager() binaryPath, err := mgr.GetPath(binary.BinaryMicrosocks) if err != nil { return fmt.Errorf("microsocks binary not found: %w", err) } + execStart := fmt.Sprintf("%s -i %s -p %d -q", binaryPath, MicrosocksBindAddr, port) + if user != "" && password != "" { + execStart = fmt.Sprintf("%s -i %s -p %d -q -u %s -P %s", binaryPath, MicrosocksBindAddr, port, user, password) + } + return service.CreateGenericService(&service.ServiceConfig{ Name: MicrosocksServiceName, Description: "Microsocks SOCKS5 Proxy", User: "nobody", Group: getNobodyGroup(), - ExecStart: fmt.Sprintf("%s -i %s -p %d -q", binaryPath, MicrosocksBindAddr, port), + ExecStart: execStart, ReadOnlyPaths: []string{binaryPath}, BindToPrivileged: false, }) } +// ReconfigureMicrosocks reconfigures and restarts microsocks with the given auth settings. +func ReconfigureMicrosocks(port int, user, password string) error { + if err := ConfigureMicrosocksWithAuth(port, user, password); err != nil { + return err + } + return RestartMicrosocks() +} + // FindAvailablePort finds an available port in the range 10000-60000. func FindAvailablePort() (int, error) { // Try random ports in the high range to avoid conflicts diff --git a/scripts/e2e-config.json.example b/scripts/e2e-config.json.example index a5a7128..63c9fa8 100644 --- a/scripts/e2e-config.json.example +++ b/scripts/e2e-config.json.example @@ -11,5 +11,9 @@ "shadowsocks": { "multi": { "method": "aes-256-gcm", "password": "your-multi-password" }, "single": { "method": "chacha20-ietf-poly1305", "password": "your-single-password" } + }, + "socks_auth": { + "user": "your-socks-user", + "password": "your-socks-password" } } diff --git a/scripts/remote-e2e.sh b/scripts/remote-e2e.sh index 14003d9..61ffaa8 100755 --- a/scripts/remote-e2e.sh +++ b/scripts/remote-e2e.sh @@ -199,6 +199,47 @@ check_result() { fi } +check_result_auth() { + local label="$1" + local socks_port="$2" + local expected_ip="$3" + local user="$4" + local password="$5" + local timeout="${6:-15}" + + local result + result=$(curl -sf --max-time "$timeout" -x "socks5h://${user}:${password}@127.0.0.1:$socks_port" https://httpbin.org/ip 2>/dev/null || true) + local origin + origin=$(echo "$result" | jq -r '.origin' 2>/dev/null || true) + + if [[ "$origin" == "$expected_ip" ]]; then + pass "$label" + return 0 + else + fail "$label" "expected=$expected_ip got=${origin:-empty}" + return 1 + fi +} + +check_result_noauth_fails() { + local label="$1" + local socks_port="$2" + local timeout="${3:-10}" + + local result + result=$(curl -sf --max-time "$timeout" -x "socks5h://127.0.0.1:$socks_port" https://httpbin.org/ip 2>/dev/null || true) + local origin + origin=$(echo "$result" | jq -r '.origin' 2>/dev/null || true) + + if [[ -z "$origin" || "$origin" == "null" ]]; then + pass "$label (no-auth rejected)" + return 0 + else + fail "$label (no-auth rejected)" "expected rejection, got origin=$origin" + return 1 + fi +} + wait_for_port() { local port="$1" local retries="${2:-20}" @@ -300,6 +341,17 @@ generate_single_config() { ss_method=$(jq -r '.shadowsocks.single.method' "$CONFIG_FILE") ss_password=$(jq -r '.shadowsocks.single.password' "$CONFIG_FILE") + local socks_user socks_pass socks_block="" + socks_user=$(jq -r '.socks_auth.user // ""' "$CONFIG_FILE") + socks_pass=$(jq -r '.socks_auth.password // ""' "$CONFIG_FILE") + if [[ -n "$socks_user" && -n "$socks_pass" ]]; then + socks_block=', + "socks": { + "user": "'"$socks_user"'", + "password": "'"$socks_pass"'" + }' + fi + cat > "$TMPDIR/config-single.json" </dev/null 2>&1 + pass "Enable SOCKS auth" + + # Test slipstream+socks with auth + info "Switching to slip-socks (auth)..." + remote "dnstm router switch -t slip-socks" >/dev/null 2>&1 + sleep 2 + next_port; test_slipstream_socks_auth "slip-socks" "$(read_domain slip_socks)" "$PORT_COUNTER" "$socks_user" "$socks_pass" + + # Test dnstt+socks with auth + info "Switching to dnstt-socks (auth)..." + remote "dnstm router switch -t dnstt-socks" >/dev/null 2>&1 + sleep 2 + next_port; test_dnstt_socks_auth "dnstt-socks" "$(read_domain dnstt_socks)" "$PORT_COUNTER" "$socks_user" "$socks_pass" + + # Disable auth + info "Disabling SOCKS auth..." + remote "dnstm backend auth -t socks --disable" >/dev/null 2>&1 + pass "Disable SOCKS auth" + + # Verify no-auth works again + info "Switching to slip-socks (no auth)..." + remote "dnstm router switch -t slip-socks" >/dev/null 2>&1 + sleep 2 + next_port; test_slipstream_socks "slip-socks" "$(read_domain slip_socks)" "$PORT_COUNTER" + fi } # ─── Phase: Multi Mode ──────────────────────────────────────────────────────── @@ -773,11 +905,29 @@ phase_config_reload() { fail "Validate cleanup" "some cleanup checks failed" fi + # Validate microsocks auth + local socks_user socks_pass + socks_user=$(jq -r '.socks_auth.user // ""' "$CONFIG_FILE") + socks_pass=$(jq -r '.socks_auth.password // ""' "$CONFIG_FILE") + if [[ -n "$socks_user" && -n "$socks_pass" ]]; then + local ms_exec + ms_exec=$(remote "systemctl cat microsocks 2>/dev/null | grep ExecStart" || true) + if echo "$ms_exec" | grep -q "\-u $socks_user"; then + pass "Microsocks has auth flags" + else + fail "Microsocks has auth flags" "ExecStart: $ms_exec" + fi + fi + # Invalidate cert cache since tunnels were recreated invalidate_cert_cache - # Test active tunnel: slip-main - next_port; test_slipstream_socks "slip-main" "$(read_domain slip_socks)" "$PORT_COUNTER" + # Test active tunnel: slip-main (with auth if configured) + if [[ -n "$socks_user" && -n "$socks_pass" ]]; then + next_port; test_slipstream_socks_auth "slip-main" "$(read_domain slip_socks)" "$PORT_COUNTER" "$socks_user" "$socks_pass" + else + next_port; test_slipstream_socks "slip-main" "$(read_domain slip_socks)" "$PORT_COUNTER" + fi } # ─── Connection output file ───────────────────────────────────────────────────