feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
package pkgmanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PackageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Manager string `json:"manager"`
|
||||
}
|
||||
|
||||
type ServiceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func DetectManager() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
|
||||
return "winget"
|
||||
}
|
||||
if _, err := exec.LookPath(resolveManagerPath("choco", interactiveProfile())); err == nil {
|
||||
return "choco"
|
||||
}
|
||||
if _, err := exec.LookPath("msiexec"); err == nil {
|
||||
return "msiexec"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
if _, err := exec.LookPath("apt"); err == nil {
|
||||
return "apt"
|
||||
}
|
||||
if _, err := exec.LookPath("dnf"); err == nil {
|
||||
return "dnf"
|
||||
}
|
||||
if _, err := exec.LookPath("yum"); err == nil {
|
||||
return "yum"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func ListPackages() ([]PackageInfo, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return listWindowsPackages()
|
||||
}
|
||||
manager := DetectManager()
|
||||
switch manager {
|
||||
case "apt":
|
||||
return listAptPackages()
|
||||
case "yum", "dnf":
|
||||
return listYumPackages()
|
||||
case "winget":
|
||||
return listWingetPackages()
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported package manager: %s", manager)
|
||||
}
|
||||
|
||||
func listAptPackages() ([]PackageInfo, error) {
|
||||
cmd := exec.Command("dpkg-query", "-W", "-f", "${Package}\t${Version}\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dpkg-query failed: %w", err)
|
||||
}
|
||||
|
||||
var packages []PackageInfo
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) == 2 {
|
||||
packages = append(packages, PackageInfo{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
Manager: "apt",
|
||||
})
|
||||
}
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func listYumPackages() ([]PackageInfo, error) {
|
||||
cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\t%{VERSION}-%{RELEASE}\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rpm query failed: %w", err)
|
||||
}
|
||||
|
||||
var packages []PackageInfo
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) == 2 {
|
||||
packages = append(packages, PackageInfo{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
Manager: "yum",
|
||||
})
|
||||
}
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func listWingetPackages() ([]PackageInfo, error) {
|
||||
cmd := exec.Command(resolveManagerPath("winget", interactiveProfile()), "list", "--accept-source-agreements", "--disable-interactivity")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget list failed: %w", err)
|
||||
}
|
||||
|
||||
var packages []PackageInfo
|
||||
lines := strings.Split(string(out), "\n")
|
||||
inTable := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "Name") && strings.Contains(trimmed, "Id") {
|
||||
inTable = true
|
||||
continue
|
||||
}
|
||||
if inTable && strings.Contains(trimmed, "---") {
|
||||
continue
|
||||
}
|
||||
if inTable && trimmed != "" {
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) >= 3 {
|
||||
pkg := PackageInfo{
|
||||
Name: fields[0],
|
||||
Version: fields[len(fields)-1],
|
||||
Manager: "winget",
|
||||
}
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func InstallPackage(name string) (string, error) {
|
||||
return installPackageWithManager(name, DetectManager(), "")
|
||||
}
|
||||
|
||||
func InstallPackageWithManager(name, manager, version string) (string, error) {
|
||||
return installPackageWithManager(name, manager, version)
|
||||
}
|
||||
|
||||
func installPackageWithManager(name, manager, version string) (string, error) {
|
||||
var executable string
|
||||
var args []string
|
||||
|
||||
switch manager {
|
||||
case "apt":
|
||||
executable = "sudo"
|
||||
args = []string{"apt-get", "install", "-y", name}
|
||||
case "yum":
|
||||
executable = "sudo"
|
||||
args = []string{"yum", "install", "-y", name}
|
||||
case "dnf":
|
||||
executable = "sudo"
|
||||
args = []string{"dnf", "install", "-y", name}
|
||||
case "winget":
|
||||
executable = "winget"
|
||||
args = []string{"install", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
|
||||
if version != "" {
|
||||
args = append(args, "--version", version)
|
||||
}
|
||||
case "choco":
|
||||
executable = "choco"
|
||||
args = []string{"install", "-y", name}
|
||||
if version != "" {
|
||||
args = append(args, "--version", version)
|
||||
}
|
||||
case "msiexec":
|
||||
executable = "msiexec"
|
||||
args = []string{"/i", name, "/quiet", "/norestart"}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported package manager: %s", manager)
|
||||
}
|
||||
|
||||
out, err := runManagerCommand(executable, args)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func InstallPackageFromFile(filePath string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
switch ext {
|
||||
case ".msi":
|
||||
cmd := exec.Command("msiexec", "/i", filePath, "/quiet", "/norestart")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("msi install failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
case ".exe":
|
||||
cmd := exec.Command(filePath, "/S")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("exe install failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
case ".msix", ".appx":
|
||||
cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Add-AppxPackage -Path '%s'", filePath))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("msix install failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
case "linux":
|
||||
switch ext {
|
||||
case ".deb":
|
||||
cmd := exec.Command("sudo", "dpkg", "-i", filePath)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
cmd2 := exec.Command("sudo", "apt-get", "install", "-f", "-y")
|
||||
out2, _ := cmd2.CombinedOutput()
|
||||
return string(out) + "\n" + string(out2), fmt.Errorf("dpkg install failed: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
case ".rpm":
|
||||
cmd := exec.Command("sudo", "rpm", "-i", filePath)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("rpm install failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
case "darwin":
|
||||
switch ext {
|
||||
case ".dmg":
|
||||
cmd := exec.Command("hdiutil", "attach", filePath, "-nobrowse", "-quiet")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("dmg mount failed: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
case ".pkg":
|
||||
cmd := exec.Command("sudo", "installer", "-pkg", filePath, "-target", "/")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("pkg install failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported file type %s for %s", ext, runtime.GOOS)
|
||||
}
|
||||
|
||||
func UpdatePackage(name string) (string, error) {
|
||||
manager := DetectManager()
|
||||
var executable string
|
||||
var args []string
|
||||
|
||||
switch manager {
|
||||
case "apt":
|
||||
executable = "sudo"
|
||||
args = []string{"apt-get", "install", "-y", "--only-upgrade", name}
|
||||
case "yum":
|
||||
executable = "sudo"
|
||||
args = []string{"yum", "update", "-y", name}
|
||||
case "dnf":
|
||||
executable = "sudo"
|
||||
args = []string{"dnf", "update", "-y", name}
|
||||
case "winget":
|
||||
executable = "winget"
|
||||
args = []string{"upgrade", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
|
||||
case "choco":
|
||||
executable = "choco"
|
||||
args = []string{"upgrade", "-y", name}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported package manager: %s", manager)
|
||||
}
|
||||
|
||||
out, err := runManagerCommand(executable, args)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RemovePackage(name string) (string, error) {
|
||||
return removePackageWithManager(name, DetectManager())
|
||||
}
|
||||
|
||||
func removePackageWithManager(name, manager string) (string, error) {
|
||||
var executable string
|
||||
var args []string
|
||||
|
||||
switch manager {
|
||||
case "apt":
|
||||
executable = "sudo"
|
||||
args = []string{"apt-get", "remove", "-y", name}
|
||||
case "yum":
|
||||
executable = "sudo"
|
||||
args = []string{"yum", "remove", "-y", name}
|
||||
case "dnf":
|
||||
executable = "sudo"
|
||||
args = []string{"dnf", "remove", "-y", name}
|
||||
case "winget":
|
||||
executable = "winget"
|
||||
args = []string{"uninstall", "--id", name, "--accept-source-agreements", "--disable-interactivity"}
|
||||
case "choco":
|
||||
executable = "choco"
|
||||
args = []string{"uninstall", "-y", name}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported package manager: %s", manager)
|
||||
}
|
||||
|
||||
out, err := runManagerCommand(executable, args)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RemovePackageWithManager(name, manager string) (string, error) {
|
||||
return removePackageWithManager(name, manager)
|
||||
}
|
||||
|
||||
func DownloadFile(url string, destPath string) error {
|
||||
dir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if _, err := exec.LookPath("curl"); err == nil {
|
||||
cmd = exec.Command("curl", "-L", "-o", destPath, url)
|
||||
} else if _, err := exec.LookPath("wget"); err == nil {
|
||||
cmd = exec.Command("wget", "-O", destPath, url)
|
||||
} else {
|
||||
return fmt.Errorf("neither curl nor wget found")
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package pkgmanager
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BootstrapManagers is a no-op on non-Windows platforms.
|
||||
// On Windows it installs the Microsoft App Installer (winget) and
|
||||
// Chocolatey if they are not already present.
|
||||
func BootstrapManagers() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// listWindowsPackages is only implemented on Windows.
|
||||
func listWindowsPackages() ([]PackageInfo, error) {
|
||||
return nil, fmt.Errorf("windows package listing only available on Windows")
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build windows
|
||||
|
||||
package pkgmanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func listWindowsPackages() ([]PackageInfo, error) {
|
||||
seen := make(map[string]PackageInfo)
|
||||
|
||||
if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
|
||||
return nil, fmt.Errorf("enumerate current user uninstall failed: %w", err)
|
||||
}
|
||||
if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
|
||||
// non-fatal; 32-bit view may not exist
|
||||
_ = err
|
||||
}
|
||||
if err := enumerateRegistryUninstall(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
|
||||
_ = err
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
|
||||
if wingetPkgs, err := listWingetPackages(); err == nil {
|
||||
for _, p := range wingetPkgs {
|
||||
key := p.Name + "@" + p.Manager
|
||||
if _, ok := seen[key]; !ok {
|
||||
seen[key] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packages := make([]PackageInfo, 0, len(seen))
|
||||
for _, p := range seen {
|
||||
packages = append(packages, p)
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func enumerateRegistryUninstall(root registry.Key, path string, seen map[string]PackageInfo) error {
|
||||
k, err := registry.OpenKey(root, path, registry.READ)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
subKeys, err := k.ReadSubKeyNames(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, name := range subKeys {
|
||||
sk, err := registry.OpenKey(k, name, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
displayName, _, err := sk.GetStringValue("DisplayName")
|
||||
if err != nil || displayName == "" {
|
||||
sk.Close()
|
||||
continue
|
||||
}
|
||||
displayVersion, _, _ := sk.GetStringValue("DisplayVersion")
|
||||
version := strings.TrimSpace(displayVersion)
|
||||
if version == "" {
|
||||
version = "unknown"
|
||||
}
|
||||
|
||||
key := displayName + "@windows"
|
||||
if _, ok := seen[key]; !ok {
|
||||
seen[key] = PackageInfo{
|
||||
Name: displayName,
|
||||
Version: version,
|
||||
Manager: "windows",
|
||||
}
|
||||
}
|
||||
sk.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BootstrapManagers() error {
|
||||
var errs []string
|
||||
|
||||
if !lookupWinget() {
|
||||
if err := installWingetViaAppInstaller(); err != nil {
|
||||
errs = append(errs, "winget: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("choco"); err != nil {
|
||||
if err := installChocolatey(); err != nil {
|
||||
errs = append(errs, "chocolatey: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("bootstrap failed: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installChocolatey() error {
|
||||
// Official Chocolatey install script (requires admin; agent runs as LocalSystem).
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
||||
"Set-ExecutionPolicy Bypass -Scope Process -Force; "+
|
||||
"[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; "+
|
||||
"iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("chocolatey install script failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupWinget() bool {
|
||||
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
|
||||
return true
|
||||
}
|
||||
appInstaller := `Microsoft.DesktopAppInstaller_8wekyb3d8bbwe`
|
||||
appxReg, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore`,
|
||||
registry.READ|registry.ENUMERATE_SUB_KEYS)
|
||||
if err == nil {
|
||||
defer appxReg.Close()
|
||||
if keys, err := appxReg.ReadSubKeyNames(0); err == nil {
|
||||
for _, keyName := range keys {
|
||||
if strings.Contains(keyName, "DesktopAppInstaller") {
|
||||
_ = appInstaller
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func installWingetViaAppInstaller() error {
|
||||
// Microsoft App Installer download link (redirects to latest winget bundle)
|
||||
installerURL := "https://aka.ms/getwinget"
|
||||
appInstallerPath := filepath.Join(os.TempDir(), "DesktopAppInstaller.msixbundle")
|
||||
|
||||
// Download App Installer bundle
|
||||
downloadCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||
"[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; "+
|
||||
"Invoke-WebRequest -Uri '"+installerURL+"' -OutFile '"+appInstallerPath+"' -UseBasicParsing")
|
||||
if out, err := downloadCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("download App Installer failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
|
||||
// Install bundle (dependencies may need to be allowed; add-AppxPackage on msixbundle handles it)
|
||||
installCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Add-AppxPackage -Path '"+appInstallerPath+"' -ForceUpdateFromAnyVersion")
|
||||
if out, err := installCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("install App Installer failed: %w\nOutput: %s", err, string(out))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package pkgmanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func resolveManagerPath(executable, profile string) string {
|
||||
return executable
|
||||
}
|
||||
|
||||
func interactiveProfile() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func runManagerCommand(executable string, args []string) (string, error) {
|
||||
cmd := exec.Command(executable, 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
|
||||
}
|
||||
|
||||
func LaunchInUserSession(exe string, args []string) (uint32, error) {
|
||||
return 0, fmt.Errorf("LaunchInUserSession is not supported on this platform")
|
||||
}
|
||||
|
||||
func RunInUserSessionCapture(exe string, args []string) (string, int, error) {
|
||||
return "", -1, fmt.Errorf("RunInUserSessionCapture is not supported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user