fleet/server/mdm/maintainedapps/scripts.go
Jahziel Villasana-Espinoza 863a37a3e5
fix: update install script for FMAs to improve re-install process (#25238)
> For #24148

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
2025-01-09 14:22:21 -05:00

521 lines
15 KiB
Go

package maintainedapps
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/groob/plist"
)
func installScriptForApp(app maintainedApp, cask *brewCask) (string, error) {
sb := newScriptBuilder()
sb.AddVariable("TMPDIR", `$(dirname "$(realpath $INSTALLER_PATH)")`)
sb.AddVariable("APPDIR", `"/Applications/"`)
formats := strings.Split(app.InstallerFormat, ":")
sb.Extract(formats[0])
var includeQuitFunc bool
for _, artifact := range cask.Artifacts {
switch {
case len(artifact.App) > 0:
sb.Write("# copy to the applications folder")
sb.Writef("quit_application '%s'", app.BundleIdentifier)
includeQuitFunc = true
for _, appPath := range artifact.App {
sb.Writef(`sudo [ -d "$APPDIR/%[1]s" ] && sudo mv "$APPDIR/%[1]s" "$TMPDIR/%[1]s.bkp"`, appPath)
sb.Copy(appPath, "$APPDIR")
}
case len(artifact.Pkg) > 0:
sb.Write("# install pkg files")
switch len(artifact.Pkg) {
case 1:
if err := sb.InstallPkg(artifact.Pkg[0].String); err != nil {
return "", fmt.Errorf("building statement to install pkg: %w", err)
}
case 2:
if err := sb.InstallPkg(artifact.Pkg[0].String, artifact.Pkg[1].Other.Choices); err != nil {
return "", fmt.Errorf("building statement to install pkg with choices: %w", err)
}
default:
return "", fmt.Errorf("application %s has unknown directive format for pkg", app.Identifier)
}
case len(artifact.Binary) > 0:
if len(artifact.Binary) == 2 {
source := artifact.Binary[0].String
target := artifact.Binary[1].Other.Target
if !strings.Contains(target, "$HOMEBREW_PREFIX") &&
!strings.Contains(source, "$HOMEBREW_PREFIX") {
sb.Symlink(source, target)
}
}
}
}
if includeQuitFunc {
sb.AddFunction("quit_application", quitApplicationFunc)
}
return sb.String(), nil
}
func uninstallScriptForApp(cask *brewCask) string {
sb := newScriptBuilder()
for _, artifact := range cask.Artifacts {
switch {
case len(artifact.App) > 0:
sb.AddVariable("APPDIR", `"/Applications/"`)
for _, appPath := range artifact.App {
sb.RemoveFile(fmt.Sprintf(`"$APPDIR/%s"`, appPath))
}
case len(artifact.Binary) > 0:
if len(artifact.Binary) == 2 {
target := artifact.Binary[1].Other.Target
if !strings.Contains(target, "$HOMEBREW_PREFIX") {
sb.RemoveFile(fmt.Sprintf(`'%s'`, target))
}
}
case len(artifact.Uninstall) > 0:
sortUninstall(artifact.Uninstall)
if len(cask.PreUninstallScripts) > 0 {
sb.Write(strings.Join(cask.PreUninstallScripts, "\n"))
}
for _, u := range artifact.Uninstall {
processUninstallArtifact(u, sb)
}
if len(cask.PostUninstallScripts) > 0 {
sb.Write(strings.Join(cask.PostUninstallScripts, "\n"))
}
case len(artifact.Zap) > 0:
sortUninstall(artifact.Zap)
for _, z := range artifact.Zap {
processUninstallArtifact(z, sb)
}
}
}
return sb.String()
}
// priority of uninstall directives is defined by homebrew here:
// https://github.com/Homebrew/brew/blob/e1ff668957dd8a66304c0290dfa66083e6c7444e/Library/Homebrew/cask/artifact/abstract_uninstall.rb#L18-L30
const (
PriorityEarlyScript = iota
PriorityLaunchctl
PriorityQuit
PrioritySignal
PriorityLoginItem
PriorityKext
PriorityScript
PriorityPkgutil
PriorityDelete
PriorityTrash
PriorityRmdir
)
// uninstallArtifactOrder returns an integer representing the priority of the
// artifact based on the uninstall directives it contains. Lower number means
// higher priority
func uninstallArtifactOrder(artifact *brewUninstall) int {
switch {
case len(artifact.LaunchCtl.String)+len(artifact.LaunchCtl.Other) > 0:
return PriorityLaunchctl
case len(artifact.Quit.String)+len(artifact.Quit.Other) > 0:
return PriorityQuit
case len(artifact.Signal.String)+len(artifact.Signal.Other) > 0:
return PrioritySignal
case len(artifact.LoginItem.String)+len(artifact.LoginItem.Other) > 0:
return PriorityLoginItem
case len(artifact.Kext.String)+len(artifact.Kext.Other) > 0:
return PriorityKext
case len(artifact.Script.String)+len(artifact.Script.Other) > 0:
return PriorityScript
case len(artifact.PkgUtil.String)+len(artifact.PkgUtil.Other) > 0:
return PriorityPkgutil
case len(artifact.Delete.String)+len(artifact.Delete.Other) > 0:
return PriorityDelete
case len(artifact.Trash.String)+len(artifact.Trash.Other) > 0:
return PriorityTrash
case len(artifact.RmDir.String)+len(artifact.RmDir.Other) > 0:
return PriorityRmdir
default:
return 999
}
}
func sortUninstall(artifacts []*brewUninstall) {
slices.SortFunc(artifacts, func(a, b *brewUninstall) int {
return uninstallArtifactOrder(a) - uninstallArtifactOrder(b)
})
}
func processUninstallArtifact(u *brewUninstall, sb *scriptBuilder) {
process := func(target optjson.StringOr[[]string], f func(path string)) {
if target.IsOther {
for _, path := range target.Other {
f(path)
}
} else if len(target.String) > 0 {
f(target.String)
}
}
addUserVar := func() {
sb.AddVariable("LOGGED_IN_USER", `$(scutil <<< "show State:/Users/ConsoleUser" | awk '/Name :/ { print $3 }')`)
}
process(u.LaunchCtl, func(lc string) {
sb.AddFunction("remove_launchctl_service", removeLaunchctlServiceFunc)
sb.Writef("remove_launchctl_service '%s'", lc)
})
process(u.Quit, func(appName string) {
sb.AddFunction("quit_application", quitApplicationFunc)
sb.Writef("quit_application '%s'", appName)
})
// per the spec, signals can't have a different format. In the homebrew
// source code an error is raised when the format is different.
if u.Signal.IsOther && len(u.Signal.Other) == 2 {
addUserVar()
sb.AddFunction("send_signal", sendSignalFunc)
sb.Writef(`send_signal '%s' '%s' "$LOGGED_IN_USER"`, u.Signal.Other[0], u.Signal.Other[1])
}
if u.Script.IsOther {
addUserVar()
for _, path := range u.Script.Other {
sb.Writef(`(cd /Users/$LOGGED_IN_USER && sudo -u "$LOGGED_IN_USER" '%s')`, path)
}
} else if len(u.Script.String) > 0 {
addUserVar()
sb.Writef(`(cd /Users/$LOGGED_IN_USER && sudo -u "$LOGGED_IN_USER" '%s')`, u.Script.String)
}
process(u.PkgUtil, func(pkgID string) {
sb.Writef("sudo pkgutil --forget '%s'", pkgID)
})
process(u.Delete, func(path string) {
sb.RemoveFile(fmt.Sprintf("'%s'", path))
})
process(u.RmDir, func(dir string) {
sb.Writef("sudo rmdir '%s'", dir)
})
process(u.Trash, func(path string) {
addUserVar()
sb.AddFunction("trash", trashFunc)
sb.Writef("trash $LOGGED_IN_USER '%s'", path)
})
}
type scriptBuilder struct {
statements []string
variables map[string]string
functions map[string]string
}
func newScriptBuilder() *scriptBuilder {
return &scriptBuilder{
statements: []string{},
variables: map[string]string{},
functions: map[string]string{},
}
}
// AddVariable adds a variable definition to the script
func (s *scriptBuilder) AddVariable(name, definition string) {
s.variables[name] = definition
}
// AddFunction adds a shell function to the script.
func (s *scriptBuilder) AddFunction(name, definition string) {
s.functions[name] = definition
}
// Write appends a raw shell command or statement to the script.
func (s *scriptBuilder) Write(in string) {
s.statements = append(s.statements, in)
}
// Writef formats a string according to the specified format and arguments,
// then appends it to the script.
func (s *scriptBuilder) Writef(format string, args ...any) {
s.statements = append(s.statements, fmt.Sprintf(format, args...))
}
// Extract writes shell commands to extract the contents of an installer based
// on the given format.
//
// Supported formats are "dmg" and "zip". It adds the necessary extraction
// commands to the script.
func (s *scriptBuilder) Extract(format string) {
switch format {
case "dmg":
s.Write("# extract contents")
s.Write(`MOUNT_POINT=$(mktemp -d /tmp/dmg_mount_XXXXXX)
hdiutil attach -plist -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$INSTALLER_PATH"
sudo cp -R "$MOUNT_POINT"/* "$TMPDIR"
hdiutil detach "$MOUNT_POINT"`)
case "zip":
s.Write("# extract contents")
s.Write(`unzip "$INSTALLER_PATH" -d "$TMPDIR"`)
}
}
// Copy writes a command to copy a file from the temporary directory to a
// destination.
func (s *scriptBuilder) Copy(file, dest string) {
s.Writef(`sudo cp -R "$TMPDIR/%s" "%s"`, file, dest)
}
// RemoveFile writes a command to remove a file or directory with sudo
// privileges.
func (s *scriptBuilder) RemoveFile(file string) {
s.Writef(`sudo rm -rf %s`, file)
}
// InstallPkg writes a command to install a package using the macOS `installer` utility.
// 'pkg' is the package file to install. Optionally, 'choices' can be provided to specify
// installation options.
//
// If no choices are provided, a simple install command is written.
//
// Returns an error if generating the XML for choices fails.
func (s *scriptBuilder) InstallPkg(pkg string, choices ...[]brewPkgConfig) error {
if len(choices) == 0 {
s.Writef(`sudo installer -pkg "$TMPDIR/%s" -target /`, pkg)
return nil
}
choiceXML, err := plist.MarshalIndent(choices[0], " ")
if err != nil {
return err
}
s.Writef(`
CHOICE_XML=$(mktemp /tmp/choice_xml_XXX)
cat << EOF > "$CHOICE_XML"
%s
EOF
sudo installer -pkg "$TMPDIR"/%s -target / -applyChoiceChangesXML "$CHOICE_XML"
`, choiceXML, pkg)
return nil
}
// Symlink writes a command to create a symbolic link from 'source' to 'target'.
func (s *scriptBuilder) Symlink(source, target string) {
s.Writef(`/bin/ln -h -f -s -- "%s" "%s"`, source, target)
}
// String generates the final script as a string.
//
// It includes the shebang, any variables, functions, and statements in the
// correct order.
func (s *scriptBuilder) String() string {
var script strings.Builder
script.WriteString("#!/bin/sh\n\n")
if len(s.variables) > 0 {
// write variables, order them alphabetically to produce deterministic
// scripts.
script.WriteString("# variables\n")
keys := make([]string, 0, len(s.variables))
for name := range s.variables {
keys = append(keys, name)
}
sort.Strings(keys)
for _, name := range keys {
script.WriteString(fmt.Sprintf("%s=%s\n", name, s.variables[name]))
}
}
if len(s.functions) > 0 {
// write functions, order them alphabetically to produce deterministic
// scripts.
script.WriteString("# functions\n")
keys := make([]string, 0, len(s.functions))
for name := range s.functions {
keys = append(keys, name)
}
sort.Strings(keys)
for _, name := range keys {
script.WriteString("\n")
script.WriteString(s.functions[name])
script.WriteString("\n")
}
}
// write any statements
if len(s.statements) > 0 {
script.WriteString("\n")
script.WriteString(strings.Join(s.statements, "\n"))
script.WriteString("\n")
}
return script.String()
}
// removeLaunchctlServiceFunc removes a launchctl service, it's a direct port
// of the homebrew implementation
// https://github.com/Homebrew/brew/blob/e1ff668957dd8a66304c0290dfa66083e6c7444e/Library/Homebrew/cask/artifact/abstract_uninstall.rb#L92
const removeLaunchctlServiceFunc = `remove_launchctl_service() {
local service="$1"
local booleans=("true" "false")
local plist_status
local paths
local should_sudo
echo "Removing launchctl service ${service}"
for should_sudo in "${booleans[@]}"; do
plist_status=$(launchctl list "${service}" 2>/dev/null)
if [[ $plist_status == \{* ]]; then
if [[ $should_sudo == "true" ]]; then
sudo launchctl remove "${service}"
else
launchctl remove "${service}"
fi
sleep 1
fi
paths=(
"/Library/LaunchAgents/${service}.plist"
"/Library/LaunchDaemons/${service}.plist"
)
# if not using sudo, prepend the home directory to the paths
if [[ $should_sudo == "false" ]]; then
for i in "${!paths[@]}"; do
paths[i]="${HOME}${paths[i]}"
done
fi
for path in "${paths[@]}"; do
if [[ -e "$path" ]]; then
if [[ $should_sudo == "true" ]]; then
sudo rm -f -- "$path"
else
rm -f -- "$path"
fi
fi
done
done
}`
// quitApplicationFunc quits a running application. It's a direct port of the
// homebrew implementation
// https://github.com/Homebrew/brew/blob/e1ff668957dd8a66304c0290dfa66083e6c7444e/Library/Homebrew/cask/artifact/abstract_uninstall.rb#L192
const quitApplicationFunc = `quit_application() {
local bundle_id="$1"
local timeout_duration=10
# check if the application is running
if ! osascript -e "application id \"$bundle_id\" is running" 2>/dev/null; then
return
fi
local console_user
console_user=$(stat -f "%Su" /dev/console)
if [[ $EUID -eq 0 && "$console_user" == "root" ]]; then
echo "Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'."
return
fi
echo "Quitting application '$bundle_id'..."
# try to quit the application within the timeout period
local quit_success=false
SECONDS=0
while (( SECONDS < timeout_duration )); do
if osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1; then
if ! pgrep -f "$bundle_id" >/dev/null 2>&1; then
echo "Application '$bundle_id' quit successfully."
quit_success=true
break
fi
fi
sleep 1
done
if [[ "$quit_success" = false ]]; then
echo "Application '$bundle_id' did not quit."
fi
}
`
const trashFunc = `trash() {
local logged_in_user="$1"
local target_file="$2"
local timestamp="$(date +%Y-%m-%d-%s)"
local rand="$(jot -r 1 0 99999)"
# replace ~ with /Users/$logged_in_user
if [[ "$target_file" == ~* ]]; then
target_file="/Users/$logged_in_user${target_file:1}"
fi
local trash="/Users/$logged_in_user/.Trash"
local file_name="$(basename "${target_file}")"
if [[ -e "$target_file" ]]; then
echo "removing $target_file."
mv -f "$target_file" "$trash/${file_name}_${timestamp}_${rand}"
else
echo "$target_file doesn't exist."
fi
}`
const sendSignalFunc = `send_signal() {
local signal="$1"
local bundle_id="$2"
local logged_in_user="$3"
local logged_in_uid pids
if [ -z "$signal" ] || [ -z "$bundle_id" ] || [ -z "$logged_in_user" ]; then
echo "Usage: uninstall_signal <signal> <bundle_id> <logged_in_user>"
return 1
fi
logged_in_uid=$(id -u "$logged_in_user")
if [ -z "$logged_in_uid" ]; then
echo "Could not find UID for user '$logged_in_user'."
return 1
fi
echo "Signalling '$signal' to application ID '$bundle_id' for user '$logged_in_user'"
pids=$(/bin/launchctl asuser "$logged_in_uid" sudo -iu "$logged_in_user" /bin/launchctl list | awk -v bundle_id="$bundle_id" '
$3 ~ bundle_id { print $1 }')
if [ -z "$pids" ]; then
echo "No processes found for bundle ID '$bundle_id'."
return 0
fi
echo "Unix PIDs are $pids for processes with bundle identifier $bundle_id"
for pid in $pids; do
if kill -s "$signal" "$pid" 2>/dev/null; then
echo "Successfully signaled PID $pid with signal $signal."
else
echo "Failed to kill PID $pid with signal $signal. Check permissions."
fi
done
sleep 3
}`