init commit

This commit is contained in:
2026-03-31 14:40:03 +03:00
commit 41c8e186ef
37 changed files with 5420 additions and 0 deletions

61
core/config_manager.go Normal file
View File

@@ -0,0 +1,61 @@
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ConfigManager handles saving/loading VPN configs to disk.
type ConfigManager struct {
configFilePath string
}
// NewConfigManager creates a new ConfigManager with config file under %APPDATA%/kettuRay.
func NewConfigManager() (*ConfigManager, 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")
if err := os.MkdirAll(appDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create app dir: %w", err)
}
return &ConfigManager{
configFilePath: filepath.Join(appDir, "configs.json"),
}, nil
}
// LoadConfigs loads saved VPN configs from disk.
func (m *ConfigManager) LoadConfigs() ([]VpnConfig, error) {
data, err := os.ReadFile(m.configFilePath)
if err != nil {
if os.IsNotExist(err) {
return make([]VpnConfig, 0), nil
}
return nil, err
}
var configs []VpnConfig
if len(data) == 0 {
return make([]VpnConfig, 0), nil
}
if err := json.Unmarshal(data, &configs); err != nil {
fmt.Printf("Error parsing configs.json: %v\n", err)
// return empty slice but still error so app knows it failed
return make([]VpnConfig, 0), err
}
return configs, nil
}
// SaveConfigs writes VPN configs to disk.
func (m *ConfigManager) SaveConfigs(configs []VpnConfig) error {
data, err := json.MarshalIndent(configs, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal configs: %w", err)
}
return os.WriteFile(m.configFilePath, data, 0o644)
}

431
core/core_manager.go Normal file
View File

@@ -0,0 +1,431 @@
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()
}

128
core/models.go Normal file
View File

@@ -0,0 +1,128 @@
package core
// ConnectionState represents VPN connection state.
type ConnectionState int
const (
Disconnected ConnectionState = iota
Connecting
Connected
Disconnecting
Error
)
func (s ConnectionState) String() string {
switch s {
case Disconnected:
return "Disconnected"
case Connecting:
return "Connecting"
case Connected:
return "Connected"
case Disconnecting:
return "Disconnecting"
case Error:
return "Error"
default:
return "Unknown"
}
}
// ProxyLink represents a parsed VPN link (trojan://, vless://, etc.)
type ProxyLink struct {
// Protocol: trojan, vless, vmess, shadowsocks
Protocol string
// Server IP or domain
Address string
// Server port
Port int
// Password (trojan) or UUID (vless/vmess)
Credential string
// Security type: reality, tls, none
Security string
// Server Name Indication for TLS/REALITY
Sni string
// TLS fingerprint (chrome, firefox, safari, etc.)
Fingerprint string
// REALITY public key
PublicKey string
// REALITY short ID
ShortId string
// Transport type: tcp, grpc, ws, h2, xhttp
Transport string
// gRPC service name
ServiceName string
// WebSocket/HTTP2 path
Path string
// Host header for WebSocket/HTTP2
Host string
// Server name from link fragment (#remark)
Remark string
// XTLS Flow (e.g. xtls-rprx-vision)
Flow string
}
func (p *ProxyLink) String() string {
return "[" + p.Protocol + "] " + p.Remark + " (" + p.Address + ":" + itoa(p.Port) + ") security=" + p.Security + " transport=" + p.Transport
}
// VpnConfig represents a saved VPN configuration.
type VpnConfig struct {
ID string `json:"Id"`
Name string `json:"Name"`
Link string `json:"Link"`
}
func (c *VpnConfig) DisplayName() string {
if c.Name == "" {
return "Unknown Server"
}
return c.Name
}
func (c *VpnConfig) ProtocolType() string {
if len(c.Link) < 5 {
return "UNKNOWN"
}
switch {
case hasPrefix(c.Link, "vless://"):
return "VLESS"
case hasPrefix(c.Link, "trojan://"):
return "TROJAN"
case hasPrefix(c.Link, "hy2://"):
return "HYSTERIA2"
case hasPrefix(c.Link, "ss://"):
return "SHADOWSOCKS"
default:
return "UNKNOWN"
}
}
// ExtractNameFromLink extracts server name from a VPN link.
func ExtractNameFromLink(link string) string {
if link == "" {
return "New Config"
}
idx := lastIndexOf(link, '#')
if idx >= 0 && idx < len(link)-1 {
encoded := link[idx+1:]
decoded, err := urlUnescape(encoded)
if err != nil {
return encoded
}
return decoded
}
parts := splitAt(link, '@')
if len(parts) > 1 {
addrPart := parts[1]
for i, ch := range addrPart {
if ch == '?' || ch == ':' || ch == '/' || ch == '#' {
if i > 0 {
return addrPart[:i]
}
break
}
}
}
return "Custom Config"
}

177
core/parser.go Normal file
View File

@@ -0,0 +1,177 @@
package core
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// ParseLink parses a VPN link and returns a ProxyLink.
// Supported protocols: trojan, vless.
func ParseLink(link string) (*ProxyLink, error) {
link = strings.TrimSpace(link)
if link == "" {
return nil, fmt.Errorf("link cannot be empty")
}
protocol, err := getProtocol(link)
if err != nil {
return nil, err
}
switch protocol {
case "trojan", "vless":
return parseTrojanOrVless(link, protocol)
default:
return nil, fmt.Errorf("unsupported protocol: %s", protocol)
}
}
// TryParseLink attempts to parse a VPN link, returning nil on failure.
func TryParseLink(link string) *ProxyLink {
result, err := ParseLink(link)
if err != nil {
return nil
}
return result
}
func getProtocol(link string) (string, error) {
idx := strings.Index(link, "://")
if idx < 0 {
return "", fmt.Errorf("invalid link format: scheme (protocol://) is missing")
}
return strings.ToLower(link[:idx]), nil
}
// parseTrojanOrVless parses links of format: protocol://credential@host:port?params#remark
func parseTrojanOrVless(link, protocol string) (*ProxyLink, error) {
result := &ProxyLink{
Protocol: protocol,
Security: "none",
Fingerprint: "chrome",
Transport: "tcp",
}
// Remove scheme
remainder := link[len(protocol)+3:]
// Extract fragment (#remark)
if fragmentIdx := strings.LastIndex(remainder, "#"); fragmentIdx >= 0 {
decoded, err := url.PathUnescape(remainder[fragmentIdx+1:])
if err != nil {
result.Remark = remainder[fragmentIdx+1:]
} else {
result.Remark = decoded
}
remainder = remainder[:fragmentIdx]
}
// Extract query string (?params)
var queryParams url.Values
if queryIdx := strings.Index(remainder, "?"); queryIdx >= 0 {
var err error
queryParams, err = url.ParseQuery(remainder[queryIdx+1:])
if err != nil {
queryParams = url.Values{}
}
remainder = remainder[:queryIdx]
} else {
queryParams = url.Values{}
}
// Extract credential@host:port
atIdx := strings.Index(remainder, "@")
if atIdx < 0 {
return nil, fmt.Errorf("invalid format: missing credential@host:port")
}
decoded, err := url.PathUnescape(remainder[:atIdx])
if err != nil {
result.Credential = remainder[:atIdx]
} else {
result.Credential = decoded
}
hostPort := remainder[atIdx+1:]
// Parse host:port
if err := parseHostPort(hostPort, result); err != nil {
return nil, err
}
// Parse query parameters
parseQueryParams(queryParams, result)
return result, nil
}
func parseHostPort(hostPort string, result *ProxyLink) error {
var lastColon int
if strings.HasPrefix(hostPort, "[") {
// IPv6: [::1]:443
bracketEnd := strings.Index(hostPort, "]")
if bracketEnd < 0 {
return fmt.Errorf("invalid IPv6 address: missing closing bracket")
}
result.Address = hostPort[1:bracketEnd]
lastColon = strings.Index(hostPort[bracketEnd:], ":")
if lastColon >= 0 {
lastColon += bracketEnd
}
} else {
lastColon = strings.LastIndex(hostPort, ":")
if lastColon < 0 {
return fmt.Errorf("invalid format: missing port")
}
result.Address = hostPort[:lastColon]
}
if lastColon < 0 || lastColon >= len(hostPort)-1 {
return fmt.Errorf("invalid format: missing port")
}
portStr := hostPort[lastColon+1:]
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %s", portStr)
}
result.Port = port
return nil
}
func parseQueryParams(params url.Values, result *ProxyLink) {
if v := params.Get("security"); v != "" {
result.Security = strings.ToLower(v)
}
if v := params.Get("sni"); v != "" {
result.Sni = v
}
if v := params.Get("fp"); v != "" {
result.Fingerprint = v
}
if v := params.Get("pbk"); v != "" {
result.PublicKey = v
}
if v := params.Get("sid"); v != "" {
result.ShortId = v
}
if v := params.Get("type"); v != "" {
result.Transport = strings.ToLower(v)
}
if v := params.Get("serviceName"); v != "" {
result.ServiceName = v
}
if v := params.Get("path"); v != "" {
result.Path = v
}
if v := params.Get("host"); v != "" {
result.Host = v
}
if v := params.Get("flow"); v != "" {
result.Flow = v
}
}

78
core/ping.go Normal file
View File

@@ -0,0 +1,78 @@
package core
import (
"net"
"sync"
"time"
)
// PingResult contains the result of a ping to a server.
type PingResult struct {
Ms int64 // round-trip time in ms, -1 if failed
Color string
}
// PingAddress pings a single address using TCP connect (ICMP requires elevated privileges).
// Returns round-trip time in ms or -1 on failure.
func PingAddress(address string, timeout time.Duration) int64 {
if timeout == 0 {
timeout = 3 * time.Second
}
start := time.Now()
conn, err := net.DialTimeout("tcp", address+":443", timeout)
if err != nil {
return -1
}
conn.Close()
return time.Since(start).Milliseconds()
}
// PingConfig pings a VPN config and returns the result.
func PingConfig(config *VpnConfig) PingResult {
link := TryParseLink(config.Link)
if link == nil {
return PingResult{Ms: -1, Color: "#666"}
}
ms := PingAddress(link.Address, 3*time.Second)
if ms < 0 {
return PingResult{Ms: -1, Color: "#666"}
}
return PingResult{Ms: ms, Color: GetPingColor(ms)}
}
// PingAllConfigs pings all configs concurrently.
func PingAllConfigs(configs []VpnConfig) []PingResult {
results := make([]PingResult, len(configs))
var wg sync.WaitGroup
for i := range configs {
wg.Add(1)
go func(idx int) {
defer wg.Done()
results[idx] = PingConfig(&configs[idx])
}(i)
}
wg.Wait()
return results
}
// GetPingColor returns a color hex code based on ping latency.
func GetPingColor(ms int64) string {
switch {
case ms < 50:
return "#4CAF50" // green
case ms < 100:
return "#8BC34A" // light green
case ms < 150:
return "#CDDC39" // lime
case ms < 200:
return "#FFC107" // amber
case ms < 300:
return "#FF9800" // orange
default:
return "#E53935" // red
}
}

70
core/process_windows.go Normal file
View File

@@ -0,0 +1,70 @@
//go:build windows
package core
import (
"bufio"
"fmt"
"io"
"os/exec"
"regexp"
"strconv"
"strings"
"syscall"
)
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// procAttr returns platform-specific process attributes.
// On Windows, hides the console window.
func procAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
}
// killProcessByName kills all processes with the given name.
func killProcessByName(name string, onKill func(pid int)) {
// Use tasklist to find processes
out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s.exe", name), "/FO", "CSV", "/NH").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.Contains(line, "No tasks") {
continue
}
// CSV format: "name.exe","PID","Session Name","Session#","Mem Usage"
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
pidStr := strings.Trim(parts[1], "\" ")
pid, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
if onKill != nil {
onKill(pid)
}
_ = exec.Command("taskkill", "/F", "/T", "/PID", pidStr).Run()
}
}
// scanPipe reads lines from a pipe and calls handler for each.
// ANSI escape codes are stripped before passing to handler.
func scanPipe(r io.Reader, handler func(string)) {
if r == nil {
return
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := ansiEscape.ReplaceAllString(scanner.Text(), "")
if line != "" {
handler(line)
}
}
}

44
core/settings.go Normal file
View File

@@ -0,0 +1,44 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
)
// AppSettings represents persistent application settings.
type AppSettings struct {
RunOnStartup bool `json:"RunOnStartup"`
AutoConnect bool `json:"AutoConnect"`
AutoReconnect bool `json:"AutoReconnect"`
LastConfigID string `json:"LastConfigId,omitempty"`
}
func settingsPath() string {
appData, _ := os.UserConfigDir()
return filepath.Join(appData, "kettuRay", "settings.json")
}
// LoadSettings loads settings from disk, returning defaults on error.
func LoadSettings() *AppSettings {
data, err := os.ReadFile(settingsPath())
if err != nil {
return &AppSettings{}
}
var s AppSettings
if err := json.Unmarshal(data, &s); err != nil {
return &AppSettings{}
}
return &s
}
// Save writes settings to disk.
func (s *AppSettings) Save() error {
path := settingsPath()
_ = os.MkdirAll(filepath.Dir(path), 0o755)
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}

30
core/settings_windows.go Normal file
View File

@@ -0,0 +1,30 @@
//go:build windows
package core
import (
"os"
"golang.org/x/sys/windows/registry"
)
const regRunKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
// ApplyStartupRegistry adds or removes the app from Windows startup registry.
func (s *AppSettings) ApplyStartupRegistry() {
key, err := registry.OpenKey(registry.CURRENT_USER, regRunKey, registry.SET_VALUE)
if err != nil {
return
}
defer key.Close()
if s.RunOnStartup {
exePath, err := os.Executable()
if err != nil {
return
}
_ = key.SetStringValue("kettuRay", `"`+exePath+`"`)
} else {
_ = key.DeleteValue("kettuRay")
}
}

156
core/singbox_config.go Normal file
View File

@@ -0,0 +1,156 @@
package core
import (
"encoding/json"
"fmt"
"net"
)
// GenerateSingBoxConfig generates a full JSON config for sing-box (v1.14+).
// sing-box acts as frontend: creates a TUN interface,
// captures system traffic and routes it to xray via SOCKS5.
func GenerateSingBoxConfig(serverAddress string, socksPort int, tunName string) (string, error) {
if socksPort == 0 {
socksPort = DefaultSocksPort
}
if tunName == "" {
tunName = "kettuTun"
}
config := map[string]any{
"log": buildSingBoxLog(),
"dns": buildSingBoxDns(),
"inbounds": []any{buildTunInbound(tunName)},
"outbounds": buildSingBoxOutbounds(socksPort),
"route": buildSingBoxRoute(serverAddress),
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal sing-box config: %w", err)
}
return string(data), nil
}
func buildSingBoxLog() map[string]any {
return map[string]any{
"level": "info",
"timestamp": true,
}
}
func buildSingBoxDns() map[string]any {
return map[string]any{
"servers": []any{
map[string]any{
"type": "https",
"tag": "remote-dns",
"server": "1.1.1.1",
"server_port": 443,
"path": "/dns-query",
"detour": "proxy-out",
},
map[string]any{
"type": "https",
"tag": "local-dns",
"server": "8.8.8.8",
"server_port": 443,
"path": "/dns-query",
},
},
}
}
func buildTunInbound(tunName string) map[string]any {
tun := map[string]any{
"type": "tun",
"tag": "tun-in",
"address": []string{
"172.18.0.1/30",
"fdfe:dcba:9876::1/126",
},
"mtu": 1400,
"auto_route": true,
"strict_route": false,
"stack": "gvisor",
}
if tunName != "" && tunName != "kettuTun" {
tun["interface_name"] = tunName
}
return tun
}
func buildSingBoxOutbounds(socksPort int) []any {
return []any{
map[string]any{
"type": "socks",
"tag": "proxy-out",
"server": "127.0.0.1",
"server_port": socksPort,
"version": "5",
"udp_over_tcp": false,
},
map[string]any{
"type": "direct",
"tag": "direct-out",
},
}
}
func buildSingBoxRoute(serverAddress string) map[string]any {
rules := []any{
// Anti routing loop: bypass VPN core processes
map[string]any{
"process_name": []string{"xray.exe", "sing-box.exe", "kettuRay.exe"},
"action": "route",
"outbound": "direct-out",
},
}
// Anti routing loop: bypass remote VPN server
bypassRule := map[string]any{
"action": "route",
"outbound": "direct-out",
}
if ip := net.ParseIP(serverAddress); ip != nil {
prefix := "32"
if ip.To4() == nil {
prefix = "128"
}
bypassRule["ip_cidr"] = []string{serverAddress + "/" + prefix}
} else {
bypassRule["domain"] = []string{serverAddress}
}
rules = append(rules, bypassRule)
// Sniff rule
rules = append(rules, map[string]any{
"action": "sniff",
"timeout": "300ms",
})
// DNS hijack
rules = append(rules, map[string]any{
"protocol": "dns",
"action": "hijack-dns",
})
// Private IPs go direct
rules = append(rules, map[string]any{
"ip_is_private": true,
"action": "route",
"outbound": "direct-out",
})
return map[string]any{
"rules": rules,
"default_domain_resolver": map[string]any{
"server": "local-dns",
},
"final": "proxy-out",
"auto_detect_interface": true,
}
}

31
core/util.go Normal file
View File

@@ -0,0 +1,31 @@
package core
import (
"net/url"
"strconv"
"strings"
)
func itoa(n int) string {
return strconv.Itoa(n)
}
func hasPrefix(s, prefix string) bool {
return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix))
}
func lastIndexOf(s string, ch byte) int {
return strings.LastIndexByte(s, ch)
}
func splitAt(s string, ch byte) []string {
idx := strings.IndexByte(s, ch)
if idx < 0 {
return []string{s}
}
return []string{s[:idx], s[idx+1:]}
}
func urlUnescape(s string) (string, error) {
return url.PathUnescape(s)
}

177
core/xray_config.go Normal file
View File

@@ -0,0 +1,177 @@
package core
import (
"encoding/json"
"fmt"
)
const DefaultSocksPort = 10808
// GenerateXrayConfig generates a full JSON config for xray-core.
// Xray acts as backend: listens SOCKS5 on a local port, connects to the remote server.
func GenerateXrayConfig(link *ProxyLink, socksPort int) (string, error) {
if socksPort == 0 {
socksPort = DefaultSocksPort
}
config := map[string]any{
"log": map[string]any{
"loglevel": "warning",
},
"inbounds": []any{
buildSocksInbound(socksPort),
},
"outbounds": []any{
buildProxyOutbound(link),
buildDirectOutbound(),
},
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal xray config: %w", err)
}
return string(data), nil
}
func buildSocksInbound(port int) map[string]any {
return map[string]any{
"port": port,
"listen": "127.0.0.1",
"protocol": "socks",
"settings": map[string]any{
"auth": "noauth",
"udp": true,
},
"tag": "socks-in",
}
}
func buildProxyOutbound(link *ProxyLink) map[string]any {
outbound := map[string]any{
"tag": "proxy",
"protocol": link.Protocol,
}
switch link.Protocol {
case "trojan":
outbound["settings"] = buildTrojanSettings(link)
case "vless":
outbound["settings"] = buildVlessSettings(link)
}
outbound["streamSettings"] = buildStreamSettings(link)
return outbound
}
func buildTrojanSettings(link *ProxyLink) map[string]any {
return map[string]any{
"servers": []any{
map[string]any{
"address": link.Address,
"port": link.Port,
"password": link.Credential,
},
},
}
}
func buildVlessSettings(link *ProxyLink) map[string]any {
user := map[string]any{
"id": link.Credential,
"encryption": "none",
}
if link.Flow != "" {
user["flow"] = link.Flow
}
return map[string]any{
"vnext": []any{
map[string]any{
"address": link.Address,
"port": link.Port,
"users": []any{user},
},
},
}
}
func buildStreamSettings(link *ProxyLink) map[string]any {
stream := map[string]any{
"network": link.Transport,
}
// Security
if link.Security != "" && link.Security != "none" {
stream["security"] = link.Security
}
// Transport-specific settings
switch link.Transport {
case "grpc":
stream["grpcSettings"] = map[string]any{
"serviceName": link.ServiceName,
}
case "xhttp":
xhttp := map[string]any{
"path": defaultIfEmpty(link.Path, "/"),
}
if link.Host != "" {
xhttp["host"] = link.Host
}
stream["xhttpSettings"] = xhttp
case "ws":
ws := map[string]any{
"path": defaultIfEmpty(link.Path, "/"),
}
if link.Host != "" {
ws["headers"] = map[string]any{
"Host": link.Host,
}
}
stream["wsSettings"] = ws
case "h2", "http":
h2 := map[string]any{
"path": defaultIfEmpty(link.Path, "/"),
}
if link.Host != "" {
h2["host"] = []string{link.Host}
}
stream["httpSettings"] = h2
}
// Security-specific settings
switch link.Security {
case "reality":
stream["realitySettings"] = map[string]any{
"publicKey": link.PublicKey,
"shortId": link.ShortId,
"serverName": link.Sni,
"fingerprint": link.Fingerprint,
}
case "tls":
tls := map[string]any{
"fingerprint": link.Fingerprint,
}
if link.Sni != "" {
tls["serverName"] = link.Sni
}
stream["tlsSettings"] = tls
}
return stream
}
func buildDirectOutbound() map[string]any {
return map[string]any{
"tag": "direct",
"protocol": "freedom",
}
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}