wswg/engine.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
}