RKNnoVPN/runtime/internal/core/manager_test.go
2026-05-07 19:21:49 +03:00

454 lines
13 KiB
Go

package core
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/youtubediscord/RKNnoVPN/runtime/internal/config"
"github.com/youtubediscord/RKNnoVPN/runtime/internal/netstack"
)
func setTestVLESSProfile(cfg *config.Config, address string) {
cfg.Profile.ActiveNodeID = "test-node"
cfg.Profile.Nodes = []json.RawMessage{json.RawMessage(strings.ReplaceAll(`{
"id":"test-node",
"name":"Test",
"server":"ADDRESS",
"port":443,
"protocol":"vless",
"outbound":{
"protocol":"vless",
"settings":{
"vnext":[{
"address":"ADDRESS",
"port":443,
"users":[{"id":"00000000-0000-0000-0000-000000000000","encryption":"none"}]
}]
},
"streamSettings":{"network":"tcp"}
}
}`, "ADDRESS", address))}
}
func TestIgnorableCleanupScriptError(t *testing.T) {
if !ignorableCleanupScriptError(errors.New("script not found: /data/adb/modules/rknnovpn/scripts/dns.sh: no such file or directory")) {
t.Fatal("missing cleanup script should be treated as an idempotent cleanup no-op")
}
if ignorableCleanupScriptError(errors.New("exec iptables.sh stop: exit status 2")) {
t.Fatal("real cleanup command failures must still be reported")
}
}
func TestSelfTestAppsAreBuiltInAlwaysDirect(t *testing.T) {
for _, packageName := range SelfTestProtectedPackages {
if !IsBuiltInAlwaysDirectPackage(packageName) {
t.Fatalf("%s self-test app must stay direct by default", packageName)
}
}
}
func TestRuntimeErrorCarriesTypedDetails(t *testing.T) {
err := runtimeError("iptables start", "RULES_NOT_APPLIED", errors.New("iptables denied"), true)
runtimeErr, ok := err.(*RuntimeError)
if !ok {
t.Fatalf("expected RuntimeError, got %T", err)
}
if runtimeErr.RuntimeCode() != "RULES_NOT_APPLIED" {
t.Fatalf("unexpected code: %#v", runtimeErr)
}
if !runtimeErr.RuntimeRollbackApplied() {
t.Fatalf("rollback flag should be preserved: %#v", runtimeErr)
}
if !strings.Contains(runtimeErr.RuntimeUserMessage(), "routing rules") {
t.Fatalf("expected user-facing message, got %q", runtimeErr.RuntimeUserMessage())
}
if runtimeErr.RuntimeDebug() != "iptables denied" {
t.Fatalf("expected debug detail, got %q", runtimeErr.RuntimeDebug())
}
}
func TestKillProcessWaitsOnTrackedExitChannel(t *testing.T) {
cmd := exec.Command("/bin/sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
manager := NewCoreManager(config.DefaultConfig(), t.TempDir(), nil)
manager.process = cmd.Process
manager.pid = cmd.Process.Pid
manager.exitCh = watchCommand(cmd)
t.Cleanup(func() {
_ = cmd.Process.Kill()
})
if err := manager.killProcess(); err != nil {
t.Fatalf("killProcess failed: %v", err)
}
if err := syscall.Kill(cmd.Process.Pid, 0); err == nil {
t.Fatalf("pid %d still exists after killProcess returned", cmd.Process.Pid)
}
}
func TestStartStopsBeforeSpawnAndNetstackWhenConfigCheckFails(t *testing.T) {
dataDir := t.TempDir()
binDir := filepath.Join(dataDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
singBoxPath := filepath.Join(binDir, "sing-box")
if err := os.WriteFile(singBoxPath, []byte("#!/bin/sh\necho invalid config >&2\nexit 2\n"), 0755); err != nil {
t.Fatal(err)
}
cfg := config.DefaultConfig()
setTestVLESSProfile(cfg, "example.com")
manager := NewCoreManager(cfg, dataDir, nil)
err := manager.Start(config.ResolveActiveProfile(cfg))
if err == nil {
t.Fatal("expected config-check failure")
}
runtimeErr, ok := err.(*RuntimeError)
if !ok {
t.Fatalf("expected RuntimeError, got %T: %v", err, err)
}
if runtimeErr.RuntimeCode() != "CONFIG_CHECK_FAILED" {
t.Fatalf("expected CONFIG_CHECK_FAILED, got %#v", runtimeErr)
}
if manager.GetState() != StateStopped {
t.Fatalf("failed config check must leave core stopped, got %s", manager.GetState())
}
}
func TestStartDoesNotRemoveExternalResetLock(t *testing.T) {
dataDir := t.TempDir()
runDir := filepath.Join(dataDir, "run")
binDir := filepath.Join(dataDir, "bin")
if err := os.MkdirAll(runDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
resetLock := filepath.Join(runDir, "reset.lock")
if err := os.WriteFile(resetLock, []byte("external reset\n"), 0640); err != nil {
t.Fatal(err)
}
singBoxPath := filepath.Join(binDir, "sing-box")
if err := os.WriteFile(singBoxPath, []byte("#!/bin/sh\necho invalid config >&2\nexit 2\n"), 0755); err != nil {
t.Fatal(err)
}
cfg := config.DefaultConfig()
setTestVLESSProfile(cfg, "example.com")
manager := NewCoreManager(cfg, dataDir, nil)
if err := manager.Start(config.ResolveActiveProfile(cfg)); err == nil {
t.Fatal("expected config-check failure")
}
if _, err := os.Stat(resetLock); err != nil {
t.Fatalf("start must not remove reset.lock, stat err=%v", err)
}
}
func TestStartCleansNetstackWhenVerifyFailsAfterApply(t *testing.T) {
dataDir := t.TempDir()
binDir := filepath.Join(dataDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
tproxyPort := freeTCPPort(t)
dnsPort := freeTCPPort(t)
singBoxPath := filepath.Join(binDir, "sing-box")
script := fmt.Sprintf("#!/bin/sh\nGO_WANT_SINGBOX_HELPER=1 FAKE_TPROXY_PORT=%d FAKE_DNS_PORT=%d exec %s -test.run=TestSingBoxHelperProcess -- \"$@\"\n", tproxyPort, dnsPort, strconv.Quote(os.Args[0]))
if err := os.WriteFile(singBoxPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
cfg := config.DefaultConfig()
setTestVLESSProfile(cfg, "example.com")
cfg.Proxy.TProxyPort = tproxyPort
cfg.Proxy.DNSPort = dnsPort
cfg.Proxy.APIPort = 0
cfg.Proxy.GID = os.Getegid()
manager := NewCoreManager(cfg, dataDir, nil)
manager.disableCoreCredentialForTest = true
fake := &fakeCoreNetstack{verifyReport: netstack.Report{
Operation: "verify",
Status: "failed",
Steps: []netstack.Step{{
Name: "iptables-status",
Status: "failed",
Detail: "missing IPv4 hook",
}},
Errors: []string{"iptables-status: missing IPv4 hook"},
}}
manager.netstackFactory = func() coreNetstack { return fake }
err := manager.Start(config.ResolveActiveProfile(cfg))
if err == nil {
t.Fatal("expected netstack verify failure")
}
runtimeErr, ok := err.(*RuntimeError)
if !ok {
t.Fatalf("expected RuntimeError, got %T: %v", err, err)
}
if runtimeErr.RuntimeCode() != "NETSTACK_VERIFY_FAILED" {
t.Fatalf("expected NETSTACK_VERIFY_FAILED, got %#v", runtimeErr)
}
if !runtimeErr.RuntimeRollbackApplied() {
t.Fatalf("verify failure must be reported as rollback-applied: %#v", runtimeErr)
}
if fake.applyCalls != 1 || fake.verifyCalls != 1 || fake.cleanupCalls != 1 {
t.Fatalf("unexpected netstack calls: apply=%d verify=%d cleanup=%d", fake.applyCalls, fake.verifyCalls, fake.cleanupCalls)
}
if manager.GetState() != StateStopped {
t.Fatalf("verify rollback should leave stopped, got %s", manager.GetState())
}
if manager.pid != 0 || manager.process != nil || manager.activeProfile != "" {
t.Fatalf("verify rollback left stale process state: pid=%d process=%v profile=%q", manager.pid, manager.process, manager.activeProfile)
}
}
type fakeCoreNetstack struct {
cleanupCalls int
applyCalls int
verifyCalls int
applyReport netstack.Report
verifyReport netstack.Report
}
func (f *fakeCoreNetstack) Apply() netstack.Report {
f.applyCalls++
if f.applyReport.Operation != "" || len(f.applyReport.Errors) > 0 {
return f.applyReport
}
return netstack.Report{Operation: "apply", Status: "ok"}
}
func (f *fakeCoreNetstack) Cleanup() netstack.Report {
f.cleanupCalls++
return netstack.Report{Operation: "cleanup", Status: "ok"}
}
func (f *fakeCoreNetstack) Verify() netstack.Report {
f.verifyCalls++
if f.verifyReport.Operation != "" || len(f.verifyReport.Errors) > 0 {
return f.verifyReport
}
return netstack.Report{Operation: "verify", Status: "ok"}
}
func freeTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func TestSingBoxHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_SINGBOX_HELPER") != "1" {
return
}
if path := os.Getenv("FAKE_PWD_FILE"); path != "" {
if err := os.WriteFile(path, []byte(mustGetwd()), 0644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
}
args := os.Args
for len(args) > 0 && args[0] != "--" {
args = args[1:]
}
if len(args) > 0 {
args = args[1:]
}
if len(args) == 0 {
os.Exit(2)
}
switch args[0] {
case "check":
os.Exit(0)
case "run":
tproxyListener, err := net.Listen("tcp", "127.0.0.1:"+os.Getenv("FAKE_TPROXY_PORT"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
defer tproxyListener.Close()
dnsListener, err := net.Listen("tcp", "127.0.0.1:"+os.Getenv("FAKE_DNS_PORT"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
defer dnsListener.Close()
select {}
default:
os.Exit(2)
}
}
func TestSingBoxConfigCheckTimeout(t *testing.T) {
dataDir := t.TempDir()
singBoxPath := filepath.Join(dataDir, "sing-box")
if err := os.WriteFile(singBoxPath, []byte("#!/bin/sh\nwhile :; do :; done\n"), 0755); err != nil {
t.Fatal(err)
}
err := runSingBoxConfigCheck(singBoxPath, filepath.Join(dataDir, "singbox.json"), "", 100*time.Millisecond)
if err == nil {
t.Fatal("expected config check timeout")
}
if !strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected timeout error, got %v", err)
}
}
func TestStartRunsSingBoxFromRunDir(t *testing.T) {
dataDir := t.TempDir()
binDir := filepath.Join(dataDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
tproxyPort := freeTCPPort(t)
dnsPort := freeTCPPort(t)
pwdFile := filepath.Join(dataDir, "singbox.pwd")
singBoxPath := filepath.Join(binDir, "sing-box")
script := fmt.Sprintf(
"#!/bin/sh\nGO_WANT_SINGBOX_HELPER=1 FAKE_TPROXY_PORT=%d FAKE_DNS_PORT=%d FAKE_PWD_FILE=%s exec %s -test.run=TestSingBoxHelperProcess -- \"$@\"\n",
tproxyPort,
dnsPort,
strconv.Quote(pwdFile),
strconv.Quote(os.Args[0]),
)
if err := os.WriteFile(singBoxPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
cfg := config.DefaultConfig()
setTestVLESSProfile(cfg, "example.com")
cfg.Proxy.TProxyPort = tproxyPort
cfg.Proxy.DNSPort = dnsPort
cfg.Proxy.APIPort = 0
cfg.Proxy.GID = os.Getegid()
manager := NewCoreManager(cfg, dataDir, nil)
manager.disableCoreCredentialForTest = true
manager.netstackFactory = func() coreNetstack { return &fakeCoreNetstack{} }
if err := manager.Start(config.ResolveActiveProfile(cfg)); err != nil {
t.Fatal(err)
}
defer func() { _ = manager.Stop() }()
pwdBytes, err := os.ReadFile(pwdFile)
if err != nil {
t.Fatal(err)
}
if got, want := string(pwdBytes), filepath.Join(dataDir, "run"); got != want {
t.Fatalf("sing-box workdir mismatch: got %q want %q", got, want)
}
}
func mustGetwd() string {
wd, err := os.Getwd()
if err != nil {
panic(err)
}
return wd
}
func TestScriptEnvIncludesLocalHelperPorts(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Profile.Inbounds = []byte(`{"socksPort":10808,"httpPort":10809}`)
manager := NewCoreManager(cfg, t.TempDir(), nil)
env := manager.scriptEnv()
if env["SOCKS_PORT"] != "10808" {
t.Fatalf("expected SOCKS_PORT=10808, got %q", env["SOCKS_PORT"])
}
if env["HTTP_PORT"] != "10809" {
t.Fatalf("expected HTTP_PORT=10809, got %q", env["HTTP_PORT"])
}
}
func TestScriptEnvDisablesLocalHelperPortsByDefault(t *testing.T) {
cfg := config.DefaultConfig()
manager := NewCoreManager(cfg, t.TempDir(), nil)
env := manager.scriptEnv()
if env["SOCKS_PORT"] != "0" {
t.Fatalf("default SOCKS_PORT must stay disabled, got %q", env["SOCKS_PORT"])
}
if env["HTTP_PORT"] != "0" {
t.Fatalf("default HTTP_PORT must stay disabled, got %q", env["HTTP_PORT"])
}
}
func TestRuntimeListenerWaitsIncludeDNSAndOptionalAPI(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Proxy.TProxyPort = 19053
cfg.Proxy.DNSPort = 19056
cfg.Proxy.APIPort = 19090
manager := NewCoreManager(cfg, t.TempDir(), nil)
specs := manager.runtimeListenerWaits()
if len(specs) != 3 {
t.Fatalf("expected tproxy, DNS, and API listener waits, got %#v", specs)
}
expected := []struct {
stage string
code string
port int
}{
{"wait-tproxy", "TPROXY_PORT_DOWN", 19053},
{"wait-dns", "DNS_LISTENER_DOWN", 19056},
{"wait-api", "API_PORT_DOWN", 19090},
}
for i, want := range expected {
if specs[i].Stage != want.stage || specs[i].Code != want.code || specs[i].Port != want.port {
t.Fatalf("unexpected listener wait %d: got %#v want %#v", i, specs[i], want)
}
}
}
func TestRuntimeListenerWaitsSkipDisabledAPI(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Proxy.APIPort = 0
manager := NewCoreManager(cfg, t.TempDir(), nil)
specs := manager.runtimeListenerWaits()
if len(specs) != 2 {
t.Fatalf("disabled API should leave only tproxy and DNS waits, got %#v", specs)
}
if specs[0].Port != 10853 || specs[1].Port != 10856 {
t.Fatalf("default listener ports not applied: %#v", specs)
}
}
func TestWaitForPortOrExitRejectsIPv6OnlyLoopbackListener(t *testing.T) {
listener, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 loopback is unavailable: %v", err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
manager := NewCoreManager(config.DefaultConfig(), t.TempDir(), nil)
exitCh := make(chan error)
if err := manager.waitForPortOrExit(port, time.Second, exitCh, ""); err == nil {
t.Fatal("IPv6-only loopback listener must not satisfy IPv4 TPROXY readiness")
}
}