diff --git a/.swiftlint.yml b/.swiftlint.yml index ec8a366..19af90a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,2 +1,11 @@ +disabled_rules: # rule identifiers turned on by default to exclude from running + - line_length + - file_length + - function_body_length + - cyclomatic_complexity + - large_tuple + - force_cast + + excluded: # paths to ignore during linting. Takes precedence over `included`. - ./build/SourcePackages/checkouts/swift-argument-parser/* diff --git a/Outset.xcodeproj/project.pbxproj b/Outset.xcodeproj/project.pbxproj index 1708e26..f7b3304 100644 --- a/Outset.xcodeproj/project.pbxproj +++ b/Outset.xcodeproj/project.pbxproj @@ -14,9 +14,18 @@ 41765ACA29C97B6400D616BF /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41765AC929C97B6400D616BF /* Services.swift */; }; 41E28EA229ACDCD6002ADBE5 /* Outset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124EF90293822F4003B00F4 /* Outset.swift */; }; 41E28EA329ACDCE3002ADBE5 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124EFA2293B2F9B003B00F4 /* FileUtils.swift */; }; - 41E28EA429ACDCE3002ADBE5 /* Processing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124EFA4293B304B003B00F4 /* Processing.swift */; }; + 41E28EA429ACDCE3002ADBE5 /* ItemProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124EFA4293B304B003B00F4 /* ItemProcessing.swift */; }; 41E28EA529ACDCE3002ADBE5 /* SystemUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124EF99293824C8003B00F4 /* SystemUtils.swift */; }; 41E28EA729ACDD1F002ADBE5 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 41E28EA629ACDD1F002ADBE5 /* ArgumentParser */; }; + CC3DC8222AA70B2D0050EE16 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8212AA70B2D0050EE16 /* Logging.swift */; }; + CC3DC8242AA70BD40050EE16 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8232AA70BD40050EE16 /* Preferences.swift */; }; + CC3DC8262AA70D2D0050EE16 /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8252AA70D2D0050EE16 /* Network.swift */; }; + CC3DC8282AA70D930050EE16 /* SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8272AA70D930050EE16 /* SystemInfo.swift */; }; + CC3DC82A2AA70E380050EE16 /* Checksum.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8292AA70E380050EE16 /* Checksum.swift */; }; + CC3DC82D2AA70EE60050EE16 /* Data+additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC82C2AA70EE60050EE16 /* Data+additions.swift */; }; + CC3DC82F2AA70F230050EE16 /* URL+additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC82E2AA70F230050EE16 /* URL+additions.swift */; }; + CC3DC8312AA70F790050EE16 /* String+additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8302AA70F790050EE16 /* String+additions.swift */; }; + CC3DC8332AA7100C0050EE16 /* ShellUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DC8322AA7100C0050EE16 /* ShellUtils.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,7 +65,7 @@ 4124EF90293822F4003B00F4 /* Outset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Outset.swift; sourceTree = ""; }; 4124EF99293824C8003B00F4 /* SystemUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemUtils.swift; sourceTree = ""; }; 4124EFA2293B2F9B003B00F4 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; - 4124EFA4293B304B003B00F4 /* Processing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Processing.swift; sourceTree = ""; }; + 4124EFA4293B304B003B00F4 /* ItemProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProcessing.swift; sourceTree = ""; }; 4124EFAD29414A5D003B00F4 /* Outset.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outset.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4124EFB329414A5E003B00F4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4124EFB629414A5E003B00F4 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -72,6 +81,15 @@ 41A67A13296BF35F000BFFCE /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 41ADC47C29AECB8B00C5B94C /* outset */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = outset; sourceTree = ""; }; 41ADC47E29AF649C00C5B94C /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; + CC3DC8212AA70B2D0050EE16 /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; + CC3DC8232AA70BD40050EE16 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + CC3DC8252AA70D2D0050EE16 /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; + CC3DC8272AA70D930050EE16 /* SystemInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfo.swift; sourceTree = ""; }; + CC3DC8292AA70E380050EE16 /* Checksum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checksum.swift; sourceTree = ""; }; + CC3DC82C2AA70EE60050EE16 /* Data+additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+additions.swift"; sourceTree = ""; }; + CC3DC82E2AA70F230050EE16 /* URL+additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+additions.swift"; sourceTree = ""; }; + CC3DC8302AA70F790050EE16 /* String+additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+additions.swift"; sourceTree = ""; }; + CC3DC8322AA7100C0050EE16 /* ShellUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellUtils.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -107,22 +125,29 @@ name = Products; sourceTree = ""; }; - 4124EFA6293B30D6003B00F4 /* Functions */ = { + 4124EFA6293B30D6003B00F4 /* Utils */ = { isa = PBXGroup; children = ( 4124EFA2293B2F9B003B00F4 /* FileUtils.swift */, - 4124EFA4293B304B003B00F4 /* Processing.swift */, + 4124EFA4293B304B003B00F4 /* ItemProcessing.swift */, 4124EF99293824C8003B00F4 /* SystemUtils.swift */, 41765AC929C97B6400D616BF /* Services.swift */, - ); - path = Functions; + CC3DC8212AA70B2D0050EE16 /* Logging.swift */, + CC3DC8232AA70BD40050EE16 /* Preferences.swift */, + CC3DC8252AA70D2D0050EE16 /* Network.swift */, + CC3DC8272AA70D930050EE16 /* SystemInfo.swift */, + CC3DC8292AA70E380050EE16 /* Checksum.swift */, + CC3DC8322AA7100C0050EE16 /* ShellUtils.swift */, + ); + path = Utils; sourceTree = ""; }; 4124EFAE29414A5D003B00F4 /* Outset */ = { isa = PBXGroup; children = ( 4124EF90293822F4003B00F4 /* Outset.swift */, - 4124EFA6293B30D6003B00F4 /* Functions */, + CC3DC82B2AA70EC90050EE16 /* Extensions */, + 4124EFA6293B30D6003B00F4 /* Utils */, 4124EFC329414DA4003B00F4 /* Info.plist */, 4124EFB329414A5E003B00F4 /* Assets.xcassets */, 4124EFB829414A5E003B00F4 /* Outset.entitlements */, @@ -167,6 +192,16 @@ path = Scripts; sourceTree = ""; }; + CC3DC82B2AA70EC90050EE16 /* Extensions */ = { + isa = PBXGroup; + children = ( + CC3DC82C2AA70EE60050EE16 /* Data+additions.swift */, + CC3DC82E2AA70F230050EE16 /* URL+additions.swift */, + CC3DC8302AA70F790050EE16 /* String+additions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -308,8 +343,17 @@ buildActionMask = 2147483647; files = ( 41765ACA29C97B6400D616BF /* Services.swift in Sources */, + CC3DC8282AA70D930050EE16 /* SystemInfo.swift in Sources */, + CC3DC8242AA70BD40050EE16 /* Preferences.swift in Sources */, + CC3DC82F2AA70F230050EE16 /* URL+additions.swift in Sources */, + CC3DC8262AA70D2D0050EE16 /* Network.swift in Sources */, + CC3DC8222AA70B2D0050EE16 /* Logging.swift in Sources */, 41E28EA329ACDCE3002ADBE5 /* FileUtils.swift in Sources */, - 41E28EA429ACDCE3002ADBE5 /* Processing.swift in Sources */, + CC3DC82D2AA70EE60050EE16 /* Data+additions.swift in Sources */, + CC3DC8332AA7100C0050EE16 /* ShellUtils.swift in Sources */, + CC3DC82A2AA70E380050EE16 /* Checksum.swift in Sources */, + CC3DC8312AA70F790050EE16 /* String+additions.swift in Sources */, + 41E28EA429ACDCE3002ADBE5 /* ItemProcessing.swift in Sources */, 41E28EA529ACDCE3002ADBE5 /* SystemUtils.swift in Sources */, 41E28EA229ACDCD6002ADBE5 /* Outset.swift in Sources */, ); @@ -468,7 +512,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.macadmins.Outset; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -505,7 +549,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.macadmins.Outset; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -543,7 +587,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.macadmins.Outset; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -585,7 +629,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 4.0.4; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.macadmins.Outset; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Outset/Extensions/Data+additions.swift b/Outset/Extensions/Data+additions.swift new file mode 100644 index 0000000..257eda6 --- /dev/null +++ b/Outset/Extensions/Data+additions.swift @@ -0,0 +1,24 @@ +// +// Data+additions.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation +import CommonCrypto + +extension Data { + // extension to the Data class that lets us compute sha256 + func sha256() -> Data { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + self.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(count), &hash) + } + return Data(hash) + } + + func hexEncodedString() -> String { + return map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/Outset/Extensions/String+additions.swift b/Outset/Extensions/String+additions.swift new file mode 100644 index 0000000..1dd3262 --- /dev/null +++ b/Outset/Extensions/String+additions.swift @@ -0,0 +1,21 @@ +// +// String+additions.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation + +extension String { + func camelCaseToUnderscored() -> String { + let regex = try? NSRegularExpression(pattern: "([a-z])([A-Z])", options: []) + let range = NSRange(location: 0, length: utf16.count) + return regex?.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "$1_$2").lowercased() ?? self + } +} + +func getValueForKey(_ key: String, inArray array: [String: String]) -> String? { + // short function that treats a [String: String] as a key value pair. + return array[key] +} diff --git a/Outset/Extensions/URL+additions.swift b/Outset/Extensions/URL+additions.swift new file mode 100644 index 0000000..8d4c180 --- /dev/null +++ b/Outset/Extensions/URL+additions.swift @@ -0,0 +1,14 @@ +// +// URL+additions.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation + +extension URL { + var isDirectory: Bool { + (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } +} diff --git a/Outset/Functions/FileUtils.swift b/Outset/Functions/FileUtils.swift deleted file mode 100644 index 2863c0f..0000000 --- a/Outset/Functions/FileUtils.swift +++ /dev/null @@ -1,414 +0,0 @@ -// -// Utils.swift -// outset -// -// Created by Bart Reardon on 3/12/2022. -// -// swiftlint:disable large_tuple line_length force_cast file_length cyclomatic_complexity function_body_length - -import Foundation -import CommonCrypto - -func runShellCommand(_ command: String, args: [String] = [], verbose: Bool = false) -> (output: String, error: String, exitCode: Int32) { - // runs a shell command passed as an argument - // If the verbose parameter is set to true, will log the command being run and its status when completed. - // returns the output, error and exit code as a tuple. - - if verbose { - writeLog("Running task \(command)", logLevel: .debug) - } - let task = Process() - let pipe = Pipe() - let errorpipe = Pipe() - - var cmd = command - for arg in args { - cmd += " '\(arg)'" - } - let arguments = ["-c", cmd] - - var output: String = "" - var error: String = "" - - task.launchPath = "/bin/sh" - task.arguments = arguments - task.standardOutput = pipe - task.standardError = errorpipe - task.launch() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let errordata = errorpipe.fileHandleForReading.readDataToEndOfFile() - - output.append(String(data: data, encoding: .utf8)!) - error.append(String(data: errordata, encoding: .utf8)!) - - task.waitUntilExit() - let status = task.terminationStatus - if verbose { - writeLog("Completed task \(command) with status \(status)", logLevel: .debug) - writeLog("Task output: \n\(output)", logLevel: .debug) - } - return (output, error, status) -} - -func installPackage(pkg: String) -> Bool { - // Installs pkg onto boot drive - if isRoot() { - var pkgToInstall: String = "" - var dmgMount: String = "" - - if pkg.lowercased().hasSuffix("dmg") { - dmgMount = mountDmg(dmg: pkg) - for files in folderContents(path: dmgMount) where ["pkg", "mpkg"].contains(files.lowercased().suffix(3)) { - pkgToInstall = dmgMount - } - } else if ["pkg", "mpkg"].contains(pkg.lowercased().suffix(3)) { - pkgToInstall = pkg - } - writeLog("Installing \(pkgToInstall)") - let cmd = "/usr/sbin/installer -pkg \(pkgToInstall) -target /" - let (output, error, status) = runShellCommand(cmd, verbose: true) - if status != 0 { - writeLog(error, logLevel: .error) - } else { - writeLog(output) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { - if !dmgMount.isEmpty { - writeLog(detachDmg(dmgMount: dmgMount)) - } - } - return true - } else { - writeLog("Unable to process \(pkg)", logLevel: .error) - writeLog("Must be root to install packages", logLevel: .error) - } - return false -} - -func ensureWorkingFolders() { - // Ensures working folders are all present and creates them if necessary - let workingDirectories = [ - bootEveryDir, - bootOnceDir, - loginWindowDir, - loginEveryDir, - loginOnceDir, - loginEveryPrivilegedDir, - loginOncePrivilegedDir, - onDemandDir, - logDirectory - ] - - for directory in workingDirectories where !checkDirectoryExists(path: directory) { - writeLog("\(directory) does not exist, creating now.", logLevel: .debug) - do { - try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) - } catch { - writeLog("could not create path at \(directory)", logLevel: .error) - } - } -} - -func migrateLegacyPreferences() { - let newoldRootUserDefaults = "/var/root/Library/Preferences/io.macadmins.Outset.plist" - // shared folder should not contain any executable content, iterate and update as required - if checkFileExists(path: shareDirectory) || checkFileExists(path: newoldRootUserDefaults) { - writeLog("Legacy preferences exist. Migrating to user defaults", logLevel: .debug) - - let legacyOutsetPreferencesFile = "\(shareDirectory)com.chilcote.outset.plist" - let legacyRootRunOncePlistFile = "com.github.outset.once.\(getConsoleUserInfo().userID).plist" - let userHomeDirectory = FileManager.default.homeDirectoryForCurrentUser - let userHomePath = userHomeDirectory.relativeString.replacingOccurrences(of: "file://", with: "") - let legacyUserRunOncePlistFile = userHomePath+"Library/Preferences/com.github.outset.once.plist" - - var shareFiles: [String] = [] - shareFiles.append(legacyOutsetPreferencesFile) - shareFiles.append(legacyRootRunOncePlistFile) - shareFiles.append(legacyUserRunOncePlistFile) - shareFiles.append(newoldRootUserDefaults) - - for filename in shareFiles where checkFileExists(path: filename) { - - let url = URL(fileURLWithPath: filename) - do { - let data = try Data(contentsOf: url) - switch filename { - - case newoldRootUserDefaults: - if isRoot() { - writeLog("\(newoldRootUserDefaults) migration", logLevel: .debug) - let legacyDefaultKeys = CFPreferencesCopyKeyList(Bundle.main.bundleIdentifier! as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost) - for key in legacyDefaultKeys as! [CFString] { - let keyValue = CFPreferencesCopyValue(key, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost) - CFPreferencesSetValue(key as CFString, - keyValue as CFPropertyList, - Bundle.main.bundleIdentifier! as CFString, - kCFPreferencesAnyUser, - kCFPreferencesAnyHost) - } - deletePath(newoldRootUserDefaults) - } - case legacyOutsetPreferencesFile: - writeLog("\(legacyOutsetPreferencesFile) migration", logLevel: .debug) - do { - let legacyPreferences = try PropertyListDecoder().decode(OutsetPreferences.self, from: data) - writePreferences(prefs: legacyPreferences) - writeLog("Migrated Legacy Outset Preferences", logLevel: .debug) - deletePath(legacyOutsetPreferencesFile) - } catch { - writeLog("legacy Preferences migration failed", logLevel: .error) - } - - case legacyRootRunOncePlistFile, legacyUserRunOncePlistFile: - writeLog("\(legacyRootRunOncePlistFile) and \(legacyUserRunOncePlistFile) migration", logLevel: .debug) - do { - let legacyRunOncePlistData = try PropertyListDecoder().decode([String: Date].self, from: data) - writeRunOnce(runOnceData: legacyRunOncePlistData) - writeLog("Migrated Legacy Runonce Data", logLevel: .debug) - if isRoot() { - deletePath(legacyRootRunOncePlistFile) - } else { - deletePath(legacyUserRunOncePlistFile) - } - } catch { - writeLog("legacy Run Once Plist migration failed", logLevel: .error) - } - - default: - continue - } - } catch { - writeLog("could not load \(filename)", logLevel: .error) - } - - } - - if checkFileExists(path: shareDirectory) && folderContents(path: shareDirectory).isEmpty { - deletePath(shareDirectory) - } - } - -} - -func checkFileExists(path: String) -> Bool { - return FileManager.default.fileExists(atPath: path) -} - -func checkDirectoryExists(path: String) -> Bool { - var isDirectory: ObjCBool = false - _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue -} - -func folderContents(path: String) -> [String] { - // Returns a array of strings containing the folder contents - // Does not perform a recursive list - var filelist: [String] = [] - do { - let files = try FileManager.default.contentsOfDirectory(atPath: path) - let sortedFiles = files.sorted() - for file in sortedFiles { - filelist.append("\(path)/\(file)") - } - } catch { - return [] - } - return filelist -} - -func verifyPermissions(pathname: String) -> Bool { - // Files should be owned by root - // Files that are not scripts should have permissions 644 (-rw-r--r--) - // Files that are scripts should have permissions 755 (-rwxr-xr-x) - // If the permission for the request file is not correct then return fals to indicate it should not be processed - - let (ownerID, mode) = getFileProperties(pathname: pathname) - let posixPermissions = String(mode.intValue, radix: 8, uppercase: false) - let errorMessage = "Permissions for \(pathname) are incorrect. Should be owned by root and with mode" - - writeLog("ownerID for \(pathname) : \(String(describing: ownerID))", logLevel: .debug) - writeLog("posixPermissions for \(pathname) : \(String(describing: posixPermissions))", logLevel: .debug) - - if ["pkg", "mpkg", "dmg", "mobileconfig"].contains(pathname.lowercased().split(separator: ".").last) { - if ownerID == 0 && mode == requiredFilePermissions { - return true - } else { - writeLog("\(errorMessage) x644", logLevel: .error) - } - } else { - if ownerID == 0 && mode == requiredExecutablePermissions { - return true - } else { - writeLog("\(errorMessage) x755", logLevel: .error) - } - } - return false -} - -func getFileProperties(pathname: String) -> (ownerID: Int, permissions: NSNumber) { - // returns the ID and permissions of the specified file - var fileAttributes: [FileAttributeKey: Any] - var ownerID: Int = 0 - var mode: NSNumber = 0 - do { - fileAttributes = try FileManager.default.attributesOfItem(atPath: pathname) - if let ownerProperty = fileAttributes[.ownerAccountID] as? Int { - ownerID = ownerProperty - } - if let modeProperty = fileAttributes[.posixPermissions] as? NSNumber { - mode = modeProperty - } - } catch { - writeLog("Could not read file at path \(pathname)", logLevel: .error) - } - return (ownerID, mode) -} - -func pathCleanup(pathname: String) { - // check if folder and clean all files in that folder - // Deletes given script or cleans folder - writeLog("Cleaning up \(pathname)", logLevel: .debug) - if checkDirectoryExists(path: pathname) { - for fileItem in folderContents(path: pathname) { - writeLog("Cleaning up \(fileItem)", logLevel: .debug) - deletePath(fileItem) - } - } else if checkFileExists(path: pathname) { - writeLog("\(pathname) exists", logLevel: .debug) - deletePath(pathname) - } else { - writeLog("\(pathname) doesn't seem to exist", logLevel: .error) - } -} - -func deletePath(_ path: String) { - // Deletes the specified file - writeLog("Deleting \(path)", logLevel: .debug) - do { - try FileManager.default.removeItem(atPath: path) - writeLog("\(path) deleted", logLevel: .debug) - } catch { - writeLog("\(path) could not be removed", logLevel: .error) - } -} - -func mountDmg(dmg: String) -> String { - // Attaches dmg and returns the path - let cmd = "/usr/bin/hdiutil attach -nobrowse -noverify -noautoopen \(dmg)" - writeLog("Attaching \(dmg)", logLevel: .debug) - let (output, error, status) = runShellCommand(cmd) - if status != 0 { - writeLog("Failed attaching \(dmg) with error \(error)", logLevel: .error) - return error - } - return output.trimmingCharacters(in: .whitespacesAndNewlines) -} - -func detachDmg(dmgMount: String) -> String { - // Detaches dmg - writeLog("Detaching \(dmgMount)", logLevel: .debug) - let cmd = "/usr/bin/hdiutil detach -force \(dmgMount)" - let (output, error, status) = runShellCommand(cmd) - if status != 0 { - writeLog("Failed detaching \(dmgMount) with error \(error)", logLevel: .error) - return error - } - return output.trimmingCharacters(in: .whitespacesAndNewlines) -} - -func verifySHASUMForFile(filename: String, shasumArray: [String: String]) -> Bool { - // Verify that the file - var proceed = false - let errorMessage = "no required hash or file hash mismatch for: \(filename). Skipping" - writeLog("checking hash for \(filename)", logLevel: .debug) - let url = URL(fileURLWithPath: filename) - if let fileHash = sha256(for: url) { - writeLog("file hash : \(fileHash)", logLevel: .debug) - if let storedHash = getValueForKey(filename, inArray: shasumArray) { - writeLog("required hash : \(storedHash)", logLevel: .debug) - if storedHash == fileHash { - proceed = true - } - } - } - if !proceed { - writeLog(errorMessage, logLevel: .error) - } - - return proceed -} - -func sha256(for url: URL) -> String? { - // computes a sha256sum for the specified file path and returns a string - do { - let fileData = try Data(contentsOf: url) - let sha256 = fileData.sha256() - return sha256.hexEncodedString() - } catch { - return nil - } -} - -func checksumAllFiles() { - // compute checksum (SHA256) for all files in the outset directory - // returns data in two formats to stdout: - // plaintext - // as plist format ready for import into an MDM or converting to a .mobileconfig - - let url = URL(fileURLWithPath: outsetDirectory) - writeLog("CHECKSUM", logLevel: .info) - var shasumPlist = FileHashes() - if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { - for case let fileURL as URL in enumerator { - do { - let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) - if fileAttributes.isRegularFile! && fileURL.pathExtension != "plist" && fileURL.lastPathComponent != "outset" { - if let shasum = sha256(for: fileURL) { - print("\(fileURL.relativePath) : \(shasum)") - shasumPlist.sha256sum[fileURL.relativePath] = shasum - } - } - } catch { print(error, fileURL) } - } - - writeLog("PLIST", logLevel: .info) - let encoder = PropertyListEncoder() - encoder.outputFormat = .xml - do { - let data = try encoder.encode(shasumPlist) - if let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] { - let formatted = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - if let string = String(data: formatted, encoding: .utf8) { - print(string) - } - } - } catch { - writeLog("plist encoding failed", logLevel: .error) - } - } -} - -extension Data { - // extension to the Data class that lets us compute sha256 - func sha256() -> Data { - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - self.withUnsafeBytes { - _ = CC_SHA256($0.baseAddress, CC_LONG(count), &hash) - } - return Data(hash) - } - - func hexEncodedString() -> String { - return map { String(format: "%02hhx", $0) }.joined() - } -} - -extension URL { - var isDirectory: Bool { - (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true - } -} - -// swiftlint:enable large_tuple line_length force_cast file_length cyclomatic_complexity function_body_length diff --git a/Outset/Functions/SystemUtils.swift b/Outset/Functions/SystemUtils.swift deleted file mode 100644 index 2e8cea5..0000000 --- a/Outset/Functions/SystemUtils.swift +++ /dev/null @@ -1,364 +0,0 @@ -// -// Functions.swift -// outset -// -// Created by Bart Reardon on 1/12/2022. -// -// swiftlint:disable line_length - -import Foundation -import SystemConfiguration -import OSLog -import IOKit -import CoreFoundation - -struct OutsetPreferences: Codable { - var waitForNetwork: Bool = false - var networkTimeout: Int = 180 - var ignoredUsers: [String] = [] - var overrideLoginOnce: [String: Date] = [String: Date]() - - enum CodingKeys: String, CodingKey { - case waitForNetwork = "wait_for_network" - case networkTimeout = "network_timeout" - case ignoredUsers = "ignored_users" - case overrideLoginOnce = "override_login_once" - } -} - -struct FileHashes: Codable { - var sha256sum: [String: String] = [String: String]() -} - -extension String { - func camelCaseToUnderscored() -> String { - let regex = try? NSRegularExpression(pattern: "([a-z])([A-Z])", options: []) - let range = NSRange(location: 0, length: utf16.count) - return regex?.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "$1_$2").lowercased() ?? self - } -} - -enum Action { - case enable - case disable -} - -func ensureRoot(_ reason: String) { - if !isRoot() { - writeLog("Must be root to \(reason)", logLevel: .error) - exit(1) - } -} - -func isRoot() -> Bool { - return NSUserName() == "root" -} - -func getValueForKey(_ key: String, inArray array: [String: String]) -> String? { - // short function that treats a [String: String] as a key value pair. - return array[key] -} - -func writeLog(_ message: String, logLevel: OSLogType = .info, log: OSLog = osLog) { - // write to the system logs - os_log("%{public}@", log: log, type: logLevel, message) - if logLevel == .error || logLevel == .info || (debugMode && logLevel == .debug) { - // print info, errors and debug to stdout - print("\(oslogTypeToString(logLevel).uppercased()): \(message)") - } - // also write to a log file for accessability of those that don't want to manage the system log - writeFileLog(message: message, logLevel: logLevel) -} - -func writeFileLog(message: String, logLevel: OSLogType) { - if logLevel == .debug && !debugMode { - return - } - let logFileURL = URL(fileURLWithPath: logFile) - if !checkFileExists(path: logFile) { - FileManager.default.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) - let attributes = [FileAttributeKey.posixPermissions: 0o666] - do { - try FileManager.default.setAttributes(attributes, ofItemAtPath: logFileURL.path) - } catch { - print("\(oslogTypeToString(.error).uppercased()): Unable to create log file at \(logFile)") - return - } - } - do { - let fileHandle = try FileHandle(forWritingTo: logFileURL) - defer { fileHandle.closeFile() } - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - - let date = dateFormatter.string(from: Date()) - let logEntry = "\(date) \(oslogTypeToString(logLevel).uppercased()): \(message)\n" - - fileHandle.seekToEndOfFile() - fileHandle.write(logEntry.data(using: .utf8)!) - } catch { - print("\(oslogTypeToString(.error).uppercased()): Unable to read log file at \(logFile)") - return - } -} - -func oslogTypeToString(_ type: OSLogType) -> String { - switch type { - case OSLogType.default: return "default" - case OSLogType.info: return "info" - case OSLogType.debug: return "debug" - case OSLogType.error: return "error" - case OSLogType.fault: return "fault" - default: return "unknown" - } -} - -func getConsoleUserInfo() -> (username: String, userID: String) { - // We need the console user, not the process owner so NSUserName() won't work for our needs when outset runs as root - var uid: uid_t = 0 - if let consoleUser = SCDynamicStoreCopyConsoleUser(nil, &uid, nil) as? String { - return (consoleUser, "\(uid)") - } else { - return ("", "") - } -} - -func writePreferences(prefs: OutsetPreferences) { - - if debugMode { - showPrefrencePath("Stor") - } - - let defaults = UserDefaults.standard - - // Take the OutsetPreferences object and write it to UserDefaults - let mirror = Mirror(reflecting: prefs) - for child in mirror.children { - // Use the name of each property as the key, and save its value to UserDefaults - if let propertyName = child.label { - let key = propertyName.camelCaseToUnderscored() - if isRoot() { - // write the preference to /Library/Preferences/ - CFPreferencesSetValue(key as CFString, - child.value as CFPropertyList, - Bundle.main.bundleIdentifier! as CFString, - kCFPreferencesAnyUser, - kCFPreferencesAnyHost) - } else { - // write the preference to ~/Library/Preferences/ - defaults.set(child.value, forKey: key) - } - } - } -} - -func loadPreferences() -> OutsetPreferences { - - if debugMode { - showPrefrencePath("Load") - } - - let defaults = UserDefaults.standard - var outsetPrefs = OutsetPreferences() - - if isRoot() { - // force preferences to be read from /Library/Preferences instead of root's preferences - outsetPrefs.networkTimeout = CFPreferencesCopyValue("network_timeout" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? Int ?? 180 - outsetPrefs.ignoredUsers = CFPreferencesCopyValue("ignored_users" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? [String] ?? [] - outsetPrefs.overrideLoginOnce = CFPreferencesCopyValue("override_login_once" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? [String: Date] ?? [:] - outsetPrefs.waitForNetwork = (CFPreferencesCopyValue("wait_for_network" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) != nil) - } else { - // load preferences for the current user, which includes /Library/Preferences - outsetPrefs.networkTimeout = defaults.integer(forKey: "network_timeout") - outsetPrefs.ignoredUsers = defaults.array(forKey: "ignored_users") as? [String] ?? [] - outsetPrefs.overrideLoginOnce = defaults.object(forKey: "override_login_once") as? [String: Date] ?? [:] - outsetPrefs.waitForNetwork = defaults.bool(forKey: "wait_for_network") - } - return outsetPrefs -} - -func loadRunOnce() -> [String: Date] { - - if debugMode { - showPrefrencePath("Load") - } - - let defaults = UserDefaults.standard - var runOnceKey = "run_once" - - if isRoot() { - runOnceKey += "-"+getConsoleUserInfo().username - return CFPreferencesCopyValue(runOnceKey as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? [String: Date] ?? [:] - } else { - return defaults.object(forKey: runOnceKey) as? [String: Date] ?? [:] - } -} - -func writeRunOnce(runOnceData: [String: Date]) { - - if debugMode { - showPrefrencePath("Stor") - } - - let defaults = UserDefaults.standard - var runOnceKey = "run_once" - - if isRoot() { - runOnceKey += "-"+getConsoleUserInfo().username - CFPreferencesSetValue(runOnceKey as CFString, - runOnceData as CFPropertyList, - Bundle.main.bundleIdentifier! as CFString, - kCFPreferencesAnyUser, - kCFPreferencesAnyHost) - } else { - defaults.set(runOnceData, forKey: runOnceKey) - } -} - -func showPrefrencePath(_ action: String) { - var prefsPath: String - if isRoot() { - prefsPath = "/Library/Preferences".appending("/\(Bundle.main.bundleIdentifier!).plist") - } else { - let path = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true) - prefsPath = path[0].appending("/Preferences").appending("/\(Bundle.main.bundleIdentifier!).plist") - } - writeLog("\(action)ing preference file: \(prefsPath)", logLevel: .debug) -} - -func checksumLoadApprovedFiles() -> [String: String] { - // imports the list of file hashes that are approved to run - var outsetFileHashList = FileHashes() - - let defaults = UserDefaults.standard - let hashes = defaults.object(forKey: "sha256sum") - - if let data = hashes as? [String: String] { - for (key, value) in data { - outsetFileHashList.sha256sum[key] = value - } - } - - return outsetFileHashList.sha256sum -} - -func isNetworkUp() -> Bool { - // https://stackoverflow.com/a/39782859/17584669 - // perform a check to see if the network is available. - - var zeroAddress = sockaddr_in(sin_len: 0, sin_family: 0, sin_port: 0, sin_addr: in_addr(s_addr: 0), sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)) - zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) - zeroAddress.sin_family = sa_family_t(AF_INET) - - let defaultRouteReachability = withUnsafePointer(to: &zeroAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {zeroSockAddress in - SCNetworkReachabilityCreateWithAddress(nil, zeroSockAddress) - } - } - - var flags: SCNetworkReachabilityFlags = SCNetworkReachabilityFlags(rawValue: 0) - if SCNetworkReachabilityGetFlags(defaultRouteReachability!, &flags) == false { - return false - } - - let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0 - let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0 - let ret = (isReachable && !needsConnection) - - return ret -} - -func waitForNetworkUp(timeout: Double) -> Bool { - // used during --boot if "wait_for_network" prefrence is true - var networkUp = false - let deadline = DispatchTime.now() + timeout - while !networkUp && DispatchTime.now() < deadline { - writeLog("Waiting for network: \(timeout) seconds", logLevel: .debug) - networkUp = isNetworkUp() - if !networkUp { - writeLog("Waiting...", logLevel: .debug) - Thread.sleep(forTimeInterval: 1) - } - } - if !networkUp && DispatchTime.now() > deadline { - writeLog("No network connectivity detected after \(timeout) seconds", logLevel: .error) - } - return networkUp -} - -func loginWindowUpdateState(_ action: Action) { - var cmd: String - let loginWindowPlist: String = "/System/Library/LaunchDaemons/com.apple.loginwindow.plist" - switch action { - case .enable: - writeLog("Enabling loginwindow process", logLevel: .debug) - cmd = "/bin/launchctl load \(loginWindowPlist)" - case .disable: - writeLog("Disabling loginwindow process", logLevel: .debug) - cmd = "/bin/launchctl unload \(loginWindowPlist)" - } - _ = runShellCommand(cmd) -} - -func getDeviceHardwareModel() -> String { - // Returns the current devices hardware model from sysctl - var size = 0 - sysctlbyname("hw.model", nil, &size, nil, 0) - var model = [CChar](repeating: 0, count: size) - sysctlbyname("hw.model", &model, &size, nil, 0) - return String(cString: model) -} - -func getMarketingModel() -> String { - let appleSiliconProduct = IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/AppleARMPE/product") - let cfKeyValue = IORegistryEntryCreateCFProperty(appleSiliconProduct, "product-description" as CFString, kCFAllocatorDefault, 0) - IOObjectRelease(appleSiliconProduct) - let keyValue: AnyObject? = cfKeyValue?.takeUnretainedValue() - if keyValue != nil, let data = keyValue as? Data { - return String(data: data, encoding: String.Encoding.utf8)?.trimmingCharacters(in: CharacterSet(["\0"])) ?? "" - } - return "" -} - -func getDeviceSerialNumber() -> String { - // Returns the current devices serial number - let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice") ) - guard platformExpert > 0 else { - return "Serial Unknown" - } - guard let serialNumber = (IORegistryEntryCreateCFProperty(platformExpert, kIOPlatformSerialNumberKey as CFString, kCFAllocatorDefault, 0).takeUnretainedValue() as? String)?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) else { - return "Serial Unknown" - } - IOObjectRelease(platformExpert) - return serialNumber -} - -func getOSBuildVersion() -> String { - // Returns the current OS build from sysctl - var size = 0 - sysctlbyname("kern.osversion", nil, &size, nil, 0) - var osversion = [CChar](repeating: 0, count: size) - sysctlbyname("kern.osversion", &osversion, &size, nil, 0) - return String(cString: osversion) - -} - -func getOSVersion() -> String { - // Returns the OS version - let osVersion = ProcessInfo().operatingSystemVersion - let version = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" - return version -} - -func writeSysReport() { - // Logs system information to log file - writeLog("User: \(getConsoleUserInfo())", logLevel: .debug) - writeLog("Model: \(getDeviceHardwareModel())", logLevel: .debug) - writeLog("Marketing Model: \(getMarketingModel())", logLevel: .debug) - writeLog("Serial: \(getDeviceSerialNumber())", logLevel: .debug) - writeLog("OS: \(getOSVersion())", logLevel: .debug) - writeLog("Build: \(getOSBuildVersion())", logLevel: .debug) -} - -// swiftlint:enable line_length diff --git a/Outset/Info.plist b/Outset/Info.plist index 501ac25..87851f5 100644 --- a/Outset/Info.plist +++ b/Outset/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0 + 4.1.0 CFBundleVersion - 4.0 + 4.1.0 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/Outset/Outset.swift b/Outset/Outset.swift index 26f0a28..3f2138b 100644 --- a/Outset/Outset.swift +++ b/Outset/Outset.swift @@ -6,7 +6,6 @@ // // swift implementation of outset by Joseph Chilcote https://github.com/chilcote/outset // -// swiftlint:disable line_length function_body_length cyclomatic_complexity import Foundation import ArgumentParser @@ -26,8 +25,6 @@ let loginEveryPrivilegedDir = outsetDirectory+"login-privileged-every" let loginOncePrivilegedDir = outsetDirectory+"login-privileged-once" let onDemandDir = outsetDirectory+"on-demand" let shareDirectory = outsetDirectory+"share/" -let logDirectory = outsetDirectory+"logs" -let logFile = logDirectory+"/outset.log" let onDemandTrigger = "/private/tmp/.io.macadmins.outset.ondemand.launchd" let loginPrivilegedTrigger = "/private/tmp/.io.macadmins.outset.login-privileged.launchd" @@ -42,11 +39,16 @@ var debugMode: Bool = false var loginwindowState: Bool = true var consoleUser: String = getConsoleUserInfo().username var continueFirstBoot: Bool = true -var prefs = loadPreferences() +var prefs = loadOutsetPreferences() // Log Stuff let bundleID = Bundle.main.bundleIdentifier ?? "io.macadmins.Outset" let osLog = OSLog(subsystem: bundleID, category: "main") +// We could make these availab as preferences perhaps +let logFileName = "outset.log" +let logFileMaxCount: Int = 30 +let logDirectory = outsetDirectory+"logs" +let logFilePath = logDirectory+"/"+logFileName // Logic insertion point @main @@ -133,6 +135,13 @@ struct Outset: ParsableCommand { debugMode = true } + if version { + printStdOut("\(outsetVersion)") + if debugMode { + writeSysReport() + } + } + if enableServices, #available(macOS 13.0, *) { let manager = ServiceManager() manager.registerDaemons() @@ -149,10 +158,13 @@ struct Outset: ParsableCommand { } if boot { + // perform log file rotation + performLogRotation(logFolderPath: logDirectory, logFileBaseName: logFileName, maxLogFiles: logFileMaxCount) + writeLog("Processing scheduled runs for boot", logLevel: .debug) ensureWorkingFolders() - writePreferences(prefs: prefs) + writeOutsetPreferences(prefs: prefs) if !folderContents(path: bootOnceDir).isEmpty { if prefs.waitForNetwork { @@ -161,7 +173,6 @@ struct Outset: ParsableCommand { continueFirstBoot = waitForNetworkUp(timeout: floor(Double(prefs.networkTimeout) / 10)) } if continueFirstBoot { - writeSysReport() processItems(bootOnceDir, deleteItems: true) } else { writeLog("Unable to connect to network. Skipping boot-once scripts...", logLevel: .error) @@ -263,12 +274,9 @@ struct Outset: ParsableCommand { if cleanup { writeLog("Cleaning up on-demand directory.", logLevel: .debug) - if checkFileExists(path: onDemandTrigger) { - pathCleanup(pathname: onDemandTrigger) - } - if !folderContents(path: onDemandDir).isEmpty { - pathCleanup(pathname: onDemandDir) - } + if checkFileExists(path: onDemandTrigger) { pathCleanup(pathname: onDemandTrigger) } + if checkFileExists(path: cleanupTrigger) { pathCleanup(pathname: cleanupTrigger) } + if !folderContents(path: onDemandDir).isEmpty { pathCleanup(pathname: onDemandDir) } } if !addIgnoredUser.isEmpty { @@ -281,7 +289,7 @@ struct Outset: ParsableCommand { prefs.ignoredUsers.append(username) } } - writePreferences(prefs: prefs) + writeOutsetPreferences(prefs: prefs) } if !removeIgnoredUser.isEmpty { @@ -291,7 +299,7 @@ struct Outset: ParsableCommand { prefs.ignoredUsers.remove(at: index) } } - writePreferences(prefs: prefs) + writeOutsetPreferences(prefs: prefs) } if !addOveride.isEmpty { @@ -304,7 +312,7 @@ struct Outset: ParsableCommand { writeLog("Adding \(overide) to overide list", logLevel: .debug) prefs.overrideLoginOnce[overide] = Date() } - writePreferences(prefs: prefs) + writeOutsetPreferences(prefs: prefs) } if !removeOveride.isEmpty { @@ -316,7 +324,7 @@ struct Outset: ParsableCommand { writeLog("Removing \(overide) from overide list", logLevel: .debug) prefs.overrideLoginOnce.removeValue(forKey: overide) } - writePreferences(prefs: prefs) + writeOutsetPreferences(prefs: prefs) } if !checksum.isEmpty || !computeSHA.isEmpty { @@ -329,7 +337,7 @@ struct Outset: ParsableCommand { for fileToHash in checksum { let url = URL(fileURLWithPath: fileToHash) if let hash = sha256(for: url) { - print("Checksum for file \(fileToHash): \(hash)") + printStdOut("Checksum for file \(fileToHash): \(hash)") } } } @@ -341,14 +349,5 @@ struct Outset: ParsableCommand { writeLog("\(filename) : \(checksum)", logLevel: .info) } } - - if version { - print(outsetVersion) - if debugMode { - writeSysReport() - } - } } } - -// swiftlint:enable line_length function_body_length cyclomatic_complexity diff --git a/Outset/Utils/Checksum.swift b/Outset/Utils/Checksum.swift new file mode 100644 index 0000000..722ddc2 --- /dev/null +++ b/Outset/Utils/Checksum.swift @@ -0,0 +1,103 @@ +// +// Checksum.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation + +struct FileHashes: Codable { + var sha256sum: [String: String] = [String: String]() +} + +func checksumLoadApprovedFiles() -> [String: String] { + // imports the list of file hashes that are approved to run + var outsetFileHashList = FileHashes() + + let defaults = UserDefaults.standard + let hashes = defaults.object(forKey: "sha256sum") + + if let data = hashes as? [String: String] { + for (key, value) in data { + outsetFileHashList.sha256sum[key] = value + } + } + + return outsetFileHashList.sha256sum +} + +func verifySHASUMForFile(filename: String, shasumArray: [String: String]) -> Bool { + // Verify that the file + var proceed = false + let errorMessage = "no required hash or file hash mismatch for: \(filename). Skipping" + writeLog("checking hash for \(filename)", logLevel: .debug) + let url = URL(fileURLWithPath: filename) + if let fileHash = sha256(for: url) { + writeLog("file hash : \(fileHash)", logLevel: .debug) + if let storedHash = getValueForKey(filename, inArray: shasumArray) { + writeLog("required hash : \(storedHash)", logLevel: .debug) + if storedHash == fileHash { + proceed = true + } + } + } + if !proceed { + writeLog(errorMessage, logLevel: .error) + } + + return proceed +} + +func sha256(for url: URL) -> String? { + // computes a sha256sum for the specified file path and returns a string + do { + let fileData = try Data(contentsOf: url) + let sha256 = fileData.sha256() + return sha256.hexEncodedString() + } catch { + return nil + } +} + +func checksumAllFiles() { + // compute checksum (SHA256) for all files in the outset directory + // returns data in two formats to stdout: + // plaintext + // as plist format ready for import into an MDM or converting to a .mobileconfig + + let url = URL(fileURLWithPath: outsetDirectory) + writeLog("CHECKSUM", logLevel: .info) + var shasumPlist = FileHashes() + if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if fileAttributes.isRegularFile! && fileURL.pathExtension != "plist" && fileURL.lastPathComponent != "outset" { + if let shasum = sha256(for: fileURL) { + printStdOut("\(fileURL.relativePath) : \(shasum)") + shasumPlist.sha256sum[fileURL.relativePath] = shasum + } + } + } catch { + printStdErr(error.localizedDescription) + printStdErr(fileURL.absoluteString) + } + } + + writeLog("PLIST", logLevel: .info) + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + do { + let data = try encoder.encode(shasumPlist) + if let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] { + let formatted = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + if let string = String(data: formatted, encoding: .utf8) { + printStdOut(string) + } + } + } catch { + writeLog("plist encoding failed", logLevel: .error) + } + } +} diff --git a/Outset/Utils/FileUtils.swift b/Outset/Utils/FileUtils.swift new file mode 100644 index 0000000..275c391 --- /dev/null +++ b/Outset/Utils/FileUtils.swift @@ -0,0 +1,226 @@ +// +// Utils.swift +// outset +// +// Created by Bart Reardon on 3/12/2022. +// + +import Foundation + +func installPackage(pkg: String) -> Bool { + // Installs pkg onto boot drive + if isRoot() { + var pkgToInstall: String = "" + var dmgMount: String = "" + + if pkg.lowercased().hasSuffix("dmg") { + dmgMount = mountDmg(dmg: pkg) + for files in folderContents(path: dmgMount) where ["pkg", "mpkg"].contains(files.lowercased().suffix(3)) { + pkgToInstall = dmgMount + } + } else if ["pkg", "mpkg"].contains(pkg.lowercased().suffix(3)) { + pkgToInstall = pkg + } + writeLog("Installing \(pkgToInstall)") + let cmd = "/usr/sbin/installer -pkg \(pkgToInstall) -target /" + let (output, error, status) = runShellCommand(cmd, verbose: true) + if status != 0 { + writeLog(error, logLevel: .error) + } else { + writeLog(output) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { + if !dmgMount.isEmpty { + writeLog(detachDmg(dmgMount: dmgMount)) + } + } + return true + } else { + writeLog("Unable to process \(pkg)", logLevel: .error) + writeLog("Must be root to install packages", logLevel: .error) + } + return false +} + +func ensureWorkingFolders() { + // Ensures working folders are all present and creates them if necessary + let workingDirectories = [ + bootEveryDir, + bootOnceDir, + loginWindowDir, + loginEveryDir, + loginOnceDir, + loginEveryPrivilegedDir, + loginOncePrivilegedDir, + onDemandDir, + logDirectory + ] + + for directory in workingDirectories where !checkDirectoryExists(path: directory) { + writeLog("\(directory) does not exist, creating now.", logLevel: .debug) + do { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + } catch { + writeLog("could not create path at \(directory)", logLevel: .error) + } + } +} + +func checkFileExists(path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) +} + +func checkDirectoryExists(path: String) -> Bool { + var isDirectory: ObjCBool = false + _ = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return isDirectory.boolValue +} + +func folderContents(path: String) -> [String] { + // Returns a array of strings containing the folder contents + // Does not perform a recursive list + var filelist: [String] = [] + do { + let files = try FileManager.default.contentsOfDirectory(atPath: path) + let sortedFiles = files.sorted() + for file in sortedFiles { + filelist.append("\(path)/\(file)") + } + } catch { + return [] + } + return filelist +} + +func verifyPermissions(pathname: String) -> Bool { + // Files should be owned by root + // Files that are not scripts should have permissions 644 (-rw-r--r--) + // Files that are scripts should have permissions 755 (-rwxr-xr-x) + // If the permission for the request file is not correct then return fals to indicate it should not be processed + + let (ownerID, mode) = getFileProperties(pathname: pathname) + let posixPermissions = String(mode.intValue, radix: 8, uppercase: false) + let errorMessage = "Permissions for \(pathname) are incorrect. Should be owned by root and with mode" + + writeLog("ownerID for \(pathname) : \(String(describing: ownerID))", logLevel: .debug) + writeLog("posixPermissions for \(pathname) : \(String(describing: posixPermissions))", logLevel: .debug) + + if ["pkg", "mpkg", "dmg", "mobileconfig"].contains(pathname.lowercased().split(separator: ".").last) { + if ownerID == 0 && mode == requiredFilePermissions { + return true + } else { + writeLog("\(errorMessage) x644", logLevel: .error) + } + } else { + if ownerID == 0 && mode == requiredExecutablePermissions { + return true + } else { + writeLog("\(errorMessage) x755", logLevel: .error) + } + } + return false +} + +func getFileProperties(pathname: String) -> (ownerID: Int, permissions: NSNumber) { + // returns the ID and permissions of the specified file + var fileAttributes: [FileAttributeKey: Any] + var ownerID: Int = 0 + var mode: NSNumber = 0 + do { + fileAttributes = try FileManager.default.attributesOfItem(atPath: pathname) + if let ownerProperty = fileAttributes[.ownerAccountID] as? Int { + ownerID = ownerProperty + } + if let modeProperty = fileAttributes[.posixPermissions] as? NSNumber { + mode = modeProperty + } + } catch { + writeLog("Could not read file at path \(pathname)", logLevel: .error) + } + return (ownerID, mode) +} + +func pathCleanup(pathname: String) { + // check if folder and clean all files in that folder + // Deletes given script or cleans folder + writeLog("Cleaning up \(pathname)", logLevel: .debug) + if checkDirectoryExists(path: pathname) { + for fileItem in folderContents(path: pathname) { + writeLog("Cleaning up \(fileItem)", logLevel: .debug) + deletePath(fileItem) + } + } else if checkFileExists(path: pathname) { + writeLog("\(pathname) exists", logLevel: .debug) + deletePath(pathname) + } else { + writeLog("\(pathname) doesn't seem to exist", logLevel: .error) + } +} + +func deletePath(_ path: String) { + // Deletes the specified file + writeLog("Deleting \(path)", logLevel: .debug) + do { + try FileManager.default.removeItem(atPath: path) + writeLog("\(path) deleted", logLevel: .debug) + } catch { + writeLog("\(path) could not be removed", logLevel: .error) + } +} + +func mountDmg(dmg: String) -> String { + // Attaches dmg and returns the path + let cmd = "/usr/bin/hdiutil attach -nobrowse -noverify -noautoopen \(dmg)" + writeLog("Attaching \(dmg)", logLevel: .debug) + let (output, error, status) = runShellCommand(cmd) + if status != 0 { + writeLog("Failed attaching \(dmg) with error \(error)", logLevel: .error) + return error + } + return output.trimmingCharacters(in: .whitespacesAndNewlines) +} + +func detachDmg(dmgMount: String) -> String { + // Detaches dmg + writeLog("Detaching \(dmgMount)", logLevel: .debug) + let cmd = "/usr/bin/hdiutil detach -force \(dmgMount)" + let (output, error, status) = runShellCommand(cmd) + if status != 0 { + writeLog("Failed detaching \(dmgMount) with error \(error)", logLevel: .error) + return error + } + return output.trimmingCharacters(in: .whitespacesAndNewlines) +} + +func performLogRotation(logFolderPath: String, logFileBaseName: String, maxLogFiles: Int = 30) { + let fileManager = FileManager.default + let currentDay = Calendar.current.component(.day, from: Date()) + + // Check if the day has changed + let newestLogFile = logFolderPath + "/" + logFileBaseName + if fileManager.fileExists(atPath: newestLogFile) { + let fileCreationDate = try? fileManager.attributesOfItem(atPath: newestLogFile)[.creationDate] as? Date + if let creationDate = fileCreationDate { + let dayOfCreation = Calendar.current.component(.day, from: creationDate) + if dayOfCreation != currentDay { + // rotate files + for archivedLogFile in (1...maxLogFiles).reversed() { + let sourcePath = logFolderPath + "/" + (archivedLogFile == 1 ? logFileBaseName : "\(logFileBaseName).\(archivedLogFile-1)") + let destinationPath = logFolderPath + "/" + "\(logFileBaseName).\(archivedLogFile)" + + if fileManager.fileExists(atPath: sourcePath) { + if archivedLogFile == maxLogFiles { + // Delete the oldest log file if it exists + try? fileManager.removeItem(atPath: sourcePath) + } else { + // Move the log file to the next number in the rotation + try? fileManager.moveItem(atPath: sourcePath, toPath: destinationPath) + } + } + } + writeLog("Logrotate complete", logLevel: .debug) + } + } + } +} diff --git a/Outset/Functions/Processing.swift b/Outset/Utils/ItemProcessing.swift similarity index 96% rename from Outset/Functions/Processing.swift rename to Outset/Utils/ItemProcessing.swift index d329ceb..1d2979e 100644 --- a/Outset/Functions/Processing.swift +++ b/Outset/Utils/ItemProcessing.swift @@ -4,7 +4,6 @@ // // Created by Bart Reardon on 3/12/2022. // -// swiftlint:disable function_body_length cyclomatic_complexity import Foundation @@ -47,7 +46,7 @@ func processItems(_ path: String, deleteItems: Bool=false, once: Bool=false, ove } // load runonce data - runOnceDict = loadRunOnce() + runOnceDict = loadRunOncePlist() // loop through the packages list and process installs. for package in packages { @@ -132,9 +131,7 @@ func processItems(_ path: String, deleteItems: Bool=false, once: Bool=false, ove } if !runOnceDict.isEmpty { - writeRunOnce(runOnceData: runOnceDict) + writeRunOncePlist(runOnceData: runOnceDict) } } - -// swiftlint:enable function_body_length cyclomatic_complexity diff --git a/Outset/Utils/Logging.swift b/Outset/Utils/Logging.swift new file mode 100644 index 0000000..195fb31 --- /dev/null +++ b/Outset/Utils/Logging.swift @@ -0,0 +1,107 @@ +// +// Logging.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation +import OSLog + +// swiftlint:disable force_try +class StandardError: TextOutputStream { + func write(_ string: String) { + if #available(macOS 10.15.4, *) { + try! FileHandle.standardError.write(contentsOf: Data(string.utf8)) + } else { + // Fallback on earlier versions (should work on pre 10.15.4 but untested) + if let data = string.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } + } +} +// swiftlint:enable force_try + +func oslogTypeToString(_ type: OSLogType) -> String { + switch type { + case OSLogType.default: return "default" + case OSLogType.info: return "info" + case OSLogType.debug: return "debug" + case OSLogType.error: return "error" + case OSLogType.fault: return "fault" + default: return "unknown" + } +} + +func printStdErr(_ errorMessage: String) { + var standardError = StandardError() + print(errorMessage, to: &standardError) +} + +func printStdOut(_ message: String) { + print(message) +} + +func writeLog(_ message: String, logLevel: OSLogType = .info, log: OSLog = osLog) { + // write to the system logs + + // let logger = Logger() // 'Logger' is only available in macOS 11.0 or newer so we use os_log + + os_log("%{public}@", log: log, type: logLevel, message) + switch logLevel { + case .error, .debug, .fault: + printStdErr("\(oslogTypeToString(logLevel).uppercased()): \(message)") + default: + printStdOut("\(oslogTypeToString(logLevel).uppercased()): \(message)") + } + + // also write to a log file + writeFileLog(message: message, logLevel: logLevel) +} + +func writeFileLog(message: String, logLevel: OSLogType) { + // write to a log file for accessability of those that don't want to manage the system log + if logLevel == .debug && !debugMode { + return + } + let logFileURL = URL(fileURLWithPath: logFilePath) + if !checkFileExists(path: logFilePath) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + let attributes = [FileAttributeKey.posixPermissions: 0o666] + do { + try FileManager.default.setAttributes(attributes, ofItemAtPath: logFileURL.path) + } catch { + printStdErr("\(oslogTypeToString(.error).uppercased()): Unable to create log file at \(logFilePath)") + printStdErr(error.localizedDescription) + return + } + } + do { + let fileHandle = try FileHandle(forWritingTo: logFileURL) + defer { fileHandle.closeFile() } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + let date = dateFormatter.string(from: Date()) + let logEntry = "\(date) \(oslogTypeToString(logLevel).uppercased()): \(message)\n" + + fileHandle.seekToEndOfFile() + fileHandle.write(logEntry.data(using: .utf8)!) + } catch { + printStdErr("\(oslogTypeToString(.error).uppercased()): Unable to read log file at \(logFilePath)") + printStdErr(error.localizedDescription) + return + } +} + +func writeSysReport() { + // Logs system information to log file + writeLog("User: \(getConsoleUserInfo())", logLevel: .debug) + writeLog("Model: \(getDeviceHardwareModel())", logLevel: .debug) + writeLog("Marketing Model: \(getMarketingModel())", logLevel: .debug) + writeLog("Serial: \(getDeviceSerialNumber())", logLevel: .debug) + writeLog("OS: \(getOSVersion())", logLevel: .debug) + writeLog("Build: \(getOSBuildVersion())", logLevel: .debug) +} diff --git a/Outset/Utils/Network.swift b/Outset/Utils/Network.swift new file mode 100644 index 0000000..d134834 --- /dev/null +++ b/Outset/Utils/Network.swift @@ -0,0 +1,53 @@ +// +// Network.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation +import SystemConfiguration + +func isNetworkUp() -> Bool { + // https://stackoverflow.com/a/39782859/17584669 + // perform a check to see if the network is available. + + var zeroAddress = sockaddr_in(sin_len: 0, sin_family: 0, sin_port: 0, sin_addr: in_addr(s_addr: 0), sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)) + zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) + zeroAddress.sin_family = sa_family_t(AF_INET) + + let defaultRouteReachability = withUnsafePointer(to: &zeroAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {zeroSockAddress in + SCNetworkReachabilityCreateWithAddress(nil, zeroSockAddress) + } + } + + var flags: SCNetworkReachabilityFlags = SCNetworkReachabilityFlags(rawValue: 0) + if SCNetworkReachabilityGetFlags(defaultRouteReachability!, &flags) == false { + return false + } + + let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0 + let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0 + let ret = (isReachable && !needsConnection) + + return ret +} + +func waitForNetworkUp(timeout: Double) -> Bool { + // used during --boot if "wait_for_network" prefrence is true + var networkUp = false + let deadline = DispatchTime.now() + timeout + while !networkUp && DispatchTime.now() < deadline { + writeLog("Waiting for network: \(timeout) seconds", logLevel: .debug) + networkUp = isNetworkUp() + if !networkUp { + writeLog("Waiting...", logLevel: .debug) + Thread.sleep(forTimeInterval: 1) + } + } + if !networkUp && DispatchTime.now() > deadline { + writeLog("No network connectivity detected after \(timeout) seconds", logLevel: .error) + } + return networkUp +} diff --git a/Outset/Utils/Preferences.swift b/Outset/Utils/Preferences.swift new file mode 100644 index 0000000..a01b20e --- /dev/null +++ b/Outset/Utils/Preferences.swift @@ -0,0 +1,206 @@ +// +// Preferences.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation + +struct OutsetPreferences: Codable { + var waitForNetwork: Bool = false + var networkTimeout: Int = 180 + var ignoredUsers: [String] = [] + var overrideLoginOnce: [String: Date] = [String: Date]() + + enum CodingKeys: String, CodingKey { + case waitForNetwork = "wait_for_network" + case networkTimeout = "network_timeout" + case ignoredUsers = "ignored_users" + case overrideLoginOnce = "override_login_once" + } +} + +func writeOutsetPreferences(prefs: OutsetPreferences) { + + if debugMode { + showPrefrencePath("Stor") + } + + let defaults = UserDefaults.standard + + // Take the OutsetPreferences object and write it to UserDefaults + let mirror = Mirror(reflecting: prefs) + for child in mirror.children { + // Use the name of each property as the key, and save its value to UserDefaults + if let propertyName = child.label { + let key = propertyName.camelCaseToUnderscored() + if isRoot() { + // write the preference to /Library/Preferences/ + CFPreferencesSetValue(key as CFString, + child.value as CFPropertyList, + Bundle.main.bundleIdentifier! as CFString, + kCFPreferencesAnyUser, + kCFPreferencesAnyHost) + } else { + // write the preference to ~/Library/Preferences/ + defaults.set(child.value, forKey: key) + } + } + } +} + +func loadOutsetPreferences() -> OutsetPreferences { + + if debugMode { + showPrefrencePath("Load") + } + + let defaults = UserDefaults.standard + var outsetPrefs = OutsetPreferences() + + if isRoot() { + // force preferences to be read from /Library/Preferences instead of root's preferences + outsetPrefs.networkTimeout = CFPreferencesCopyValue("network_timeout" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? Int ?? 180 + outsetPrefs.ignoredUsers = CFPreferencesCopyValue("ignored_users" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? [String] ?? [] + outsetPrefs.overrideLoginOnce = CFPreferencesCopyValue("override_login_once" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? [String: Date] ?? [:] + outsetPrefs.waitForNetwork = (CFPreferencesCopyValue("wait_for_network" as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) != nil) + } else { + // load preferences for the current user, which includes /Library/Preferences + outsetPrefs.networkTimeout = defaults.integer(forKey: "network_timeout") + outsetPrefs.ignoredUsers = defaults.array(forKey: "ignored_users") as? [String] ?? [] + outsetPrefs.overrideLoginOnce = defaults.object(forKey: "override_login_once") as? [String: Date] ?? [:] + outsetPrefs.waitForNetwork = defaults.bool(forKey: "wait_for_network") + } + return outsetPrefs +} + +func loadRunOncePlist() -> [String: Date] { + + if debugMode { + showPrefrencePath("Load") + } + + let defaults = UserDefaults.standard + var runOnceKey = "run_once" + + if isRoot() { + runOnceKey += "-"+getConsoleUserInfo().username + return CFPreferencesCopyValue(runOnceKey as CFString, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) as? [String: Date] ?? [:] + } else { + return defaults.object(forKey: runOnceKey) as? [String: Date] ?? [:] + } +} + +func writeRunOncePlist(runOnceData: [String: Date]) { + + if debugMode { + showPrefrencePath("Stor") + } + + let defaults = UserDefaults.standard + var runOnceKey = "run_once" + + if isRoot() { + runOnceKey += "-"+getConsoleUserInfo().username + CFPreferencesSetValue(runOnceKey as CFString, + runOnceData as CFPropertyList, + Bundle.main.bundleIdentifier! as CFString, + kCFPreferencesAnyUser, + kCFPreferencesAnyHost) + } else { + defaults.set(runOnceData, forKey: runOnceKey) + } +} + +func migrateLegacyPreferences() { + let newoldRootUserDefaults = "/var/root/Library/Preferences/io.macadmins.Outset.plist" + // shared folder should not contain any executable content, iterate and update as required + if checkFileExists(path: shareDirectory) || checkFileExists(path: newoldRootUserDefaults) { + writeLog("Legacy preferences exist. Migrating to user defaults", logLevel: .debug) + + let legacyOutsetPreferencesFile = "\(shareDirectory)com.chilcote.outset.plist" + let legacyRootRunOncePlistFile = "com.github.outset.once.\(getConsoleUserInfo().userID).plist" + let userHomeDirectory = FileManager.default.homeDirectoryForCurrentUser + let userHomePath = userHomeDirectory.relativeString.replacingOccurrences(of: "file://", with: "") + let legacyUserRunOncePlistFile = userHomePath+"Library/Preferences/com.github.outset.once.plist" + + var shareFiles: [String] = [] + shareFiles.append(legacyOutsetPreferencesFile) + shareFiles.append(legacyRootRunOncePlistFile) + shareFiles.append(legacyUserRunOncePlistFile) + shareFiles.append(newoldRootUserDefaults) + + for filename in shareFiles where checkFileExists(path: filename) { + + let url = URL(fileURLWithPath: filename) + do { + let data = try Data(contentsOf: url) + switch filename { + + case newoldRootUserDefaults: + if isRoot() { + writeLog("\(newoldRootUserDefaults) migration", logLevel: .debug) + let legacyDefaultKeys = CFPreferencesCopyKeyList(Bundle.main.bundleIdentifier! as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost) + for key in legacyDefaultKeys as! [CFString] { + let keyValue = CFPreferencesCopyValue(key, Bundle.main.bundleIdentifier! as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost) + CFPreferencesSetValue(key as CFString, + keyValue as CFPropertyList, + Bundle.main.bundleIdentifier! as CFString, + kCFPreferencesAnyUser, + kCFPreferencesAnyHost) + } + deletePath(newoldRootUserDefaults) + } + case legacyOutsetPreferencesFile: + writeLog("\(legacyOutsetPreferencesFile) migration", logLevel: .debug) + do { + let legacyPreferences = try PropertyListDecoder().decode(OutsetPreferences.self, from: data) + writeOutsetPreferences(prefs: legacyPreferences) + writeLog("Migrated Legacy Outset Preferences", logLevel: .debug) + deletePath(legacyOutsetPreferencesFile) + } catch { + writeLog("legacy Preferences migration failed", logLevel: .error) + } + + case legacyRootRunOncePlistFile, legacyUserRunOncePlistFile: + writeLog("\(legacyRootRunOncePlistFile) and \(legacyUserRunOncePlistFile) migration", logLevel: .debug) + do { + let legacyRunOncePlistData = try PropertyListDecoder().decode([String: Date].self, from: data) + writeRunOncePlist(runOnceData: legacyRunOncePlistData) + writeLog("Migrated Legacy Runonce Data", logLevel: .debug) + if isRoot() { + deletePath(legacyRootRunOncePlistFile) + } else { + deletePath(legacyUserRunOncePlistFile) + } + } catch { + writeLog("legacy Run Once Plist migration failed", logLevel: .error) + } + + default: + continue + } + } catch { + writeLog("could not load \(filename)", logLevel: .error) + } + + } + + if checkFileExists(path: shareDirectory) && folderContents(path: shareDirectory).isEmpty { + deletePath(shareDirectory) + } + } + +} + +func showPrefrencePath(_ action: String) { + var prefsPath: String + if isRoot() { + prefsPath = "/Library/Preferences".appending("/\(Bundle.main.bundleIdentifier!).plist") + } else { + let path = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true) + prefsPath = path[0].appending("/Preferences").appending("/\(Bundle.main.bundleIdentifier!).plist") + } + writeLog("\(action)ing preference file: \(prefsPath)", logLevel: .debug) +} diff --git a/Outset/Functions/Services.swift b/Outset/Utils/Services.swift similarity index 98% rename from Outset/Functions/Services.swift rename to Outset/Utils/Services.swift index e8278cb..2d75b14 100644 --- a/Outset/Functions/Services.swift +++ b/Outset/Utils/Services.swift @@ -4,7 +4,6 @@ // // Created by Bart Reardon on 21/3/2023. // -// swiftlint:disable line_length import Foundation import ServiceManagement @@ -126,5 +125,3 @@ class ServiceManager { status(loginWindowAgent) } } - -// swiftlint:enable line_length diff --git a/Outset/Utils/ShellUtils.swift b/Outset/Utils/ShellUtils.swift new file mode 100644 index 0000000..ab811e0 --- /dev/null +++ b/Outset/Utils/ShellUtils.swift @@ -0,0 +1,50 @@ +// +// ShellUtils.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation + +func runShellCommand(_ command: String, args: [String] = [], verbose: Bool = false) -> (output: String, error: String, exitCode: Int32) { + // runs a shell command passed as an argument + // If the verbose parameter is set to true, will log the command being run and its status when completed. + // returns the output, error and exit code as a tuple. + + if verbose { + writeLog("Running task \(command)", logLevel: .debug) + } + let task = Process() + let pipe = Pipe() + let errorpipe = Pipe() + + var cmd = command + for arg in args { + cmd += " '\(arg)'" + } + let arguments = ["-c", cmd] + + var output: String = "" + var error: String = "" + + task.launchPath = "/bin/sh" + task.arguments = arguments + task.standardOutput = pipe + task.standardError = errorpipe + task.launch() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let errordata = errorpipe.fileHandleForReading.readDataToEndOfFile() + + output.append(String(data: data, encoding: .utf8)!) + error.append(String(data: errordata, encoding: .utf8)!) + + task.waitUntilExit() + let status = task.terminationStatus + if verbose { + writeLog("Completed task \(command) with status \(status)", logLevel: .debug) + writeLog("Task output: \n\(output)", logLevel: .debug) + } + return (output, error, status) +} diff --git a/Outset/Utils/SystemInfo.swift b/Outset/Utils/SystemInfo.swift new file mode 100644 index 0000000..a6a214e --- /dev/null +++ b/Outset/Utils/SystemInfo.swift @@ -0,0 +1,58 @@ +// +// SystemInfo.swift +// Outset +// +// Created by Bart E Reardon on 5/9/2023. +// + +import Foundation + +func getOSVersion() -> String { + // Returns the OS version + let osVersion = ProcessInfo().operatingSystemVersion + let version = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + return version +} + +func getOSBuildVersion() -> String { + // Returns the current OS build from sysctl + var size = 0 + sysctlbyname("kern.osversion", nil, &size, nil, 0) + var osversion = [CChar](repeating: 0, count: size) + sysctlbyname("kern.osversion", &osversion, &size, nil, 0) + return String(cString: osversion) + +} + +func getDeviceSerialNumber() -> String { + // Returns the current devices serial number + let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice") ) + guard platformExpert > 0 else { + return "Serial Unknown" + } + guard let serialNumber = (IORegistryEntryCreateCFProperty(platformExpert, kIOPlatformSerialNumberKey as CFString, kCFAllocatorDefault, 0).takeUnretainedValue() as? String)?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) else { + return "Serial Unknown" + } + IOObjectRelease(platformExpert) + return serialNumber +} + +func getMarketingModel() -> String { + let appleSiliconProduct = IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/AppleARMPE/product") + let cfKeyValue = IORegistryEntryCreateCFProperty(appleSiliconProduct, "product-description" as CFString, kCFAllocatorDefault, 0) + IOObjectRelease(appleSiliconProduct) + let keyValue: AnyObject? = cfKeyValue?.takeUnretainedValue() + if keyValue != nil, let data = keyValue as? Data { + return String(data: data, encoding: String.Encoding.utf8)?.trimmingCharacters(in: CharacterSet(["\0"])) ?? "" + } + return "" +} + +func getDeviceHardwareModel() -> String { + // Returns the current devices hardware model from sysctl + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var model = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &model, &size, nil, 0) + return String(cString: model) +} diff --git a/Outset/Utils/SystemUtils.swift b/Outset/Utils/SystemUtils.swift new file mode 100644 index 0000000..27d9366 --- /dev/null +++ b/Outset/Utils/SystemUtils.swift @@ -0,0 +1,51 @@ +// +// Functions.swift +// outset +// +// Created by Bart Reardon on 1/12/2022. +// + +import Foundation +import SystemConfiguration +import IOKit +import CoreFoundation + +enum Action { + case enable + case disable +} + +func ensureRoot(_ reason: String) { + if !isRoot() { + writeLog("Must be root to \(reason)", logLevel: .error) + exit(1) + } +} + +func isRoot() -> Bool { + return NSUserName() == "root" +} + +func getConsoleUserInfo() -> (username: String, userID: String) { + // We need the console user, not the process owner so NSUserName() won't work for our needs when outset runs as root + var uid: uid_t = 0 + if let consoleUser = SCDynamicStoreCopyConsoleUser(nil, &uid, nil) as? String { + return (consoleUser, "\(uid)") + } else { + return ("", "") + } +} + +func loginWindowUpdateState(_ action: Action) { + var cmd: String + let loginWindowPlist: String = "/System/Library/LaunchDaemons/com.apple.loginwindow.plist" + switch action { + case .enable: + writeLog("Enabling loginwindow process", logLevel: .debug) + cmd = "/bin/launchctl load \(loginWindowPlist)" + case .disable: + writeLog("Disabling loginwindow process", logLevel: .debug) + cmd = "/bin/launchctl unload \(loginWindowPlist)" + } + _ = runShellCommand(cmd) +} diff --git a/README.md b/README.md index e2cf4a4..abeb581 100644 --- a/README.md +++ b/README.md @@ -5,54 +5,52 @@ Outset Outset is a utility application which automatically processes scripts and packages during the boot sequence, user logins, or on demand. -Requirements ------------- +[Check out the wiki](https://github.com/macadmins/outset/wiki) for more information on how to use Outset or find out [how it works](https://github.com/chilcote/outset/wiki/FAQ). + +## Requirements + macOS 10.15+ -Legacy python version can be found here: https://github.com/chilcote/outset/ +## Usage -Usage ------ + OPTIONS: + --boot Used by launchd for scheduled runs at boot + --login Used by launchd for scheduled runs at login + --login-window Used by launchd for scheduled runs at the login window + --login-privileged Used by launchd for scheduled privileged runs at login + --on-demand Process scripts on demand + --login-every Manually process scripts in login-every + --login-once Manually process scripts in login-once + --cleanup Used by launchd to clean up on-demand dir + --add-ignored-user + Add one or more users to ignored list + --remove-ignored-user + Remove one or more users from ignored list + --add-override