diff --git a/Makefile b/Makefile index 3aa7db6..8c90e69 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/go.mod b/go.mod index e233636..d4f7ac3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 882eb81..833bd47 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/daemon/daemon_name_unix.go b/internal/daemon/daemon_name_unix.go new file mode 100644 index 0000000..2bd76fa --- /dev/null +++ b/internal/daemon/daemon_name_unix.go @@ -0,0 +1,5 @@ +//go:build !windows + +package daemon + +const daemonBinaryName = "lmvpnd" diff --git a/internal/daemon/daemon_name_windows.go b/internal/daemon/daemon_name_windows.go new file mode 100644 index 0000000..c1a2f25 --- /dev/null +++ b/internal/daemon/daemon_name_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package daemon + +const daemonBinaryName = "lmvpnd.exe" diff --git a/internal/daemon/launch_common.go b/internal/daemon/launch_common.go new file mode 100644 index 0000000..9ae97a7 --- /dev/null +++ b/internal/daemon/launch_common.go @@ -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) +} diff --git a/internal/daemon/launch.go b/internal/daemon/launch_unix.go similarity index 81% rename from internal/daemon/launch.go rename to internal/daemon/launch_unix.go index 77a1552..95ff856 100644 --- a/internal/daemon/launch.go +++ b/internal/daemon/launch_unix.go @@ -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. diff --git a/internal/daemon/launch_windows.go b/internal/daemon/launch_windows.go new file mode 100644 index 0000000..b443cdc --- /dev/null +++ b/internal/daemon/launch_windows.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 +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index bc88bac..d377bbc 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -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" } + diff --git a/internal/paths/paths_darwin.go b/internal/paths/paths_darwin.go index d397864..1f3220b 100644 --- a/internal/paths/paths_darwin.go +++ b/internal/paths/paths_darwin.go @@ -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) diff --git a/internal/paths/paths_other.go b/internal/paths/paths_other.go index 77e8c2a..5fff550 100644 --- a/internal/paths/paths_other.go +++ b/internal/paths/paths_other.go @@ -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) diff --git a/internal/paths/paths_windows.go b/internal/paths/paths_windows.go new file mode 100644 index 0000000..9888823 --- /dev/null +++ b/internal/paths/paths_windows.go @@ -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%\\ data (db, config) +// %LOCALAPPDATA%\\ cache +// %LOCALAPPDATA%\\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"), + } +} diff --git a/internal/route/route_linux.go b/internal/route/route_linux.go index d408438..77fb319 100644 --- a/internal/route/route_linux.go +++ b/internal/route/route_linux.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows package route diff --git a/internal/route/route_windows.go b/internal/route/route_windows.go new file mode 100644 index 0000000..1c530bd --- /dev/null +++ b/internal/route/route_windows.go @@ -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 +} diff --git a/internal/tun/tun_linux.go b/internal/tun/tun_linux.go index a392736..831119a 100644 --- a/internal/tun/tun_linux.go +++ b/internal/tun/tun_linux.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows package tun diff --git a/internal/tun/tun_windows.go b/internal/tun/tun_windows.go new file mode 100644 index 0000000..b39bd9f --- /dev/null +++ b/internal/tun/tun_windows.go @@ -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 +} + + diff --git a/internal/tun/wintun.dll b/internal/tun/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/internal/tun/wintun.dll differ diff --git a/internal/ui/daemon.go b/internal/ui/daemon.go index e4b5543..fca9fa4 100644 --- a/internal/ui/daemon.go +++ b/internal/ui/daemon.go @@ -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 -} + diff --git a/internal/ui/elevation_darwin.go b/internal/ui/elevation_darwin.go new file mode 100644 index 0000000..8cc7838 --- /dev/null +++ b/internal/ui/elevation_darwin.go @@ -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 +} diff --git a/internal/ui/elevation_other.go b/internal/ui/elevation_other.go new file mode 100644 index 0000000..638c9d4 --- /dev/null +++ b/internal/ui/elevation_other.go @@ -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 +} diff --git a/internal/ui/elevation_windows.go b/internal/ui/elevation_windows.go new file mode 100644 index 0000000..6067eb3 --- /dev/null +++ b/internal/ui/elevation_windows.go @@ -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 +} diff --git a/resources/wintun.dll b/resources/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/resources/wintun.dll differ