RKNnoVPN/runtime/internal/core/manager.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

1066 lines
32 KiB
Go

// Package core manages the sing-box process lifecycle around an APK-rendered
// runtime config.
package core
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/config"
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/modulecontract"
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/netstack"
)
// State represents the runtime proxy-core lifecycle state.
type State int
const (
StateStopped State = iota // sing-box is not running
StateStarting // spawn in progress, waiting for port
StateRunning // sing-box is listening and iptables are applied
StateStopping // teardown in progress
)
// RuntimeError carries a stable startup/runtime code across package
// boundaries so the runtime caller can show the failing stage.
type RuntimeError struct {
Layer string
Code string
Hard bool
UserMessage string
Debug string
RollbackApplied bool
Err error
}
func (e *RuntimeError) Error() string {
if e == nil {
return ""
}
if e.Layer != "" {
return fmt.Sprintf("core: %s: %v", e.Layer, e.Err)
}
return fmt.Sprintf("core: %v", e.Err)
}
func (e *RuntimeError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func (e *RuntimeError) RuntimeCode() string {
if e == nil {
return ""
}
return e.Code
}
func (e *RuntimeError) RuntimeUserMessage() string {
if e == nil {
return ""
}
return e.UserMessage
}
func (e *RuntimeError) RuntimeDebug() string {
if e == nil {
return ""
}
return e.Debug
}
func (e *RuntimeError) RuntimeRollbackApplied() bool {
if e == nil {
return false
}
return e.RollbackApplied
}
func runtimeError(layer string, code string, err error, rollbackApplied bool) error {
if err == nil {
return nil
}
return &RuntimeError{
Layer: layer,
Code: code,
Hard: true,
UserMessage: runtimeUserMessage(code),
Debug: err.Error(),
RollbackApplied: rollbackApplied,
Err: err,
}
}
func runtimeUserMessage(code string) string {
switch code {
case "CONFIG_RENDER_FAILED":
return "Generated sing-box config could not be rendered."
case "CONFIG_CHECK_FAILED":
return "Generated sing-box config did not pass sing-box check."
case "CORE_LOG_OPEN_FAILED":
return "sing-box log file could not be opened."
case "CORE_SPAWN_FAILED":
return "sing-box process could not be started."
case "CORE_STOP_FAILED":
return "previous sing-box process could not be stopped."
case "TPROXY_PORT_DOWN":
return "sing-box did not open the TPROXY listener."
case "DNS_LISTENER_DOWN":
return "sing-box did not open the local DNS listener."
case "API_PORT_DOWN":
return "sing-box did not open the local API listener."
case "RULES_NOT_APPLIED":
return "RKNnoVPN routing rules could not be applied."
case "DNS_APPLY_FAILED":
return "RKNnoVPN DNS interception could not be applied."
case "NETSTACK_VERIFY_FAILED":
return "RKNnoVPN network rules were applied but did not pass verification."
case "NETSTACK_CLEANUP_FAILED":
return "Previous RKNnoVPN network rules could not be cleaned up."
case "LOCAL_PROXY_OWNER_MISMATCH":
return "Local proxy port is not owned by the configured app."
default:
return "Runtime stage failed."
}
}
// String returns a human-readable label for the state.
func (s State) String() string {
switch s {
case StateStopped:
return "stopped"
case StateStarting:
return "starting"
case StateRunning:
return "running"
case StateStopping:
return "stopping"
default:
return "unknown"
}
}
type listenerWaitSpec struct {
Stage string
Layer string
Code string
Label string
Port int
Timeout time.Duration
}
const singBoxCheckTimeout = 20 * time.Second
type coreNetstack interface {
Apply() netstack.Report
Cleanup() netstack.Report
Verify() netstack.Report
}
// CoreManager owns the sing-box child process and the iptables / DNS
// rules that make transparent proxying work.
type CoreManager struct {
config *config.Config
process *os.Process
exitCh <-chan error
pid int
xrayProcess *os.Process
xrayExitCh <-chan error
xrayPID int
state State
dataDir string
logger *log.Logger
activeProfile string
startedAt time.Time
netstackFactory func() coreNetstack
disableCoreCredentialForTest bool
mu sync.Mutex
}
// NewCoreManager creates a CoreManager that stores runtime data under dataDir
// (normally /data/adb/modules/rknnovpn).
func NewCoreManager(cfg *config.Config, dataDir string, logger *log.Logger) *CoreManager {
if logger == nil {
logger = log.New(os.Stderr, "[core] ", log.LstdFlags)
}
manager := &CoreManager{
config: cfg,
dataDir: dataDir,
logger: logger,
state: StateStopped,
}
manager.netstackFactory = func() coreNetstack {
return netstack.New(manager.dataDir, manager.scriptEnv(), ExecScript)
}
return manager
}
// State returns the current lifecycle state (safe for concurrent access).
func (m *CoreManager) GetState() State {
m.mu.Lock()
defer m.mu.Unlock()
return m.state
}
// ResetState forgets any tracked sing-box process metadata after an external
// cleanup path already tore the runtime down.
func (m *CoreManager) ResetState() {
m.mu.Lock()
defer m.mu.Unlock()
m.process = nil
m.exitCh = nil
m.pid = 0
m.xrayProcess = nil
m.xrayExitCh = nil
m.xrayPID = 0
m.activeProfile = ""
m.startedAt = time.Time{}
m.state = StateStopped
paths := modulecontract.NewPaths(m.dataDir)
_ = os.Remove(paths.SingBoxPIDFile())
_ = os.Remove(paths.XrayPIDFile())
_ = os.Remove(paths.ActiveFile())
}
// --------------------------------------------------------------------------
// Start
// --------------------------------------------------------------------------
// Start renders a sing-box configuration from the given profile, spawns the
// sing-box process, waits for its tproxy port to be listening, and applies
// iptables + DNS interception rules.
func (m *CoreManager) Start(profile *config.NodeProfile) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.state == StateRunning || m.state == StateStarting {
return fmt.Errorf("core: already %s", m.state)
}
if profile == nil {
return fmt.Errorf("core: active profile is required")
}
recordStage := func(name string, status string, code string, detail string, rollbackApplied bool) {
if code != "" || detail != "" {
m.logger.Printf("start stage %s: %s %s %s", name, status, code, detail)
}
}
failStage := func(name string, layer string, code string, err error, rollbackApplied bool) error {
recordStage(name, "failed", code, err.Error(), rollbackApplied)
return runtimeError(layer, code, err, rollbackApplied)
}
m.state = StateStarting
m.logger.Printf("starting sing-box for profile %q", profile.Protocol)
// 1. Render and validate optional Xray sidecar config for VLESS/XHTTP.
paths := modulecontract.NewPaths(m.dataDir)
xrayConfigPath := filepath.Join(paths.RenderedConfigDir(), "xray-xhttp.json")
needsXray, err := m.prepareXraySidecar(profile, xrayConfigPath)
if err != nil {
m.logger.Printf("render xray sidecar config failed: %v", err)
m.state = StateStopped
return failStage("render-xray-config", "render xray config", "CONFIG_RENDER_FAILED", err, false)
}
if needsXray {
recordStage("render-xray-config", "ok", "", xrayConfigPath, false)
m.logger.Printf("checking xray sidecar config %s", xrayConfigPath)
if err := m.checkXrayConfig(xrayConfigPath); err != nil {
m.logger.Printf("xray sidecar config check failed: %v", err)
m.state = StateStopped
return failStage("xray-config-check", "xray config check", "CONFIG_CHECK_FAILED", err, false)
}
recordStage("xray-config-check", "ok", "", xrayConfigPath, false)
}
// 2. Render the sing-box JSON config.
configPath := filepath.Join(paths.RenderedConfigDir(), "singbox.json")
m.logger.Printf("rendering sing-box config to %s", configPath)
if err := renderConfig(m.config, profile, configPath); err != nil {
m.logger.Printf("render sing-box config failed: %v", err)
m.state = StateStopped
return failStage("render-config", "render config", "CONFIG_RENDER_FAILED", err, false)
}
recordStage("render-config", "ok", "", configPath, false)
m.logger.Printf("checking sing-box config %s", configPath)
if err := m.checkSingBoxConfig(configPath); err != nil {
m.logger.Printf("sing-box config check failed: %v", err)
m.state = StateStopped
return failStage("config-check", "config check", "CONFIG_CHECK_FAILED", err, false)
}
m.logger.Printf("sing-box config check passed")
recordStage("config-check", "ok", "", configPath, false)
// 3. Spawn optional Xray sidecar before sing-box starts routing traffic.
if needsXray {
process, exitCh, pid, logPath, err := m.spawnXraySidecar(xrayConfigPath)
if err != nil {
m.state = StateStopped
return failStage("spawn-xray-sidecar", "spawn xray sidecar", "XRAY_SIDECAR_SPAWN_FAILED", err, false)
}
m.xrayProcess = process
m.xrayExitCh = exitCh
m.xrayPID = pid
_ = writeSingBoxPIDFile(paths.XrayPIDFile(), pid)
recordStage("spawn-xray-sidecar", "ok", "", fmt.Sprintf("pid=%d", pid), false)
if err := m.waitForPortOrExit(config.XraySidecarSocksPort, 10*time.Second, exitCh, logPath); err != nil {
_ = m.stopXraySidecar()
m.state = StateStopped
return failStage("wait-xray-sidecar", "wait xray sidecar socks port", "XRAY_SIDECAR_PORT_DOWN", err, true)
}
recordStage("wait-xray-sidecar", "ok", "", fmt.Sprintf("port=%d", config.XraySidecarSocksPort), false)
}
// 4. Spawn sing-box.
binPath := filepath.Join(paths.BinDir(), "sing-box")
cmd := exec.Command(binPath, "run", "-c", configPath)
if err := m.prepareSingBoxCommand(cmd); err != nil {
_ = m.stopXraySidecar()
m.state = StateStopped
return failStage("spawn-core", "prepare sing-box", "CORE_SPAWN_FAILED", err, false)
}
logFile, logPath, err := m.openSingBoxLog()
if err != nil {
_ = m.stopXraySidecar()
m.state = StateStopped
return failStage("open-core-log", "open sing-box log", "CORE_LOG_OPEN_FAILED", err, false)
}
recordStage("open-core-log", "ok", "", logPath, false)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = m.singBoxSysProcAttr()
if err := cmd.Start(); err != nil {
logFile.Close()
_ = m.stopXraySidecar()
m.state = StateStopped
return failStage("spawn-core", "spawn sing-box", "CORE_SPAWN_FAILED", err, false)
}
logFile.Close()
m.process = cmd.Process
m.pid = cmd.Process.Pid
exitCh := watchCommand(cmd)
m.exitCh = exitCh
recordStage("spawn-core", "ok", "", fmt.Sprintf("pid=%d", m.pid), false)
// Write PID file.
pidPath := paths.SingBoxPIDFile()
_ = writeSingBoxPIDFile(pidPath, m.pid)
netstackApplied := false
rollbackStarted := func(signal syscall.Signal, cleanupNetstack bool) {
if cleanupNetstack || netstackApplied {
_ = m.netstack().Cleanup().Err()
}
if m.process != nil {
_ = m.process.Signal(signal)
select {
case <-m.exitCh:
case <-time.After(2 * time.Second):
}
}
m.process = nil
m.exitCh = nil
m.pid = 0
m.activeProfile = ""
m.startedAt = time.Time{}
m.state = StateStopped
_ = os.Remove(pidPath)
_ = m.stopXraySidecar()
}
m.logger.Printf("sing-box spawned, pid=%d", m.pid)
// 4. Wait for runtime listeners.
if spec, err := m.waitRuntimeListeners(exitCh, logPath, recordStage); err != nil {
m.logger.Printf("%s port %d did not open in time, killing pid %d", spec.Label, spec.Port, m.pid)
rollbackStarted(syscall.SIGKILL, false)
return failStage(spec.Stage, spec.Layer, spec.Code, fmt.Errorf("%s port %d not ready: %w", spec.Label, spec.Port, err), true)
}
// 5-6. Apply RKNnoVPN-owned iptables and DNS interception.
if err := VerifyChainedProxyOwnerPackages(m.config); err != nil {
m.logger.Printf("local proxy owner verification failed: %v — rolling back", err)
rollbackStarted(syscall.SIGTERM, false)
return failStage("verify-chain-proxy-owners", "verify local proxy owners", "LOCAL_PROXY_OWNER_MISMATCH", err, true)
}
recordStage("verify-chain-proxy-owners", "ok", "", "", false)
netReport := m.netstack().Apply()
if err := netReport.Err(); err != nil {
code := "RULES_NOT_APPLIED"
var netErr *netstack.Error
if errors.As(err, &netErr) && netErr.Code != "" {
code = netErr.Code
}
m.logger.Printf("netstack apply failed: %v — rolling back", err)
rollbackStarted(syscall.SIGTERM, true)
return failStage("netstack-apply", "netstack apply", code, err, true)
}
netstackApplied = true
m.logger.Println("netstack applied")
recordStage("netstack-apply", "ok", "", fmt.Sprintf("steps=%d", len(netReport.Steps)), false)
netVerifyReport := m.netstack().Verify()
if err := netVerifyReport.Err(); err != nil {
code := "NETSTACK_VERIFY_FAILED"
var netErr *netstack.Error
if errors.As(err, &netErr) && netErr.Code != "" {
code = netErr.Code
}
m.logger.Printf("netstack verify failed: %v — rolling back", err)
rollbackStarted(syscall.SIGTERM, true)
return failStage("netstack-verify", "netstack verify", code, err, true)
}
m.logger.Println("netstack verified")
recordStage("netstack-verify", "ok", "", fmt.Sprintf("steps=%d", len(netVerifyReport.Steps)), false)
// 7. Mark running.
m.activeProfile = profile.Protocol + "://" + profile.Address
m.startedAt = time.Now()
m.state = StateRunning
m.markActive()
_ = os.Remove(modulecontract.NewPaths(m.dataDir).ManualFlag())
m.logger.Printf("core is running (pid=%d)", m.pid)
recordStage("commit-state", "ok", "", m.activeProfile, false)
return nil
}
// --------------------------------------------------------------------------
// Stop
// --------------------------------------------------------------------------
// Stop tears down networking rules and terminates the sing-box process.
// Order matters: DNS first, then iptables, then kill — so traffic is never
// black-holed through a dead proxy.
func (m *CoreManager) Stop() error {
return m.stopWithMode(true)
}
// RescueReset tears down RKNnoVPN-owned runtime state even if the in-memory
// lifecycle already says stopped. It backs explicit cleanup/reset entrypoints
// where kernel rules can outlive runtime state.
func (m *CoreManager) RescueReset() error {
return m.stopWithMode(true)
}
func (m *CoreManager) stopWithMode(forceCleanup bool) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.state == StateStopped && !forceCleanup {
return nil
}
m.state = StateStopping
m.logger.Println("stopping core")
paths := modulecontract.NewPaths(m.dataDir)
_ = os.Remove(paths.ActiveFile())
var firstErr error
// 1-2. Remove DNS interception and iptables BEFORE killing sing-box so
// inflight connections are not TPROXY'd into a dead socket.
if err := m.netstack().Cleanup().Err(); err != nil {
m.logger.Printf("netstack cleanup: %v", err)
firstErr = err
}
// 3. SIGTERM → wait 5 s → SIGKILL.
if m.process != nil {
if err := m.killProcess(); err != nil && firstErr == nil {
firstErr = err
}
} else if err := m.killTrackedSingBox(); err != nil && firstErr == nil {
firstErr = err
}
if err := m.stopXraySidecar(); err != nil && firstErr == nil {
firstErr = err
}
// 4. Clean PID file.
pidPath := paths.SingBoxPIDFile()
_ = os.Remove(pidPath)
_ = os.Remove(paths.XrayPIDFile())
m.process = nil
m.exitCh = nil
m.pid = 0
m.xrayProcess = nil
m.xrayExitCh = nil
m.xrayPID = 0
m.activeProfile = ""
m.startedAt = time.Time{}
m.state = StateStopped
m.logger.Println("core stopped")
return firstErr
}
func ignorableCleanupScriptError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "script not found:") ||
strings.Contains(lower, "no such file or directory")
}
func (m *CoreManager) spawnSingBox(configPath string) (*os.Process, <-chan error, int, string, error) {
binPath := filepath.Join(modulecontract.NewPaths(m.dataDir).BinDir(), "sing-box")
cmd := exec.Command(binPath, "run", "-c", configPath)
if err := m.prepareSingBoxCommand(cmd); err != nil {
return nil, nil, 0, "", err
}
logFile, logPath, err := m.openSingBoxLog()
if err != nil {
return nil, nil, 0, "", err
}
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = m.singBoxSysProcAttr()
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, nil, 0, logPath, err
}
logFile.Close()
return cmd.Process, watchCommand(cmd), cmd.Process.Pid, logPath, nil
}
func (m *CoreManager) markActive() {
paths := modulecontract.NewPaths(m.dataDir)
runDir := paths.RunDir()
if err := os.MkdirAll(runDir, 0750); err != nil {
m.logger.Printf("mark active: mkdir %s: %v", runDir, err)
return
}
activePath := paths.ActiveFile()
content := []byte(time.Now().Format(time.RFC3339) + "\n")
if err := os.WriteFile(activePath, content, 0640); err != nil {
m.logger.Printf("mark active: write %s: %v", activePath, err)
}
}
func (m *CoreManager) openSingBoxLog() (*os.File, string, error) {
logDir := modulecontract.NewPaths(m.dataDir).LogDir()
if err := os.MkdirAll(logDir, 0750); err != nil {
return nil, "", err
}
logPath := filepath.Join(logDir, "sing-box.log")
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, "", err
}
_, _ = fmt.Fprintf(file, "\n--- sing-box start %s ---\n", time.Now().Format(time.RFC3339))
return file, logPath, nil
}
func (m *CoreManager) checkSingBoxConfig(configPath string) error {
binPath := filepath.Join(modulecontract.NewPaths(m.dataDir).BinDir(), "sing-box")
workDir, err := m.ensureSingBoxWorkDir()
if err != nil {
return err
}
return runSingBoxConfigCheck(binPath, configPath, workDir, singBoxCheckTimeout)
}
func runSingBoxConfigCheck(binPath string, configPath string, workDir string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, "check", "-c", configPath)
if workDir != "" {
cmd.Dir = workDir
}
outBytes, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(outBytes))
if ctx.Err() == context.DeadlineExceeded {
if out != "" {
return fmt.Errorf("sing-box check timed out after %s; output: %s", timeout, out)
}
return fmt.Errorf("sing-box check timed out after %s", timeout)
}
if err != nil {
if out != "" {
return fmt.Errorf("sing-box check failed: %w; output: %s", err, out)
}
return fmt.Errorf("sing-box check failed: %w", err)
}
return nil
}
func (m *CoreManager) singBoxWorkDir() string {
return modulecontract.NewPaths(m.dataDir).RunDir()
}
func (m *CoreManager) ensureSingBoxWorkDir() (string, error) {
workDir := m.singBoxWorkDir()
if err := os.MkdirAll(workDir, 0750); err != nil {
return "", fmt.Errorf("mkdir sing-box workdir %s: %w", workDir, err)
}
return workDir, nil
}
func (m *CoreManager) prepareSingBoxCommand(cmd *exec.Cmd) error {
workDir, err := m.ensureSingBoxWorkDir()
if err != nil {
return err
}
cmd.Dir = workDir
return nil
}
func (m *CoreManager) waitForPortOrExit(port int, timeout time.Duration, exitCh <-chan error, logPath string) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
var lastErr error
for {
_, err := DialAnyPort(LocalListenerHosts(), port, 500*time.Millisecond)
if err == nil {
return nil
}
lastErr = err
select {
case exitErr := <-exitCh:
tail := tailFile(logPath, 4096)
if tail != "" {
return fmt.Errorf("sing-box exited before listening on %s: %v; log tail: %s", formatPortTargets(LocalListenerHosts(), port), exitErr, tail)
}
return fmt.Errorf("sing-box exited before listening on %s: %v", formatPortTargets(LocalListenerHosts(), port), exitErr)
case <-ticker.C:
if time.Now().After(deadline) {
tail := tailFile(logPath, 4096)
if tail != "" {
return fmt.Errorf("port %s not listening after %s: %v; sing-box log tail: %s", formatPortTargets(LocalListenerHosts(), port), timeout, lastErr, tail)
}
return fmt.Errorf("port %s not listening after %s: %v", formatPortTargets(LocalListenerHosts(), port), timeout, lastErr)
}
}
}
}
func (m *CoreManager) waitRuntimeListeners(exitCh <-chan error, logPath string, recordStage func(string, string, string, string, bool)) (listenerWaitSpec, error) {
for _, spec := range m.runtimeListenerWaits() {
if err := m.waitForPortOrExit(spec.Port, spec.Timeout, exitCh, logPath); err != nil {
return spec, err
}
m.logger.Printf("%s port %d is listening", spec.Label, spec.Port)
if recordStage != nil {
recordStage(spec.Stage, "ok", "", fmt.Sprintf("port=%d", spec.Port), false)
}
}
return listenerWaitSpec{}, nil
}
func (m *CoreManager) runtimeListenerWaits() []listenerWaitSpec {
tproxyPort := defaultPort(m.config.Proxy.TProxyPort, 10853)
dnsPort := defaultPort(m.config.Proxy.DNSPort, 10856)
specs := []listenerWaitSpec{
{
Stage: "wait-tproxy",
Layer: "wait tproxy port",
Code: "TPROXY_PORT_DOWN",
Label: "TPROXY",
Port: tproxyPort,
Timeout: 30 * time.Second,
},
{
Stage: "wait-dns",
Layer: "wait dns port",
Code: "DNS_LISTENER_DOWN",
Label: "DNS",
Port: dnsPort,
Timeout: 10 * time.Second,
},
}
if apiPort := m.config.Proxy.APIPort; apiPort > 0 {
specs = append(specs, listenerWaitSpec{
Stage: "wait-api",
Layer: "wait API port",
Code: "API_PORT_DOWN",
Label: "API",
Port: apiPort,
Timeout: 10 * time.Second,
})
}
profileInbounds := m.config.ResolveProfileInbounds()
if socksPort := profileInbounds.SocksPort; socksPort > 0 {
specs = append(specs, listenerWaitSpec{
Stage: "wait-socks-helper",
Layer: "wait SOCKS helper port",
Code: "SOCKS_HELPER_DOWN",
Label: "SOCKS helper",
Port: socksPort,
Timeout: 10 * time.Second,
})
}
if httpPort := profileInbounds.HTTPPort; httpPort > 0 {
specs = append(specs, listenerWaitSpec{
Stage: "wait-http-helper",
Layer: "wait HTTP helper port",
Code: "HTTP_HELPER_DOWN",
Label: "HTTP helper",
Port: httpPort,
Timeout: 10 * time.Second,
})
}
return specs
}
func defaultPort(value int, fallback int) int {
if value > 0 {
return value
}
return fallback
}
func tailFile(path string, maxBytes int) string {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
if len(data) > maxBytes {
data = data[len(data)-maxBytes:]
}
return strings.TrimSpace(string(data))
}
// --------------------------------------------------------------------------
// Internal helpers
// --------------------------------------------------------------------------
// killProcess sends SIGTERM, waits up to 5 s, then SIGKILL.
func (m *CoreManager) killProcess() error {
return m.killProcessWithTimeout(5 * time.Second)
}
func (m *CoreManager) killProcessWithTimeout(grace time.Duration) error {
if m.process == nil {
return nil
}
if grace <= 0 {
grace = 5 * time.Second
}
proc := m.process
pid := m.pid
waitCh := m.exitCh
if waitCh == nil {
return fmt.Errorf("pid %d has no tracked process wait channel", pid)
}
m.logger.Printf("sending SIGTERM to pid %d", pid)
if err := proc.Signal(syscall.SIGTERM); err != nil {
// Process may have already exited — not fatal.
m.logger.Printf("SIGTERM failed (may be already dead): %v", err)
select {
case err := <-waitCh:
if err != nil {
m.logger.Printf("tracked wait after SIGTERM failure returned: %v", err)
}
case <-time.After(500 * time.Millisecond):
if proc.Signal(syscall.Signal(0)) == nil {
return fmt.Errorf("pid %d still appears alive after SIGTERM failed: %w", pid, err)
}
}
return nil
}
select {
case err := <-waitCh:
if err != nil {
m.logger.Printf("wait after SIGTERM returned: %v", err)
} else {
m.logger.Printf("pid %d exited after SIGTERM", pid)
}
return nil
case <-time.After(grace):
}
// Still alive — escalate.
m.logger.Printf("pid %d did not exit, sending SIGKILL", pid)
if err := proc.Signal(syscall.SIGKILL); err != nil {
return fmt.Errorf("SIGKILL pid %d: %w", pid, err)
}
select {
case err := <-waitCh:
if err != nil {
m.logger.Printf("wait after SIGKILL returned: %v", err)
}
case <-time.After(2 * time.Second):
return fmt.Errorf("pid %d did not exit after SIGKILL", pid)
}
return nil
}
func watchCommand(cmd *exec.Cmd) <-chan error {
exitCh := make(chan error, 1)
go func() {
exitCh <- cmd.Wait()
}()
return exitCh
}
func (m *CoreManager) killTrackedSingBox() error {
type trackedPID struct {
pid int
startTime string
}
pids := make(map[int]trackedPID)
if m.pid > 0 {
pids[m.pid] = trackedPID{pid: m.pid}
}
if raw, err := os.ReadFile(modulecontract.NewPaths(m.dataDir).SingBoxPIDFile()); err == nil {
if pid, startTime, parseErr := parseSingBoxPIDFile(raw); parseErr == nil && pid > 0 {
pids[pid] = trackedPID{pid: pid, startTime: startTime}
}
}
var errs []string
for pid, tracked := range pids {
if pid == os.Getpid() {
continue
}
if tracked.startTime != "" {
currentStartTime, err := procStartTime(pid)
if err != nil || currentStartTime != tracked.startTime {
m.logger.Printf("skipping stale sing-box pid %d: starttime mismatch", pid)
continue
}
}
if !m.pidLooksLikeOwnedSingBox(pid) {
m.logger.Printf("skipping stale sing-box pid %d: ownership does not match", pid)
continue
}
proc, err := os.FindProcess(pid)
if err != nil {
continue
}
m.logger.Printf("sending SIGTERM to tracked sing-box pid %d", pid)
_ = proc.Signal(syscall.SIGTERM)
time.Sleep(500 * time.Millisecond)
if err := proc.Signal(syscall.Signal(0)); err == nil {
m.logger.Printf("tracked sing-box pid %d still alive, sending SIGKILL", pid)
if killErr := proc.Signal(syscall.SIGKILL); killErr != nil {
errs = append(errs, fmt.Sprintf("SIGKILL pid %d: %v", pid, killErr))
}
}
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
func (m *CoreManager) pidLooksLikeOwnedSingBox(pid int) bool {
paths := modulecontract.NewPaths(m.dataDir)
expectedBin := filepath.Clean(filepath.Join(paths.BinDir(), "sing-box"))
expectedConfig := filepath.Clean(filepath.Join(paths.RenderedConfigDir(), "singbox.json"))
exe := ""
if target, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "exe")); err == nil {
exe = filepath.Clean(target)
}
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
if err != nil {
return false
}
cmdTokens := cleanedCmdlinePathTokens(string(data))
binOK := exe == expectedBin || stringSliceContains(cmdTokens, expectedBin)
configOK := stringSliceContains(cmdTokens, expectedConfig)
return binOK && configOK
}
func writeSingBoxPIDFile(path string, pid int) error {
startTime, err := procStartTime(pid)
if err != nil {
return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0640)
}
return os.WriteFile(path, []byte(fmt.Sprintf("%d %s\n", pid, startTime)), 0640)
}
func parseSingBoxPIDFile(raw []byte) (int, string, error) {
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0, "", fmt.Errorf("empty sing-box 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 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 cleanedCmdlinePathTokens(cmdline string) []string {
raw := strings.Fields(strings.ReplaceAll(cmdline, "\x00", " "))
tokens := make([]string, 0, len(raw))
for _, token := range raw {
tokens = append(tokens, filepath.Clean(token))
}
return tokens
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func (m *CoreManager) coreGID() uint32 {
gid := m.config.Proxy.GID
if gid == 0 {
gid = 23333
}
return uint32(gid)
}
func (m *CoreManager) singBoxSysProcAttr() *syscall.SysProcAttr {
if m.disableCoreCredentialForTest {
return nil
}
return &syscall.SysProcAttr{
Credential: &syscall.Credential{
Gid: m.coreGID(),
},
}
}
// scriptEnv returns the environment variables that shell scripts expect.
// These must match the validate_env() check in iptables.sh.
func (m *CoreManager) scriptEnv() map[string]string {
tproxyPort := m.config.Proxy.TProxyPort
if tproxyPort == 0 {
tproxyPort = 10853
}
dnsPort := m.config.Proxy.DNSPort
if dnsPort == 0 {
dnsPort = 10856
}
apiPort := m.config.Proxy.APIPort
gid := m.config.Proxy.GID
if gid == 0 {
gid = 23333
}
mark := m.config.Proxy.Mark
if mark == 0 {
mark = 0x2023
}
profileInbounds := m.config.ResolveProfileInbounds()
appRouting := BuildRuntimeAppRoutingEnvWithExclusions(
m.config.Apps.Mode,
m.config.Apps.Packages,
m.config.Routing.AlwaysDirectApps,
m.config.Routing.AlwaysDirectExcludedApps,
m.config.Routing.AlwaysDirectSystemApps,
m.config.Routing.Mode,
)
privacyGuardPackages := ResolveAlwaysDirectPackageNamesWithExclusions(
m.config.Routing.AlwaysDirectApps,
m.config.Routing.AlwaysDirectExcludedApps,
m.config.Routing.AlwaysDirectSystemApps,
)
chainProxyPorts, chainProxyUIDs, chainProxyRules := BuildChainedProxyProtectionEnv(m.config)
return map[string]string{
"RKNNOVPN_DIR": m.dataDir,
"CORE_GID": strconv.Itoa(gid),
"TPROXY_PORT": strconv.Itoa(tproxyPort),
"DNS_PORT": strconv.Itoa(dnsPort),
"API_PORT": strconv.Itoa(apiPort),
"SOCKS_PORT": strconv.Itoa(profileInbounds.SocksPort),
"HTTP_PORT": strconv.Itoa(profileInbounds.HTTPPort),
"XRAY_SIDECAR_PORT": strconv.Itoa(config.XraySidecarSocksPort),
"CHAIN_PROXY_PORTS": chainProxyPorts,
"CHAIN_PROXY_UIDS": chainProxyUIDs,
"CHAIN_PROXY_RULES": chainProxyRules,
"FWMARK": fmt.Sprintf("0x%x", mark),
"ROUTE_TABLE": "2023",
"ROUTE_TABLE_V6": "2024",
"ROUTE_RULE_PREF": "10000",
"ROUTE_RULE_PREF_V6": "10001",
"APP_MODE": appRouting.AppMode,
"PROXY_UIDS": appRouting.ProxyUIDs,
"DIRECT_UIDS": appRouting.DirectUIDs,
"BYPASS_UIDS": appRouting.BypassUIDs,
"PRIVACY_GUARD_PACKAGES": strings.Join(privacyGuardPackages, " "),
"DNS_SCOPE": appRouting.DNSScope,
"DNS_MODE": appRouting.DNSMode,
"PROXY_MODE": "tproxy",
"IPV6_MODE": m.config.IPv6.Mode,
"IPV6_FAIL_CLOSED": ipv6FailClosedEnv(m.config.IPv6.Mode),
"SHARING_MODE": m.config.SharingModeEnv(),
"SHARING_IFACES": m.config.SharingInterfacesEnv(),
}
}
func ipv6FailClosedEnv(mode string) string {
switch mode {
case "disable", "disabled", "off", "ipv4_only":
return "0"
default:
return "1"
}
}
func (m *CoreManager) netstack() coreNetstack {
if m.netstackFactory != nil {
return m.netstackFactory()
}
return netstack.New(m.dataDir, m.scriptEnv(), ExecScript)
}
// renderConfig uses the config package's RenderSingboxConfig to produce
// a complete sing-box JSON configuration and writes it to path.
func renderConfig(cfg *config.Config, profile *config.NodeProfile, path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
data, err := config.RenderSingboxConfigForDataDir(cfg, profile, filepath.Dir(filepath.Dir(filepath.Dir(path))))
if err != nil {
return fmt.Errorf("render: %w", err)
}
data = append(data, '\n')
return os.WriteFile(path, data, 0600)
}