Files
bsncubed 23dcfb393f Scaffold ipswap: tray-driven Windows IP preset switcher
Implements the design in claude.md as a building skeleton: pure Go, no cgo,
cross-compiles to a single Windows .exe from Linux.

Architecture follows the spec's deliberate split. The fast path is native —
tray icon, grouped submenus, a Win32 MessageBox showing a live before/after
diff, then netsh. The slow path is an embedded web editor served on a random
loopback port and opened in the default browser.

Two decisions worth recording:

Reads use GetAdaptersAddresses, writes use netsh. The spec left the
enumeration mechanism open; parsing `netsh show config` breaks on a
non-English Windows because the output is localised. DNS static-vs-DHCP
origin is not exposed by that API, so it comes from one registry read.

The netsh command plan is built in portable code. That puts the delete-every-
existing-address step — the one that stops secondary addresses leaking across
switches — under test without needing a Windows box.

The editor requires the session token in a header for mutations, not just the
cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie
attached, but it cannot set a header. The updater refuses to install a release
that publishes no SHA256.

Not yet done: no group picker for export (the API supports it), no
single-instance guard, and internal/server/web/app.css is reconstructed from
the description in claude.md rather than the canonical apointless.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:29:35 +10:00

106 lines
3.0 KiB
Go

//go:build windows
// Package elevate answers "is this process actually elevated?" and, if not,
// can re-exec it through the UAC prompt.
//
// The manifest asks for asInvoker rather than requireAdministrator: on a
// machine with UAC relaxed, or when the parent is already elevated, that means
// ipswap starts with zero prompts. The cost is that on a locked-down machine
// we start unprivileged and have to offer the relaunch, which is what this
// package is for.
package elevate
import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
shell32 = syscall.NewLazyDLL("shell32.dll")
shellExecuteW = shell32.NewProc("ShellExecuteW")
)
const swShowNormal = 1
// IsElevated reports whether the current process token carries administrator
// rights right now.
//
// Membership of the Administrators group is not the same thing: with UAC on,
// a member's process gets a filtered token, and `netsh interface ipv4 set
// address` then fails with "The requested operation requires elevation."
// Checking the token is the only answer that predicts whether an apply works.
func IsElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// RelaunchAsAdmin re-execs the current binary through the UAC prompt and
// returns once the new process has been started. The caller is expected to
// exit immediately afterwards so the two copies do not both own a tray icon.
//
// A user who clicks "No" on the UAC prompt produces ERROR_CANCELLED, which is
// reported as an error but is not a failure worth a message box.
func RelaunchAsAdmin() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locating the running executable: %w", err)
}
exe, err = filepath.Abs(exe)
if err != nil {
return fmt.Errorf("resolving %s: %w", exe, err)
}
verb, err := syscall.UTF16PtrFromString("runas")
if err != nil {
return err
}
file, err := syscall.UTF16PtrFromString(exe)
if err != nil {
return err
}
args, err := syscall.UTF16PtrFromString(strings.Join(quoteArgs(os.Args[1:]), " "))
if err != nil {
return err
}
cwd, err := syscall.UTF16PtrFromString(filepath.Dir(exe))
if err != nil {
return err
}
// ShellExecuteW signals failure with a return value of 32 or less.
ret, _, callErr := shellExecuteW.Call(
0,
uintptr(unsafe.Pointer(verb)),
uintptr(unsafe.Pointer(file)),
uintptr(unsafe.Pointer(args)),
uintptr(unsafe.Pointer(cwd)),
swShowNormal,
)
if ret <= 32 {
if ret == uintptr(windows.ERROR_CANCELLED) {
return fmt.Errorf("the elevation prompt was cancelled")
}
return fmt.Errorf("ShellExecuteW returned %d: %v", ret, callErr)
}
return nil
}
// quoteArgs re-quotes arguments for the single command-line string
// ShellExecuteW takes, since it does not accept an argv.
func quoteArgs(args []string) []string {
out := make([]string, 0, len(args))
for _, a := range args {
if strings.ContainsAny(a, ` "`) {
out = append(out, `"`+strings.ReplaceAll(a, `"`, `\"`)+`"`)
} else {
out = append(out, a)
}
}
return out
}