From fe1cab8d12b5b9ec3cee32aaf3547f93fba28efc Mon Sep 17 00:00:00 2001 From: Greg Neagle Date: Thu, 29 Aug 2024 08:25:04 -0700 Subject: [PATCH] Implment more managedsoftwareupdate support functions --- code/cli/munki/launchapp/main.swift | 4 +- .../distributednotifications.swift | 62 ++++ .../managedsoftwareupdate/msuutils.swift | 308 ++++++++++++++++++ .../cli/munki/munki.xcodeproj/project.pbxproj | 12 + code/cli/munki/shared/munkistatus.swift | 2 +- code/cli/munki/shared/prefs.swift | 24 +- 6 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 code/cli/munki/managedsoftwareupdate/distributednotifications.swift create mode 100644 code/cli/munki/managedsoftwareupdate/msuutils.swift diff --git a/code/cli/munki/launchapp/main.swift b/code/cli/munki/launchapp/main.swift index 297f9848..b3d79bbb 100644 --- a/code/cli/munki/launchapp/main.swift +++ b/code/cli/munki/launchapp/main.swift @@ -48,12 +48,12 @@ func main() { } task.waitUntilExit() // sleep 10 secs to make launchd happy - usleep(10 * 1_000_000) + usleep(10_000_000) exit(0) } else { // we aren't in the current GUI session // sleep 10 secs to make launchd happy - usleep(10 * 1_000_000) + usleep(10_000_000) exit(0) } } diff --git a/code/cli/munki/managedsoftwareupdate/distributednotifications.swift b/code/cli/munki/managedsoftwareupdate/distributednotifications.swift new file mode 100644 index 00000000..862a3310 --- /dev/null +++ b/code/cli/munki/managedsoftwareupdate/distributednotifications.swift @@ -0,0 +1,62 @@ +// +// distributednotifications.swift +// munki +// +// Created by Greg Neagle on 8/28/24. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +func sendDistributedNotification(_ name: NSNotification.Name, userInfo: PlistDict? = nil) { + // Sends a NSDistributedNotification + let dnc = DistributedNotificationCenter.default() + dnc.postNotificationName( + name, + object: nil, + userInfo: userInfo, + options: [.deliverImmediately, .postToAllSessions] + ) +} + +func sendUpdateNotification() { + // Sends an update notification via NSDistributedNotificationCenter + // Managed Software Center.app and MunkiStatus.app register to receive these + // events. + let name = NSNotification.Name(rawValue: "com.googlecode.munki.managedsoftwareupdate.updateschanged") + let userInfo = ["pid": ProcessInfo().processIdentifier] + sendDistributedNotification(name, userInfo: userInfo) +} + +func sendDockUpdateNotification() { + // Sends an update notification via NSDistributedNotificationCenter + // Managed Software Center.app's dock tile plugin registers to receive these + // events. + let name = NSNotification.Name(rawValue: "com.googlecode.munki.managedsoftwareupdate.dock.updateschanged") + let userInfo = ["pid": ProcessInfo().processIdentifier] + sendDistributedNotification(name, userInfo: userInfo) +} + +func sendStartNotification() { + // Sends a start notification via NSDistributedNotificationCenter + let name = NSNotification.Name(rawValue: "com.googlecode.munki.managedsoftwareupdate.started") + let userInfo = ["pid": ProcessInfo().processIdentifier] + sendDistributedNotification(name, userInfo: userInfo) +} + +func sendEndedNotification() { + // Sends an ended notification via NSDistributedNotificationCenter + let name = NSNotification.Name(rawValue: "com.googlecode.munki.managedsoftwareupdate.ended") + let userInfo = ["pid": ProcessInfo().processIdentifier] + sendDistributedNotification(name, userInfo: userInfo) +} diff --git a/code/cli/munki/managedsoftwareupdate/msuutils.swift b/code/cli/munki/managedsoftwareupdate/msuutils.swift new file mode 100644 index 00000000..ff0a7ddc --- /dev/null +++ b/code/cli/munki/managedsoftwareupdate/msuutils.swift @@ -0,0 +1,308 @@ +// +// msuutils.swift +// munki +// +// Created by Greg Neagle on 8/27/24. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +func clearLastNotifiedDate() { + // Clear the last date the user was notified of updates. + setPref("LastNotifiedDate", nil) +} + +func initMunkiDirs() -> Bool { + // attempts to create any missing directories needed by managedsoftwareupdate + // returns a boolean to indicate success + var dirlist = [managedInstallsDir()] + for subdir in [ + "Archives", + "Cache", + "Logs", + "catalogs", + "client_resources", + "icons", + "manifests", + ] { + dirlist.append(managedInstallsDir(subpath: subdir)) + } + var success = true + for dir in dirlist { + if !pathExists(dir) { + do { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: false) + } catch { + displayError("Could not create missing directory \(dir): \(error.localizedDescription)") + success = false + } + } + } + return success +} + +func runPreOrPostScript(_ scriptPath: String, displayName: String, runType: String) async -> Int { + // Run an external script. Do not run if the permissions on the external + // script file are weaker than the current executable. + if !pathExists(scriptPath) { + return 0 + } + displayMinorStatus("Performing \(displayName) tasks...") + do { + let result = try await runExternalScript( + scriptPath, arguments: [runType] + ) + if result.exitcode != 0 { + displayInfo("\(displayName) return code: \(result.exitcode)") + } + if !result.output.isEmpty { + displayInfo("\(displayName) stdout: \(result.output)") + } + if !result.error.isEmpty { + displayInfo("\(displayName) stderr: \(result.error)") + } + return result.exitcode + } catch ExternalScriptError.notFound { + // not required, so pass + } catch { + displayWarning("Unexpected error when attempting to run \(displayName): \(error.localizedDescription)") + } + return 0 +} + +func doCleanupTasks(runType _: String) { + // If there are executables inside the cleanup directory, + // run them and remove them if successful + // TODO: implement this +} + +private func getInstallInfo() -> PlistDict? { + // gets info from InstallInfo.plist + // TODO: there is at least one other similar function elsewhere; de-dup + let installInfoPath = managedInstallsDir(subpath: "InstallInfo.plist") + if pathExists(installInfoPath) { + do { + if let plist = try readPlist(fromFile: installInfoPath) as? PlistDict { + return plist + } else { + displayError("\(installInfoPath) does not have the expected format") + } + } catch { + displayError("Could not read \(installInfoPath): \(error.localizedDescription)") + } + } else { + displayInfo("\(installInfoPath) does not exist") + } + return nil +} + +func munkiUpdatesAvailable() -> Int { + // Return count of available updates. + if let plist = getInstallInfo() { + var updatesAvailable = 0 + if let removals = plist["removals"] as? [PlistDict] { + updatesAvailable += removals.count + } + if let installs = plist["managed_installs"] as? [PlistDict] { + updatesAvailable += installs.count + } + return updatesAvailable + } + return 0 +} + +func munkiUpdatesContainItemWithInstallerType(_ installerType: String) -> Bool { + // Return true if there is an item with this installerType in the list of updates + if let plist = getInstallInfo(), + let managedInstalls = plist["managed_installs"] as? [PlistDict] + { + for item in managedInstalls { + if let type = item["installer_type"] as? String, + type == installerType + { + return true + } + } + } + return false +} + +func munkiUpdatesContainAppleItems() -> Bool { + // Return True if there are any Apple items in the list of updates + if let plist = getInstallInfo() { + for key in ["managed_installs", "removals"] { + if let items = plist[key] as? [PlistDict] { + for item in items { + if let appleItem = item["apple_item"] as? Bool, + appleItem == true + { + return true + } + } + } + } + } + return false +} + +func recordUpdateCheckResult(_ result: Int) { + // Record last check date and result + let now = Date() + setPref("LastCheckDate", now) + setPref("LastCheckResult", result) +} + +func notifyUserOfUpdates(force: Bool = false) -> Bool { + // Notify the logged-in user of available updates. + // + // Args: + // force: bool, default false, forcefully notify user regardless + // of LastNotifiedDate. + // Returns: + // Bool. true if the user was notified, false otherwise. + var userWasNotified = false + let lastNotifiedDate = datePref("LastNotifiedDate") ?? Date.distantPast + if !(pref("DaysBetweenNotifications") is Int) { + displayWarning("Preference DaysBetweenNotifications is not an integer; using a value of 1") + } + let daysBetweenNotifications = intPref("DaysBetweenNotifications") ?? 1 + let now = Date() + // calculate interval in seconds + let interval = if daysBetweenNotifications > 0 { + // we make this adjustment so a 'daily' notification + // doesn't require exactly 24 hours to elapse + // subtract 6 hours + // IOW, if we notify today at 4pm, we don't really want to wait + // until after 4pm tomorrow to notifiy again. + Double((daysBetweenNotifications * 24 * 60 * 60) - (6 * 60 * 60)) + } else { + 0.0 + } + if force || now.timeIntervalSince(lastNotifiedDate) >= interval { + // record current notification date + setPref("LastNotifiedDate", now) + munkiLog("Notifying user of available updates.") + munkiLog("LastNotifiedDate was \(lastNotifiedDate)") + // trigger LaunchAgent to launch munki-notifier.app in the right context + let launchfile = "/var/run/com.googlecode.munki.munki-notifier" + FileManager.default.createFile(atPath: launchfile, contents: nil) + usleep(1_000_000) + // clear the trigger file. We have to do it because we're root, + // and the munki-notifier process is running as the user + try? FileManager.default.removeItem(atPath: launchfile) + userWasNotified = true + } + return userWasNotified +} + +func warnIfServerIsDefault(_ url: String) { + // Munki defaults to using http://munki/repo as the base URL. + // This is useful as a bootstrapping default, but is insecure. + // Warn the admin if Munki is using an insecure default. + if url.isEmpty { + // hasn't been defined yet; will be auto-detected later + return + } + var server = url + if server.last == "/" { + server = String(server.dropLast()) + } + if [DEFAULT_INSECURE_REPO_URL, DEFAULT_INSECURE_REPO_URL + "/manifests"].contains(server) { + displayWarning("Client is configured to use the default repo (\(DEFAULT_INSECURE_REPO_URL)), which is insecure. Client could be trivially compromised when off your organization's network. Consider using a non-default URL, and preferably an https:// URL.") + } +} + +func removeLaunchdLogoutJobs() { + // Removes the jobs that launch MunkiStatus and managedsoftwareupdate at + // the loginwindow. We do this if we decide it's not applicable to run right + // now so we don't get relaunched repeatedly, but don't want to remove the + // trigger file because we do want to run again at the next logout/reboot. + // These jobs will be reloaded the next time we're in the loginwindow context. + munkiStatusQuit() + _ = runCLI("/bin/launchctl", arguments: ["remove", "com.googlecode.munki.MunkiStatus"]) + _ = runCLI("/bin/launchctl", arguments: ["remove", "com.googlecode.munki.managedsoftwareupdate-loginwindow"]) +} + +func doRestart(shutdown: Bool = false) { + // Handle the need for a restart or a possible shutdown. + let message = if shutdown { + "Software installed or removed requires a shut down." + } else { + "Software installed or removed requires a restart." + } + if DisplayOptions.shared.munkistatusoutput { + munkiStatusHideStopButton() + munkiStatusMessage(message) + munkiStatusDetail("") + munkiStatusPercent(-1) + munkiLog(message) + } else { + displayInfo(message) + } + + // check current console user + let consoleUser = getConsoleUser() + if consoleUser.isEmpty || consoleUser == "loginwindow" { + // no-one is logged in or we're at the loginwindow + usleep(5_000_000) + if shutdown { + // TODO: doAuthorizedOrNormalRestart(shutdown: shutdown) + } else if false { // TODO: !authrestartdRestart() { + // TODO: doAuthorizedOrNormalRestart(shutdown: shutdown) + } + } else { + if DisplayOptions.shared.munkistatusoutput { + // someone is logged in and we're using Managed Software Center. + // We need to notify the active user that a restart is required. + // We actually should almost never get here; generally Munki knows + // a restart is needed before even starting the updates and forces + // a logout before applying the updates + displayInfo("Notifying currently logged-in user to restart.") + munkiStatusActivate() + munkiStatusRestartAlert() + } else { + print("Please restart immediately.") + } + } +} + +func doInstallTasks(doAppleUpdates _: Bool = false, onlyUnattended: Bool = false) async -> Bool { + // Perform our installation/removal tasks. + // + // Args: + // doAppleUpdates: Bool. If true, install Apple updates + // onlyUnattended: Bool. If true, only do unattended_(un)install items. + // + // Returns: + // Bool. True if a restart is required, false otherwise. + if !onlyUnattended { + // first, clear the last notified date so we can get notified of new + // changes after this round of installs + clearLastNotifiedDate() + } + + var munkiItemsRestartAction = POSTACTION_NONE + var appleItemsRestartAction = POSTACTION_NONE + + if munkiUpdatesAvailable() > 0 {} + return true +} + +func startLogoutHelper() { + // Handle the need for a forced logout. Start our logouthelper +} + +func doFinishingTasks(runtype _: String = "") { + // A collection of tasks to do as we finish up +} diff --git a/code/cli/munki/munki.xcodeproj/project.pbxproj b/code/cli/munki/munki.xcodeproj/project.pbxproj index 658b7efd..c9e43b5a 100644 --- a/code/cli/munki/munki.xcodeproj/project.pbxproj +++ b/code/cli/munki/munki.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ C0011CAD2C7A64F30004ED70 /* Predicates.m in Sources */ = {isa = PBXBuildFile; fileRef = C0011CAB2C7A64F30004ED70 /* Predicates.m */; }; C0011CB22C7CDEC40004ED70 /* updatecheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0011CB12C7CDEC40004ED70 /* updatecheck.swift */; }; C0011CB32C7CDEC40004ED70 /* updatecheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0011CB12C7CDEC40004ED70 /* updatecheck.swift */; }; + C0011CB52C7EC5B60004ED70 /* msuutils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0011CB42C7EC5B60004ED70 /* msuutils.swift */; }; + C0011CB62C7EC5B60004ED70 /* msuutils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0011CB42C7EC5B60004ED70 /* msuutils.swift */; }; + C0011CB82C7F86480004ED70 /* distributednotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0011CB72C7F86480004ED70 /* distributednotifications.swift */; }; + C0011CB92C7F86480004ED70 /* distributednotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0011CB72C7F86480004ED70 /* distributednotifications.swift */; }; C013643D2C2DC529008DB215 /* makecatalogslib.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013643B2C2DC529008DB215 /* makecatalogslib.swift */; }; C01364402C2DCA5C008DB215 /* admincommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013643E2C2DCA5C008DB215 /* admincommon.swift */; }; C01364412C2DCA5C008DB215 /* admincommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013643E2C2DCA5C008DB215 /* admincommon.swift */; }; @@ -310,6 +314,8 @@ C0011CAE2C7A98280004ED70 /* Predicates.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Predicates.h; sourceTree = ""; }; C0011CAF2C7A990B0004ED70 /* managedsoftwareupdate-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "managedsoftwareupdate-Bridging-Header.h"; sourceTree = ""; }; C0011CB12C7CDEC40004ED70 /* updatecheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = updatecheck.swift; sourceTree = ""; }; + C0011CB42C7EC5B60004ED70 /* msuutils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = msuutils.swift; sourceTree = ""; }; + C0011CB72C7F86480004ED70 /* distributednotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = distributednotifications.swift; sourceTree = ""; }; C013643B2C2DC529008DB215 /* makecatalogslib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = makecatalogslib.swift; sourceTree = ""; }; C013643E2C2DCA5C008DB215 /* admincommon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = admincommon.swift; sourceTree = ""; }; C01364422C2DD1BA008DB215 /* plistutils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = plistutils.swift; sourceTree = ""; }; @@ -586,6 +592,8 @@ isa = PBXGroup; children = ( C07A6FA82C2A82B400090743 /* managedsoftwareupdate.swift */, + C0011CB42C7EC5B60004ED70 /* msuutils.swift */, + C0011CB72C7F86480004ED70 /* distributednotifications.swift */, ); path = managedsoftwareupdate; sourceTree = ""; @@ -1074,7 +1082,9 @@ C01792EE2C75187E008CBC22 /* autoconfig.swift in Sources */, C0D00FB02C458EAA0021DA9C /* version.swift in Sources */, C043ED1F2C4822C70047C025 /* sqlite3.swift in Sources */, + C0011CB52C7EC5B60004ED70 /* msuutils.swift in Sources */, C0011CAC2C7A64F30004ED70 /* Predicates.m in Sources */, + C0011CB82C7F86480004ED70 /* distributednotifications.swift in Sources */, C07074DC2C33AE5F00B86310 /* munkilog.swift in Sources */, C0D9C2B02C62D4120019A067 /* powermanager.swift in Sources */, C07A6FB22C2B22D300090743 /* constants.swift in Sources */, @@ -1101,6 +1111,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C0011CB92C7F86480004ED70 /* distributednotifications.swift in Sources */, C01364442C2DD1BA008DB215 /* plistutils.swift in Sources */, C01364462C2E051F008DB215 /* makecatalogslib.swift in Sources */, C01792EC2C75089F008CBC22 /* licensing.swift in Sources */, @@ -1148,6 +1159,7 @@ C0D9C2982C6012C80019A067 /* dmg.swift in Sources */, C030A9C22C41B556007F0B34 /* pkginfolib.swift in Sources */, C07074E92C34932400B86310 /* pkgutils.swift in Sources */, + C0011CB62C7EC5B60004ED70 /* msuutils.swift in Sources */, C07074EC2C34A6AD00B86310 /* versionutils.swift in Sources */, C01792EF2C75187E008CBC22 /* autoconfig.swift in Sources */, C013644A2C2F8EFE008DB215 /* GitFileRepo.swift in Sources */, diff --git a/code/cli/munki/shared/munkistatus.swift b/code/cli/munki/shared/munkistatus.swift index ed30600f..cf54c242 100644 --- a/code/cli/munki/shared/munkistatus.swift +++ b/code/cli/munki/shared/munkistatus.swift @@ -52,7 +52,7 @@ func munkiStatusLaunch() { let launchfile = "/var/run/com.googlecode.munki.MunkiStatus" if FileManager.default.createFile(atPath: launchfile, contents: nil) { - usleep(100_000) + usleep(1_000_000) try? FileManager.default.removeItem(atPath: launchfile) } else { printStderr("Couldn't create launchpath \(launchfile)") diff --git a/code/cli/munki/shared/prefs.swift b/code/cli/munki/shared/prefs.swift index bbdab7e2..e821a4d4 100644 --- a/code/cli/munki/shared/prefs.swift +++ b/code/cli/munki/shared/prefs.swift @@ -125,14 +125,20 @@ func reloadPrefs() { CFPreferencesAppSynchronize(BUNDLE_ID) } -func setPref(_ prefName: String, _ prefValue: Any) { +func setPref(_ prefName: String, _ prefValue: Any?) { /* Sets a preference, writing it to /Library/Preferences/ManagedInstalls.plist. This should normally be used only for 'bookkeeping' values; values that control the behavior of munki may be overridden elsewhere (by MCX, for example) */ if let key = prefName as CFString? { - if let value = prefValue as CFPropertyList? { + if prefValue == nil { + CFPreferencesSetValue( + key, nil, BUNDLE_ID, + kCFPreferencesAnyUser, kCFPreferencesCurrentHost + ) + CFPreferencesAppSynchronize(BUNDLE_ID) + } else if let value = prefValue as CFPropertyList? { CFPreferencesSetValue( key, value, BUNDLE_ID, kCFPreferencesAnyUser, kCFPreferencesCurrentHost @@ -188,6 +194,20 @@ func intPref(_ prefName: String) -> Int? { return pref(prefName) as? Int } +func datePref(_ prefName: String) -> Date? { + // returns preference as a Date if possible + if let date = pref(prefName) as? Date { + return date + } + if let str = pref(prefName) as? String { + let dateFormatter = ISO8601DateFormatter() + if let date = dateFormatter.date(from: str) { + return date + } + } + return nil +} + func managedInstallsDir(subpath: String? = nil) -> String { // convenience function to return the path to the Managed Installs dir // or a subpath of that directory