432 lines
11 KiB
Go
432 lines
11 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// StateChangedHandler is called when VPN connection state changes.
|
|
type StateChangedHandler func(state ConnectionState)
|
|
|
|
// LogHandler is called when a log message is produced. (source, message)
|
|
type LogHandler func(source, message string)
|
|
|
|
// CoreManager manages the lifecycle of xray (proxy backend) and sing-box (TUN frontend).
|
|
//
|
|
// Connection flow:
|
|
// 1. Parse VPN link
|
|
// 2. Generate configs for xray and sing-box
|
|
// 3. Start xray (SOCKS5 inbound -> proxy outbound)
|
|
// 4. Wait for SOCKS5 port readiness
|
|
// 5. Start sing-box (TUN -> SOCKS5)
|
|
type CoreManager struct {
|
|
mu sync.Mutex
|
|
|
|
xrayProcess *exec.Cmd
|
|
singBoxProcess *exec.Cmd
|
|
xrayConfigPath string
|
|
singBoxConfigPath string
|
|
cancel context.CancelFunc
|
|
|
|
coresPath string
|
|
configsPath string
|
|
|
|
State ConnectionState
|
|
CurrentServer *ProxyLink
|
|
|
|
OnStateChanged StateChangedHandler
|
|
OnLog LogHandler
|
|
}
|
|
|
|
// NewCoreManager creates a new CoreManager with paths under %APPDATA%/kettuRay.
|
|
func NewCoreManager() (*CoreManager, error) {
|
|
appData, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get app data dir: %w", err)
|
|
}
|
|
|
|
appDir := filepath.Join(appData, "kettuRay")
|
|
coresPath := filepath.Join(appDir, "cores")
|
|
configsPath := filepath.Join(appDir, "configs")
|
|
|
|
if err := os.MkdirAll(coresPath, 0o755); err != nil {
|
|
return nil, fmt.Errorf("failed to create cores dir: %w", err)
|
|
}
|
|
if err := os.MkdirAll(configsPath, 0o755); err != nil {
|
|
return nil, fmt.Errorf("failed to create configs dir: %w", err)
|
|
}
|
|
|
|
cm := &CoreManager{
|
|
coresPath: coresPath,
|
|
configsPath: configsPath,
|
|
State: Disconnected,
|
|
}
|
|
// Kill any leftover xray/sing-box processes from a previous crash
|
|
cm.killStaleProcesses()
|
|
return cm, nil
|
|
}
|
|
|
|
// Connect starts the VPN connection using the provided link.
|
|
func (cm *CoreManager) Connect(link string) error {
|
|
cm.mu.Lock()
|
|
if cm.State == Connected || cm.State == Connecting {
|
|
cm.mu.Unlock()
|
|
cm.log("Core", "Already connected or connecting. Disconnect first.")
|
|
return nil
|
|
}
|
|
cm.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cm.mu.Lock()
|
|
cm.cancel = cancel
|
|
cm.mu.Unlock()
|
|
|
|
defer func() {
|
|
if cm.State != Connected {
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
cm.setState(Connecting)
|
|
|
|
// 0. Kill stale processes
|
|
cm.killStaleProcesses()
|
|
if err := sleepCtx(ctx, 1*time.Second); err != nil {
|
|
cm.log("Core", "Connection cancelled.")
|
|
cm.cleanup()
|
|
cm.setState(Disconnected)
|
|
return nil
|
|
}
|
|
|
|
// 1. Parse link
|
|
cm.log("Core", "Parsing link...")
|
|
proxyLink, err := ParseLink(link)
|
|
if err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to parse link: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
cm.mu.Lock()
|
|
cm.CurrentServer = proxyLink
|
|
cm.mu.Unlock()
|
|
cm.log("Core", fmt.Sprintf("Server: %s", proxyLink))
|
|
|
|
// 2. Generate configs
|
|
cm.log("Core", "Generating configurations...")
|
|
xrayConfig, err := GenerateXrayConfig(proxyLink, DefaultSocksPort)
|
|
if err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to generate xray config: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
singBoxConfig, err := GenerateSingBoxConfig(proxyLink.Address, DefaultSocksPort, "kettuTun")
|
|
if err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to generate sing-box config: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
|
|
// 3. Save configs
|
|
cm.xrayConfigPath = filepath.Join(cm.configsPath, "xray-config.json")
|
|
cm.singBoxConfigPath = filepath.Join(cm.configsPath, "singbox-config.json")
|
|
|
|
if err := os.WriteFile(cm.xrayConfigPath, []byte(xrayConfig), 0o644); err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to save xray config: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
if err := os.WriteFile(cm.singBoxConfigPath, []byte(singBoxConfig), 0o644); err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to save sing-box config: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
|
|
cm.log("Core", fmt.Sprintf("Xray config saved: %s", cm.xrayConfigPath))
|
|
cm.log("Core", fmt.Sprintf("Sing-box config saved: %s", cm.singBoxConfigPath))
|
|
|
|
// 4. Start xray
|
|
cm.log("Xray", "Starting xray-core...")
|
|
xrayPath := filepath.Join(cm.coresPath, "xray.exe")
|
|
xrayCmd, err := cm.startProcess(xrayPath, []string{"run", "-config", cm.xrayConfigPath}, "Xray")
|
|
if err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to start xray: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
cm.mu.Lock()
|
|
cm.xrayProcess = xrayCmd
|
|
cm.mu.Unlock()
|
|
|
|
// 5. Wait for SOCKS5 port
|
|
cm.log("Core", fmt.Sprintf("Waiting for SOCKS5 port %d...", DefaultSocksPort))
|
|
if err := waitForPort(ctx, DefaultSocksPort, 10*time.Second); err != nil {
|
|
cm.log("Core", fmt.Sprintf("Xray failed to open port %d: %v", DefaultSocksPort, err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return fmt.Errorf("xray failed to open port %d within timeout", DefaultSocksPort)
|
|
}
|
|
cm.log("Core", "SOCKS5 port ready.")
|
|
|
|
if ctx.Err() != nil {
|
|
cm.log("Core", "Connection cancelled.")
|
|
cm.cleanup()
|
|
cm.setState(Disconnected)
|
|
return nil
|
|
}
|
|
|
|
// 6. Start sing-box
|
|
cm.log("SingBox", "Starting sing-box (TUN)...")
|
|
singBoxPath := filepath.Join(cm.coresPath, "sing-box.exe")
|
|
singBoxCmd, err := cm.startProcess(singBoxPath, []string{"run", "-c", cm.singBoxConfigPath}, "SingBox")
|
|
if err != nil {
|
|
cm.log("Core", fmt.Sprintf("Failed to start sing-box: %v", err))
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
cm.mu.Lock()
|
|
cm.singBoxProcess = singBoxCmd
|
|
cm.mu.Unlock()
|
|
|
|
// 7. Wait for TUN setup
|
|
if err := sleepCtx(ctx, 2*time.Second); err != nil {
|
|
cm.log("Core", "Connection cancelled.")
|
|
cm.cleanup()
|
|
cm.setState(Disconnected)
|
|
return nil
|
|
}
|
|
|
|
// Check both processes are alive
|
|
if cm.xrayProcess.ProcessState != nil {
|
|
err := fmt.Errorf("xray exited with code %d", cm.xrayProcess.ProcessState.ExitCode())
|
|
cm.log("Core", err.Error())
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
if cm.singBoxProcess.ProcessState != nil {
|
|
err := fmt.Errorf("sing-box exited with code %d", cm.singBoxProcess.ProcessState.ExitCode())
|
|
cm.log("Core", err.Error())
|
|
cm.cleanup()
|
|
cm.setState(Error)
|
|
return err
|
|
}
|
|
|
|
cm.setState(Connected)
|
|
cm.log("Core", fmt.Sprintf("Connected to %s (%s:%d)", proxyLink.Remark, proxyLink.Address, proxyLink.Port))
|
|
return nil
|
|
}
|
|
|
|
// Disconnect stops the VPN connection.
|
|
func (cm *CoreManager) Disconnect() {
|
|
cm.mu.Lock()
|
|
if cm.State == Disconnected || cm.State == Disconnecting {
|
|
cm.mu.Unlock()
|
|
return
|
|
}
|
|
cm.mu.Unlock()
|
|
|
|
cm.setState(Disconnecting)
|
|
cm.log("Core", "Disconnecting...")
|
|
|
|
cm.mu.Lock()
|
|
if cm.cancel != nil {
|
|
cm.cancel()
|
|
}
|
|
cm.mu.Unlock()
|
|
|
|
cm.cleanup()
|
|
|
|
cm.mu.Lock()
|
|
cm.CurrentServer = nil
|
|
cm.mu.Unlock()
|
|
|
|
cm.setState(Disconnected)
|
|
cm.log("Core", "Disconnected.")
|
|
}
|
|
|
|
func (cm *CoreManager) startProcess(path string, args []string, source string) (*exec.Cmd, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
return nil, fmt.Errorf("binary not found: %s", path)
|
|
}
|
|
|
|
cmd := exec.Command(path, args...)
|
|
cmd.Dir = filepath.Dir(path)
|
|
cmd.SysProcAttr = procAttr() // platform-specific: hide window on Windows
|
|
|
|
stdout, _ := cmd.StdoutPipe()
|
|
stderr, _ := cmd.StderrPipe()
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("failed to start %s: %w", source, err)
|
|
}
|
|
|
|
cm.log(source, fmt.Sprintf("Process started (PID: %d)", cmd.Process.Pid))
|
|
|
|
// Read stdout/stderr in background
|
|
go scanPipe(stdout, func(line string) { cm.log(source, line) })
|
|
go scanPipe(stderr, func(line string) { cm.log(source, markIfError(line)) })
|
|
|
|
// Monitor process exit
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
cm.log(source, fmt.Sprintf("Process exited (code: %d)", cmd.ProcessState.ExitCode()))
|
|
|
|
cm.mu.Lock()
|
|
state := cm.State
|
|
isOurs := cmd == cm.xrayProcess || cmd == cm.singBoxProcess
|
|
cm.mu.Unlock()
|
|
|
|
if isOurs && (state == Connected || state == Connecting) {
|
|
cm.log(source, fmt.Sprintf("CRITICAL: Process %s crashed! Cleaning up...", source))
|
|
cm.setState(Error)
|
|
cm.mu.Lock()
|
|
if cm.cancel != nil {
|
|
cm.cancel()
|
|
}
|
|
cm.mu.Unlock()
|
|
}
|
|
}()
|
|
|
|
return cmd, nil
|
|
}
|
|
|
|
func waitForPort(ctx context.Context, port int, timeout time.Duration) error {
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
conn, err := net.DialTimeout("tcp", addr, 1*time.Second)
|
|
if err == nil {
|
|
conn.Close()
|
|
return nil
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cm *CoreManager) cleanup() {
|
|
// Stop sing-box first (TUN), then xray
|
|
cm.mu.Lock()
|
|
singBox := cm.singBoxProcess
|
|
xray := cm.xrayProcess
|
|
cm.singBoxProcess = nil
|
|
cm.xrayProcess = nil
|
|
xrayConfig := cm.xrayConfigPath
|
|
singBoxConfig := cm.singBoxConfigPath
|
|
cm.xrayConfigPath = ""
|
|
cm.singBoxConfigPath = ""
|
|
cm.mu.Unlock()
|
|
|
|
stopProcess(singBox, "SingBox", cm.log)
|
|
stopProcess(xray, "Xray", cm.log)
|
|
|
|
tryDeleteFile(xrayConfig)
|
|
tryDeleteFile(singBoxConfig)
|
|
}
|
|
|
|
func stopProcess(cmd *exec.Cmd, source string, logFn func(string, string)) {
|
|
if cmd == nil || cmd.Process == nil {
|
|
return
|
|
}
|
|
if cmd.ProcessState != nil {
|
|
return // already exited
|
|
}
|
|
|
|
logFn(source, fmt.Sprintf("Terminating process (PID: %d)...", cmd.Process.Pid))
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
logFn(source, fmt.Sprintf("Error terminating process: %v", err))
|
|
return
|
|
}
|
|
logFn(source, "Process terminated.")
|
|
}
|
|
|
|
func tryDeleteFile(path string) {
|
|
if path == "" {
|
|
return
|
|
}
|
|
_ = os.Remove(path)
|
|
}
|
|
|
|
func (cm *CoreManager) killStaleProcesses() {
|
|
for _, name := range []string{"xray", "sing-box"} {
|
|
killProcessByName(name, func(pid int) {
|
|
cm.log("Core", fmt.Sprintf("Killing stale %s process (PID: %d)", name, pid))
|
|
})
|
|
}
|
|
}
|
|
|
|
func (cm *CoreManager) setState(state ConnectionState) {
|
|
cm.mu.Lock()
|
|
if cm.State == state {
|
|
cm.mu.Unlock()
|
|
return
|
|
}
|
|
cm.State = state
|
|
handler := cm.OnStateChanged
|
|
cm.mu.Unlock()
|
|
|
|
if handler != nil {
|
|
handler(state)
|
|
}
|
|
}
|
|
|
|
func (cm *CoreManager) log(source, message string) {
|
|
cm.mu.Lock()
|
|
handler := cm.OnLog
|
|
cm.mu.Unlock()
|
|
|
|
if handler != nil {
|
|
handler(source, message)
|
|
}
|
|
}
|
|
|
|
// markIfError prefixes a line with [ERR] only if it actually contains an error/fatal level.
|
|
// sing-box and xray write INFO/WARN/ERROR to stderr, so we can't blindly mark all stderr as errors.
|
|
func markIfError(line string) string {
|
|
upper := strings.ToUpper(line)
|
|
if strings.Contains(upper, "ERROR") || strings.Contains(upper, "FATAL") || strings.Contains(upper, "PANIC") {
|
|
return "[ERR] " + line
|
|
}
|
|
return line
|
|
}
|
|
|
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(d):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Close stops all processes and cleans up.
|
|
func (cm *CoreManager) Close() {
|
|
cm.mu.Lock()
|
|
if cm.cancel != nil {
|
|
cm.cancel()
|
|
}
|
|
cm.mu.Unlock()
|
|
|
|
cm.cleanup()
|
|
}
|