RKNnoVPN/runtime/cmd/rknnovpn-runtime/main.go
loop-uh 2ccfe8ce0a
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
Перенести выпуск RKNnoVPN на Forgejo
2026-08-07 03:08:36 +03:00

1099 lines
29 KiB
Go

package main
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strconv"
"strings"
"time"
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/config"
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/core"
)
var Version = "dev"
var VersionCode = "0"
const (
moduleDir = "/data/adb/modules/rknnovpn"
wireEpoch = 1
activeFile = moduleDir + "/run/active"
configFile = moduleDir + "/config/config.json"
)
type envelope struct {
OK bool `json:"ok"`
Result any `json:"result,omitempty"`
Error *errorValue `json:"error,omitempty"`
}
type errorValue struct {
Code string `json:"code"`
Message string `json:"message"`
}
type runtimeVersionResult struct {
ReleaseVersion string `json:"releaseVersion"`
VersionCode int `json:"versionCode"`
WireEpoch int `json:"wireEpoch"`
}
type runtimeTrafficResult struct {
TxBytes int64 `json:"txBytes"`
RxBytes int64 `json:"rxBytes"`
TxRate int64 `json:"txRate"`
RxRate int64 `json:"rxRate"`
}
type runtimeHealthResult struct {
Healthy bool `json:"healthy"`
CoreRunning bool `json:"coreRunning"`
DNSOperational bool `json:"dnsOperational"`
RoutingReady bool `json:"routingReady"`
EgressReady bool `json:"egressReady"`
OperationalHealthy bool `json:"operationalHealthy"`
LastCode string `json:"lastCode,omitempty"`
LastError string `json:"lastError,omitempty"`
LastUserMessage string `json:"lastUserMessage,omitempty"`
LastDebug string `json:"lastDebug,omitempty"`
CheckedAt int64 `json:"checkedAt"`
}
type runtimeStatusResult struct {
State string `json:"state"`
Uptime int64 `json:"uptime"`
Traffic runtimeTrafficResult `json:"traffic"`
Health runtimeHealthResult `json:"health"`
}
type runtimeActionResult struct {
Accepted bool `json:"accepted"`
Status runtimeStatusResult `json:"status"`
Stage string `json:"stage"`
RuntimeCode string `json:"runtimeCode"`
UserMessage string `json:"userMessage"`
Debug string `json:"debug"`
RollbackApplied bool `json:"rollbackApplied"`
}
func main() {
if len(os.Args) < 2 {
fail(2, "usage", "usage: rknnovpn-runtime {version|status|apply|start|stop|restart|reset|logs|test-node|stage-module}")
}
var err error
switch os.Args[1] {
case "version":
ok(runtimeVersionResult{
ReleaseVersion: moduleVersion(),
VersionCode: moduleVersionCode(),
WireEpoch: wireEpoch,
})
case "status":
ok(runtimeStatus())
case "apply":
err = applyRuntimeConfig(os.Args[2:])
case "start":
ok(runtimeActionResultFrom("start", startRuntime()))
case "stop":
ok(runtimeActionResultFrom("stop", stopRuntime()))
case "restart":
ok(runtimeActionResultFrom("restart", restartRuntime()))
case "reset":
ok(runtimeActionResultFrom("reset", resetRuntime()))
case "logs":
ok(map[string]string{"text": readLogs(parseLines(os.Args[2:], 160))})
case "test-node":
err = testRuntimeNodes(os.Args[2:])
case "stage-module":
err = stageModule(os.Args[2:])
default:
fail(2, "unknown_command", "unknown runtime command")
}
if err != nil {
fail(1, "runtime_error", err.Error())
}
if os.Args[1] == "apply" {
ok(runtimeStatus())
}
}
func applyRuntimeConfig(args []string) error {
if len(args) != 1 {
return fmt.Errorf("apply requires runtime config path")
}
next, err := config.Load(args[0])
if err != nil {
return fmt.Errorf("load runtime config: %w", err)
}
if _, err := next.EnsureLocalClashAPIForMultiNode(); err != nil {
return err
}
if err := next.Validate(); err != nil {
return err
}
if err := next.Save(configFile); err != nil {
return fmt.Errorf("save runtime config: %w", err)
}
if err := renderRuntimeConfig(next); err != nil {
return err
}
return nil
}
type testNodeArgs struct {
ProfilePath string
NodeIDs []string
URL string
Mode string
}
type nodeTestResult struct {
NodeID string `json:"node_id"`
LatencyMs int `json:"latency_ms"`
ResponseMs *int `json:"response_ms,omitempty"`
Throughput *int64 `json:"throughput_bps,omitempty"`
Status string `json:"status"`
ErrorMessage string `json:"error,omitempty"`
}
func testRuntimeNodes(args []string) error {
parsed, err := parseTestNodeArgs(args)
if err != nil {
return err
}
cfg, err := config.Load(parsed.ProfilePath)
if err != nil {
return fmt.Errorf("load runtime profile: %w", err)
}
profiles := config.ProfilesFromConfigNodes(cfg)
if len(profiles) == 0 {
return fmt.Errorf("runtime profile has no nodes")
}
selected := selectTestProfiles(profiles, parsed.NodeIDs)
results := make([]nodeTestResult, 0, len(selected))
for _, profile := range selected {
results = append(results, testRuntimeProfileTCP(profile))
}
ok(results)
os.Exit(0)
return nil
}
func parseTestNodeArgs(args []string) (testNodeArgs, error) {
result := testNodeArgs{
URL: "https://www.gstatic.com/generate_204",
Mode: "tcp",
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--profile":
i++
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
return result, fmt.Errorf("test-node requires --profile value")
}
result.ProfilePath = args[i]
case "--node-id":
i++
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
return result, fmt.Errorf("test-node requires --node-id value")
}
result.NodeIDs = append(result.NodeIDs, strings.TrimSpace(args[i]))
case "--url":
i++
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
return result, fmt.Errorf("test-node requires --url value")
}
result.URL = strings.TrimSpace(args[i])
case "--mode":
i++
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
return result, fmt.Errorf("test-node requires --mode value")
}
result.Mode = strings.TrimSpace(args[i])
default:
return result, fmt.Errorf("unknown test-node argument %q", args[i])
}
}
if strings.TrimSpace(result.ProfilePath) == "" {
return result, fmt.Errorf("test-node requires --profile")
}
return result, nil
}
func selectTestProfiles(profiles []*config.NodeProfile, nodeIDs []string) []*config.NodeProfile {
if len(nodeIDs) == 0 {
return profiles
}
wanted := map[string]bool{}
for _, id := range nodeIDs {
id = strings.TrimSpace(id)
if id != "" {
wanted[id] = true
}
}
selected := make([]*config.NodeProfile, 0, len(wanted))
for _, profile := range profiles {
if wanted[profile.ID] {
selected = append(selected, profile)
}
}
return selected
}
func testRuntimeProfileTCP(profile *config.NodeProfile) nodeTestResult {
result := nodeTestResult{
NodeID: profile.ID,
LatencyMs: -1,
Status: "tcp_failed",
}
address := strings.TrimSpace(profile.Address)
if address == "" || profile.Port <= 0 {
result.ErrorMessage = "node endpoint is incomplete"
return result
}
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(address, strconv.Itoa(profile.Port)), 5*time.Second)
if err != nil {
result.ErrorMessage = err.Error()
return result
}
_ = conn.Close()
result.LatencyMs = int(time.Since(start).Milliseconds())
result.Status = "tcp_ok"
return result
}
func renderRuntimeConfig(cfg *config.Config) error {
runtimeProfile := config.ResolveActiveProfile(cfg)
if runtimeProfile == nil || runtimeProfile.Address == "" {
return clearRenderedRuntimeConfig()
}
return renderRuntimeConfigForProfile(cfg, runtimeProfile)
}
func renderRuntimeConfigForProfile(cfg *config.Config, runtimeProfile *config.NodeProfile) error {
if runtimeProfile == nil || strings.TrimSpace(runtimeProfile.Address) == "" {
return fmt.Errorf("runtime config has no active profile node")
}
data, err := config.RenderSingboxConfigForDataDir(cfg, runtimeProfile, moduleDir)
if err != nil {
return err
}
path := filepath.Join(moduleDir, "config", "rendered", "singbox.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func clearRenderedRuntimeConfig() error {
renderedDir := filepath.Join(moduleDir, "config", "rendered")
entries, err := os.ReadDir(renderedDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if err := os.Remove(filepath.Join(renderedDir, entry.Name())); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func startRuntime() error {
cfg, err := config.Load(configFile)
if err != nil {
return fmt.Errorf("load runtime config: %w", err)
}
runtimeProfile := config.ResolveActiveProfile(cfg)
if runtimeProfile == nil || strings.TrimSpace(runtimeProfile.Address) == "" {
_ = clearRenderedRuntimeConfig()
return fmt.Errorf("runtime config has no active profile node")
}
if err := renderRuntimeConfigForProfile(cfg, runtimeProfile); err != nil {
return err
}
manager := core.NewCoreManager(cfg, moduleDir, runtimeLogger())
if err := manager.Start(runtimeProfile); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(activeFile), 0o750); err != nil {
return err
}
if err := os.WriteFile(activeFile, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o600); err != nil {
return err
}
return nil
}
func stopRuntime() error {
cfg, _ := config.Load(configFile)
manager := core.NewCoreManager(cfg, moduleDir, runtimeLogger())
_ = manager.Stop()
_ = os.Remove(activeFile)
for _, pidFile := range []string{"singbox.pid", "xray.pid"} {
stopPidFile(filepath.Join(moduleDir, "run", pidFile))
}
return nil
}
func resetRuntime() error {
script := filepath.Join(moduleDir, "scripts", "rescue_reset.sh")
if _, err := os.Stat(script); err != nil {
return err
}
return exec.Command("/system/bin/sh", script, "hard-reset").Run()
}
func restartRuntime() error {
if err := stopRuntime(); err != nil {
return err
}
return startRuntime()
}
func runtimeActionResultFrom(command string, err error) runtimeActionResult {
if err == nil {
return runtimeActionResult{
Accepted: true,
Status: runtimeStatus(),
Stage: command,
}
}
stage := command
runtimeCode := "runtime_error"
userMessage := "Runtime action failed."
debug := err.Error()
rollbackApplied := false
var runtimeErr *core.RuntimeError
if errors.As(err, &runtimeErr) {
if strings.TrimSpace(runtimeErr.Layer) != "" {
stage = runtimeErr.Layer
}
if strings.TrimSpace(runtimeErr.RuntimeCode()) != "" {
runtimeCode = runtimeErr.RuntimeCode()
}
if strings.TrimSpace(runtimeErr.RuntimeUserMessage()) != "" {
userMessage = runtimeErr.RuntimeUserMessage()
}
if strings.TrimSpace(runtimeErr.RuntimeDebug()) != "" {
debug = runtimeErr.RuntimeDebug()
}
rollbackApplied = runtimeErr.RuntimeRollbackApplied()
}
return runtimeActionResult{
Accepted: false,
Status: runtimeStatusError(runtimeCode, userMessage, debug),
Stage: stage,
RuntimeCode: runtimeCode,
UserMessage: userMessage,
Debug: debug,
RollbackApplied: rollbackApplied,
}
}
func stageModule(args []string) error {
if len(args) != 1 {
return fmt.Errorf("stage-module requires module zip path")
}
stagedDir, err := stageModuleZip(args[0], moduleDir)
if err != nil {
return err
}
ok(map[string]any{"accepted": true, "message": "Module staged for root manager activation on reboot", "path": stagedDir})
os.Exit(0)
return nil
}
func stageModuleZip(src string, currentModuleDir string) (string, error) {
updateDir := rootManagerUpdateModuleDir(currentModuleDir)
updateParent := filepath.Dir(updateDir)
if err := os.MkdirAll(updateParent, 0o700); err != nil {
return "", err
}
workDir, err := os.MkdirTemp(updateParent, ".rknnovpn-stage-*")
if err != nil {
return "", err
}
defer os.RemoveAll(workDir)
extracted := filepath.Join(workDir, "archive")
if err := os.MkdirAll(extracted, 0o700); err != nil {
return "", err
}
if err := extractZipSecure(src, extracted); err != nil {
return "", err
}
if err := validateModuleArchive(extracted); err != nil {
return "", err
}
tmpDir := filepath.Join(workDir, "module")
if err := buildStagedModuleLayout(extracted, tmpDir, stagedArchDir()); err != nil {
return "", err
}
if err := os.RemoveAll(updateDir); err != nil {
return "", err
}
if err := os.Rename(tmpDir, updateDir); err != nil {
return "", err
}
return updateDir, nil
}
func rootManagerUpdateModuleDir(currentModuleDir string) string {
cleanModuleDir := filepath.Clean(currentModuleDir)
root := filepath.Dir(filepath.Dir(cleanModuleDir))
for _, managerRoot := range []string{"/data/adb/ksu", "/data/adb/ap"} {
if _, err := os.Stat(managerRoot); err == nil {
return filepath.Join(managerRoot, "modules_update", "rknnovpn")
}
}
return filepath.Join(root, "modules_update", "rknnovpn")
}
func extractZipSecure(src string, dst string) error {
reader, err := zip.OpenReader(src)
if err != nil {
return err
}
defer reader.Close()
for _, file := range reader.File {
name, ok := cleanZipEntryName(file.Name)
if !ok {
return fmt.Errorf("module archive contains unsafe path %q", file.Name)
}
target := filepath.Join(dst, name)
if file.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
input, err := file.Open()
if err != nil {
return err
}
mode := file.Mode().Perm()
if mode == 0 {
mode = 0o644
}
output, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
_ = input.Close()
return err
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
_ = input.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
return nil
}
func cleanZipEntryName(raw string) (string, bool) {
if raw == "" || filepath.IsAbs(raw) || strings.Contains(raw, "\\") {
return "", false
}
cleaned := filepath.Clean(raw)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", false
}
return cleaned, true
}
func validateModuleArchive(root string) error {
moduleProp := filepath.Join(root, "module.prop")
raw, err := os.ReadFile(moduleProp)
if err != nil {
return fmt.Errorf("module archive missing module.prop: %w", err)
}
if moduleProperty(string(raw), "id") != "rknnovpn" {
return fmt.Errorf("module archive id must be rknnovpn")
}
for _, required := range []string{
"customize.sh",
"service.sh",
"post-fs-data.sh",
"uninstall.sh",
"defaults/config.json",
"scripts/dns.sh",
"scripts/iptables.sh",
"scripts/privacy_guard.sh",
"scripts/rescue_reset.sh",
"scripts/routing.sh",
"scripts/lib/rknnovpn_env.sh",
"scripts/lib/rknnovpn_install.sh",
"scripts/lib/rknnovpn_installer_flow.sh",
"scripts/lib/rknnovpn_iptables_rules.sh",
"scripts/lib/rknnovpn_netstack.sh",
} {
if _, err := os.Stat(filepath.Join(root, required)); err != nil {
return fmt.Errorf("module archive missing %s", required)
}
}
return nil
}
func moduleProperty(raw string, key string) string {
prefix := key + "="
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
func buildStagedModuleLayout(extracted string, staged string, archDir string) error {
for _, dir := range []string{"bin", "config", "config/rendered", "scripts", "run", "logs", "releases"} {
if err := mkdirAllMode(filepath.Join(staged, dir), dirMode(dir)); err != nil {
return err
}
}
for _, file := range []string{"OWNERSHIP.md", "module.prop", "service.sh", "post-fs-data.sh", "uninstall.sh", "customize.sh", "sepolicy.rule"} {
if err := copyIfPresent(filepath.Join(extracted, file), filepath.Join(staged, file), rootFileMode(file)); err != nil {
return err
}
}
if err := copyTree(filepath.Join(extracted, "scripts"), filepath.Join(staged, "scripts"), stagedFileMode); err != nil {
return err
}
if err := copyTree(filepath.Join(extracted, "defaults"), filepath.Join(staged, "defaults"), stagedFileMode); err != nil {
return err
}
if err := copyTree(filepath.Join(extracted, "binaries", archDir), filepath.Join(staged, "bin"), func(string) os.FileMode { return 0o750 }); err != nil {
return fmt.Errorf("module archive missing binaries/%s: %w", archDir, err)
}
if err := copyIfPresent(filepath.Join(extracted, "defaults", "config.json"), filepath.Join(staged, "config", "config.json"), 0o600); err != nil {
return err
}
if err := copyIfPresent(filepath.Join(extracted, "defaults", "config.json"), filepath.Join(staged, "config", "config.defaults.json"), 0o600); err != nil {
return err
}
manualPath := filepath.Join(staged, "config", "manual")
if err := os.WriteFile(manualPath, []byte{}, 0o600); err != nil {
return err
}
if err := os.Chmod(manualPath, 0o600); err != nil {
return err
}
if err := writeStagedReleaseCatalog(staged); err != nil {
return err
}
return nil
}
func stagedArchDir() string {
switch goruntime.GOARCH {
case "arm64":
return "arm64"
case "arm":
return "armv7"
default:
return goruntime.GOARCH
}
}
func dirMode(dir string) os.FileMode {
switch dir {
case "config", "config/rendered", "logs":
return 0o700
case "bin", "run":
return 0o750
default:
return 0o755
}
}
func rootFileMode(path string) os.FileMode {
if strings.HasSuffix(path, ".sh") {
return 0o755
}
return 0o644
}
func stagedFileMode(path string) os.FileMode {
if strings.HasSuffix(path, ".sh") {
return 0o755
}
return 0o644
}
func copyTree(src string, dst string, modeFor func(string) os.FileMode) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", src)
}
return filepath.WalkDir(src, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
if entry.IsDir() {
return mkdirAllMode(target, 0o755)
}
return copyFile(path, target, modeFor(path))
})
}
func copyIfPresent(src string, dst string, mode os.FileMode) error {
if _, err := os.Stat(src); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return copyFile(src, dst, mode)
}
func copyFile(src string, dst string, mode os.FileMode) error {
input, err := os.Open(src)
if err != nil {
return err
}
defer input.Close()
if err := mkdirAllMode(filepath.Dir(dst), 0o755); err != nil {
return err
}
output, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
return os.Chmod(dst, mode)
}
func mkdirAllMode(path string, mode os.FileMode) error {
if err := os.MkdirAll(path, mode); err != nil {
return err
}
return os.Chmod(path, mode)
}
func writeStagedReleaseCatalog(staged string) error {
rawProp, err := os.ReadFile(filepath.Join(staged, "module.prop"))
if err != nil {
return err
}
version := moduleProperty(string(rawProp), "version")
if version == "" {
version = "unknown"
}
releaseDir := filepath.Join(staged, "releases", safeReleaseName(version))
if err := mkdirAllMode(filepath.Join(releaseDir, "bin"), 0o755); err != nil {
return err
}
if err := mkdirAllMode(filepath.Join(releaseDir, "module", "scripts"), 0o755); err != nil {
return err
}
if err := mkdirAllMode(filepath.Join(releaseDir, "module", "defaults"), 0o755); err != nil {
return err
}
for _, name := range []string{"rknnovpn-runtime", "sing-box", "xray"} {
if err := copyIfPresent(filepath.Join(staged, "bin", name), filepath.Join(releaseDir, "bin", name), 0o750); err != nil {
return err
}
}
for _, name := range []string{"OWNERSHIP.md", "module.prop", "service.sh", "post-fs-data.sh", "uninstall.sh", "customize.sh", "sepolicy.rule"} {
if err := copyIfPresent(filepath.Join(staged, name), filepath.Join(releaseDir, "module", name), rootFileMode(name)); err != nil {
return err
}
}
if err := copyTree(filepath.Join(staged, "scripts"), filepath.Join(releaseDir, "module", "scripts"), stagedFileMode); err != nil {
return err
}
if err := copyTree(filepath.Join(staged, "defaults"), filepath.Join(releaseDir, "module", "defaults"), stagedFileMode); err != nil {
return err
}
if err := writeInstallManifest(releaseDir, version); err != nil {
return err
}
current := filepath.Join(staged, "current")
if err := os.RemoveAll(current); err != nil {
return err
}
return os.Symlink(filepath.Join("releases", filepath.Base(releaseDir)), current)
}
func safeReleaseName(raw string) string {
replacer := func(r rune) rune {
if r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-' {
return r
}
return '_'
}
safe := strings.Trim(strings.Map(replacer, raw), "._-")
if safe == "" {
return "unknown"
}
return safe
}
func writeInstallManifest(releaseDir string, version string) error {
files := map[string]string{}
for _, rel := range []string{
"bin/rknnovpn-runtime",
"bin/sing-box",
"bin/xray",
"module/OWNERSHIP.md",
"module/module.prop",
"module/service.sh",
"module/post-fs-data.sh",
"module/uninstall.sh",
"module/customize.sh",
"module/sepolicy.rule",
"module/scripts/dns.sh",
"module/scripts/iptables.sh",
"module/scripts/privacy_guard.sh",
"module/scripts/rescue_reset.sh",
"module/scripts/routing.sh",
"module/scripts/lib/rknnovpn_env.sh",
"module/scripts/lib/rknnovpn_install.sh",
"module/scripts/lib/rknnovpn_installer_flow.sh",
"module/scripts/lib/rknnovpn_netstack.sh",
"module/scripts/lib/rknnovpn_iptables_rules.sh",
"module/defaults/config.json",
} {
hash, err := fileSHA256(filepath.Join(releaseDir, filepath.FromSlash(rel)))
if os.IsNotExist(err) {
continue
}
if err != nil {
return err
}
files[rel] = hash
}
manifest := struct {
Version string `json:"version"`
Installed string `json:"installed_at"`
Files map[string]string `json:"files_sha256"`
}{
Version: version,
Installed: time.Now().UTC().Format(time.RFC3339),
Files: files,
}
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
path := filepath.Join(releaseDir, "install-manifest.json")
if err := os.WriteFile(path, raw, 0o600); err != nil {
return err
}
return os.Chmod(path, 0o600)
}
func fileSHA256(path string) (string, error) {
input, err := os.Open(path)
if err != nil {
return "", err
}
defer input.Close()
hash := sha256.New()
if _, err := io.Copy(hash, input); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func runtimeStatus() runtimeStatusResult {
state := "DISCONNECTED"
healthy := false
if runtimeRunning() {
state = "CONNECTED"
healthy = true
}
return runtimeStatusResult{
State: state,
Uptime: int64(0),
Traffic: runtimeTrafficResult{
TxBytes: int64(0),
RxBytes: int64(0),
TxRate: int64(0),
RxRate: int64(0),
},
Health: runtimeHealthResult{
Healthy: healthy,
CoreRunning: healthy,
DNSOperational: healthy,
RoutingReady: healthy,
EgressReady: healthy,
OperationalHealthy: healthy,
CheckedAt: time.Now().UTC().Unix(),
},
}
}
func runtimeStatusError(runtimeCode string, userMessage string, debug string) runtimeStatusResult {
status := runtimeStatus()
status.State = "ERROR"
status.Health.Healthy = false
status.Health.OperationalHealthy = false
status.Health.LastCode = runtimeCode
status.Health.LastUserMessage = userMessage
status.Health.LastDebug = debug
return status
}
func runtimeRunning() bool {
return runtimeRunningFromDir(filepath.Join(moduleDir, "run"))
}
func runtimeRunningFromDir(runDir string) bool {
return runtimePIDFileRunning(filepath.Join(runDir, "singbox.pid"), "sing-box")
}
func runtimePIDFileRunning(path string, binary string) bool {
raw, err := os.ReadFile(path)
if err != nil {
return false
}
pid, startTime, err := parseRuntimePIDFile(raw)
return err == nil && pidLooksLikeRuntimeProcess(pid, startTime, binary)
}
func runtimeLogger() *log.Logger {
_ = os.MkdirAll(filepath.Join(moduleDir, "logs"), 0o700)
file, err := os.OpenFile(filepath.Join(moduleDir, "logs", "runtime-cli.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return log.New(io.Discard, "", 0)
}
return log.New(file, "rknnovpn-runtime: ", log.LstdFlags)
}
func moduleVersion() string {
if v := prop("version"); v != "" {
return v
}
return Version
}
func moduleVersionCode() int {
if code, err := strconv.Atoi(prop("versionCode")); err == nil {
return code
}
code, _ := strconv.Atoi(VersionCode)
return code
}
func prop(key string) string {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.prop"))
if err != nil {
return ""
}
prefix := key + "="
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
func readLogs(lines int) string {
var b strings.Builder
for _, name := range []string{"service.log", "runtime-cli.log", "sing-box.log", "xray.log", "rescue_reset.log"} {
path := filepath.Join(moduleDir, "logs", name)
raw, err := os.ReadFile(path)
if err != nil || len(raw) == 0 {
continue
}
b.WriteString("== " + name + " ==\n")
b.WriteString(tailLines(string(raw), lines))
b.WriteString("\n")
}
return b.String()
}
func parseLines(args []string, fallback int) int {
for i := 0; i < len(args); i++ {
if args[i] == "--lines" && i+1 < len(args) {
if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 {
return n
}
}
}
return fallback
}
func tailLines(raw string, lines int) string {
parts := strings.Split(strings.TrimRight(raw, "\n"), "\n")
if len(parts) > lines {
parts = parts[len(parts)-lines:]
}
return strings.Join(parts, "\n")
}
func stopPidFile(path string) {
raw, err := os.ReadFile(path)
if err != nil {
return
}
pid, startTime, err := parseRuntimePIDFile(raw)
if err != nil || pid <= 1 {
_ = os.Remove(path)
return
}
binary := "sing-box"
if strings.Contains(filepath.Base(path), "xray") {
binary = "xray"
}
if !pidLooksLikeRuntimeProcess(pid, startTime, binary) {
_ = os.Remove(path)
return
}
_ = exec.Command("kill", strconv.Itoa(pid)).Run()
_ = os.Remove(path)
}
func parseRuntimePIDFile(raw []byte) (int, string, error) {
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0, "", fmt.Errorf("empty runtime pid file")
}
pid, err := strconv.Atoi(fields[0])
if err != nil {
return 0, "", err
}
startTime := ""
if len(fields) > 1 {
startTime = fields[1]
}
return pid, startTime, nil
}
func pidLooksLikeRuntimeProcess(pid int, startTime string, binary string) bool {
if pid <= 1 || pid == os.Getpid() {
return false
}
if exec.Command("kill", "-0", strconv.Itoa(pid)).Run() != nil {
return false
}
if startTime != "" {
currentStartTime, err := procStartTime(pid)
if err != nil || currentStartTime != startTime {
return false
}
}
expectedBin := filepath.Join(moduleDir, "bin", binary)
expectedConfig := runtimeProcessConfigPath(binary)
if expectedConfig == "" {
return false
}
rawCmdline, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
if err != nil {
return false
}
binOK := false
if target, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "exe")); err == nil {
if filepath.Clean(target) == expectedBin {
binOK = true
}
}
configOK := false
for _, token := range strings.Split(string(rawCmdline), "\x00") {
cleaned := filepath.Clean(token)
if cleaned == expectedBin {
binOK = true
}
if cleaned == expectedConfig {
configOK = true
}
}
return binOK && configOK
}
func runtimeProcessConfigPath(binary string) string {
switch binary {
case "sing-box":
return filepath.Join(moduleDir, "config", "rendered", "singbox.json")
case "xray":
return filepath.Join(moduleDir, "config", "rendered", "xray-xhttp.json")
default:
return ""
}
}
func procStartTime(pid int) (string, error) {
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
if err != nil {
return "", err
}
text := string(data)
idx := strings.LastIndex(text, ")")
if idx < 0 || idx+2 >= len(text) {
return "", fmt.Errorf("invalid proc stat for pid %d", pid)
}
fields := strings.Fields(text[idx+2:])
if len(fields) < 20 {
return "", fmt.Errorf("short proc stat for pid %d", pid)
}
return fields[19], nil
}
func ok(result any) {
_ = json.NewEncoder(os.Stdout).Encode(envelope{OK: true, Result: result})
}
func fail(exit int, code string, message string) {
_ = json.NewEncoder(os.Stdout).Encode(envelope{
OK: false,
Error: &errorValue{
Code: code,
Message: message,
},
})
os.Exit(exit)
}