411 lines
10 KiB
Go
411 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Status struct {
|
|
Connected bool `json:"connected"`
|
|
Config string `json:"config"`
|
|
IP string `json:"ip"`
|
|
Handshake string `json:"handshake"`
|
|
Ping string `json:"ping"`
|
|
PacketLoss int `json:"packetLoss"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func (s Status) Line() string {
|
|
if !s.Connected {
|
|
if s.Error != "" {
|
|
return "Disconnected | " + s.Error
|
|
}
|
|
return "Disconnected"
|
|
}
|
|
return fmt.Sprintf("Connected to %s | Last handshake %ss | Ping %sms | Packet loss: %d%%", s.IP, s.Handshake, s.Ping, s.PacketLoss)
|
|
}
|
|
|
|
type Engine struct {
|
|
mu sync.Mutex
|
|
dir string
|
|
profile Profile
|
|
conf string
|
|
cmd *exec.Cmd
|
|
childDone chan error
|
|
routes [][]string
|
|
state Status
|
|
stopMonitor chan struct{}
|
|
loss lossWindow
|
|
}
|
|
|
|
func bin(name string) string {
|
|
if runtime.GOOS == "windows" {
|
|
name += ".exe"
|
|
}
|
|
exe, _ := os.Executable()
|
|
for _, p := range []string{filepath.Join(filepath.Dir(exe), "bin", name), filepath.Join(filepath.Dir(exe), name)} {
|
|
if st, e := os.Stat(p); e == nil && !st.IsDir() {
|
|
return p
|
|
}
|
|
}
|
|
if runtime.GOOS == "windows" && (name == "wireguard.exe" || name == "wg.exe") {
|
|
p := filepath.Join(os.Getenv("ProgramFiles"), "WireGuard", name)
|
|
if _, e := os.Stat(p); e == nil {
|
|
return p
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
func command(name string, args ...string) error {
|
|
c := exec.Command(bin(name), args...)
|
|
c.Env = commandEnv()
|
|
b, e := c.CombinedOutput()
|
|
if e != nil {
|
|
return fmt.Errorf("%s: %w: %s", name, e, strings.TrimSpace(string(b)))
|
|
}
|
|
return nil
|
|
}
|
|
func commandEnv() []string {
|
|
exe, _ := os.Executable()
|
|
return append(os.Environ(), "NO_COLOR=true", "PATH="+filepath.Join(filepath.Dir(exe), "bin")+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
}
|
|
func (e *Engine) activeID() string {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.state.Connected {
|
|
return e.profile.ID
|
|
}
|
|
return ""
|
|
}
|
|
func (e *Engine) status() Status { e.mu.Lock(); defer e.mu.Unlock(); return e.state }
|
|
func (e *Engine) start(p Profile) (err error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.state.Connected {
|
|
return errors.New("disconnect current profile first")
|
|
}
|
|
if err = validateProfile(p); err != nil {
|
|
return err
|
|
}
|
|
if !admin() {
|
|
return errors.New("WireGuard needs root / Administrator; Termux also needs a WireGuard-enabled Android kernel")
|
|
}
|
|
probe := exec.Command(bin("wg"), "show", "wswg0")
|
|
probe.Env = commandEnv()
|
|
if probe.Run() == nil {
|
|
return errors.New("interface wswg0 already exists; disconnect it before starting")
|
|
}
|
|
e.loss = lossWindow{}
|
|
e.state = Status{Handshake: "—", Ping: "—"}
|
|
e.profile = p
|
|
defer func() {
|
|
if err != nil {
|
|
e.cleanup()
|
|
e.state = Status{Error: err.Error()}
|
|
}
|
|
}()
|
|
w, _ := parseWG(p.WireGuard)
|
|
endpoint := ""
|
|
if p.Tunnel.Enabled {
|
|
host := p.Tunnel.Host
|
|
if host == "" {
|
|
host = w.Host
|
|
}
|
|
ips, x := net.LookupIP(host)
|
|
if x != nil || len(ips) == 0 {
|
|
return fmt.Errorf("resolve wstunnel host %s: %v", host, x)
|
|
}
|
|
var ip net.IP
|
|
for _, v := range ips {
|
|
if v.To4() != nil {
|
|
ip = v
|
|
break
|
|
}
|
|
}
|
|
if ip == nil {
|
|
ip = ips[0]
|
|
}
|
|
if err = e.pinRoute(ip.String()); err != nil {
|
|
return err
|
|
}
|
|
u, x := net.ListenPacket("udp4", "127.0.0.1:0")
|
|
if x != nil {
|
|
return x
|
|
}
|
|
addr := u.LocalAddr().String()
|
|
u.Close()
|
|
_, port, _ := net.SplitHostPort(addr)
|
|
endpoint = addr
|
|
scheme := "ws"
|
|
if p.Tunnel.HTTPS {
|
|
scheme = "wss"
|
|
}
|
|
// The remote UDP destination is resolved by the server. localhost matches a WireGuard server on that host.
|
|
args := []string{"client", "-L", "udp://127.0.0.1:" + port + ":127.0.0.1:" + strconv.Itoa(w.Port) + "?timeout_sec=0"}
|
|
if net.ParseIP(host) == nil {
|
|
if p.Tunnel.HTTPS {
|
|
args = append(args, "--tls-sni-override", host)
|
|
}
|
|
args = append(args, "--http-headers", "Host: "+net.JoinHostPort(host, strconv.Itoa(p.Tunnel.Port)))
|
|
}
|
|
args = append(args, scheme+"://"+net.JoinHostPort(ip.String(), strconv.Itoa(p.Tunnel.Port)))
|
|
if err = e.startTunnel(args); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
e.conf = filepath.Join(e.dir, "wswg0.conf")
|
|
if err = os.WriteFile(e.conf, []byte(renderWG(p, endpoint)), 0600); err != nil {
|
|
return err
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
err = command("wireguard", "/installtunnelservice", e.conf)
|
|
} else {
|
|
err = command("wg-quick", "up", e.conf)
|
|
}
|
|
if err != nil { // wg-quick rolls back partially configured interfaces itself.
|
|
if runtime.GOOS == "windows" {
|
|
_ = command("wireguard", "/uninstalltunnelservice", "wswg0")
|
|
}
|
|
return err
|
|
}
|
|
e.state = Status{Connected: true, Config: p.Name, IP: w.Host, Handshake: "—", Ping: "—"}
|
|
e.stopMonitor = make(chan struct{})
|
|
go e.monitor(e.stopMonitor)
|
|
return nil
|
|
}
|
|
func (e *Engine) startTunnel(args []string) error {
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
log, x := os.OpenFile(filepath.Join(e.dir, "wstunnel.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
|
if x != nil {
|
|
return x
|
|
}
|
|
c := exec.Command(bin("wstunnel"), args...)
|
|
c.Env = commandEnv()
|
|
c.Stdout = log
|
|
c.Stderr = log
|
|
x = c.Start()
|
|
if x == nil {
|
|
done := make(chan error, 1)
|
|
go func() { done <- c.Wait(); log.Close() }()
|
|
select {
|
|
case x = <-done:
|
|
if x == nil {
|
|
x = errors.New("wstunnel exited")
|
|
}
|
|
case <-time.After(1200 * time.Millisecond):
|
|
e.cmd = c
|
|
e.childDone = done
|
|
return nil
|
|
}
|
|
} else {
|
|
log.Close()
|
|
}
|
|
if runtime.GOOS != "windows" || attempt != 0 || !missingRuntime(x) {
|
|
return fmt.Errorf("wstunnel failed: %w; see %s", x, filepath.Join(e.dir, "wstunnel.log"))
|
|
}
|
|
if x = installRuntime(); x != nil {
|
|
return x
|
|
}
|
|
}
|
|
return errors.New("wstunnel failed after redistributable installation")
|
|
}
|
|
func missingRuntime(err error) bool {
|
|
s := strings.ToLower(err.Error())
|
|
return strings.Contains(s, "0xc0000135") || strings.Contains(s, "0xc000007b") || strings.Contains(s, "3221225781") || strings.Contains(s, "3221225595")
|
|
}
|
|
func installRuntime() error {
|
|
arch := runtime.GOARCH
|
|
if arch == "amd64" {
|
|
arch = "x64"
|
|
}
|
|
if arch == "386" {
|
|
arch = "x86"
|
|
}
|
|
exe, _ := os.Executable()
|
|
p := filepath.Join(filepath.Dir(exe), "redist", "vc_redist."+arch+".exe")
|
|
if _, e := os.Stat(p); e != nil {
|
|
return fmt.Errorf("bundled Visual C++ redistributable: %w", e)
|
|
}
|
|
e := exec.Command(p, "/install", "/quiet", "/norestart").Run()
|
|
if x, ok := e.(*exec.ExitError); ok && (x.ExitCode() == 3010 || x.ExitCode() == 1638) {
|
|
return nil
|
|
}
|
|
return e
|
|
}
|
|
func (e *Engine) cleanup() {
|
|
if e.cmd != nil {
|
|
_ = e.cmd.Process.Kill()
|
|
<-e.childDone
|
|
e.cmd = nil
|
|
e.childDone = nil
|
|
}
|
|
for i := len(e.routes) - 1; i >= 0; i-- {
|
|
r := e.routes[i]
|
|
_ = command(r[0], r[1:]...)
|
|
}
|
|
e.routes = nil
|
|
if e.conf != "" {
|
|
os.Remove(e.conf)
|
|
e.conf = ""
|
|
}
|
|
}
|
|
func (e *Engine) stop() {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.stopMonitor != nil {
|
|
close(e.stopMonitor)
|
|
e.stopMonitor = nil
|
|
}
|
|
if e.state.Connected {
|
|
var err error
|
|
if runtime.GOOS == "windows" {
|
|
err = command("wireguard", "/uninstalltunnelservice", "wswg0")
|
|
} else {
|
|
err = command("wg-quick", "down", e.conf)
|
|
}
|
|
if err != nil {
|
|
e.state.Error = err.Error()
|
|
return
|
|
}
|
|
}
|
|
e.cleanup()
|
|
e.state = Status{}
|
|
}
|
|
func (e *Engine) monitor(stop <-chan struct{}) {
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-ticker.C:
|
|
e.mu.Lock()
|
|
if !e.state.Connected {
|
|
e.mu.Unlock()
|
|
return
|
|
}
|
|
if e.childDone != nil {
|
|
select {
|
|
case err := <-e.childDone:
|
|
e.cmd = nil
|
|
e.childDone = nil
|
|
e.mu.Unlock()
|
|
e.stop()
|
|
e.mu.Lock()
|
|
e.state.Error = fmt.Sprintf("wstunnel exited: %v; see wstunnel.log", err)
|
|
e.mu.Unlock()
|
|
return
|
|
default:
|
|
}
|
|
}
|
|
c := exec.Command(bin("wg"), "show", "wswg0", "latest-handshakes")
|
|
c.Env = commandEnv()
|
|
out, err := c.Output()
|
|
if err == nil {
|
|
latest := int64(0)
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) == 2 {
|
|
v, _ := strconv.ParseInt(f[1], 10, 64)
|
|
if v > latest {
|
|
latest = v
|
|
}
|
|
}
|
|
}
|
|
if latest > 0 {
|
|
e.state.Handshake = strconv.FormatInt(time.Now().Unix()-latest, 10)
|
|
}
|
|
}
|
|
host := e.state.IP
|
|
e.mu.Unlock()
|
|
ping, success := pingHost(host)
|
|
e.mu.Lock()
|
|
if e.state.Connected && e.stopMonitor == stop {
|
|
e.state.Ping = ping
|
|
e.state.PacketLoss = e.loss.add(success)
|
|
}
|
|
e.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
func pingHost(host string) (string, bool) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 950*time.Millisecond)
|
|
defer cancel()
|
|
args := []string{"-n", "-c", "1", "-W", "1", host}
|
|
if runtime.GOOS == "windows" {
|
|
args = []string{"-n", "1", "-w", "1000", host}
|
|
}
|
|
out, err := exec.CommandContext(ctx, "ping", args...).Output()
|
|
if err != nil {
|
|
return "—", false
|
|
}
|
|
s := strings.ToLower(string(out))
|
|
// Windows ping uses the system OEM encoding. TTL remains ASCII even when
|
|
// the time label and the unit are localized.
|
|
if runtime.GOOS == "windows" {
|
|
for _, line := range strings.Split(s, "\n") {
|
|
f := strings.Fields(line)
|
|
for i, part := range f {
|
|
if i > 0 && strings.HasPrefix(part, "ttl=") {
|
|
value := f[i-1]
|
|
if j := strings.LastIndexAny(value, "=<"); j >= 0 {
|
|
value = value[j+1:]
|
|
k := 0
|
|
for k < len(value) && ((value[k] >= '0' && value[k] <= '9') || value[k] == '.' || value[k] == ',') {
|
|
k++
|
|
}
|
|
if k > 0 {
|
|
return value[:k], true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, key := range []string{"time=", "time<", "время="} {
|
|
i := strings.Index(s, key)
|
|
if i >= 0 {
|
|
tail := s[i+len(key):]
|
|
end := 0
|
|
for end < len(tail) && ((tail[end] >= '0' && tail[end] <= '9') || tail[end] == '.' || tail[end] == ',') {
|
|
end++
|
|
}
|
|
if end > 0 {
|
|
return tail[:end], true
|
|
}
|
|
}
|
|
}
|
|
return "—", true
|
|
}
|
|
|
|
// lossWindow stores the last 100 ICMP attempts; an unanswered probe is a loss.
|
|
type lossWindow struct {
|
|
results [100]bool
|
|
next, count, lost int
|
|
}
|
|
|
|
func (w *lossWindow) add(success bool) int {
|
|
if w.count == len(w.results) {
|
|
if !w.results[w.next] {
|
|
w.lost--
|
|
}
|
|
} else {
|
|
w.count++
|
|
}
|
|
w.results[w.next] = success
|
|
if !success {
|
|
w.lost++
|
|
}
|
|
w.next = (w.next + 1) % len(w.results)
|
|
return (w.lost*100 + w.count/2) / w.count
|
|
}
|