523 lines
17 KiB
Go
523 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/youtubediscord/RKNnoVPN/runtime/internal/core"
|
|
)
|
|
|
|
func TestRuntimeCLIContractFixtures(t *testing.T) {
|
|
assertJSONShape(t, "version-ok.json", envelope{
|
|
OK: true,
|
|
Result: runtimeVersionResult{
|
|
ReleaseVersion: "v2.3.7",
|
|
VersionCode: 2030700,
|
|
WireEpoch: wireEpoch,
|
|
},
|
|
})
|
|
assertJSONShape(t, "status-ok.json", envelope{
|
|
OK: true,
|
|
Result: runtimeStatusResult{
|
|
State: "CONNECTED",
|
|
Uptime: int64(0),
|
|
Traffic: runtimeTrafficResult{
|
|
TxBytes: int64(0),
|
|
RxBytes: int64(0),
|
|
TxRate: int64(0),
|
|
RxRate: int64(0),
|
|
},
|
|
Health: runtimeHealthResult{
|
|
Healthy: true,
|
|
CoreRunning: true,
|
|
DNSOperational: true,
|
|
RoutingReady: true,
|
|
EgressReady: true,
|
|
OperationalHealthy: true,
|
|
CheckedAt: int64(1710000000),
|
|
},
|
|
},
|
|
})
|
|
assertJSONShape(t, "node-test-ok.json", envelope{
|
|
OK: true,
|
|
Result: []nodeTestResult{
|
|
{
|
|
NodeID: "node-1",
|
|
LatencyMs: 42,
|
|
ResponseMs: intPtr(57),
|
|
Throughput: int64Ptr(1024),
|
|
Status: "tcp_ok",
|
|
},
|
|
},
|
|
})
|
|
assertJSONShape(t, "node-ping-ok.json", envelope{
|
|
OK: true,
|
|
Result: nodePingResult{
|
|
NodeID: "node-1",
|
|
LatencyMs: 42,
|
|
Status: "tcp_ok",
|
|
},
|
|
})
|
|
assertJSONShape(t, "action-start-ok.json", envelope{
|
|
OK: true,
|
|
Result: runtimeActionResult{
|
|
Accepted: true,
|
|
Status: runtimeStatusResult{
|
|
State: "CONNECTED",
|
|
Uptime: int64(0),
|
|
Traffic: runtimeTrafficResult{
|
|
TxBytes: int64(0),
|
|
RxBytes: int64(0),
|
|
TxRate: int64(0),
|
|
RxRate: int64(0),
|
|
},
|
|
Health: runtimeHealthResult{
|
|
Healthy: true,
|
|
CoreRunning: true,
|
|
DNSOperational: true,
|
|
RoutingReady: true,
|
|
EgressReady: true,
|
|
OperationalHealthy: true,
|
|
CheckedAt: int64(1710000000),
|
|
},
|
|
},
|
|
Stage: "start",
|
|
RuntimeCode: "",
|
|
UserMessage: "",
|
|
Debug: "",
|
|
RollbackApplied: false,
|
|
},
|
|
})
|
|
assertJSONShape(t, "action-start-error.json", envelope{
|
|
OK: true,
|
|
Result: runtimeActionResult{
|
|
Accepted: false,
|
|
Status: runtimeStatusResult{
|
|
State: "ERROR",
|
|
Uptime: int64(0),
|
|
Traffic: runtimeTrafficResult{
|
|
TxBytes: int64(0),
|
|
RxBytes: int64(0),
|
|
TxRate: int64(0),
|
|
RxRate: int64(0),
|
|
},
|
|
Health: runtimeHealthResult{
|
|
Healthy: false,
|
|
CoreRunning: false,
|
|
DNSOperational: false,
|
|
RoutingReady: false,
|
|
EgressReady: false,
|
|
OperationalHealthy: false,
|
|
LastCode: "RULES_NOT_APPLIED",
|
|
LastUserMessage: "RKNnoVPN routing rules could not be applied.",
|
|
LastDebug: "iptables denied",
|
|
CheckedAt: int64(1710000000),
|
|
},
|
|
},
|
|
Stage: "netstack apply",
|
|
RuntimeCode: "RULES_NOT_APPLIED",
|
|
UserMessage: "RKNnoVPN routing rules could not be applied.",
|
|
Debug: "iptables denied",
|
|
RollbackApplied: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestRuntimeActionResultCapturesRuntimeError(t *testing.T) {
|
|
result := runtimeActionResultFrom("start", &core.RuntimeError{
|
|
Layer: "netstack apply",
|
|
Code: "RULES_NOT_APPLIED",
|
|
UserMessage: "RKNnoVPN routing rules could not be applied.",
|
|
Debug: "iptables denied",
|
|
RollbackApplied: true,
|
|
Err: errors.New("iptables denied"),
|
|
})
|
|
|
|
if result.Accepted {
|
|
t.Fatal("runtime action error must be a rejected action result")
|
|
}
|
|
if result.Stage != "netstack apply" {
|
|
t.Fatalf("stage = %q", result.Stage)
|
|
}
|
|
if result.RuntimeCode != "RULES_NOT_APPLIED" {
|
|
t.Fatalf("runtime code = %q", result.RuntimeCode)
|
|
}
|
|
if result.UserMessage != "RKNnoVPN routing rules could not be applied." {
|
|
t.Fatalf("user message = %q", result.UserMessage)
|
|
}
|
|
if result.Debug != "iptables denied" {
|
|
t.Fatalf("debug = %q", result.Debug)
|
|
}
|
|
if !result.RollbackApplied {
|
|
t.Fatal("rollback flag must be preserved")
|
|
}
|
|
if result.Status.State != "ERROR" {
|
|
t.Fatalf("status state = %q", result.Status.State)
|
|
}
|
|
if result.Status.Health.LastCode != result.RuntimeCode {
|
|
t.Fatalf("health last code = %q", result.Status.Health.LastCode)
|
|
}
|
|
}
|
|
|
|
func TestParseRuntimePIDFileAcceptsStartTime(t *testing.T) {
|
|
pid, startTime, err := parseRuntimePIDFile([]byte("123 456\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pid != 123 || startTime != "456" {
|
|
t.Fatalf("pid=%d startTime=%q", pid, startTime)
|
|
}
|
|
}
|
|
|
|
func TestStopPidFileRemovesStalePIDWithoutKillingCurrentProcess(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pidFile := filepath.Join(dir, "singbox.pid")
|
|
if err := os.WriteFile(pidFile, []byte("1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stopPidFile(pidFile)
|
|
|
|
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
|
|
t.Fatalf("invalid pid file should be removed without killing pid 1, stat err=%v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(pidFile, []byte("999999\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stopPidFile(pidFile)
|
|
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
|
|
t.Fatalf("stale missing-process pid file should be removed, stat err=%v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stopPidFile(pidFile)
|
|
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
|
|
t.Fatalf("stale current-process pid file should be removed without killing this process, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeRunningIgnoresXrayOnlyPID(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "xray.pid"), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runtimeRunningFromDir(dir) {
|
|
t.Fatal("xray-only pid file must not mark runtime connected")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeProcessConfigPathIsCanonicalOnly(t *testing.T) {
|
|
if got, want := runtimeProcessConfigPath("sing-box"), filepath.Join(moduleDir, "config", "rendered", "singbox.json"); got != want {
|
|
t.Fatalf("sing-box config path = %q, want %q", got, want)
|
|
}
|
|
if got, want := runtimeProcessConfigPath("xray"), filepath.Join(moduleDir, "config", "rendered", "xray-xhttp.json"); got != want {
|
|
t.Fatalf("xray config path = %q, want %q", got, want)
|
|
}
|
|
if got := runtimeProcessConfigPath("old-hot-swap"); got != "" {
|
|
t.Fatalf("unknown runtime binary should not get a config path, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStageModuleWritesRootManagerUpdateLayout(t *testing.T) {
|
|
dir := t.TempDir()
|
|
zipPath := filepath.Join(dir, "module.zip")
|
|
archDir := stagedArchDir()
|
|
writeModuleZip(t, zipPath, map[string]string{
|
|
"module.prop": "id=rknnovpn\nversion=v2.4.0\n",
|
|
"customize.sh": "#!/system/bin/sh\n",
|
|
"service.sh": "#!/system/bin/sh\n",
|
|
"post-fs-data.sh": "#!/system/bin/sh\n",
|
|
"uninstall.sh": "#!/system/bin/sh\n",
|
|
"defaults/config.json": "{}\n",
|
|
"scripts/dns.sh": "#!/system/bin/sh\n",
|
|
"scripts/iptables.sh": "#!/system/bin/sh\n",
|
|
"scripts/privacy_guard.sh": "#!/system/bin/sh\n",
|
|
"scripts/rescue_reset.sh": "#!/system/bin/sh\n",
|
|
"scripts/routing.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_env.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_install.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_installer_flow.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_iptables_rules.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_netstack.sh": "#!/system/bin/sh\n",
|
|
filepath.ToSlash(filepath.Join("binaries", archDir, "sing-box")): "sing-box",
|
|
filepath.ToSlash(filepath.Join("binaries", archDir, "rknnovpn-runtime")): "runtime",
|
|
})
|
|
|
|
staged, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantDir := filepath.Join(dir, "modules_update", "rknnovpn")
|
|
if staged != wantDir {
|
|
t.Fatalf("staged dir = %q, want %q", staged, wantDir)
|
|
}
|
|
if got := readFile(t, filepath.Join(wantDir, "bin", "rknnovpn-runtime")); got != "runtime" {
|
|
t.Fatalf("runtime binary = %q", got)
|
|
}
|
|
if got := readFile(t, filepath.Join(wantDir, "bin", "sing-box")); got != "sing-box" {
|
|
t.Fatalf("sing-box binary = %q", got)
|
|
}
|
|
assertFileMode(t, filepath.Join(wantDir, "customize.sh"), 0o755)
|
|
assertFileMode(t, filepath.Join(wantDir, "service.sh"), 0o755)
|
|
assertFileMode(t, filepath.Join(wantDir, "scripts", "rescue_reset.sh"), 0o755)
|
|
assertFileMode(t, filepath.Join(wantDir, "bin", "rknnovpn-runtime"), 0o750)
|
|
if got := readFile(t, filepath.Join(wantDir, "module.prop")); !strings.Contains(got, "id=rknnovpn") {
|
|
t.Fatalf("module.prop = %q", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(wantDir, "config", "manual")); err != nil {
|
|
t.Fatalf("manual marker missing: %v", err)
|
|
}
|
|
if got := readFile(t, filepath.Join(wantDir, "config", "config.json")); got != "{}\n" {
|
|
t.Fatalf("staged config = %q", got)
|
|
}
|
|
current, err := os.Readlink(filepath.Join(wantDir, "current"))
|
|
if err != nil {
|
|
t.Fatalf("current release link missing: %v", err)
|
|
}
|
|
if current != filepath.Join("releases", "v2.4.0") {
|
|
t.Fatalf("current release link = %q", current)
|
|
}
|
|
manifest := readFile(t, filepath.Join(wantDir, "releases", "v2.4.0", "install-manifest.json"))
|
|
for _, want := range []string{`"version": "v2.4.0"`, `"bin/rknnovpn-runtime"`, `"module/module.prop"`, `"module/scripts/lib/rknnovpn_netstack.sh"`} {
|
|
if !strings.Contains(manifest, want) {
|
|
t.Fatalf("release manifest missing %s: %s", want, manifest)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "modules", "rknnovpn", "releases", "pending-module.zip")); !os.IsNotExist(err) {
|
|
t.Fatalf("obsolete pending-module.zip must not be written, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestStageModulePermissionsIgnoreProcessUmask(t *testing.T) {
|
|
oldUmask := syscall.Umask(0o077)
|
|
t.Cleanup(func() {
|
|
syscall.Umask(oldUmask)
|
|
})
|
|
|
|
dir := t.TempDir()
|
|
zipPath := filepath.Join(dir, "module.zip")
|
|
archDir := stagedArchDir()
|
|
writeModuleZip(t, zipPath, map[string]string{
|
|
"module.prop": "id=rknnovpn\nversion=v2.4.0\n",
|
|
"customize.sh": "#!/system/bin/sh\n",
|
|
"service.sh": "#!/system/bin/sh\n",
|
|
"post-fs-data.sh": "#!/system/bin/sh\n",
|
|
"uninstall.sh": "#!/system/bin/sh\n",
|
|
"defaults/config.json": "{}\n",
|
|
"scripts/dns.sh": "#!/system/bin/sh\n",
|
|
"scripts/iptables.sh": "#!/system/bin/sh\n",
|
|
"scripts/privacy_guard.sh": "#!/system/bin/sh\n",
|
|
"scripts/rescue_reset.sh": "#!/system/bin/sh\n",
|
|
"scripts/routing.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_env.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_install.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_installer_flow.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_iptables_rules.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_netstack.sh": "#!/system/bin/sh\n",
|
|
filepath.ToSlash(filepath.Join("binaries", archDir, "rknnovpn-runtime")): "runtime",
|
|
})
|
|
|
|
staged, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
assertFileMode(t, filepath.Join(staged, "customize.sh"), 0o755)
|
|
assertFileMode(t, filepath.Join(staged, "service.sh"), 0o755)
|
|
assertFileMode(t, filepath.Join(staged, "scripts", "rescue_reset.sh"), 0o755)
|
|
assertFileMode(t, filepath.Join(staged, "bin", "rknnovpn-runtime"), 0o750)
|
|
}
|
|
|
|
func TestStageModuleRejectsArchiveMissingRequiredLibs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
zipPath := filepath.Join(dir, "module.zip")
|
|
archDir := stagedArchDir()
|
|
writeModuleZip(t, zipPath, map[string]string{
|
|
"module.prop": "id=rknnovpn\nversion=v2.4.0\n",
|
|
"customize.sh": "#!/system/bin/sh\n",
|
|
"service.sh": "#!/system/bin/sh\n",
|
|
"post-fs-data.sh": "#!/system/bin/sh\n",
|
|
"uninstall.sh": "#!/system/bin/sh\n",
|
|
"defaults/config.json": "{}\n",
|
|
"scripts/dns.sh": "#!/system/bin/sh\n",
|
|
"scripts/iptables.sh": "#!/system/bin/sh\n",
|
|
"scripts/privacy_guard.sh": "#!/system/bin/sh\n",
|
|
"scripts/rescue_reset.sh": "#!/system/bin/sh\n",
|
|
"scripts/routing.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_env.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_install.sh": "#!/system/bin/sh\n",
|
|
"scripts/lib/rknnovpn_installer_flow.sh": "#!/system/bin/sh\n",
|
|
filepath.ToSlash(filepath.Join("binaries", archDir, "rknnovpn-runtime")): "runtime",
|
|
})
|
|
|
|
if _, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn")); err == nil {
|
|
t.Fatal("expected archive missing required scripts/lib files to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestStageModuleRejectsZipPathTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
zipPath := filepath.Join(dir, "module.zip")
|
|
writeModuleZip(t, zipPath, map[string]string{
|
|
"module.prop": "id=rknnovpn\n",
|
|
"../escape.sh": "bad",
|
|
"scripts/ok.sh": "ok",
|
|
})
|
|
|
|
if _, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn")); err == nil {
|
|
t.Fatal("expected path traversal archive to be rejected")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "escape.sh")); !os.IsNotExist(err) {
|
|
t.Fatalf("path traversal wrote outside update dir, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseTestNodeArgs(t *testing.T) {
|
|
args, err := parseTestNodeArgs([]string{
|
|
"--profile", "/tmp/profile.json",
|
|
"--node-id", "a",
|
|
"--node-id", "b",
|
|
"--url", "https://example.com/generate_204",
|
|
"--mode", "tcp",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if args.ProfilePath != "/tmp/profile.json" {
|
|
t.Fatalf("profile path = %q", args.ProfilePath)
|
|
}
|
|
if len(args.NodeIDs) != 2 || args.NodeIDs[0] != "a" || args.NodeIDs[1] != "b" {
|
|
t.Fatalf("node ids = %#v", args.NodeIDs)
|
|
}
|
|
if args.URL != "https://example.com/generate_204" || args.Mode != "tcp" {
|
|
t.Fatalf("url/mode = %q/%q", args.URL, args.Mode)
|
|
}
|
|
}
|
|
|
|
func TestParseTestNodeArgsRequiresProfile(t *testing.T) {
|
|
if _, err := parseTestNodeArgs([]string{"--node-id", "a"}); err == nil {
|
|
t.Fatal("expected missing profile to fail")
|
|
}
|
|
}
|
|
|
|
func assertJSONShape(t *testing.T, fixtureName string, actual any) {
|
|
t.Helper()
|
|
expectedRaw, err := os.ReadFile(filepath.Join("..", "..", "testdata", fixtureName))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actualRaw, err := json.Marshal(actual)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var expectedValue any
|
|
if err := json.Unmarshal(expectedRaw, &expectedValue); err != nil {
|
|
t.Fatalf("decode fixture %s: %v", fixtureName, err)
|
|
}
|
|
var actualValue any
|
|
if err := json.Unmarshal(actualRaw, &actualValue); err != nil {
|
|
t.Fatalf("decode actual %s: %v", fixtureName, err)
|
|
}
|
|
compareJSONShape(t, fixtureName, expectedValue, actualValue)
|
|
}
|
|
|
|
func compareJSONShape(t *testing.T, path string, expected any, actual any) {
|
|
t.Helper()
|
|
expectedType := reflect.TypeOf(expected)
|
|
actualType := reflect.TypeOf(actual)
|
|
if expectedType != actualType {
|
|
t.Fatalf("%s type = %v, want %v", path, actualType, expectedType)
|
|
}
|
|
|
|
switch expectedValue := expected.(type) {
|
|
case map[string]any:
|
|
actualValue := actual.(map[string]any)
|
|
if len(actualValue) != len(expectedValue) {
|
|
t.Fatalf("%s key count = %d, want %d; actual=%v expected=%v", path, len(actualValue), len(expectedValue), actualValue, expectedValue)
|
|
}
|
|
for key, expectedChild := range expectedValue {
|
|
actualChild, ok := actualValue[key]
|
|
if !ok {
|
|
t.Fatalf("%s missing key %q", path, key)
|
|
}
|
|
compareJSONShape(t, path+"."+key, expectedChild, actualChild)
|
|
}
|
|
case []any:
|
|
actualValue := actual.([]any)
|
|
if len(expectedValue) == 0 {
|
|
if len(actualValue) != 0 {
|
|
t.Fatalf("%s array length = %d, want empty", path, len(actualValue))
|
|
}
|
|
return
|
|
}
|
|
if len(actualValue) == 0 {
|
|
t.Fatalf("%s array is empty", path)
|
|
}
|
|
compareJSONShape(t, path+"[0]", expectedValue[0], actualValue[0])
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
func intPtr(value int) *int {
|
|
return &value
|
|
}
|
|
|
|
func int64Ptr(value int64) *int64 {
|
|
return &value
|
|
}
|
|
|
|
func writeModuleZip(t *testing.T, path string, files map[string]string) {
|
|
t.Helper()
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
archive := zip.NewWriter(file)
|
|
for name, content := range files {
|
|
entry, err := archive.Create(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := entry.Write([]byte(content)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := archive.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func assertFileMode(t *testing.T, path string, want os.FileMode) {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := info.Mode().Perm(); got != want {
|
|
t.Fatalf("%s mode = %o, want %o", path, got, want)
|
|
}
|
|
}
|
|
|
|
func readFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|