Some checks failed
RKNnoVPN Linux and Android CI / policy-and-go (push) Successful in 14s
RKNnoVPN Linux and Android CI / android-debug (push) Successful in 2m5s
Full build and Forgejo release / Privacy Lint (push) Successful in 3s
Full build and Forgejo release / Local-first Runtime Guardrail (push) Successful in 2s
Full build and Forgejo release / Go Tests (push) Successful in 13s
Full build and Forgejo release / Resolve sing-box release (push) Successful in 1s
Full build and Forgejo release / Resolve Xray-core release (push) Successful in 1s
Full build and Forgejo release / Android Guardrails & Tests (push) Failing after 16s
Full build and Forgejo release / Build APK (push) Has been skipped
Full build and Forgejo release / Build Runtime CLI (arm64) (push) Successful in 11s
Full build and Forgejo release / Build Runtime CLI (armv7) (push) Successful in 11s
Full build and Forgejo release / Build sing-box (arm64) (push) Failing after 2s
Full build and Forgejo release / Build sing-box (armv7) (push) Failing after 2s
Full build and Forgejo release / Build Xray-core (arm64) (push) Failing after 3s
Full build and Forgejo release / Build Xray-core (armv7) (push) Failing after 2s
Full build and Forgejo release / Build Magisk Module (push) Has been skipped
Full build and Forgejo release / Create Release (push) Has been skipped
454 lines
13 KiB
Go
454 lines
13 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/config"
|
|
"git.zapret.moe/zapretdiscordyoutube/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")
|
|
}
|
|
}
|