mirror of
https://github.com/munki/munki.git
synced 2026-05-24 23:28:39 -05:00
705 lines
26 KiB
Swift
705 lines
26 KiB
Swift
//
|
|
// fetch.swift
|
|
// munki
|
|
//
|
|
// Created by Greg Neagle on 8/13/24.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// XATTR name storing the ETAG of the file when downloaded via http(s).
|
|
// let XATTR_ETAG = "com.googlecode.munki.etag"
|
|
// XATTR name storing the sha256 of the file after original download by munki.
|
|
let XATTR_SHA = "com.googlecode.munki.sha256"
|
|
|
|
// default value for User-Agent header
|
|
let DEFAULT_USER_AGENT = "managedsoftwareupdate/\(getVersion()) Darwin/\(uname_release())"
|
|
|
|
enum FetchError: Error {
|
|
case connection(errorCode: Int, description: String)
|
|
case http(errorCode: Int, description: String)
|
|
case download(errorCode: Int, description: String)
|
|
case fileSystem(_ description: String)
|
|
case verification
|
|
}
|
|
|
|
extension FetchError: LocalizedError {
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .connection(errorCode, description):
|
|
return "Connection error \(errorCode): \(description)"
|
|
case let .http(errorCode, description):
|
|
return "HTTP error \(errorCode): \(description)"
|
|
case let .download(errorCode, description):
|
|
return "Download error \(errorCode): \(description)"
|
|
case let .fileSystem(description):
|
|
return "File system error: \(description)"
|
|
case .verification:
|
|
return "Checksum verification error"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stores a sha256 hash of the file in an extended attribute, generating the hash if needed.
|
|
func storeCachedChecksum(toPath path: String, hash: String? = nil) -> String? {
|
|
let fhash: String = if let hash {
|
|
hash
|
|
} else {
|
|
sha256hash(file: path)
|
|
}
|
|
if fhash.count == 64, let data = fhash.data(using: .utf8) {
|
|
do {
|
|
try setXattr(named: XATTR_SHA, data: data, atPath: path)
|
|
return fhash
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Verifies the integrity of the given software package.
|
|
///
|
|
/// The feature is controlled through the PackageVerificationMode key in
|
|
/// Munki's preferences. Following modes currently exist:
|
|
/// none: No integrity check is performed.
|
|
/// hash: Integrity check is performed by calculating a SHA-256 hash of
|
|
/// the given file and comparing it against the reference value in
|
|
/// catalog. Only applies for package plists that contain the
|
|
/// item_key; for packages without the item_key, verification always
|
|
/// returns true.
|
|
/// hash_strict: Same as hash, but returns false for package plists that
|
|
/// do not contain the item_key.
|
|
///
|
|
/// Args:
|
|
/// path: The file to check integrity on.
|
|
/// expectedHash: the sha256 hash expected.
|
|
/// alwaysHash: Boolean. Always check and return the hash even if not
|
|
/// necessary for this function.
|
|
///
|
|
/// Returns:
|
|
/// (true/false, sha256-hash)
|
|
/// true if the package integrity could be validated. Otherwise, false.
|
|
func verifySoftwarePackageIntegrity(_ path: String, expectedHash: String, alwaysHash: Bool = false) -> (Bool, String) {
|
|
let display = DisplayAndLog.main
|
|
let mode = pref("PackageVerificationMode") as? String ?? "hash"
|
|
let itemName = (path as NSString).lastPathComponent
|
|
var calculatedHash = ""
|
|
if alwaysHash {
|
|
calculatedHash = sha256hash(file: path)
|
|
}
|
|
switch mode.lowercased() {
|
|
case "none":
|
|
display.warning("Package integrity checking is disabled.")
|
|
return (true, calculatedHash)
|
|
case "hash", "hash_strict":
|
|
if !expectedHash.isEmpty {
|
|
display.minorStatus("Verifying package integrity...")
|
|
if calculatedHash.isEmpty {
|
|
calculatedHash = sha256hash(file: path)
|
|
}
|
|
if expectedHash == calculatedHash {
|
|
return (true, calculatedHash)
|
|
}
|
|
// expectedHash != calculatedHash
|
|
display.error("Hash value integrity check for \(itemName) failed.")
|
|
return (false, calculatedHash)
|
|
} else {
|
|
// no expected hash
|
|
if mode.lowercased() == "hash_strict" {
|
|
display.error("Expected hash value for \(itemName) is missing in catalog.")
|
|
return (false, calculatedHash)
|
|
}
|
|
// mode is "hash"
|
|
display.warning(
|
|
"Expected hash value missing for \(itemName) -- package integrity verification skipped.")
|
|
return (true, calculatedHash)
|
|
}
|
|
default:
|
|
display.error("The PackageVerificationMode in the ManagedInstalls preferences has an illegal value: \(mode)")
|
|
}
|
|
return (false, calculatedHash)
|
|
}
|
|
|
|
/// Given a list of strings in http header format, return a dict.
|
|
/// A User-Agent header is added if none is present in the list.
|
|
/// If strList is nil, returns a dict with only the User-Agent header.
|
|
func headerDictFromList(_ strList: [String]?) -> [String: String] {
|
|
var headerDict = [String: String]()
|
|
headerDict["User-Agent"] = DEFAULT_USER_AGENT
|
|
|
|
if let strList {
|
|
for item in strList {
|
|
if item.contains(":") {
|
|
let parts = item.components(separatedBy: ":")
|
|
if parts.count == 2 {
|
|
headerDict[parts[0]] = parts[1].trimmingCharacters(in: .whitespaces)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return headerDict
|
|
}
|
|
|
|
/// Singleton to avoid loading the middleware dylib for each request
|
|
class MiddlewarePluginLoader {
|
|
static let shared = MiddlewarePluginLoader()
|
|
|
|
var middleware: MunkiMiddleware?
|
|
|
|
private init() {
|
|
do {
|
|
if let middleware = try loadMiddlewarePlugin() {
|
|
self.middleware = middleware
|
|
}
|
|
} catch {
|
|
DisplayAndLog.main.error("Could not load middleware plugin: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Attempts to process our request via a middleware plugin, if one is found
|
|
func runMiddleware(_ request: MunkiMiddlewareRequest) -> MunkiMiddlewareRequest {
|
|
let display = DisplayAndLog.main
|
|
|
|
if let middleware = MiddlewarePluginLoader.shared.middleware {
|
|
display.debug2("Running middleware plugin")
|
|
display.debug2("Input: \(request)")
|
|
let modifiedRequest = middleware.processRequest(request)
|
|
display.debug2("Output: \(modifiedRequest)")
|
|
return modifiedRequest
|
|
}
|
|
|
|
// no plugin found, or error loading plugin -- just return unmodified request
|
|
return request
|
|
}
|
|
|
|
/// Gets an HTTP or HTTPS URL and stores it in
|
|
/// destination path. Returns a dictionary of headers, which includes
|
|
/// http_result_code and http_result_description.
|
|
/// Will throw FetchError.connection if Gurl has a connection error.
|
|
/// Will throw FetchError.http if HTTP Result code is not 2xx or 304.
|
|
/// Will throw FetchError.fileSystem if Gurl has a filesystem error.
|
|
/// If destinationpath already exists, you can set 'onlyifnewer' to true to
|
|
/// indicate you only want to download the file only if it's newer on the
|
|
/// server.
|
|
/// If you set resume to true, Gurl will attempt to resume an
|
|
/// interrupted download.
|
|
func getURL(
|
|
_ url: String,
|
|
destinationPath: String,
|
|
customHeaders: [String]? = nil,
|
|
message: String = "",
|
|
onlyIfNewer: Bool = false,
|
|
resume: Bool = false,
|
|
followRedirects: String = "none",
|
|
) throws -> [String: String] {
|
|
let tempDownloadPath = destinationPath + ".download"
|
|
if pathExists(tempDownloadPath), !resume {
|
|
try? FileManager.default.removeItem(atPath: tempDownloadPath)
|
|
}
|
|
|
|
let headers = headerDictFromList(customHeaders)
|
|
|
|
// Run middleware
|
|
var request = MunkiMiddlewareRequest(
|
|
url: url,
|
|
headers: headers
|
|
)
|
|
request = runMiddleware(request)
|
|
|
|
var cacheData: [String: String]?
|
|
if onlyIfNewer, pathExists(destinationPath) {
|
|
// create a temporary Gurl object so we can extract the
|
|
// stored caching data so we can download only if the
|
|
// file has changed on the server
|
|
let temp = Gurl(options: GurlOptions(url: url, destinationPath: destinationPath))
|
|
cacheData = temp.getStoredHeaders()
|
|
}
|
|
|
|
let ignoreSystemProxy = pref("IgnoreSystemProxies") as? Bool ?? false
|
|
|
|
let options = GurlOptions(
|
|
url: request.url,
|
|
destinationPath: tempDownloadPath,
|
|
additionalHeaders: request.headers,
|
|
followRedirects: followRedirects,
|
|
ignoreSystemProxy: ignoreSystemProxy,
|
|
canResume: resume,
|
|
downloadOnlyIfChanged: onlyIfNewer,
|
|
cacheData: cacheData,
|
|
log: DisplayAndLog.main.debug2
|
|
)
|
|
|
|
let display = DisplayAndLog.main
|
|
let session = Gurl(options: options)
|
|
var displayMessage = message
|
|
var storedPercentComplete = -1
|
|
var storedBytesReceived = 0
|
|
session.start()
|
|
while true {
|
|
// if we did `while !session.isDone()` we'd miss printing
|
|
// messages and displaying percentages if we exit the loop first
|
|
let done = session.isDone()
|
|
if !displayMessage.isEmpty, session.status != 0, session.status != 304 {
|
|
// log always, display if verbose is 1 or more
|
|
// also display in MunkiStatus detail field
|
|
display.minorStatus(displayMessage)
|
|
// now clear message so we don't display it again
|
|
displayMessage = ""
|
|
}
|
|
if String(session.status).hasPrefix("2"), session.percentComplete != -1 {
|
|
if session.percentComplete != storedPercentComplete {
|
|
// display percent done if it has changed
|
|
storedPercentComplete = session.percentComplete
|
|
displayPercentDone(current: storedPercentComplete, maximum: 100)
|
|
}
|
|
} else if session.bytesReceived != storedBytesReceived {
|
|
// if we don't have percent done info, log bytes received
|
|
storedBytesReceived = session.bytesReceived
|
|
display.detail("Bytes received: \(storedBytesReceived)")
|
|
}
|
|
if done {
|
|
break
|
|
}
|
|
}
|
|
|
|
if let error = session.error {
|
|
// gurl had an NSError
|
|
var errorCode = 0
|
|
var errorDescription = ""
|
|
if let urlError = error as? URLError {
|
|
errorCode = urlError.code.rawValue
|
|
errorDescription = urlError.localizedDescription
|
|
display.detail("Download error \(errorCode): \(errorDescription)")
|
|
} else {
|
|
errorDescription = error.localizedDescription
|
|
display.detail("Download error: \(errorDescription)")
|
|
}
|
|
if session.SSLerror != 0 {
|
|
errorCode = session.SSLerror
|
|
errorDescription = sslErrorForCode(errorCode)
|
|
display.detail("SSL error \(errorCode) detail: \(errorDescription)")
|
|
debugKeychainOutput()
|
|
}
|
|
display.detail("Headers: \(session.headers ?? [:])")
|
|
if pathExists(tempDownloadPath) {
|
|
try? FileManager.default.removeItem(atPath: tempDownloadPath)
|
|
}
|
|
throw FetchError.connection(errorCode: errorCode, description: errorDescription)
|
|
}
|
|
|
|
display.debug1("Status: \(session.status)")
|
|
display.debug1("Headers: \(session.headers ?? [:])")
|
|
// TODO: (maybe) track and display redirection info
|
|
|
|
var returnedHeaders = session.headers ?? [:]
|
|
returnedHeaders["http_result_code"] = String(session.status)
|
|
let statusDescription = HTTPURLResponse.localizedString(forStatusCode: session.status)
|
|
returnedHeaders["http_result_description"] = statusDescription
|
|
|
|
if String(session.status).hasPrefix("2"), pathIsRegularFile(tempDownloadPath) {
|
|
do {
|
|
if pathIsRegularFile(destinationPath) {
|
|
try? FileManager.default.removeItem(atPath: destinationPath)
|
|
}
|
|
try FileManager.default.moveItem(atPath: tempDownloadPath, toPath: destinationPath)
|
|
} catch {
|
|
throw FetchError.fileSystem(error.localizedDescription)
|
|
}
|
|
return returnedHeaders
|
|
}
|
|
if session.status == 304 {
|
|
// unchanged on server
|
|
display.debug1("Item is unchanged on the server.")
|
|
return returnedHeaders
|
|
}
|
|
// if we get here there was an HTTP error of some sort
|
|
if pathExists(tempDownloadPath) {
|
|
try? FileManager.default.removeItem(atPath: tempDownloadPath)
|
|
}
|
|
throw FetchError.http(errorCode: session.status, description: statusDescription)
|
|
}
|
|
|
|
/// Gets file from HTTP URL, checking first to see if it has changed on the
|
|
/// server.
|
|
///
|
|
/// Returns True if a new download was required; False if the
|
|
/// item is already in the local cache.
|
|
///
|
|
/// Throws a FetchError if there is an error (.connection or .download)
|
|
func getHTTPfileIfChangedAtomically(
|
|
_ url: String,
|
|
destinationPath: String,
|
|
customHeaders: [String]? = nil,
|
|
message: String = "",
|
|
resume: Bool = false,
|
|
followRedirects: String = "none",
|
|
) throws -> Bool {
|
|
var getOnlyIfNewer = false
|
|
if pathExists(destinationPath) {
|
|
// see if we have a stored etag or last-modified header
|
|
do {
|
|
let data = try getXattr(named: GURL_XATTR, atPath: destinationPath)
|
|
if let headers = try readPlist(fromData: data) as? [String: String] {
|
|
// We can use onlyIfNewer if we have either etag or last-modified
|
|
if headers["etag"] != nil || headers["last-modified"] != nil {
|
|
getOnlyIfNewer = true
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through - no cached headers
|
|
}
|
|
}
|
|
var headers: [String: String]
|
|
do {
|
|
headers = try getURL(
|
|
url,
|
|
destinationPath: destinationPath,
|
|
customHeaders: customHeaders,
|
|
message: message,
|
|
onlyIfNewer: getOnlyIfNewer,
|
|
resume: resume,
|
|
followRedirects: followRedirects
|
|
)
|
|
} catch let err as FetchError {
|
|
switch err {
|
|
case .connection:
|
|
// just rethrow it
|
|
throw err
|
|
case let .http(errorCode, description):
|
|
// rethrow as download error
|
|
throw FetchError.download(errorCode: errorCode, description: description)
|
|
case let .fileSystem(description):
|
|
// rethrow as download error
|
|
throw FetchError.download(errorCode: -1, description: description)
|
|
default:
|
|
// these can't actually happen, but makes compiler happy
|
|
throw err
|
|
}
|
|
} catch {
|
|
throw FetchError.download(errorCode: -1, description: error.localizedDescription)
|
|
}
|
|
|
|
if (headers["http_result_code"] ?? "") == "304" {
|
|
// not modified, return existing file
|
|
DisplayAndLog.main.debug1("\(destinationPath) already exists and is up-to-date.")
|
|
// file already exists and is unchanged, so we return false
|
|
return false
|
|
}
|
|
if let lastModified = headers["last-modified"] {
|
|
// set the modtime of the downloaded file to the modtime of the
|
|
// file on the server
|
|
let dateformatter = DateFormatter()
|
|
// Sample header -> Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
|
|
dateformatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
|
if let modDate = dateformatter.date(from: lastModified) {
|
|
let attrs = [
|
|
FileAttributeKey.modificationDate: modDate,
|
|
]
|
|
try? FileManager.default.setAttributes(attrs, ofItemAtPath: destinationPath)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Gets file from path, checking first to see if it has changed on the
|
|
/// source.
|
|
///
|
|
/// Returns true if a new copy was required; false if the
|
|
/// item is already in the local cache.
|
|
///
|
|
/// Throws FetchError.fileSystem if there is an error.
|
|
func getFileIfChangedAtomically(_ path: String, destinationPath: String) throws -> Bool {
|
|
let filemanager = FileManager.default
|
|
if !pathExists(path) {
|
|
throw FetchError.fileSystem("Source does not exist: \(path)")
|
|
}
|
|
guard let sourceAttrs = try? filemanager.attributesOfItem(atPath: path) else {
|
|
throw FetchError.fileSystem("Could not get file attributes for: \(path)")
|
|
}
|
|
if let destAttrs = try? filemanager.attributesOfItem(atPath: destinationPath) {
|
|
// destinationPath exists. We should check the attributes to see if they
|
|
// match
|
|
if (sourceAttrs as NSDictionary).fileModificationDate() == (destAttrs as NSDictionary).fileModificationDate(),
|
|
(sourceAttrs as NSDictionary).fileSize() == (destAttrs as NSDictionary).fileSize()
|
|
{
|
|
// modification dates and sizes are the same, we'll say they are the same
|
|
// file -- return false to say it hasn't changed
|
|
return false
|
|
}
|
|
}
|
|
// copy to a temporary destination
|
|
let tempDestinationPath = destinationPath + ".download"
|
|
|
|
if pathExists(tempDestinationPath) {
|
|
do {
|
|
try filemanager.removeItem(atPath: tempDestinationPath)
|
|
} catch {
|
|
throw FetchError.fileSystem("Removing \(tempDestinationPath) failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
do {
|
|
try filemanager.copyItem(atPath: path, toPath: tempDestinationPath)
|
|
} catch {
|
|
throw FetchError.fileSystem("Copying \(path) to \(tempDestinationPath) failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
if pathExists(destinationPath) {
|
|
do {
|
|
try filemanager.removeItem(atPath: destinationPath)
|
|
} catch {
|
|
throw FetchError.fileSystem("Could not remove previous \(destinationPath): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
do {
|
|
try filemanager.moveItem(atPath: tempDestinationPath, toPath: destinationPath)
|
|
} catch {
|
|
throw FetchError.fileSystem("Could not move \(tempDestinationPath) to \(destinationPath): \(error.localizedDescription)")
|
|
}
|
|
// set modification date of destinationPath to the same as the source
|
|
if let modDate = (sourceAttrs as NSDictionary).fileModificationDate() {
|
|
let attrs = [
|
|
FileAttributeKey.modificationDate: modDate,
|
|
]
|
|
try? filemanager.setAttributes(attrs, ofItemAtPath: destinationPath)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Gets file from a URL.
|
|
/// Checks first if there is already a file with the necessary checksum.
|
|
/// Then checks if the file has changed on the server, resuming or
|
|
/// re-downloading as necessary.
|
|
///
|
|
/// If the file has changed verify the pkg hash if so configured.
|
|
///
|
|
/// Supported schemes are http, https, file.
|
|
///
|
|
/// Returns true if a new download was required; False if the
|
|
/// item is already in the local cache.
|
|
///
|
|
/// Throws a FetchError if there is an error.
|
|
func getResourceIfChangedAtomically(
|
|
_ url: String,
|
|
destinationPath: String,
|
|
customHeaders: [String]? = nil,
|
|
expectedHash: String? = nil,
|
|
message: String = "",
|
|
resume: Bool = false,
|
|
verify: Bool = false,
|
|
followRedirects: String? = nil,
|
|
) throws -> Bool {
|
|
guard let resolvedURL = URL(string: url) else {
|
|
throw FetchError.connection(errorCode: -1, description: "Invalid URL: \(url)")
|
|
}
|
|
|
|
var changed = false
|
|
let verificationMode = (pref("PackageVerificationMode") as? String ?? "").lowercased()
|
|
|
|
// If we already have a downloaded file & its (cached) hash matches what
|
|
// we need, do nothing, return unchanged.
|
|
if resume, let expectedHash, pathIsRegularFile(destinationPath) {
|
|
var xattrHash: String?
|
|
do {
|
|
let data = try getXattr(named: XATTR_SHA, atPath: destinationPath)
|
|
xattrHash = String(data: data, encoding: .utf8)
|
|
} catch {
|
|
// no hahs stored in xattrs, so generate one and store it
|
|
xattrHash = storeCachedChecksum(toPath: destinationPath)
|
|
}
|
|
if let xattrHash, xattrHash == expectedHash {
|
|
// File is already current, no change.
|
|
munkiLog(" Cached item is current.")
|
|
return false
|
|
} else if ["hash_strict", "hash"].contains(verificationMode) {
|
|
try? FileManager.default.removeItem(atPath: destinationPath)
|
|
}
|
|
munkiLog("Cached item does not match hash in catalog, will check if changed and redownload: \(destinationPath)")
|
|
}
|
|
let resolvedFollowRedirects: String = if let followRedirects {
|
|
followRedirects
|
|
} else {
|
|
// If we haven't explicitly specified followRedirects,
|
|
// the preference decides
|
|
pref("FollowHTTPRedirects") as? String ?? "none"
|
|
}
|
|
DisplayAndLog.main.debug1("FollowHTTPRedirects is: \(resolvedFollowRedirects)")
|
|
|
|
if ["http", "https"].contains(resolvedURL.scheme) {
|
|
changed = try getHTTPfileIfChangedAtomically(
|
|
url,
|
|
destinationPath: destinationPath,
|
|
customHeaders: customHeaders,
|
|
message: message,
|
|
resume: resume,
|
|
followRedirects: resolvedFollowRedirects
|
|
)
|
|
} else if resolvedURL.scheme == "file" {
|
|
if let sourcePath = resolvedURL.path.removingPercentEncoding {
|
|
changed = try getFileIfChangedAtomically(
|
|
sourcePath, destinationPath: destinationPath
|
|
)
|
|
} else {
|
|
throw FetchError.connection(
|
|
errorCode: -1,
|
|
description: "Invalid path in URL \(url)"
|
|
)
|
|
}
|
|
} else {
|
|
throw FetchError.connection(
|
|
errorCode: -1,
|
|
description: "Unsupported url scheme: \(String(describing: resolvedURL.scheme)) in \(url)"
|
|
)
|
|
}
|
|
|
|
if changed, verify {
|
|
let (verifyOK, calculatedHash) = verifySoftwarePackageIntegrity(
|
|
destinationPath, expectedHash: expectedHash ?? ""
|
|
)
|
|
if !verifyOK {
|
|
try? FileManager.default.removeItem(atPath: destinationPath)
|
|
throw FetchError.verification
|
|
}
|
|
if !calculatedHash.isEmpty {
|
|
let _ = storeCachedChecksum(toPath: destinationPath, hash: calculatedHash)
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
/// A high-level function for getting resources from the Munki repo.
|
|
/// Gets a given URL from the Munki server.
|
|
/// Adds any additional headers to the request if present
|
|
/// Throws a FetchError if there's an error
|
|
///
|
|
/// Add any additional headers specified in ManagedInstalls.plist.
|
|
/// AdditionalHttpHeaders must be an array of strings with valid HTTP
|
|
/// header format. For example:
|
|
/// <key>AdditionalHttpHeaders</key>
|
|
/// <array>
|
|
/// <string>Key-With-Optional-Dashes: Foo Value</string>
|
|
/// <string>another-custom-header: bar value</string>
|
|
/// </array>
|
|
func fetchMunkiResourceByURL(
|
|
_ url: String,
|
|
destinationPath: String,
|
|
message: String = "",
|
|
resume: Bool = false,
|
|
expectedHash: String? = nil,
|
|
verify: Bool = false
|
|
) throws -> Bool {
|
|
DisplayAndLog.main.debug2("Fetching URL: \(url)")
|
|
let customHeaders = pref(ADDITIONAL_HTTP_HEADERS_KEY) as? [String]
|
|
return try getResourceIfChangedAtomically(
|
|
url,
|
|
destinationPath: destinationPath,
|
|
customHeaders: customHeaders,
|
|
expectedHash: expectedHash,
|
|
message: message,
|
|
resume: resume,
|
|
verify: verify
|
|
)
|
|
}
|
|
|
|
enum MunkiResourceType: String {
|
|
case catalog = "catalogs"
|
|
case clientResource = "client_resources"
|
|
case icon = "icons"
|
|
case manifest = "manifests"
|
|
case package = "pkgs"
|
|
}
|
|
|
|
/// An even higher-level function for getting resources from the Munki repo.
|
|
func fetchMunkiResource(
|
|
kind: MunkiResourceType,
|
|
name: String,
|
|
destinationPath: String,
|
|
message: String = "",
|
|
resume: Bool = false,
|
|
expectedHash: String? = nil,
|
|
verify: Bool = false
|
|
) throws -> Bool {
|
|
guard let url = munkiRepoURL(kind.rawValue, resource: name) else {
|
|
throw FetchError.connection(
|
|
errorCode: -1,
|
|
description: "Could not encode all characters in URL"
|
|
)
|
|
}
|
|
return try fetchMunkiResourceByURL(
|
|
url,
|
|
destinationPath: destinationPath,
|
|
message: message,
|
|
resume: resume,
|
|
expectedHash: expectedHash,
|
|
verify: verify
|
|
)
|
|
}
|
|
|
|
/// Returns data from URL.
|
|
/// We use the existing fetchMunkiResource function so any custom
|
|
/// authentication/authorization headers are used
|
|
/// (including, eventually, middleware-generated headers)
|
|
/// May throw a FetchError
|
|
func getDataFromURL(_ url: String) throws -> Data? {
|
|
guard let tmpDir = TempDir.shared.makeTempDir() else {
|
|
DisplayAndLog.main.error("Could not create temporary directory")
|
|
return nil
|
|
}
|
|
defer { try? FileManager.default.removeItem(atPath: tmpDir) }
|
|
let tempDataPath = (tmpDir as NSString).appendingPathComponent("urldata")
|
|
_ = try fetchMunkiResourceByURL(
|
|
url, destinationPath: tempDataPath
|
|
)
|
|
return FileManager.default.contents(atPath: tempDataPath)
|
|
}
|
|
|
|
/// A function we can call to check to see if the server is
|
|
/// available before we kick off a full run. This can be fooled by
|
|
/// ISPs that return results for non-existent web servers...
|
|
/// Returns a tuple (exitCode, exitDescription)
|
|
func checkServer(_ urlString: String = "") -> (Int, String) {
|
|
let serverURL: String = if !urlString.isEmpty {
|
|
urlString
|
|
} else {
|
|
munkiRepoURL() ?? ""
|
|
}
|
|
|
|
guard let url = URL(string: serverURL) else {
|
|
return (-1, "Invalid URL")
|
|
}
|
|
|
|
if ["http", "https"].contains(url.scheme) {
|
|
// pass
|
|
} else if url.scheme == "file" {
|
|
if let host = url.host, host != "localhost" {
|
|
return (-1, "Non-local hostnames not supported for file:// URLs")
|
|
}
|
|
if let path = url.path.removingPercentEncoding, pathExists(path) {
|
|
return (0, "OK")
|
|
}
|
|
return (-1, "Path \(url.path) does not exist")
|
|
} else {
|
|
return (-1, "Unsupported URL scheme: \(url.scheme ?? "<none>")")
|
|
}
|
|
do {
|
|
_ = try getDataFromURL(url.absoluteString)
|
|
} catch let err as FetchError {
|
|
switch err {
|
|
case let .connection(errorCode, description):
|
|
return (errorCode, description)
|
|
case .http:
|
|
return (0, "OK")
|
|
case .download:
|
|
return (0, "OK")
|
|
case .fileSystem:
|
|
return (0, "OK")
|
|
default:
|
|
return (-1, err.localizedDescription)
|
|
}
|
|
} catch {
|
|
return (-1, error.localizedDescription)
|
|
}
|
|
return (0, "OK")
|
|
}
|