Release 1.9.0: add VLESS, VMess and Trojan via Xray-core.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
||||
|
||||
// ResolveBinary finds xray executable.
|
||||
func ResolveBinary(binDir string) (string, error) {
|
||||
for _, name := range candidateNames() {
|
||||
candidates := []string{
|
||||
filepath.Join(binDir, name),
|
||||
filepath.Join(binDir, "xray", name),
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("xray binary not found in %s; run install-core", binDir)
|
||||
}
|
||||
|
||||
func candidateNames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"xray.exe", "Xray.exe"}
|
||||
}
|
||||
return []string{"xray", "Xray"}
|
||||
}
|
||||
|
||||
// EnsureBinary downloads official Xray-core release if missing.
|
||||
func EnsureBinary(binDir string) (string, error) {
|
||||
if path, err := ResolveBinary(binDir); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
want, err := releaseZipName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
|
||||
name, url, err := findReleaseAsset(want)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zipPath := filepath.Join(binDir, name+".download")
|
||||
_ = os.Remove(zipPath)
|
||||
if err := downloadFile(zipPath, url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
|
||||
destName := "xray"
|
||||
if runtime.GOOS == "windows" {
|
||||
destName = "xray.exe"
|
||||
}
|
||||
dest := filepath.Join(binDir, destName)
|
||||
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
|
||||
// Some zips nest the binary; try case-insensitive match.
|
||||
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
|
||||
return "", fmt.Errorf("%v; %w", err, err2)
|
||||
}
|
||||
}
|
||||
_ = os.Chmod(dest, 0o755)
|
||||
return ResolveBinary(binDir)
|
||||
}
|
||||
|
||||
func releaseZipName() (string, error) {
|
||||
switch {
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
|
||||
return "Xray-windows-64.zip", nil
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "arm64":
|
||||
return "Xray-windows-arm64-v8a.zip", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
||||
return "Xray-macos-64.zip", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
||||
return "Xray-macos-arm64-v8a.zip", nil
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||
return "Xray-linux-64.zip", nil
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "arm64":
|
||||
return "Xray-linux-arm64-v8a.zip", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform %s/%s for xray auto-download", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == exactName {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if strings.EqualFold(a.Name, exactName) {
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 15 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func extractNamedFromZip(zipPath, wantName, dest string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
wantBase := strings.ToLower(filepath.Base(wantName))
|
||||
for _, f := range r.File {
|
||||
base := strings.ToLower(filepath.Base(f.Name))
|
||||
if base != wantBase {
|
||||
continue
|
||||
}
|
||||
return writeZipFile(f, dest)
|
||||
}
|
||||
return fmt.Errorf("%s not found in zip", wantName)
|
||||
}
|
||||
|
||||
func extractXrayFromZip(zipPath, dest string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
base := strings.ToLower(filepath.Base(f.Name))
|
||||
if base == "xray" || base == "xray.exe" {
|
||||
return writeZipFile(f, dest)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("xray binary not found in archive")
|
||||
}
|
||||
|
||||
func writeZipFile(f *zip.File, dest string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
_ = os.Remove(dest)
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, io.LimitReader(rc, 80<<20))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// writeRuntimeConfig builds an Xray config.json for SOCKS+HTTP inbounds and one proxy outbound.
|
||||
func writeRuntimeConfig(path string, profile config.Profile) error {
|
||||
link, err := Parse(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socksHost, socksPort := "127.0.0.1", 1080
|
||||
httpHost, httpPort := "127.0.0.1", 1081
|
||||
if hp, ok := profile.SOCKSListenHostPort(); ok {
|
||||
h, p, err := splitHostPort(hp, 1080)
|
||||
if err == nil {
|
||||
socksHost, socksPort = h, p
|
||||
}
|
||||
}
|
||||
if hp, ok := profile.HTTPListenHostPort(); ok {
|
||||
h, p, err := splitHostPort(hp, 1081)
|
||||
if err == nil {
|
||||
httpHost, httpPort = h, p
|
||||
}
|
||||
}
|
||||
|
||||
outbound, err := buildOutbound(link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"tag": "socks-in",
|
||||
"listen": socksHost,
|
||||
"port": socksPort,
|
||||
"protocol": "socks",
|
||||
"settings": map[string]any{"udp": true, "auth": "noauth"},
|
||||
},
|
||||
map[string]any{
|
||||
"tag": "http-in",
|
||||
"listen": httpHost,
|
||||
"port": httpPort,
|
||||
"protocol": "http",
|
||||
},
|
||||
},
|
||||
"outbounds": []any{
|
||||
outbound,
|
||||
map[string]any{"protocol": "freedom", "tag": "direct"},
|
||||
map[string]any{"protocol": "blackhole", "tag": "block"},
|
||||
},
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func buildOutbound(link Link) (map[string]any, error) {
|
||||
stream, err := buildStreamSettings(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch link.Protocol {
|
||||
case config.ProtocolVLESS:
|
||||
user := map[string]any{
|
||||
"id": link.UUID,
|
||||
"encryption": firstNonEmpty(link.Security, "none"),
|
||||
}
|
||||
if link.Flow != "" {
|
||||
user["flow"] = link.Flow
|
||||
}
|
||||
settings := map[string]any{
|
||||
"vnext": []any{
|
||||
map[string]any{
|
||||
"address": link.Address,
|
||||
"port": link.Port,
|
||||
"users": []any{user},
|
||||
},
|
||||
},
|
||||
}
|
||||
if link.PacketEncoding != "" {
|
||||
settings["packetEncoding"] = link.PacketEncoding
|
||||
}
|
||||
return map[string]any{
|
||||
"tag": "proxy",
|
||||
"protocol": "vless",
|
||||
"settings": settings,
|
||||
"streamSettings": stream,
|
||||
}, nil
|
||||
case config.ProtocolVMess:
|
||||
user := map[string]any{
|
||||
"id": link.UUID,
|
||||
"alterId": link.AlterID,
|
||||
"security": firstNonEmpty(link.Security, "auto"),
|
||||
}
|
||||
return map[string]any{
|
||||
"tag": "proxy",
|
||||
"protocol": "vmess",
|
||||
"settings": map[string]any{
|
||||
"vnext": []any{
|
||||
map[string]any{
|
||||
"address": link.Address,
|
||||
"port": link.Port,
|
||||
"users": []any{user},
|
||||
},
|
||||
},
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}, nil
|
||||
case config.ProtocolTrojan:
|
||||
return map[string]any{
|
||||
"tag": "proxy",
|
||||
"protocol": "trojan",
|
||||
"settings": map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{
|
||||
"address": link.Address,
|
||||
"port": link.Port,
|
||||
"password": link.UUID,
|
||||
},
|
||||
},
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported xray protocol %q", link.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStreamSettings(link Link) (map[string]any, error) {
|
||||
network := strings.ToLower(firstNonEmpty(link.Network, "tcp"))
|
||||
stream := map[string]any{"network": network}
|
||||
|
||||
switch network {
|
||||
case "ws", "websocket":
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]any{}
|
||||
if link.Path != "" {
|
||||
ws["path"] = link.Path
|
||||
}
|
||||
if link.Host != "" {
|
||||
ws["headers"] = map[string]any{"Host": link.Host}
|
||||
}
|
||||
stream["wsSettings"] = ws
|
||||
case "grpc", "gun":
|
||||
stream["network"] = "grpc"
|
||||
grpc := map[string]any{"serviceName": firstNonEmpty(link.ServiceName, link.Path)}
|
||||
if link.Mode != "" {
|
||||
grpc["multiMode"] = strings.EqualFold(link.Mode, "multi")
|
||||
}
|
||||
stream["grpcSettings"] = grpc
|
||||
case "h2", "http":
|
||||
stream["network"] = "h2"
|
||||
h2 := map[string]any{}
|
||||
if link.Path != "" {
|
||||
h2["path"] = link.Path
|
||||
}
|
||||
if link.Host != "" {
|
||||
h2["host"] = strings.Split(link.Host, ",")
|
||||
}
|
||||
stream["httpSettings"] = h2
|
||||
case "httpupgrade":
|
||||
stream["network"] = "httpupgrade"
|
||||
hu := map[string]any{}
|
||||
if link.Path != "" {
|
||||
hu["path"] = link.Path
|
||||
}
|
||||
if link.Host != "" {
|
||||
hu["host"] = link.Host
|
||||
}
|
||||
stream["httpupgradeSettings"] = hu
|
||||
case "xhttp", "splithttp":
|
||||
stream["network"] = "xhttp"
|
||||
xh := map[string]any{}
|
||||
if link.Path != "" {
|
||||
xh["path"] = link.Path
|
||||
}
|
||||
if link.Host != "" {
|
||||
xh["host"] = link.Host
|
||||
}
|
||||
stream["xhttpSettings"] = xh
|
||||
default: // tcp
|
||||
stream["network"] = "tcp"
|
||||
if link.Type != "" && link.Type != "none" {
|
||||
stream["tcpSettings"] = map[string]any{
|
||||
"header": map[string]any{"type": link.Type},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sec := strings.ToLower(firstNonEmpty(link.TLS, "none"))
|
||||
switch sec {
|
||||
case "", "none", "0", "false":
|
||||
stream["security"] = "none"
|
||||
case "tls":
|
||||
stream["security"] = "tls"
|
||||
tls := map[string]any{
|
||||
"serverName": firstNonEmpty(link.SNI, link.Host, link.Address),
|
||||
"allowInsecure": link.AllowInsecure,
|
||||
}
|
||||
if link.FP != "" {
|
||||
tls["fingerprint"] = link.FP
|
||||
}
|
||||
if link.ALPN != "" {
|
||||
tls["alpn"] = splitCSV(link.ALPN)
|
||||
}
|
||||
stream["tlsSettings"] = tls
|
||||
case "reality":
|
||||
stream["security"] = "reality"
|
||||
reality := map[string]any{
|
||||
"serverName": firstNonEmpty(link.SNI, link.Host, link.Address),
|
||||
"fingerprint": firstNonEmpty(link.FP, "chrome"),
|
||||
"publicKey": link.PBK,
|
||||
"shortId": link.SID,
|
||||
"spiderX": firstNonEmpty(link.SPX, ""),
|
||||
}
|
||||
if link.PBK == "" {
|
||||
return nil, fmt.Errorf("reality: нужен pbk (publicKey)")
|
||||
}
|
||||
stream["realitySettings"] = reality
|
||||
default:
|
||||
stream["security"] = sec
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func splitHostPort(hp string, defPort int) (string, int, error) {
|
||||
host, portStr, err := netSplitHostPort(hp)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if portStr == "" {
|
||||
return host, defPort, nil
|
||||
}
|
||||
p, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, p, nil
|
||||
}
|
||||
|
||||
func netSplitHostPort(hp string) (host, port string, err error) {
|
||||
if strings.HasPrefix(hp, "[") {
|
||||
return splitBracket(hp)
|
||||
}
|
||||
if i := strings.LastIndex(hp, ":"); i >= 0 {
|
||||
return hp[:i], hp[i+1:], nil
|
||||
}
|
||||
return hp, "", nil
|
||||
}
|
||||
|
||||
func splitBracket(hp string) (string, string, error) {
|
||||
end := strings.Index(hp, "]")
|
||||
if end < 0 {
|
||||
return "", "", fmt.Errorf("bad host:port")
|
||||
}
|
||||
host := hp[1:end]
|
||||
rest := hp[end+1:]
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return host, rest[1:], nil
|
||||
}
|
||||
return host, "", nil
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan.
|
||||
type Engine struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
workdir string
|
||||
profile config.Profile
|
||||
stderr io.Writer
|
||||
running bool
|
||||
proto config.Protocol
|
||||
}
|
||||
|
||||
func New(stderr io.Writer) *Engine {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
return &Engine{stderr: stderr}
|
||||
}
|
||||
|
||||
func (e *Engine) Protocol() config.Protocol {
|
||||
if e.proto != "" {
|
||||
return e.proto
|
||||
}
|
||||
return config.ProtocolVLESS
|
||||
}
|
||||
|
||||
func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.running {
|
||||
return fmt.Errorf("xray: already running")
|
||||
}
|
||||
|
||||
link, err := Parse(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.proto = link.Protocol
|
||||
|
||||
bin, err := ResolveBinary(binDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workdir, err := os.MkdirTemp("", "vpnclient-xray-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("xray: temp dir: %w", err)
|
||||
}
|
||||
cfgPath := filepath.Join(workdir, "config.json")
|
||||
if err := writeRuntimeConfig(cfgPath, profile); err != nil {
|
||||
os.RemoveAll(workdir)
|
||||
return err
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
cmd := exec.CommandContext(runCtx, bin, "run", "-c", cfgPath)
|
||||
cmd.Dir = workdir
|
||||
cmd.Stdout = e.stderr
|
||||
cmd.Stderr = e.stderr
|
||||
applySysProcAttr(cmd)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
os.RemoveAll(workdir)
|
||||
return fmt.Errorf("xray: start %s: %w", bin, err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
e.cmd = cmd
|
||||
e.cancel = cancel
|
||||
e.done = done
|
||||
e.workdir = workdir
|
||||
e.profile = profile
|
||||
e.running = true
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil {
|
||||
fmt.Fprintf(e.stderr, "xray: process ended: %v\n", err)
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.cleanupLocked()
|
||||
e.mu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
e.mu.Unlock()
|
||||
timer := time.NewTimer(900 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
var startErr error
|
||||
select {
|
||||
case <-done:
|
||||
startErr = fmt.Errorf("xray: process exited immediately; check link and install-core (xray)")
|
||||
case <-ctx.Done():
|
||||
_ = e.Stop()
|
||||
startErr = ctx.Err()
|
||||
case <-timer.C:
|
||||
e.mu.Lock()
|
||||
alive := e.running
|
||||
e.mu.Unlock()
|
||||
if !alive {
|
||||
startErr = fmt.Errorf("xray: process exited during startup")
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
return startErr
|
||||
}
|
||||
|
||||
func (e *Engine) Stop() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.stopLocked()
|
||||
}
|
||||
|
||||
func (e *Engine) stopLocked() error {
|
||||
if !e.running || e.cmd == nil {
|
||||
return nil
|
||||
}
|
||||
done := e.done
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
}
|
||||
proc := e.cmd.Process
|
||||
e.mu.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
if proc != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
e.mu.Lock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupLocked() {
|
||||
if e.workdir != "" {
|
||||
_ = os.RemoveAll(e.workdir)
|
||||
e.workdir = ""
|
||||
}
|
||||
e.cmd = nil
|
||||
e.cancel = nil
|
||||
e.running = false
|
||||
}
|
||||
|
||||
func (e *Engine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
|
||||
func (e *Engine) LocalHTTPProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
return e.profile.HTTPListenHostPort()
|
||||
}
|
||||
|
||||
func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
return e.profile.SOCKSListenHostPort()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package xray
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func applySysProcAttr(cmd *exec.Cmd) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package xray
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applySysProcAttr(cmd *exec.Cmd) {
|
||||
const createNoWindow = 0x08000000
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Link is a normalized VLESS / VMess / Trojan share link.
|
||||
type Link struct {
|
||||
Protocol config.Protocol
|
||||
Raw string
|
||||
Remark string
|
||||
Address string
|
||||
Port int
|
||||
UUID string // vless/vmess id, or trojan password
|
||||
AlterID int
|
||||
Security string // encryption for vmess (auto/aes-128-gcm/…) or vless encryption
|
||||
Flow string
|
||||
Network string // tcp/ws/grpc/h2/httpupgrade/xhttp/splithttp
|
||||
Type string // header type for tcp
|
||||
Host string // ws/http host header
|
||||
Path string
|
||||
TLS string // none/tls/reality
|
||||
SNI string
|
||||
ALPN string
|
||||
FP string
|
||||
PBK string // reality public key
|
||||
SID string // reality shortId
|
||||
SPX string // reality spiderX
|
||||
ServiceName string // grpc
|
||||
Mode string // grpc multi/gun
|
||||
AllowInsecure bool
|
||||
PacketEncoding string // xudp/packetaddr for vless
|
||||
}
|
||||
|
||||
// Detect reports whether raw is a vless/vmess/trojan share link.
|
||||
func Detect(raw string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
return strings.HasPrefix(lower, "vless://") ||
|
||||
strings.HasPrefix(lower, "vmess://") ||
|
||||
strings.HasPrefix(lower, "trojan://")
|
||||
}
|
||||
|
||||
// DetectProtocol returns the specific protocol or "".
|
||||
func DetectProtocol(raw string) config.Protocol {
|
||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "vless://"):
|
||||
return config.ProtocolVLESS
|
||||
case strings.HasPrefix(lower, "vmess://"):
|
||||
return config.ProtocolVMess
|
||||
case strings.HasPrefix(lower, "trojan://"):
|
||||
return config.ProtocolTrojan
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeShareLink parses and returns a canonical share URI + remark.
|
||||
func NormalizeShareLink(raw string) (normalized string, remark string, err error) {
|
||||
link, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return link.Raw, link.Remark, nil
|
||||
}
|
||||
|
||||
// Parse accepts vless://, vmess://, trojan:// share links.
|
||||
func Parse(raw string) (Link, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Link{}, fmt.Errorf("пустая ссылка")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "vless://"):
|
||||
return parseVLESS(raw)
|
||||
case strings.HasPrefix(lower, "vmess://"):
|
||||
return parseVMess(raw)
|
||||
case strings.HasPrefix(lower, "trojan://"):
|
||||
return parseTrojan(raw)
|
||||
default:
|
||||
return Link{}, fmt.Errorf("ожидалась ссылка vless://, vmess:// или trojan://")
|
||||
}
|
||||
}
|
||||
|
||||
// HostPort extracts server host/port for ping.
|
||||
func HostPort(raw string) (host, port string, err error) {
|
||||
link, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if link.Address == "" {
|
||||
return "", "", fmt.Errorf("нет хоста")
|
||||
}
|
||||
p := strconv.Itoa(link.Port)
|
||||
if link.Port <= 0 {
|
||||
p = "443"
|
||||
}
|
||||
return link.Address, p, nil
|
||||
}
|
||||
|
||||
func parseVLESS(raw string) (Link, error) {
|
||||
remark := ""
|
||||
body := raw
|
||||
if i := strings.IndexByte(body, '#'); i >= 0 {
|
||||
remark, _ = url.QueryUnescape(strings.TrimSpace(body[i+1:]))
|
||||
body = body[:i]
|
||||
}
|
||||
u, err := url.Parse(body)
|
||||
if err != nil {
|
||||
return Link{}, fmt.Errorf("parse vless: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := 443
|
||||
if p := u.Port(); p != "" {
|
||||
port, _ = strconv.Atoi(p)
|
||||
}
|
||||
uuid := ""
|
||||
if u.User != nil {
|
||||
uuid = u.User.Username()
|
||||
}
|
||||
if host == "" || uuid == "" {
|
||||
return Link{}, fmt.Errorf("vless: нужен uuid@host:port")
|
||||
}
|
||||
q := u.Query()
|
||||
link := Link{
|
||||
Protocol: config.ProtocolVLESS,
|
||||
Raw: rebuildURI("vless", uuid, "", host, port, q, remark),
|
||||
Remark: remark,
|
||||
Address: host,
|
||||
Port: port,
|
||||
UUID: uuid,
|
||||
Security: firstNonEmpty(q.Get("encryption"), "none"),
|
||||
Flow: q.Get("flow"),
|
||||
Network: firstNonEmpty(q.Get("type"), q.Get("network"), "tcp"),
|
||||
Type: q.Get("headerType"),
|
||||
Host: firstNonEmpty(q.Get("host"), q.Get("authority")),
|
||||
Path: firstNonEmpty(q.Get("path"), q.Get("serviceName")),
|
||||
TLS: firstNonEmpty(q.Get("security"), "none"),
|
||||
SNI: firstNonEmpty(q.Get("sni"), q.Get("serverName")),
|
||||
ALPN: q.Get("alpn"),
|
||||
FP: firstNonEmpty(q.Get("fp"), q.Get("fingerprint")),
|
||||
PBK: firstNonEmpty(q.Get("pbk"), q.Get("publicKey")),
|
||||
SID: firstNonEmpty(q.Get("sid"), q.Get("shortId")),
|
||||
SPX: firstNonEmpty(q.Get("spx"), q.Get("spiderX")),
|
||||
ServiceName: firstNonEmpty(q.Get("serviceName"), q.Get("path")),
|
||||
Mode: q.Get("mode"),
|
||||
AllowInsecure: truthy(q.Get("allowInsecure")) || truthy(q.Get("insecure")),
|
||||
PacketEncoding: q.Get("packetEncoding"),
|
||||
}
|
||||
if link.Network == "grpc" && link.ServiceName == "" {
|
||||
link.ServiceName = link.Path
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func parseTrojan(raw string) (Link, error) {
|
||||
remark := ""
|
||||
body := raw
|
||||
if i := strings.IndexByte(body, '#'); i >= 0 {
|
||||
remark, _ = url.QueryUnescape(strings.TrimSpace(body[i+1:]))
|
||||
body = body[:i]
|
||||
}
|
||||
u, err := url.Parse(body)
|
||||
if err != nil {
|
||||
return Link{}, fmt.Errorf("parse trojan: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := 443
|
||||
if p := u.Port(); p != "" {
|
||||
port, _ = strconv.Atoi(p)
|
||||
}
|
||||
password := ""
|
||||
if u.User != nil {
|
||||
password = u.User.Username()
|
||||
if p, ok := u.User.Password(); ok && p != "" {
|
||||
password = password + ":" + p
|
||||
}
|
||||
}
|
||||
if host == "" || password == "" {
|
||||
return Link{}, fmt.Errorf("trojan: нужен password@host:port")
|
||||
}
|
||||
q := u.Query()
|
||||
tls := firstNonEmpty(q.Get("security"), "tls")
|
||||
if tls == "" || tls == "none" {
|
||||
tls = "tls"
|
||||
}
|
||||
link := Link{
|
||||
Protocol: config.ProtocolTrojan,
|
||||
Raw: rebuildURI("trojan", password, "", host, port, q, remark),
|
||||
Remark: remark,
|
||||
Address: host,
|
||||
Port: port,
|
||||
UUID: password,
|
||||
Network: firstNonEmpty(q.Get("type"), q.Get("network"), "tcp"),
|
||||
Type: q.Get("headerType"),
|
||||
Host: firstNonEmpty(q.Get("host"), q.Get("authority")),
|
||||
Path: q.Get("path"),
|
||||
TLS: tls,
|
||||
SNI: firstNonEmpty(q.Get("sni"), q.Get("peer"), q.Get("serverName")),
|
||||
ALPN: q.Get("alpn"),
|
||||
FP: firstNonEmpty(q.Get("fp"), q.Get("fingerprint")),
|
||||
PBK: firstNonEmpty(q.Get("pbk"), q.Get("publicKey")),
|
||||
SID: firstNonEmpty(q.Get("sid"), q.Get("shortId")),
|
||||
SPX: firstNonEmpty(q.Get("spx"), q.Get("spiderX")),
|
||||
ServiceName: firstNonEmpty(q.Get("serviceName"), q.Get("path")),
|
||||
Mode: q.Get("mode"),
|
||||
AllowInsecure: truthy(q.Get("allowInsecure")) || truthy(q.Get("insecure")),
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
type vmessShare struct {
|
||||
V any `json:"v"`
|
||||
PS string `json:"ps"`
|
||||
Add string `json:"add"`
|
||||
Port any `json:"port"`
|
||||
ID string `json:"id"`
|
||||
Aid any `json:"aid"`
|
||||
Scy string `json:"scy"`
|
||||
Net string `json:"net"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
TLS string `json:"tls"`
|
||||
SNI string `json:"sni"`
|
||||
ALPN string `json:"alpn"`
|
||||
FP string `json:"fp"`
|
||||
}
|
||||
|
||||
func parseVMess(raw string) (Link, error) {
|
||||
body := strings.TrimSpace(raw)
|
||||
if i := strings.Index(strings.ToLower(body), "vmess://"); i >= 0 {
|
||||
body = body[i+len("vmess://"):]
|
||||
}
|
||||
if i := strings.IndexByte(body, '#'); i >= 0 {
|
||||
body = body[:i]
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
decoded, err := decodeBase64Flexible(body)
|
||||
if err != nil {
|
||||
return Link{}, fmt.Errorf("vmess base64: %w", err)
|
||||
}
|
||||
var m vmessShare
|
||||
if err := json.Unmarshal([]byte(decoded), &m); err != nil {
|
||||
return Link{}, fmt.Errorf("vmess json: %w", err)
|
||||
}
|
||||
port := anyToInt(m.Port, 443)
|
||||
aid := anyToInt(m.Aid, 0)
|
||||
if m.Add == "" || m.ID == "" {
|
||||
return Link{}, fmt.Errorf("vmess: нет add/id")
|
||||
}
|
||||
tls := strings.ToLower(strings.TrimSpace(m.TLS))
|
||||
if tls == "1" || tls == "true" {
|
||||
tls = "tls"
|
||||
}
|
||||
if tls == "" {
|
||||
tls = "none"
|
||||
}
|
||||
link := Link{
|
||||
Protocol: config.ProtocolVMess,
|
||||
Remark: m.PS,
|
||||
Address: m.Add,
|
||||
Port: port,
|
||||
UUID: m.ID,
|
||||
AlterID: aid,
|
||||
Security: firstNonEmpty(m.Scy, "auto"),
|
||||
Network: firstNonEmpty(m.Net, "tcp"),
|
||||
Type: m.Type,
|
||||
Host: m.Host,
|
||||
Path: m.Path,
|
||||
TLS: tls,
|
||||
SNI: firstNonEmpty(m.SNI, m.Host),
|
||||
ALPN: m.ALPN,
|
||||
FP: m.FP,
|
||||
}
|
||||
// Rebuild canonical vmess:// for storage.
|
||||
out, _ := json.Marshal(map[string]any{
|
||||
"v": "2", "ps": link.Remark, "add": link.Address, "port": link.Port,
|
||||
"id": link.UUID, "aid": link.AlterID, "scy": link.Security, "net": link.Network,
|
||||
"type": link.Type, "host": link.Host, "path": link.Path, "tls": link.TLS,
|
||||
"sni": link.SNI, "alpn": link.ALPN, "fp": link.FP,
|
||||
})
|
||||
link.Raw = "vmess://" + base64.StdEncoding.EncodeToString(out)
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func rebuildURI(scheme, user, pass, host string, port int, q url.Values, remark string) string {
|
||||
u := &url.URL{Scheme: scheme, Host: net.JoinHostPort(host, strconv.Itoa(port))}
|
||||
if pass != "" {
|
||||
u.User = url.UserPassword(user, pass)
|
||||
} else {
|
||||
u.User = url.User(user)
|
||||
}
|
||||
if len(q) > 0 {
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
s := u.String()
|
||||
if remark != "" {
|
||||
s += "#" + url.PathEscape(remark)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func decodeBase64Flexible(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ReplaceAll(s, "-", "+")
|
||||
s = strings.ReplaceAll(s, "_", "/")
|
||||
switch len(s) % 4 {
|
||||
case 2:
|
||||
s += "=="
|
||||
case 3:
|
||||
s += "="
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
b, err = base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func anyToInt(v any, def int) int {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(t))
|
||||
if err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truthy(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package xray
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVLESS(t *testing.T) {
|
||||
raw := "vless://11111111-1111-1111-1111-111111111111@example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.cloudflare.com&fp=chrome&pbk=PUBLIC&sid=abcd&type=tcp#EU"
|
||||
link, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.Protocol != "vless" || link.Address != "example.com" || link.Port != 443 {
|
||||
t.Fatalf("%+v", link)
|
||||
}
|
||||
if link.TLS != "reality" || link.PBK != "PUBLIC" || link.Flow != "xtls-rprx-vision" {
|
||||
t.Fatalf("%+v", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrojan(t *testing.T) {
|
||||
raw := "trojan://secret@1.2.3.4:443?security=tls&sni=example.com&type=ws&path=%2Fws&host=example.com#tr"
|
||||
link, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.UUID != "secret" || link.Network != "ws" || link.Path != "/ws" {
|
||||
t.Fatalf("%+v", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVMess(t *testing.T) {
|
||||
// {"v":"2","ps":"n","add":"1.2.3.4","port":"443","id":"11111111-1111-1111-1111-111111111111","aid":"0","scy":"auto","net":"tcp","type":"none","host":"","path":"","tls":"tls","sni":"example.com"}
|
||||
b64 := "eyJ2IjoiMiIsInBzIjoibiIsImFkZCI6IjEuMi4zLjQiLCJwb3J0IjoiNDQzIiwiaWQiOiIxMTExMTExMS0xMTExLTExMTEtMTExMS0xMTExMTExMTExMTEiLCJhaWQiOiIwIiwic2N5IjoiYXV0byIsIm5ldCI6InRjcCIsInR5cGUiOiJub25lIiwiaG9zdCI6IiIsInBhdGgiOiIiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJleGFtcGxlLmNvbSJ9"
|
||||
link, err := Parse("vmess://" + b64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.Address != "1.2.3.4" || link.Port != 443 || link.TLS != "tls" {
|
||||
t.Fatalf("%+v", link)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user