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
1099 lines
29 KiB
Go
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)
|
|
}
|