feat: 支持 Windows x86_64 平台编译
- 新增 WinTun TUN 设备实现(tun_windows.go),无需安装驱动, wintun.dll 嵌入 exe 运行时自动释放 - 新增 Windows 路由管理(route_windows.go),使用 route/netsh 命令 - 新增 Windows 路径(paths_windows.go),使用 %APPDATA%/%LOCALAPPDATA% - 拆分 daemon 启动逻辑:launch_unix.go(Setsid) / launch_windows.go (CREATE_NEW_PROCESS_GROUP),公共代码提取到 launch_common.go - 拆分提权逻辑:elevation_darwin.go(osascript) / elevation_windows.go (ShellExecute UAC) / elevation_other.go(pkexec) - IPCSocketPath 按平台分散,Windows 用 %TEMP%\lmvpn.sock - daemon 二进制名用编译时常量(daemonBinaryName),按平台选定 .exe 后缀 - Makefile 新增 build-windows 目标 - 重新标记 !darwin 文件为 !darwin && !windows
This commit is contained in:
@@ -13,7 +13,7 @@ GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
VERSION = 0.3.4-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
||||
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon build-windows
|
||||
|
||||
## all: build the .app bundle (default)
|
||||
all: app
|
||||
@@ -80,3 +80,12 @@ fmt:
|
||||
## clean: remove build artifacts
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(APP_BUNDLE)
|
||||
|
||||
## build-windows: cross-compile Windows x86_64 exes (requires mingw-w64)
|
||||
build-windows:
|
||||
mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||
$(GO) build -ldflags "$(LDFLAGS) -H windowsgui" -o $(BUILD_DIR)/$(GUI_BIN).exe ./cmd/lmvpn
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||
$(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(DAEMON_BIN).exe ./cmd/lmvpnd
|
||||
@echo "Built $(BUILD_DIR)/$(GUI_BIN).exe and $(BUILD_DIR)/$(DAEMON_BIN).exe"
|
||||
|
||||
@@ -13,6 +13,8 @@ require (
|
||||
modernc.org/sqlite v1.34.1
|
||||
)
|
||||
|
||||
require golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.4
|
||||
fyne.io/systray v1.12.1 // indirect
|
||||
|
||||
@@ -102,6 +102,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.24.1 h1:vxuHLTNS3Np5zrYoPRpcheASHX/7KiGo+8Y4ZM1J2O8=
|
||||
golang.org/x/tools v0.24.1/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
const daemonBinaryName = "lmvpnd.exe"
|
||||
@@ -0,0 +1,32 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// resolveDaemonBinary locates the lmvpnd binary relative to the
|
||||
// launcher's executable path. In a .app bundle, lmvpnd lives in the
|
||||
// same Contents/MacOS/ directory as lmvpn. In development, it lives
|
||||
// in the same build directory.
|
||||
func resolveDaemonBinary() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any symlinks.
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, daemonBinaryName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find %s near %s", daemonBinaryName, exe)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
@@ -120,33 +122,4 @@ func Launch(userHome string, uid, gid int, daemonBin string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDaemonBinary locates the lmvpnd binary relative to the
|
||||
// launcher's executable path. In a .app bundle, lmvpnd lives in the
|
||||
// same Contents/MacOS/ directory as lmvpn. In development, it lives
|
||||
// in the same build directory.
|
||||
func resolveDaemonBinary() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any symlinks.
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "lmvpnd"),
|
||||
filepath.Join(dir, "lmvpn-daemon"),
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find lmvpnd binary near %s (tried %v)", exe, candidates)
|
||||
}
|
||||
// resolveDaemonBinary is in launch_common.go.
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// Launch starts the daemon binary (lmvpnd.exe) as a detached process
|
||||
// and returns immediately. On Windows, detachment is achieved via
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS creation flags instead
|
||||
// of the Unix Setsid call.
|
||||
//
|
||||
// The launcher itself is invoked by the GUI via ShellExecute "runas"
|
||||
// (UAC elevation). It:
|
||||
// 1. Computes the daemon log path using the user's home directory
|
||||
// 2. Opens the log file (append) and NUL
|
||||
// 3. Starts the lmvpnd.exe binary, redirecting stdio
|
||||
// 4. Returns immediately — the child continues running independently
|
||||
func Launch(userHome string, uid, gid int, daemonBin string) error {
|
||||
paths.SetUserHome(userHome)
|
||||
if err := paths.EnsureDirs(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ensure dirs: %v\n", err)
|
||||
}
|
||||
|
||||
if daemonBin == "" {
|
||||
var err error
|
||||
daemonBin, err = resolveDaemonBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
absBin, err := filepath.Abs(daemonBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve absolute path for %s: %w", daemonBin, err)
|
||||
}
|
||||
daemonBin = absBin
|
||||
|
||||
if _, err := os.Stat(daemonBin); err != nil {
|
||||
return fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
|
||||
}
|
||||
|
||||
logPath := paths.DaemonLogFile()
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open daemon log %s: %w", logPath, err)
|
||||
}
|
||||
|
||||
devNull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("open NUL: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--user-home", userHome,
|
||||
"--uid", fmt.Sprintf("%d", uid),
|
||||
"--gid", fmt.Sprintf("%d", gid),
|
||||
}
|
||||
|
||||
fmt.Fprintf(logFile, "=== launcher: starting daemon %s %v ===\n", daemonBin, args)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x200) | DETACHED_PROCESS (0x8)
|
||||
procAttr := &os.ProcAttr{
|
||||
Dir: os.TempDir(),
|
||||
Env: append(os.Environ(),
|
||||
"LMVPN_DAEMON=1",
|
||||
),
|
||||
Files: []*os.File{devNull, logFile, logFile},
|
||||
Sys: &syscall.SysProcAttr{
|
||||
CreationFlags: 0x00000200 | 0x00000008,
|
||||
},
|
||||
}
|
||||
|
||||
pid, err := os.StartProcess(daemonBin, append([]string{daemonBin}, args...), procAttr)
|
||||
logFile.Close()
|
||||
devNull.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("start daemon process: %w", err)
|
||||
}
|
||||
|
||||
_ = pid.Release()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "daemon launched (pid %d, bin %s)\n", pid.Pid, daemonBin)
|
||||
return nil
|
||||
}
|
||||
@@ -57,6 +57,4 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
|
||||
// DaemonLogFile returns the path to the daemon log file.
|
||||
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
|
||||
|
||||
// IPCSocketPath returns the path to the unix domain socket used for
|
||||
// GUI <-> daemon communication.
|
||||
func IPCSocketPath() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCSocketPath returns the path to the unix domain socket used for
|
||||
// GUI <-> daemon communication.
|
||||
func IPCSocketPath() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package paths
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCSocketPath returns the path to the unix domain socket used for
|
||||
// GUI <-> daemon communication.
|
||||
func IPCSocketPath() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package paths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCSocketPath returns the path to the unix domain socket used for
|
||||
// GUI <-> daemon communication. On Windows 10 1803+ AF_UNIX is
|
||||
// supported; the socket is placed in the user's temp directory.
|
||||
func IPCSocketPath() string {
|
||||
return filepath.Join(os.TempDir(), "lmvpn.sock")
|
||||
}
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
}
|
||||
|
||||
// recomputePaths sets Paths based on the given home directory.
|
||||
// On Windows the layout follows platform conventions:
|
||||
//
|
||||
// %APPDATA%\<BundleID>\ data (db, config)
|
||||
// %LOCALAPPDATA%\<BundleID>\ cache
|
||||
// %LOCALAPPDATA%\<BundleID>\Logs\ logs
|
||||
func recomputePaths(home string) {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData == "" {
|
||||
localAppData = filepath.Join(home, "AppData", "Local")
|
||||
}
|
||||
Paths = Dirs{
|
||||
Data: filepath.Join(appData, BundleID),
|
||||
Cache: filepath.Join(localAppData, BundleID),
|
||||
Log: filepath.Join(localAppData, BundleID, "Logs"),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package route
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//go:build windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cidrToNetworkMask splits a CIDR string into network address and
|
||||
// dotted-decimal mask, as required by the Windows `route` command.
|
||||
func cidrToNetworkMask(cidr string) (network, mask string, err error) {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ipNet.IP.String(), net.IP(ipNet.Mask).String(), nil
|
||||
}
|
||||
|
||||
// ifaceIndex resolves a Windows interface name to its index.
|
||||
func ifaceIndex(name string) (int, error) {
|
||||
ifc, err := net.InterfaceByName(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve interface %s: %w", name, err)
|
||||
}
|
||||
return ifc.Index, nil
|
||||
}
|
||||
|
||||
// --- IPv4 ---
|
||||
|
||||
func addRoute(cidr, iface string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "add", network, "mask", mask, "0.0.0.0",
|
||||
"if", fmt.Sprintf("%d", idx), "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRoute(cidr, iface string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "delete", network, "mask", mask)
|
||||
}
|
||||
|
||||
func addRouteVia(cidr, gateway string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "add", network, "mask", mask, gateway, "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRouteVia(cidr, gateway string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "delete", network, "mask", mask, gateway)
|
||||
}
|
||||
|
||||
// --- IPv6 ---
|
||||
|
||||
// cidrToV6Prefix splits an IPv6 CIDR into network and prefix length.
|
||||
func cidrToV6Prefix(cidr string) (network, prefix string, err error) {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ipNet.IP.String(), fmt.Sprintf("%d", ipNet.Mask), nil
|
||||
}
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("netsh", "interface", "ipv6", "add", "route",
|
||||
network+"/"+prefix, "interface="+fmt.Sprintf("%d", idx), "metric=1")
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("netsh", "interface", "ipv6", "delete", "route",
|
||||
network+"/"+prefix, "interface="+fmt.Sprintf("%d", idx))
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("netsh", "interface", "ipv6", "add", "route",
|
||||
network+"/"+prefix, gateway, "metric=1")
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("netsh", "interface", "ipv6", "delete", "route",
|
||||
network+"/"+prefix, gateway)
|
||||
}
|
||||
|
||||
// --- Default gateway ---
|
||||
|
||||
func defaultGateway() (string, error) {
|
||||
out, err := exec.Command("route", "print", "0.0.0.0").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRouteGateway(string(out))
|
||||
}
|
||||
|
||||
func defaultGateway6() (string, error) {
|
||||
out, err := exec.Command("route", "-6", "print", "::").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRoute6Gateway(string(out))
|
||||
}
|
||||
|
||||
// parseRouteGateway extracts the IPv4 default gateway from `route print`
|
||||
// output. Looks for the line with Network Destination 0.0.0.0 and
|
||||
// Netmask 0.0.0.0, and returns the Gateway column.
|
||||
func parseRouteGateway(out string) (string, error) {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == "0.0.0.0" && fields[1] == "0.0.0.0" {
|
||||
return fields[2], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no default gateway found")
|
||||
}
|
||||
|
||||
// parseRoute6Gateway extracts the IPv6 default gateway from
|
||||
// `route -6 print` output. Looks for ::/0 route and returns the
|
||||
// Gateway column.
|
||||
func parseRoute6Gateway(out string) (string, error) {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "::/0" && i+1 < len(fields) {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no IPv6 default gateway found")
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w (%s)", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package tun
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build windows
|
||||
|
||||
// Package tun — Windows implementation using WinTun.
|
||||
//
|
||||
// WinTun is a userspace TUN driver for Windows developed by the
|
||||
// WireGuard project. It requires only a single wintun.dll (MIT
|
||||
// licensed) placed next to the executable — no driver installation
|
||||
// is needed.
|
||||
//
|
||||
// The DLL is embedded at compile time via //go:embed and extracted
|
||||
// to the executable's directory on first use.
|
||||
package tun
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.zx2c4.com/wintun"
|
||||
)
|
||||
|
||||
//go:embed wintun.dll
|
||||
var wintunDLL []byte
|
||||
|
||||
type wintunDevice struct {
|
||||
adapter *wintun.Adapter
|
||||
session wintun.Session
|
||||
readWait windows.Handle
|
||||
name string
|
||||
}
|
||||
|
||||
func createTUN(name string) (Device, error) {
|
||||
if err := ensureWintunDLL(); err != nil {
|
||||
return nil, fmt.Errorf("extract wintun.dll: %w", err)
|
||||
}
|
||||
if name == "" {
|
||||
name = "LMVPN"
|
||||
}
|
||||
adapter, err := wintun.CreateAdapter(name, "Wintun", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create wintun adapter: %w", err)
|
||||
}
|
||||
session, err := adapter.StartSession(0x800000) // 8 MiB ring
|
||||
if err != nil {
|
||||
adapter.Close()
|
||||
return nil, fmt.Errorf("start wintun session: %w", err)
|
||||
}
|
||||
return &wintunDevice{
|
||||
adapter: adapter,
|
||||
session: session,
|
||||
readWait: session.ReadWaitEvent(),
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Name() string { return d.name }
|
||||
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
||||
|
||||
func (d *wintunDevice) Read(p []byte) (int, error) {
|
||||
for {
|
||||
packet, err := d.session.ReceivePacket()
|
||||
if err == nil {
|
||||
n := copy(p, packet)
|
||||
d.session.ReleaseReceivePacket(packet)
|
||||
return n, nil
|
||||
}
|
||||
if err == windows.ERROR_NO_MORE_ITEMS {
|
||||
windows.WaitForSingleObject(d.readWait, windows.INFINITE)
|
||||
continue
|
||||
}
|
||||
if err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, fmt.Errorf("wintun closed")
|
||||
}
|
||||
return 0, fmt.Errorf("wintun read: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Write(p []byte) (int, error) {
|
||||
buf, err := d.session.AllocateSendPacket(len(p))
|
||||
if err != nil {
|
||||
if err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, fmt.Errorf("wintun closed")
|
||||
}
|
||||
return 0, fmt.Errorf("wintun allocate: %w", err)
|
||||
}
|
||||
copy(buf, p)
|
||||
d.session.SendPacket(buf)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
|
||||
if localIP == nil {
|
||||
return execCmd("netsh", "interface", "ip", "set", "address", "name="+d.name, "source=dhcp")
|
||||
}
|
||||
if localIP.To4() == nil {
|
||||
return d.configureIPv6Addr(localIP, prefix)
|
||||
}
|
||||
mask := net.IP(net.CIDRMask(prefix, 32))
|
||||
return execCmd("netsh", "interface", "ip", "set", "address",
|
||||
"name="+d.name, "source=static",
|
||||
"addr="+localIP.String(), "mask="+mask.String())
|
||||
}
|
||||
|
||||
func (d *wintunDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error {
|
||||
if localIP6 == nil {
|
||||
return nil
|
||||
}
|
||||
return d.configureIPv6Addr(localIP6, prefix6)
|
||||
}
|
||||
|
||||
func (d *wintunDevice) configureIPv6Addr(ip net.IP, prefix int) error {
|
||||
addr := fmt.Sprintf("%s/%d", ip.String(), prefix)
|
||||
return execCmd("netsh", "interface", "ipv6", "add", "address",
|
||||
"name="+d.name, "address="+addr)
|
||||
}
|
||||
|
||||
func (d *wintunDevice) SetMTU(mtu int) error {
|
||||
return execCmd("netsh", "interface", "ipv4", "set", "subinterface",
|
||||
"name="+d.name, fmt.Sprintf("mtu=%d", mtu))
|
||||
}
|
||||
|
||||
// ensureWintunDLL extracts the embedded wintun.dll to the executable's
|
||||
// directory if it does not already exist. The wintun Go package loads
|
||||
// the DLL via LoadLibrary which searches the exe's directory first.
|
||||
func ensureWintunDLL() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(exe), "wintun.dll")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, wintunDLL, 0o644)
|
||||
}
|
||||
|
||||
// execCmd runs a command, forwarding stdout/stderr.
|
||||
func execCmd(name string, arg ...string) error {
|
||||
cmd := exec.Command(name, arg...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w", name, strings.Join(arg, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
+4
-29
@@ -3,7 +3,6 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -60,7 +59,7 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
}
|
||||
|
||||
// Compute the daemon binary path: same directory as the GUI binary.
|
||||
daemonBin := filepath.Join(filepath.Dir(exe), "lmvpnd")
|
||||
daemonBin := filepath.Join(filepath.Dir(exe), daemonBinaryName)
|
||||
if _, err := os.Stat(daemonBin); err != nil {
|
||||
return nil, fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
|
||||
}
|
||||
@@ -69,20 +68,9 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
uid := os.Getuid()
|
||||
gid := os.Getgid()
|
||||
|
||||
// Launch the daemon via osascript (prompts for admin password).
|
||||
// The `daemon-launch` subcommand forks lmvpnd with Setsid and exits
|
||||
// immediately, so osascript returns right away.
|
||||
script := fmt.Sprintf(
|
||||
`do shell script %q with administrator privileges`,
|
||||
fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)),
|
||||
)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
if err := launchElevated(exe, daemonBin, home, uid, gid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.L().Info("daemon launched via osascript",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
|
||||
// Wait for the daemon to become reachable.
|
||||
logFile := paths.DaemonLogFile()
|
||||
@@ -96,17 +84,4 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\” pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
result += "'\\''"
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
result += "'"
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with admin
|
||||
// privileges via osascript on macOS.
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
script := fmt.Sprintf(
|
||||
`do shell script %q with administrator privileges`,
|
||||
fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)),
|
||||
)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via osascript",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\'' pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
result += "'\\''"
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
result += "'"
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with admin
|
||||
// privileges. On Linux, pkexec is used (common on desktop Linux).
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
cmd := exec.Command("pkexec", exe, "daemon-launch",
|
||||
"--user-home", home,
|
||||
"--uid", fmt.Sprintf("%d", uid),
|
||||
"--gid", fmt.Sprintf("%d", gid),
|
||||
"--daemon-bin", daemonBin)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via pkexec",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd.exe"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with UAC
|
||||
// elevation via ShellExecute "runas" on Windows.
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
args := fmt.Sprintf("daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
home, uid, gid, daemonBin)
|
||||
|
||||
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||
proc := shell32.NewProc("ShellExecuteW")
|
||||
|
||||
verb, _ := syscall.UTF16PtrFromString("runas")
|
||||
file, _ := syscall.UTF16PtrFromString(exe)
|
||||
params, _ := syscall.UTF16PtrFromString(args)
|
||||
dir, _ := syscall.UTF16PtrFromString("")
|
||||
|
||||
ret, _, err := proc.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(verb)),
|
||||
uintptr(unsafe.Pointer(file)),
|
||||
uintptr(unsafe.Pointer(params)),
|
||||
uintptr(unsafe.Pointer(dir)),
|
||||
1, // SW_SHOWNORMAL
|
||||
)
|
||||
|
||||
if ret <= 32 {
|
||||
return fmt.Errorf("UAC elevation failed (code %d): %w", ret, err)
|
||||
}
|
||||
log.L().Info("daemon launched via UAC",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user