diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift new file mode 100644 index 00000000..977b1a2d --- /dev/null +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import PackagePlugin + +@main +@available(macOS 15.0, *) +struct AWSLambdaDeployer: CommandPlugin { + + + func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { + let configuration = try Configuration(context: context, arguments: arguments) + + if configuration.help { + self.displayHelpMessage() + return + } + + let tool = try context.tool(named: "AWSLambdaDeployerHelper") + try Utils.execute(executable: tool.url, arguments: [], logLevel: .debug) + } + + private func displayHelpMessage() { + print( + """ + OVERVIEW: A SwiftPM plugin to deploy a Lambda function. + + USAGE: swift package lambda-deploy + [--with-url] + [--help] [--verbose] + + OPTIONS: + --with-url Add an URL to access the Lambda function + --verbose Produce verbose output for debugging. + --help Show help information. + """ + ) + } +} + +private struct Configuration: CustomStringConvertible { + public let help: Bool + public let verboseLogging: Bool + + public init( + context: PluginContext, + arguments: [String] + ) throws { + var argumentExtractor = ArgumentExtractor(arguments) + let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 + let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 + + // help required ? + self.help = helpArgument + + // verbose logging required ? + self.verboseLogging = verboseArgument + } + + var description: String { + """ + { + verboseLogging: \(self.verboseLogging) + } + """ + } +} diff --git a/Plugins/AWSLambdaDeployer/PluginUtils.swift b/Plugins/AWSLambdaDeployer/PluginUtils.swift new file mode 100644 index 00000000..52d1b2be --- /dev/null +++ b/Plugins/AWSLambdaDeployer/PluginUtils.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Dispatch +import Foundation +import PackagePlugin +import Synchronization + +@available(macOS 15.0, *) +struct Utils { + @discardableResult + static func execute( + executable: URL, + arguments: [String], + customWorkingDirectory: URL? = .none, + logLevel: ProcessLogLevel + ) throws -> String { + if logLevel >= .debug { + print("\(executable.path()) \(arguments.joined(separator: " "))") + } + + let fd = dup(1) + let stdout = fdopen(fd, "rw") + defer { if let so = stdout { fclose(so) } } + + // We need to use an unsafe transfer here to get the fd into our Sendable closure. + // This transfer is fine, because we write to the variable from a single SerialDispatchQueue here. + // We wait until the process is run below process.waitUntilExit(). + // This means no further writes to output will happen. + // This makes it save for us to read the output + struct UnsafeTransfer: @unchecked Sendable { + let value: Value + } + + let outputMutex = Mutex("") + let outputSync = DispatchGroup() + let outputQueue = DispatchQueue(label: "AWSLambdaPackager.output") + let unsafeTransfer = UnsafeTransfer(value: stdout) + let outputHandler = { @Sendable (data: Data?) in + dispatchPrecondition(condition: .onQueue(outputQueue)) + + outputSync.enter() + defer { outputSync.leave() } + + guard + let _output = data.flatMap({ + String(data: $0, encoding: .utf8)?.trimmingCharacters(in: CharacterSet(["\n"])) + }), !_output.isEmpty + else { + return + } + + outputMutex.withLock { output in + output += _output + "\n" + } + + switch logLevel { + case .silent: + break + case .debug(let outputIndent), .output(let outputIndent): + print(String(repeating: " ", count: outputIndent), terminator: "") + print(_output) + fflush(unsafeTransfer.value) + } + } + + let pipe = Pipe() + pipe.fileHandleForReading.readabilityHandler = { fileHandle in + outputQueue.async { outputHandler(fileHandle.availableData) } + } + + let process = Process() + process.standardOutput = pipe + process.standardError = pipe + process.executableURL = executable + process.arguments = arguments + if let workingDirectory = customWorkingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory.path()) + } + process.terminationHandler = { _ in + outputQueue.async { + outputHandler(try? pipe.fileHandleForReading.readToEnd()) + } + } + + try process.run() + process.waitUntilExit() + + // wait for output to be full processed + outputSync.wait() + + let output = outputMutex.withLock { $0 } + + if process.terminationStatus != 0 { + // print output on failure and if not already printed + if logLevel < .output { + print(output) + fflush(stdout) + } + throw ProcessError.processFailed([executable.path()] + arguments, process.terminationStatus) + } + + return output + } + + enum ProcessError: Error, CustomStringConvertible { + case processFailed([String], Int32) + + var description: String { + switch self { + case .processFailed(let arguments, let code): + return "\(arguments.joined(separator: " ")) failed with code \(code)" + } + } + } + + enum ProcessLogLevel: Comparable { + case silent + case output(outputIndent: Int) + case debug(outputIndent: Int) + + var naturalOrder: Int { + switch self { + case .silent: + return 0 + case .output: + return 1 + case .debug: + return 2 + } + } + + static var output: Self { + .output(outputIndent: 2) + } + + static var debug: Self { + .debug(outputIndent: 2) + } + + static func < (lhs: ProcessLogLevel, rhs: ProcessLogLevel) -> Bool { + lhs.naturalOrder < rhs.naturalOrder + } + } +} diff --git a/Sources/AWSLambdaDeployerHelper/AWSCredentials.swift b/Sources/AWSLambdaDeployerHelper/AWSCredentials.swift new file mode 100644 index 00000000..aec469b1 --- /dev/null +++ b/Sources/AWSLambdaDeployerHelper/AWSCredentials.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// +// credentials.swift +// aws-sign +// +// Created by Adam Fowler on 29/08/2019. +// +import class Foundation.ProcessInfo + +/// Protocol for providing credential details for accessing AWS services +public protocol Credential { + var accessKeyId: String {get} + var secretAccessKey: String {get} + var sessionToken: String? {get} +} + +/// basic version of Credential where you supply the credentials +public struct StaticCredential: Credential { + public let accessKeyId: String + public let secretAccessKey: String + public let sessionToken: String? + + public init(accessKeyId: String, secretAccessKey: String, sessionToken: String? = nil) { + self.accessKeyId = accessKeyId + self.secretAccessKey = secretAccessKey + self.sessionToken = sessionToken + } +} + +/// environment variable version of credential that uses system environment variables to get credential details +public struct EnvironmentCredential: Credential { + public let accessKeyId: String + public let secretAccessKey: String + public let sessionToken: String? + + public init?() { + guard let accessKeyId = ProcessInfo.processInfo.environment["AWS_ACCESS_KEY_ID"] else { + return nil + } + guard let secretAccessKey = ProcessInfo.processInfo.environment["AWS_SECRET_ACCESS_KEY"] else { + return nil + } + self.accessKeyId = accessKeyId + self.secretAccessKey = secretAccessKey + self.sessionToken = ProcessInfo.processInfo.environment["AWS_SESSION_TOKEN"] + } +} diff --git a/Sources/AWSLambdaDeployerHelper/AWSSigner.swift b/Sources/AWSLambdaDeployerHelper/AWSSigner.swift new file mode 100644 index 00000000..41774b93 --- /dev/null +++ b/Sources/AWSLambdaDeployerHelper/AWSSigner.swift @@ -0,0 +1,269 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// +// signer.swift +// AWSSigner +// +// Created by Adam Fowler on 2019/08/29. +// Amazon Web Services V4 Signer +// AWS documentation about signing requests is here https://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html +// + +import struct Foundation.CharacterSet +import struct Foundation.Data +import struct Foundation.Date +import class Foundation.DateFormatter +import struct Foundation.Locale +import struct Foundation.TimeZone +import struct Foundation.URL +import Crypto +import NIO +import NIOHTTP1 + +/// Amazon Web Services V4 Signer +public struct AWSSigner { + /// security credentials for accessing AWS services + public let credentials: Credential + /// service signing name. In general this is the same as the service name + public let name: String + /// AWS region you are working in + public let region: String + + static let hashedEmptyBody = SHA256.hash(data: [UInt8]()).hexDigest() + + static private let timeStampDateFormatter: DateFormatter = createTimeStampDateFormatter() + + /// Initialise the Signer class with AWS credentials + public init(credentials: Credential, name: String, region: String) { + self.credentials = credentials + self.name = name + self.region = region + } + + /// Enum for holding your body data + public enum BodyData { + case string(String) + case data(Data) + case byteBuffer(ByteBuffer) + } + + /// Generate signed headers, for a HTTP request + public func signHeaders(url: URL, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: BodyData? = nil, date: Date = Date()) -> HTTPHeaders { + let bodyHash = AWSSigner.hashedPayload(body) + let dateString = AWSSigner.timestamp(date) + var headers = headers + // add date, host, sha256 and if available security token headers + headers.add(name: "X-Amz-Date", value: dateString) + headers.add(name: "host", value: url.host ?? "") + headers.add(name: "x-amz-content-sha256", value: bodyHash) + if let sessionToken = credentials.sessionToken { + headers.add(name: "x-amz-security-token", value: sessionToken) + } + + // construct signing data. Do this after adding the headers as it uses data from the headers + let signingData = AWSSigner.SigningData(url: url, method: method, headers: headers, body: body, bodyHash: bodyHash, date: dateString, signer: self) + + // construct authorization string + let authorization = "AWS4-HMAC-SHA256 " + + "Credential=\(credentials.accessKeyId)/\(signingData.date)/\(region)/\(name)/aws4_request, " + + "SignedHeaders=\(signingData.signedHeaders), " + + "Signature=\(signature(signingData: signingData))" + + // add Authorization header + headers.add(name: "Authorization", value: authorization) + + return headers + } + + /// Generate a signed URL, for a HTTP request + public func signURL(url: URL, method: HTTPMethod = .GET, body: BodyData? = nil, date: Date = Date(), expires: Int = 86400) -> URL { + let headers = HTTPHeaders([("host", url.host ?? "")]) + // Create signing data + var signingData = AWSSigner.SigningData(url: url, method: method, headers: headers, body: body, date: AWSSigner.timestamp(date), signer: self) + // Construct query string. Start with original query strings and append all the signing info. + var query = url.query ?? "" + if query.count > 0 { + query += "&" + } + query += "X-Amz-Algorithm=AWS4-HMAC-SHA256" + query += "&X-Amz-Credential=\(credentials.accessKeyId)/\(signingData.date)/\(region)/\(name)/aws4_request" + query += "&X-Amz-Date=\(signingData.datetime)" + query += "&X-Amz-Expires=\(expires)" + query += "&X-Amz-SignedHeaders=\(signingData.signedHeaders)" + if let sessionToken = credentials.sessionToken { + query += "&X-Amz-Security-Token=\(sessionToken.uriEncode())" + } + // Split the string and sort to ensure the order of query strings is the same as AWS + query = query.split(separator: "&") + .sorted() + .joined(separator: "&") + .queryEncode() + + // update unsignedURL in the signingData so when the canonical request is constructed it includes all the signing query items + signingData.unsignedURL = URL(string: url.absoluteString.split(separator: "?")[0]+"?"+query)! // NEED TO DEAL WITH SITUATION WHERE THIS FAILS + query += "&X-Amz-Signature=\(signature(signingData: signingData))" + + // Add signature to query items and build a new Request + let signedURL = URL(string: url.absoluteString.split(separator: "?")[0]+"?"+query)! + + return signedURL + } + + /// structure used to store data used throughout the signing process + struct SigningData { + let url : URL + let method : HTTPMethod + let hashedPayload : String + let datetime : String + let headersToSign: [String: String] + let signedHeaders : String + var unsignedURL : URL + + var date : String { return String(datetime.prefix(8))} + + init(url: URL, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: BodyData? = nil, bodyHash: String? = nil, date: String, signer: AWSSigner) { + if url.path == "" { + //URL has to have trailing slash + self.url = url.appendingPathComponent("/") + } else { + self.url = url + } + self.method = method + self.datetime = date + self.unsignedURL = self.url + + if let hash = bodyHash { + self.hashedPayload = hash + } else if signer.name == "s3" { + self.hashedPayload = "UNSIGNED-PAYLOAD" + } else { + self.hashedPayload = AWSSigner.hashedPayload(body) + } + + let headersNotToSign: Set = [ + "Authorization" + ] + var headersToSign: [String: String] = [:] + var signedHeadersArray: [String] = [] + for header in headers { + if headersNotToSign.contains(header.name) { + continue + } + headersToSign[header.name] = header.value + signedHeadersArray.append(header.name.lowercased()) + } + self.headersToSign = headersToSign + self.signedHeaders = signedHeadersArray.sorted().joined(separator: ";") + } + } + + // Stage 3 Calculating signature as in https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + func signature(signingData: SigningData) -> String { + let kDate = HMAC.authenticationCode(for: Data(signingData.date.utf8), using: SymmetricKey(data: Array("AWS4\(credentials.secretAccessKey)".utf8))) + let kRegion = HMAC.authenticationCode(for: Data(region.utf8), using: SymmetricKey(data: kDate)) + let kService = HMAC.authenticationCode(for: Data(name.utf8), using: SymmetricKey(data: kRegion)) + let kSigning = HMAC.authenticationCode(for: Data("aws4_request".utf8), using: SymmetricKey(data: kService)) + let kSignature = HMAC.authenticationCode(for: stringToSign(signingData: signingData), using: SymmetricKey(data: kSigning)) + return kSignature.hexDigest() + } + + /// Stage 2 Create the string to sign as in https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + func stringToSign(signingData: SigningData) -> Data { + let stringToSign = "AWS4-HMAC-SHA256\n" + + "\(signingData.datetime)\n" + + "\(signingData.date)/\(region)/\(name)/aws4_request\n" + + SHA256.hash(data: canonicalRequest(signingData: signingData)).hexDigest() + return Data(stringToSign.utf8) + } + + /// Stage 1 Create the canonical request as in https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + func canonicalRequest(signingData: SigningData) -> Data { + let canonicalHeaders = signingData.headersToSign.map { return "\($0.key.lowercased()):\($0.value.trimmingCharacters(in: CharacterSet.whitespaces))" } + .sorted() + .joined(separator: "\n") + let canonicalRequest = "\(signingData.method.rawValue)\n" + + "\(signingData.unsignedURL.path.uriEncodeWithSlash())\n" + + "\(signingData.unsignedURL.query ?? "")\n" + // should really uriEncode all the query string values + "\(canonicalHeaders)\n\n" + + "\(signingData.signedHeaders)\n" + + signingData.hashedPayload + return Data(canonicalRequest.utf8) + } + + /// Create a SHA256 hash of the Requests body + static func hashedPayload(_ payload: BodyData?) -> String { + guard let payload = payload else { return hashedEmptyBody } + let hash : String? + switch payload { + case .string(let string): + hash = SHA256.hash(data: Data(string.utf8)).hexDigest() + case .data(let data): + hash = SHA256.hash(data: data).hexDigest() + case .byteBuffer(let byteBuffer): + let byteBufferView = byteBuffer.readableBytesView + hash = byteBufferView.withContiguousStorageIfAvailable { bytes in + return SHA256.hash(data: bytes).hexDigest() + } + } + if let hash = hash { + return hash + } else { + return hashedEmptyBody + } + } + + /// return a hexEncoded string buffer from an array of bytes + static func hexEncoded(_ buffer: [UInt8]) -> String { + return buffer.map{String(format: "%02x", $0)}.joined(separator: "") + } + /// create timestamp dateformatter + static private func createTimeStampDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + formatter.timeZone = TimeZone(abbreviation: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } + + /// return a timestamp formatted for signing requests + static func timestamp(_ date: Date) -> String { + return timeStampDateFormatter.string(from: date) + } +} + +extension String { + func queryEncode() -> String { + return addingPercentEncoding(withAllowedCharacters: String.queryAllowedCharacters) ?? self + } + + func uriEncode() -> String { + return addingPercentEncoding(withAllowedCharacters: String.uriAllowedCharacters) ?? self + } + + func uriEncodeWithSlash() -> String { + return addingPercentEncoding(withAllowedCharacters: String.uriAllowedWithSlashCharacters) ?? self + } + + static let uriAllowedWithSlashCharacters = CharacterSet(charactersIn:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/") + static let uriAllowedCharacters = CharacterSet(charactersIn:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + static let queryAllowedCharacters = CharacterSet(charactersIn:"/;+").inverted +} + +public extension Sequence where Element == UInt8 { + /// return a hexEncoded string buffer from an array of bytes + func hexDigest() -> String { + return self.map{String(format: "%02x", $0)}.joined(separator: "") + } +} diff --git a/Sources/AWSLambdaDeployerHelper/main.swift b/Sources/AWSLambdaDeployerHelper/main.swift new file mode 100644 index 00000000..c28454c6 --- /dev/null +++ b/Sources/AWSLambdaDeployerHelper/main.swift @@ -0,0 +1 @@ +print("Deployer") \ No newline at end of file