init commit
This commit is contained in:
61
core/config_manager.go
Normal file
61
core/config_manager.go
Normal 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
431
core/core_manager.go
Normal 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
128
core/models.go
Normal 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
177
core/parser.go
Normal 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
78
core/ping.go
Normal 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
70
core/process_windows.go
Normal 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
44
core/settings.go
Normal 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
30
core/settings_windows.go
Normal 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
156
core/singbox_config.go
Normal 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
31
core/util.go
Normal 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
177
core/xray_config.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user