346 lines
10 KiB
Go
346 lines
10 KiB
Go
//go:build windows
|
|
|
|
package pkgmanager
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
// wingetPath returns a usable winget.exe for the given user profile, falling
|
|
// back to SYSTEM/global locations. winget is a UWP app execution alias that is
|
|
// commonly missing from the PATH of service accounts (e.g. LocalSystem).
|
|
func wingetPath(profile string) string {
|
|
if profile != "" {
|
|
if p := fileExists(filepath.Join(profile, "AppData", "Local", "Microsoft", "WindowsApps", "winget.exe")); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
if p, err := exec.LookPath("winget"); err == nil {
|
|
return p
|
|
}
|
|
for _, base := range []string{
|
|
os.ExpandEnv(`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`),
|
|
os.ExpandEnv(`%SystemRoot%\System32\config\systemprofile\AppData\Local\Microsoft\WindowsApps\winget.exe`),
|
|
} {
|
|
if p := fileExists(base); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
matches, _ := filepath.Glob(filepath.Join(os.ExpandEnv(`%ProgramFiles%`), "WindowsApps", "Microsoft.DesktopAppInstaller_*", "winget.exe"))
|
|
if len(matches) > 0 {
|
|
sort.Strings(matches)
|
|
return matches[len(matches)-1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func chocoPath() string {
|
|
if p, err := exec.LookPath("choco"); err == nil {
|
|
return p
|
|
}
|
|
if p := fileExists(filepath.Join(os.ExpandEnv(`%ProgramData%`), "chocolatey", "bin", "choco.exe")); p != "" {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveManagerPath(executable, profile string) string {
|
|
switch executable {
|
|
case "winget":
|
|
if p := wingetPath(profile); p != "" {
|
|
return p
|
|
}
|
|
case "choco":
|
|
if profile != "" {
|
|
if p := fileExists(filepath.Join(profile, "AppData", "Local", "chocolatey", "bin", "choco.exe")); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
if p := chocoPath(); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// interactiveProfile returns the profile directory of the logged-on user, or ""
|
|
// when no interactive session exists.
|
|
func interactiveProfile() string {
|
|
token, profile, err := interactiveUser()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
token.Close()
|
|
return profile
|
|
}
|
|
|
|
// runManagerCommand runs a package manager command. winget and choco runs are
|
|
// executed in the context of the interactive (logged-on) user so that the winget
|
|
// app execution alias resolves and per-user installers land in the user's own
|
|
// profile (and therefore are visible to them). If the user context lacks
|
|
// elevation, the command is retried in the SYSTEM context.
|
|
func runManagerCommand(executable string, args []string) (string, error) {
|
|
switch executable {
|
|
case "choco", "winget":
|
|
token, profile, err := interactiveUser()
|
|
if err == nil {
|
|
defer token.Close()
|
|
exe := resolveManagerPath(executable, profile)
|
|
if exe == "" {
|
|
return "", fmt.Errorf("%s not found in user context or system locations", executable)
|
|
}
|
|
out, code, spawnErr := runAsUser(token, profile, exe, args)
|
|
if spawnErr == nil {
|
|
if code == 0 {
|
|
return out, nil
|
|
}
|
|
if !needsElevation(out) {
|
|
return out, fmt.Errorf("%s failed with exit code %d\nOutput: %s", executable, code, out)
|
|
}
|
|
}
|
|
// user context failed to launch or needs elevation: retry as SYSTEM
|
|
}
|
|
}
|
|
|
|
exe := resolveManagerPath(executable, "")
|
|
if exe == "" {
|
|
return "", fmt.Errorf("%s not found in any known location", executable)
|
|
}
|
|
cmd := exec.Command(exe, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out))
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// interactiveUser returns a duplicated primary token for the logged-on user and
|
|
// their profile directory. It fails when no interactive session exists.
|
|
func interactiveUser() (windows.Token, string, error) {
|
|
var sessionID uint32
|
|
var count uint32
|
|
var sessions *windows.WTS_SESSION_INFO
|
|
if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil {
|
|
sessionID = windows.WTSGetActiveConsoleSessionId()
|
|
} else {
|
|
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions)))
|
|
info := unsafe.Slice(sessions, count)
|
|
for _, s := range info {
|
|
if s.SessionID != 0 && (s.State == windows.WTSActive || s.State == windows.WTSConnected) {
|
|
sessionID = s.SessionID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if sessionID == 0 {
|
|
return 0, "", fmt.Errorf("no interactive session")
|
|
}
|
|
|
|
var token windows.Token
|
|
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
|
return 0, "", fmt.Errorf("WTSQueryUserToken: %w", err)
|
|
}
|
|
|
|
var dup windows.Token
|
|
if err := windows.DuplicateTokenEx(token, windows.TOKEN_ALL_ACCESS, nil, windows.SecurityImpersonation, windows.TokenPrimary, &dup); err != nil {
|
|
token.Close()
|
|
return 0, "", fmt.Errorf("DuplicateTokenEx: %w", err)
|
|
}
|
|
token.Close()
|
|
|
|
profile, err := userProfileDir(dup)
|
|
if err != nil {
|
|
dup.Close()
|
|
return 0, "", fmt.Errorf("GetUserProfileDirectory: %w", err)
|
|
}
|
|
return dup, profile, nil
|
|
}
|
|
|
|
func userProfileDir(token windows.Token) (string, error) {
|
|
buf := make([]uint16, 1024)
|
|
for {
|
|
size := uint32(len(buf))
|
|
err := windows.GetUserProfileDirectory(token, &buf[0], &size)
|
|
if err == nil {
|
|
return windows.UTF16ToString(buf), nil
|
|
}
|
|
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
|
return "", err
|
|
}
|
|
buf = make([]uint16, size)
|
|
}
|
|
}
|
|
|
|
// runAsUser runs executable in the given user's context, redirecting stdout and
|
|
// stderr to a temp file via inherited handles. Returns combined output and the
|
|
// process exit code.
|
|
func runAsUser(token windows.Token, profile, exe string, args []string) (string, int, error) {
|
|
outFile, err := os.CreateTemp("", "ff-agent-out-*.txt")
|
|
if err != nil {
|
|
return "", -1, fmt.Errorf("CreateTemp: %w", err)
|
|
}
|
|
outPath := outFile.Name()
|
|
outFile.Close()
|
|
defer os.Remove(outPath)
|
|
|
|
var sa windows.SecurityAttributes
|
|
sa.Length = uint32(unsafe.Sizeof(sa))
|
|
sa.InheritHandle = 1
|
|
|
|
shareMode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE)
|
|
outHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(outPath), windows.GENERIC_WRITE, shareMode, &sa, windows.CREATE_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
|
if err != nil {
|
|
return "", -1, fmt.Errorf("CreateFile output: %w", err)
|
|
}
|
|
defer windows.CloseHandle(outHandle)
|
|
|
|
nulHandle, err := windows.CreateFile(windows.StringToUTF16Ptr("NUL"), windows.GENERIC_READ, shareMode, &sa, windows.OPEN_EXISTING, 0, 0)
|
|
if err != nil {
|
|
return "", -1, fmt.Errorf("CreateFile NUL: %w", err)
|
|
}
|
|
defer windows.CloseHandle(nulHandle)
|
|
|
|
commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args))
|
|
if err != nil {
|
|
return "", -1, fmt.Errorf("UTF16PtrFromString: %w", err)
|
|
}
|
|
|
|
var envBlock *uint16
|
|
if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil {
|
|
envBlock = nil
|
|
} else {
|
|
defer windows.DestroyEnvironmentBlock(envBlock)
|
|
}
|
|
|
|
cwd, _ := windows.UTF16PtrFromString(profile)
|
|
if cwd == nil || *cwd == 0 {
|
|
cwd = nil
|
|
}
|
|
|
|
var si windows.StartupInfo
|
|
si.Cb = uint32(unsafe.Sizeof(si))
|
|
si.Flags = windows.STARTF_USESTDHANDLES
|
|
si.StdInput = nulHandle
|
|
si.StdOutput = outHandle
|
|
si.StdErr = outHandle
|
|
|
|
var pi windows.ProcessInformation
|
|
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
|
|
err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, true, flags, envBlock, cwd, &si, &pi)
|
|
if err != nil {
|
|
return "", -1, fmt.Errorf("CreateProcessAsUser: %w", err)
|
|
}
|
|
windows.CloseHandle(pi.Thread)
|
|
defer windows.CloseHandle(pi.Process)
|
|
|
|
_, _ = windows.WaitForSingleObject(pi.Process, windows.INFINITE)
|
|
|
|
var exitCode uint32
|
|
_ = windows.GetExitCodeProcess(pi.Process, &exitCode)
|
|
|
|
out, _ := os.ReadFile(outPath)
|
|
return string(out), int(exitCode), nil
|
|
}
|
|
|
|
func windowsQuote(s string) string {
|
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
|
}
|
|
|
|
// RunInUserSessionCapture runs an executable in the context of the logged-on
|
|
// interactive user and returns its combined output and exit code. It is used
|
|
// e.g. to write per-user registry settings for the VNC server.
|
|
func RunInUserSessionCapture(exe string, args []string) (string, int, error) {
|
|
token, profile, err := interactiveUser()
|
|
if err != nil {
|
|
return "", -1, err
|
|
}
|
|
defer token.Close()
|
|
return runAsUser(token, profile, exe, args)
|
|
}
|
|
|
|
// LaunchInUserSession launches an executable in the context of the logged-on
|
|
// interactive user without waiting for it to finish. It returns the process ID
|
|
// of the launched process, which can be used to terminate it later. This is
|
|
// used e.g. to start a VNC server inside the user's desktop session.
|
|
func LaunchInUserSession(exe string, args []string) (uint32, error) {
|
|
token, profile, err := interactiveUser()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer token.Close()
|
|
|
|
commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("UTF16PtrFromString: %w", err)
|
|
}
|
|
|
|
var envBlock *uint16
|
|
if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil {
|
|
envBlock = nil
|
|
} else {
|
|
defer windows.DestroyEnvironmentBlock(envBlock)
|
|
}
|
|
|
|
cwd, _ := windows.UTF16PtrFromString(profile)
|
|
if cwd == nil || *cwd == 0 {
|
|
cwd = nil
|
|
}
|
|
|
|
var si windows.StartupInfo
|
|
si.Cb = uint32(unsafe.Sizeof(si))
|
|
si.Flags = windows.STARTF_USESHOWWINDOW
|
|
si.ShowWindow = windows.SW_SHOWNORMAL
|
|
|
|
var pi windows.ProcessInformation
|
|
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
|
|
err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, false, flags, envBlock, cwd, &si, &pi)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateProcessAsUser: %w", err)
|
|
}
|
|
windows.CloseHandle(pi.Thread)
|
|
windows.CloseHandle(pi.Process)
|
|
return pi.ProcessId, nil
|
|
}
|
|
|
|
func windowsQuoteArgs(args []string) string {
|
|
out := make([]string, 0, len(args))
|
|
for _, a := range args {
|
|
out = append(out, windowsQuote(a))
|
|
}
|
|
return strings.Join(out, " ")
|
|
}
|
|
|
|
func fileExists(p string) string {
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func needsElevation(output string) bool {
|
|
low := strings.ToLower(output)
|
|
for _, s := range []string{
|
|
"elevat",
|
|
"administrator",
|
|
"requires authentication",
|
|
"access is denied",
|
|
"access denied",
|
|
"run as admin",
|
|
} {
|
|
if strings.Contains(low, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
} |