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:
2026-07-07 16:50:42 +08:00
parent 6b9960076d
commit 65a5932eba
22 changed files with 658 additions and 66 deletions
+10 -1
View File
@@ -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"
+2
View File
@@ -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
+2
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package daemon
const daemonBinaryName = "lmvpnd"
+5
View File
@@ -0,0 +1,5 @@
//go:build windows
package daemon
const daemonBinaryName = "lmvpnd.exe"
+32
View File
@@ -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.
+93
View File
@@ -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
}
+1 -3
View File
@@ -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" }
+4
View File
@@ -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)
+5 -1
View File
@@ -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)
+42
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin
//go:build !darwin && !windows
package route
+179
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin
//go:build !darwin && !windows
package tun
+153
View File
@@ -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
View File
@@ -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
}
+44
View File
@@ -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
}
+28
View File
@@ -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
}
+44
View File
@@ -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.