Compare commits

..

23 Commits

Author SHA1 Message Date
db5311074a Update device state with unlock error 2024-03-11 00:41:26 +01:00
e0e2a1cb06 Allow absolute key path 2023-12-25 20:12:43 +01:00
c9d13bb150 Detect environment 2023-12-19 21:43:58 +01:00
7968f64581 Reduce amount of logging 2023-12-12 23:08:50 +01:00
ad1c959ead Log all messages instead of printing 2023-12-10 19:34:30 +01:00
1a1eeb6547 Fix system status update 2023-12-10 19:32:09 +01:00
5e72137d0e Fix error 2023-12-08 20:28:24 +01:00
b9f1827b63 Test logger 2023-12-08 20:26:17 +01:00
4489092a6f Attempt to see logs properly 2023-12-08 19:54:51 +01:00
160c9a1a97 Attempt to fix socket data processing 2023-12-08 19:40:49 +01:00
d9bd0c6e30 Fix race condition waiting for message delivery 2023-12-08 16:55:47 +01:00
9feab409ab Improve socket connection and logging 2023-12-08 16:28:48 +01:00
1917d1d10e Fix header key 2023-12-08 16:12:37 +01:00
17d7a8e6c4 Fix run 2023-12-08 15:58:57 +01:00
c25c4f3dc6 Improve logging and shutdown 2023-12-08 15:57:33 +01:00
2e11023096 Check device message size 2023-12-08 15:53:01 +01:00
7652bb24a3 Improve message result clarity 2023-12-08 15:43:29 +01:00
e76029270a Update to challenge-response system 2023-12-08 12:39:10 +01:00
ac22fcd4eb Remove key error enum 2023-12-06 09:53:24 +01:00
b2b3c74586 Move to new Vapor main 2023-12-06 09:49:26 +01:00
eb10ae6626 Update LICENSE 2023-12-06 09:48:52 +01:00
f4864127f8 Update device authentication 2023-12-06 09:05:41 +01:00
6117ae8305 Ignore more resources 2023-12-06 09:05:16 +01:00
23 changed files with 546 additions and 370 deletions

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ Package.resolved
.swiftpm
.build
Resources/config.json
Resources/logs
Resources/keys

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2023 Christoph Hagen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -11,27 +11,18 @@ let package = Package(
.package(url: "https://github.com/christophhagen/Clairvoyant", from: "0.13.0"),
.package(url: "https://github.com/christophhagen/ClairvoyantVapor", from: "0.5.0"),
.package(url: "https://github.com/christophhagen/ClairvoyantBinaryCodable", from: "0.3.1"),
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"),
],
targets: [
.target(
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Clairvoyant", package: "Clairvoyant"),
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
],
swiftSettings: [
// Enable better optimizations when building in Release configuration. Despite the use of
// the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
// builds. See <https://github.com/swift-server/guides/blob/main/docs/building.md#building-for-production> for details.
.unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
.product(name: "Crypto", package: "swift-crypto"),
]
),
.executableTarget(name: "Run", dependencies: [.target(name: "App")]),
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
])
)
]
)

View File

@ -0,0 +1,5 @@
import Foundation
enum Message {
}

View File

@ -0,0 +1,9 @@
import Foundation
import RoutingKit
extension SesameRoute {
var path: PathComponent {
.init(stringLiteral: rawValue)
}
}

View File

@ -0,0 +1,5 @@
import Foundation
enum SignedMessage {
}

View File

@ -0,0 +1,17 @@
import Foundation
extension Data {
func convert<T>(into value: T) -> T {
withUnsafeBytes {
$0.baseAddress!.load(as: T.self)
}
}
init<T>(from value: T) {
var target = value
self = Swift.withUnsafeBytes(of: &target) {
Data($0)
}
}
}

View File

@ -40,20 +40,3 @@ extension Data {
}
}
}
extension Data {
func convert<T>(into value: T) -> T {
withUnsafeBytes {
$0.baseAddress!.load(as: T.self)
}
}
init<T>(from value: T) {
var target = value
self = Swift.withUnsafeBytes(of: &target) {
Data($0)
}
}
}

View File

@ -16,4 +16,7 @@ extension UInt32 {
var encoded: Data {
Data(from: CFSwapInt32HostToLittle(self))
}
/// The size of a `UInt32` when converted to data
static let byteSize = MemoryLayout<UInt32>.size
}

View File

@ -0,0 +1,8 @@
import Foundation
extension Message {
/// The byte length of an encoded message content
static let size: Int = 2 + 2 * UInt32.byteSize
}

View File

@ -3,98 +3,228 @@ import Foundation
/**
A result from sending a key to the device.
*/
enum MessageResult: UInt8, Error {
enum MessageResult: UInt8 {
// MARK: Device status
/// Text content was received, although binary data was expected
/// The message was accepted.
case messageAccepted = 0
/// The web socket received text while waiting for binary data.
case textReceived = 1
/// A socket event on the device was unexpected (not binary data)
/// An unexpected socket event occured while performing the exchange.
case unexpectedSocketEvent = 2
/// The size of the payload (i.e. message) was invalid
case invalidMessageSize = 3
/// The received message size is invalid.
case invalidMessageSizeFromRemote = 3
/// The transmitted message could not be authenticated using the key
case messageAuthenticationFailed = 4
/// The message signature was incorrect.
case invalidSignatureFromRemote = 4
/// The message time was not within the acceptable bounds
case messageTimeMismatch = 5
/// The server challenge of the message did not match previous messages
case invalidServerChallengeFromRemote = 5
/// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication)
case messageCounterInvalid = 6
/// The client challenge of the message did not match previous messages
case invalidClientChallengeFromRemote = 6
/// The key was accepted by the device, and the door will be opened
case messageAccepted = 7
/// An unexpected or unsupported message type was received
case invalidMessageTypeFromRemote = 7
/// The device id is invalid
case messageDeviceInvalid = 8
/// The request did not contain body data with the key
case noBodyData = 10
/// The device is not connected
case deviceNotConnected = 12
/// The device did not respond within the timeout
case deviceTimedOut = 13
/// Another message is being processed by the device
case operationInProgress = 14
/// The device is connected
case deviceConnected = 15
case invalidUrlParameter = 20
case invalidResponseAuthentication = 21
/// A message is already being processed
case tooManyRequests = 8
/// The received message result was not ``messageAccepted``
case invalidMessageResultFromRemote = 9
/// An invalid Url parameter was set sending a message to the device over a local connection
case invalidUrlParameter = 10
// MARK: Server status
/// The body data posting a message was missing or of wrong length
case noOrInvalidBodyDataFromRemote = 21
/// The authentication token for the server was invalid
case invalidServerAuthenticationFromRemote = 22
/// The request took too long to complete
case deviceTimedOut = 23
/// The device is not connected to the server via web socket
case deviceNotConnected = 24
/// The device sent a response of invalid size
case invalidMessageSizeFromDevice = 25
/// The header with the authentication token was missing or invalid (not a hex string) from a server request.
case missingOrInvalidAuthenticationHeaderFromRemote = 26
/// The server produced an internal error (500)
case internalServerError = 27
// MARK: Remote status
/// The url string is not a valid url
case serverUrlInvalid = 31
/// The device key or auth token is missing for a request.
case noKeyAvailable = 32
/// The Sesame server behind the proxy could not be found (502)
case serviceBehindProxyUnavailable = 33
/// The server url could not be found (404)
case pathOnServerNotFound = 34
/// The url session request returned an unknown response
case unexpectedUrlResponseType = 35
/// The request to the server returned an unhandled HTTP code
case unexpectedServerResponseCode = 36
/// A valid server challenge was received
case deviceAvailable = 37
case invalidSignatureFromDevice = 38
case invalidMessageTypeFromDevice = 39
case unknownMessageResultFromDevice = 40
/// The device sent a message with an invalid client challenge
case invalidClientChallengeFromDevice = 41
/// The device used an invalid server challenge in a response
case invalidServerChallengeFromDevice = 42
/// The unlock process was successfully completed
case unlocked = 43
}
extension MessageResult: Error {
case invalidDeviceResponse = 22
}
extension MessageResult: CustomStringConvertible {
var description: String {
switch self {
case .messageAccepted:
return "Message accepted"
case .textReceived:
return "The device received unexpected text"
case .unexpectedSocketEvent:
return "Unexpected socket event for the device"
case .invalidMessageSize:
return "Invalid message data"
case .messageAuthenticationFailed:
case .invalidMessageSizeFromRemote:
return "Invalid message data from remote"
case .invalidSignatureFromRemote:
return "Message authentication failed"
case .messageTimeMismatch:
return "Message time invalid"
case .messageCounterInvalid:
return "Message counter invalid"
case .messageAccepted:
return "Message accepted"
case .messageDeviceInvalid:
return "Invalid device ID"
case .noBodyData:
return "No body data included in the request"
case .deviceNotConnected:
return "Device not connected"
case .deviceTimedOut:
return "The device did not respond"
case .operationInProgress:
return "Another operation is in progress"
case .deviceConnected:
return "The device is connected"
case .invalidServerChallengeFromRemote:
return "Server challenge mismatch"
case .invalidClientChallengeFromRemote:
return "Wrong client challenge sent"
case .invalidMessageTypeFromRemote:
return "Message type from remote invalid"
case .tooManyRequests:
return "Device busy"
case .invalidMessageResultFromRemote:
return "Invalid message result"
case .invalidUrlParameter:
return "The url parameter could not be found"
case .invalidResponseAuthentication:
return "The response could not be authenticated"
case .invalidDeviceResponse:
return "The device responded with invalid data"
case .noOrInvalidBodyDataFromRemote:
return "Invalid body data in server request"
case .invalidServerAuthenticationFromRemote:
return "Invalid server token"
case .deviceTimedOut:
return "The device did not respond"
case .deviceNotConnected:
return "Device not connected to server"
case .invalidMessageSizeFromDevice:
return "Invalid device message size"
case .missingOrInvalidAuthenticationHeaderFromRemote:
return "Invalid server token format"
case .internalServerError:
return "Internal server error"
case .serverUrlInvalid:
return "Invalid server url"
case .noKeyAvailable:
return "No key available"
case .serviceBehindProxyUnavailable:
return "Service behind proxy not found"
case .pathOnServerNotFound:
return "Invalid server path"
case .unexpectedUrlResponseType:
return "Unexpected URL response"
case .unexpectedServerResponseCode:
return "Unexpected server response code"
case .deviceAvailable:
return "Device available"
case .invalidSignatureFromDevice:
return "Invalid device signature"
case .invalidMessageTypeFromDevice:
return "Message type from device invalid"
case .unknownMessageResultFromDevice:
return "Unknown message result"
case .invalidClientChallengeFromDevice:
return "Device sent invalid client challenge"
case .invalidServerChallengeFromDevice:
return "Invalid"
case .unlocked:
return "Unlocked"
}
}
}
extension MessageResult: Codable {
}
extension MessageResult {
var encoded: Data {
Data([rawValue])
}
}
extension MessageResult {
init(httpCode: Int) {
switch httpCode {
case 200: self = .messageAccepted
case 204: self = .noOrInvalidBodyDataFromRemote
case 403: self = .invalidServerAuthenticationFromRemote
case 404: self = .pathOnServerNotFound
case 408: self = .deviceTimedOut
case 412: self = .deviceNotConnected
case 413: self = .invalidMessageSizeFromDevice
case 422: self = .missingOrInvalidAuthenticationHeaderFromRemote
case 429: self = .tooManyRequests
case 500: self = .internalServerError
case 501: self = .unexpectedServerResponseCode
case 502: self = .serviceBehindProxyUnavailable
default: self = .unexpectedServerResponseCode
}
}
var statusCode: Int {
switch self {
case .messageAccepted: return 200 // ok
case .noOrInvalidBodyDataFromRemote: return 204 // noContent
case .invalidServerAuthenticationFromRemote: return 403 // forbidden
case .pathOnServerNotFound: return 404 // notFound
case .deviceTimedOut: return 408 // requestTimeout
case .invalidMessageSizeFromRemote: return 411 // lengthRequired
case .deviceNotConnected: return 412 // preconditionFailed
case .invalidMessageSizeFromDevice: return 413 // payloadTooLarge
case .missingOrInvalidAuthenticationHeaderFromRemote: return 422 // unprocessableEntity
case .tooManyRequests: return 429 // tooManyRequests
case .internalServerError: return 500 // internalServerError
case .unexpectedServerResponseCode: return 501 // notImplemented
case .serviceBehindProxyUnavailable: return 502 // badGateway
default: return 501 // == unexpectedServerResponseCode
}
}
}

View File

@ -1,47 +0,0 @@
import Foundation
import NIOCore
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
struct ServerMessage {
static let authTokenSize = SHA256.byteCount
static let maxLength = authTokenSize + 200
let authToken: Data
let message: Data
/**
Decode a message from a byte buffer.
The buffer must contain at least `ServerMessage.length` bytes, or it will return `nil`.
- Parameter buffer: The buffer containing the bytes.
*/
init?(decodeFrom buffer: ByteBuffer) {
guard buffer.readableBytes < ServerMessage.maxLength else {
log("Received invalid message with \(buffer.readableBytes) bytes")
return nil
}
guard let data = buffer.getBytes(at: 0, length: buffer.readableBytes) else {
log("Failed to read bytes of received message")
return nil
}
self.authToken = Data(data.prefix(ServerMessage.authTokenSize))
self.message = Data(data.dropFirst(ServerMessage.authTokenSize))
}
static func token(from buffer: ByteBuffer) -> Data? {
guard buffer.readableBytes == authTokenSize else {
return nil
}
guard let bytes = buffer.getBytes(at: 0, length: authTokenSize) else {
return nil
}
return Data(bytes)
}
}

View File

@ -0,0 +1,14 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
enum SesameHeader {
static let authenticationHeader = "Authorization"
static let serverAuthenticationTokenSize = SHA256.byteCount
}

View File

@ -3,10 +3,7 @@ import Foundation
/**
The active urls on the server, for the device and the remote to connect
*/
enum RouteAPI: String {
/// Check the device status
case getDeviceStatus = "status"
enum SesameRoute: String {
/// Send a message to the server, to relay to the device
case postMessage = "message"

View File

@ -0,0 +1,15 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
extension SignedMessage {
/// The length of a message in bytes
static var size: Int {
SHA256.byteCount + Message.size
}
}

View File

@ -5,7 +5,11 @@ struct Config {
/// The port where the server runs
let port: Int
/// The name of the file in the `Resources` folder containing the device authentication token
/**
The path to the file containing the containing the device authentication token.
If the path is relative, then it is relative to the `Resources` folder.
*/
let keyFileName: String
/// The seconds to wait for a response from the device
@ -14,20 +18,30 @@ struct Config {
/// The authentication tokens to use for monitoring of the service
let authenticationTokens: Set<String>
/// The path to the folder where the metric logs are stored
///
/// If no path is provided, then a folder `logs` in the resources directory is created
/// If the path is relative, then it is assumed relative to the resources directory
/**
The path to the folder where the metric logs are stored
If no path is provided, then a folder `logs` in the resources directory is created.
If the path is relative, then it is assumed relative to the resources directory.
*/
let logPath: String?
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
guard let logPath else {
return resourcesDirectory.appendingPathComponent("logs")
}
guard !logPath.hasPrefix("/") else {
return .init(fileURLWithPath: logPath)
return Config.url(logPath, possiblyRelativeTo: resourcesDirectory)
}
func keyURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
Config.url(keyFileName, possiblyRelativeTo: resourcesDirectory)
}
private static func url(_ name: String, possiblyRelativeTo resourcesDirectory: URL) -> URL {
guard !name.hasPrefix("/") else {
return .init(fileURLWithPath: name)
}
return resourcesDirectory.appendingPathComponent(logPath)
return resourcesDirectory.appendingPathComponent(name)
}
}
@ -39,20 +53,20 @@ extension Config {
init(loadFrom url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else {
print("No configuration file found at \(url.path)")
printAndFlush("No configuration file found at \(url.path)")
fatalError("No configuration file found")
}
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
print("Failed to read config data: \(error)")
printAndFlush("Failed to read config data: \(error)")
throw error
}
do {
self = try JSONDecoder().decode(Config.self, from: data)
} catch {
print("Failed to decode config data: \(error)")
printAndFlush("Failed to decode config data: \(error)")
throw error
}
}

View File

@ -3,74 +3,63 @@ import WebSocketKit
import Vapor
import Clairvoyant
enum DeviceState: UInt8 {
case disconnected = 0
case connected = 1
case authenticated = 2
}
extension DeviceState: MetricValue {
static let valueType: MetricType = .customType(named: "DeviceState")
}
final class DeviceManager {
/// The connection to the device
private var connection: WebSocket?
/// The authentication token of the device for the socket connection
private let deviceKey: Data
/// The authentication token of the remote
private let remoteKey: Data
/// Indicate that the socket is fully initialized with an authorized device
private var deviceIsAuthenticated = false
private let deviceTimeout: Int64
private let deviceStateMetric: Metric<DeviceState>
private let deviceConnectedMetric: Metric<Bool>
private let messagesToDeviceMetric: Metric<Int>
var deviceState: DeviceState {
guard let connection, !connection.isClosed else {
return .disconnected
}
guard deviceIsAuthenticated else {
return .connected
}
return .authenticated
}
let serverStatus: Metric<ServerStatus>
/// Indicator for device availability
var deviceIsConnected: Bool {
deviceIsAuthenticated && !(connection?.isClosed ?? true)
guard let connection, !connection.isClosed else {
return false
}
return true
}
/// A promise to finish the request once the device responds or times out
private var requestInProgress: CheckedContinuation<Data, Error>?
private var receivedMessageData: Data?
init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64) {
var logger: Logger?
private func printAndFlush(_ message: String) {
logger?.notice(.init(stringLiteral: message))
}
init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64, serverStatus: Metric<ServerStatus>) {
self.deviceKey = deviceKey
self.remoteKey = remoteKey
self.deviceTimeout = deviceTimeout
self.deviceStateMetric = .init(
"sesame.device",
name: "Device status",
description: "Shows if the device is connected and authenticated via WebSocket")
self.deviceConnectedMetric = .init(
"sesame.connected",
name: "Device connection",
description: "Shows if the device is connected via WebSocket")
self.messagesToDeviceMetric = .init(
"sesame.messages",
name: "Forwarded Messages",
description: "The number of messages transmitted to the device")
self.serverStatus = serverStatus
}
func updateDeviceConnectionMetric() async {
_ = try? await deviceStateMetric.update(deviceState)
func updateDeviceConnectionMetrics() async {
let isConnected = deviceIsConnected
_ = try? await serverStatus.update(isConnected ? .nominal : .reducedFunctionality)
_ = try? await deviceConnectedMetric.update(isConnected)
}
private func updateMessageCountMetric() async {
let lastValue = await messagesToDeviceMetric.lastValue()?.value ?? 0
_ = try? await messagesToDeviceMetric.update(lastValue + 1)
@ -78,61 +67,91 @@ final class DeviceManager {
// MARK: API
private var deviceStatus: String {
"\(deviceState.rawValue)"
}
func sendMessageToDevice(_ message: Data, on eventLoop: EventLoop) async throws -> Data {
func sendMessageToDevice(_ message: Data, authToken: Data, on eventLoop: EventLoop) async throws -> Data {
guard message.count == SignedMessage.size else {
throw MessageResult.invalidMessageSizeFromRemote
}
guard SHA256.hash(data: authToken) == remoteKey else {
throw MessageResult.invalidServerAuthenticationFromRemote
}
guard let socket = connection, !socket.isClosed else {
connection = nil
// Ensure that metric is updated
didCloseDeviceSocket()
throw MessageResult.deviceNotConnected
}
guard requestInProgress == nil else {
throw MessageResult.operationInProgress
guard receivedMessageData == nil else {
throw MessageResult.tooManyRequests
}
// Indicate that a message is in transit
receivedMessageData = Data()
do {
try await socket.send(Array(message))
await updateMessageCountMetric()
} catch {
throw MessageResult.deviceNotConnected
}
startTimeoutForDeviceRequest(on: eventLoop)
// Check if a full message has already been received
if let receivedMessageData, receivedMessageData.count == SignedMessage.size {
self.receivedMessageData = nil
return receivedMessageData
}
// Wait until a fill message is received, or a timeout occurs
let result: Data = try await withCheckedThrowingContinuation { continuation in
self.requestInProgress = continuation
}
await updateMessageCountMetric()
return result
}
private func startTimeoutForDeviceRequest(on eventLoop: EventLoop) {
eventLoop.scheduleTask(in: .seconds(deviceTimeout)) { [weak self] in
self?.resumeDeviceRequest(with: .deviceTimedOut)
guard let self else {
log("[WARN] No reference to self after timeout of message")
return
}
self.resumeDeviceRequest(with: .deviceTimedOut)
}
}
private func resumeDeviceRequest(with data: Data) {
requestInProgress?.resume(returning: data)
requestInProgress = nil
guard let receivedMessageData else {
log("[WARN] Received \(data.count) bytes after message completion")
self.requestInProgress = nil
return
}
let newData = receivedMessageData + data
if newData.count < SignedMessage.size {
// Wait for more data
self.receivedMessageData = newData
return
}
self.receivedMessageData = nil
guard let requestInProgress else {
log("[WARN] Received \(newData.count) bytes, but no continuation to resume")
return
}
self.requestInProgress = nil
guard newData.count == SignedMessage.size else {
log("[WARN] Received \(newData.count) bytes, expected \(SignedMessage.size) for a message.")
requestInProgress.resume(throwing: MessageResult.invalidMessageSizeFromDevice)
return
}
requestInProgress.resume(returning: newData)
}
private func resumeDeviceRequest(with result: MessageResult) {
requestInProgress?.resume(throwing: result)
requestInProgress = nil
}
func authenticateDevice(hash: String) async {
guard let key = Data(fromHexEncodedString: hash),
SHA256.hash(data: key) == self.deviceKey else {
log("Invalid device key")
await removeDeviceConnection()
guard let receivedMessageData else {
self.requestInProgress = nil
return
}
guard let connection, !connection.isClosed else {
await updateDeviceConnectionMetric()
self.receivedMessageData = nil
guard let requestInProgress else {
log("[WARN] Request in progress (\(receivedMessageData.count) bytes), but no continuation found for result: \(result)")
return
}
deviceIsAuthenticated = true
await updateDeviceConnectionMetric()
self.requestInProgress = nil
requestInProgress.resume(throwing: result)
}
func authenticateRemote(_ token: Data) -> Bool {
@ -142,40 +161,67 @@ final class DeviceManager {
func processDeviceResponse(_ buffer: ByteBuffer) {
guard let data = buffer.getData(at: 0, length: buffer.readableBytes) else {
log("Failed to get data buffer received from device")
self.resumeDeviceRequest(with: .invalidDeviceResponse)
log("[WARN] Failed to get data buffer received from device")
self.resumeDeviceRequest(with: .invalidMessageSizeFromDevice)
return
}
self.resumeDeviceRequest(with: data)
}
func didCloseDeviceSocket() {
deviceIsAuthenticated = false
connection = nil
Task {
await updateDeviceConnectionMetrics()
}
}
func removeDeviceConnection() async {
try? await connection?.close()
connection = nil
deviceIsAuthenticated = false
await updateDeviceConnectionMetric()
await updateDeviceConnectionMetrics()
}
func createNewDeviceConnection(_ socket: WebSocket) async {
func createNewDeviceConnection(socket: WebSocket, auth: String) async {
guard let key = Data(fromHexEncodedString: auth),
SHA256.hash(data: key) == self.deviceKey else {
log("[WARN] Invalid device key while opening socket")
try? await socket.close()
return
}
await removeDeviceConnection()
connection = socket
socket.eventLoop.execute {
socket.pingInterval = .seconds(10)
socket.onText { [weak self] socket, text in
self?.printAndFlush("[WARN] Received text over socket: \(text)")
// Close connection to prevent spamming the log
try? await socket.close()
guard let self else {
log("[WARN] No reference to self to handle text over socket")
return
}
self.didCloseDeviceSocket()
}
socket.onBinary { [weak self] _, data in
self?.processDeviceResponse(data)
guard let self else {
log("[WARN] No reference to self to process binary data on socket")
return
}
self.processDeviceResponse(data)
}
socket.onText { [weak self] _, text async in
await self?.authenticateDevice(hash: text)
}
_ = socket.onClose.always { [weak self] _ in
self?.didCloseDeviceSocket()
socket.onClose.whenComplete { [weak self] _ in
guard let self else {
log("[WARN] No reference to self to handle socket closing")
return
}
self.didCloseDeviceSocket()
}
}
connection = socket
await updateDeviceConnectionMetric()
await updateDeviceConnectionMetrics()
}
}

16
Sources/App/Print.swift Normal file
View File

@ -0,0 +1,16 @@
import Foundation
#if os(Linux)
import Glibc
#else
import Darwin.C
#endif
func printAndFlush(_ message: String) {
print(message)
flushStdout()
}
func flushStdout() {
fflush(stdout)
}

View File

@ -6,7 +6,6 @@ import ClairvoyantBinaryCodable
var deviceManager: DeviceManager!
private var provider: VaporMetricProvider!
private var status: Metric<ServerStatus>!
private var asyncScheduler = MultiThreadedEventLoopGroup(numberOfThreads: 2)
@ -17,23 +16,8 @@ private let df: DateFormatter = {
return df
}()
enum ServerError: Error {
case invalidAuthenticationFileContent
case invalidAuthenticationToken
}
private func updateStatus(_ newStatus: ServerStatus) {
asyncScheduler.schedule {
do {
try await status.update(newStatus)
} catch {
print("Failed to update server status: \(error)")
}
}
}
// configures your application
public func configure(_ app: Application) throws {
public func configure(_ app: Application) async throws {
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
let configUrl = storageFolder.appendingPathComponent("config.json")
@ -44,44 +28,38 @@ public func configure(_ app: Application) throws {
let monitor = MetricObserver(logFileFolder: logFolder, logMetricId: "sesame.log")
MetricObserver.standard = monitor
status = Metric<ServerStatus>("sesame.status")
updateStatus(.initializing)
let status = Metric<ServerStatus>("sesame.status")
try await status.update(.initializing)
app.http.server.configuration.port = config.port
let keyFile = storageFolder.appendingPathComponent(config.keyFileName)
let keyFile = config.keyURL(possiblyRelativeTo: storageFolder)
let (deviceKey, remoteKey) = try loadKeys(at: keyFile)
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey, deviceTimeout: config.deviceTimeout)
try routes(app)
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey, deviceTimeout: config.deviceTimeout, serverStatus: status)
deviceManager.logger = app.logger
routes(app)
provider = .init(observer: monitor, accessManager: config.authenticationTokens)
provider.asyncScheduler = asyncScheduler
provider.registerRoutes(app)
monitor.saveCurrentListOfMetricsToLogFolder()
updateStatus(.nominal)
// Update the metric of the device status to ensure that it is accurate
asyncScheduler.schedule {
await deviceManager.updateDeviceConnectionMetric()
}
// Update the metric of the device and server status
await deviceManager.updateDeviceConnectionMetrics()
log("[\(df.string(from: Date()))] Server started")
}
public func shutdown() {
print("[\(df.string(from: Date()))] Server shutdown")
asyncScheduler.schedule {
// Gracefully shut down by closing potentially open socket
await deviceManager.removeDeviceConnection()
do {
try await asyncScheduler.shutdownGracefully()
} catch {
print("Failed to shut down MultiThreadedEventLoopGroup: \(error)")
}
public func shutdown() async {
// Gracefully shut down by closing potentially open socket
await deviceManager.removeDeviceConnection()
do {
try await asyncScheduler.shutdownGracefully()
} catch {
printAndFlush("Failed to shut down MultiThreadedEventLoopGroup: \(error)")
}
printAndFlush("[\(df.string(from: Date()))] Server shutdown")
}
private func loadKeys(at url: URL) throws -> (deviceKey: Data, remoteKey: Data) {
@ -91,25 +69,26 @@ private func loadKeys(at url: URL) throws -> (deviceKey: Data, remoteKey: Data)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.map {
guard let key = Data(fromHexEncodedString: $0) else {
throw ServerError.invalidAuthenticationToken
fatalError("Invalid key data: Failed to convert hex to binary.")
}
guard key.count == SHA256.byteCount else {
throw ServerError.invalidAuthenticationToken
fatalError("Invalid key data: Length should be \(SHA256.byteCount), not \(key.count)")
}
return key
}
guard authContent.count == 2 else {
throw ServerError.invalidAuthenticationFileContent
fatalError("Invalid keys: Expected 2, found \(authContent.count)")
}
return (deviceKey: authContent[0], remoteKey: authContent[1])
}
func log(_ message: String) {
guard let observer = MetricObserver.standard else {
print(message)
printAndFlush(message)
return
}
asyncScheduler.schedule {
await observer.log(message)
flushStdout()
}
}

View File

@ -0,0 +1,50 @@
import Vapor
import Dispatch
import Logging
/// This extension is temporary and can be removed once Vapor gets this support.
private extension Vapor.Application {
static let baseExecutionQueue = DispatchQueue(label: "vapor.codes.entrypoint")
func runFromAsyncMainEntrypoint() async throws {
try await withCheckedThrowingContinuation { continuation in
Vapor.Application.baseExecutionQueue.async { [self] in
do {
try self.run()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
func cleanup() async {
await shutdown()
app.shutdown()
}
do {
try await configure(app)
} catch {
app.logger.report(error: error)
await cleanup()
throw error
}
do {
try await app.runFromAsyncMainEntrypoint()
await cleanup()
} catch {
await cleanup()
throw error
}
}
}

View File

@ -1,83 +1,48 @@
import Vapor
extension RouteAPI {
var path: PathComponent {
.init(stringLiteral: rawValue)
}
var pathParameter: PathComponent {
.parameter(rawValue)
}
}
private func messageTransmission(_ req: Request) async throws -> Data {
guard let body = req.body.data else {
throw MessageResult.noBodyData
}
guard let message = ServerMessage(decodeFrom: body) else {
throw MessageResult.invalidMessageSize
}
guard deviceManager.authenticateRemote(message.authToken) else {
throw MessageResult.messageAuthenticationFailed
}
return try await deviceManager.sendMessageToDevice(message.message, on: req.eventLoop)
}
private func deviceStatus(_ req: Request) -> MessageResult {
guard let body = req.body.data else {
return .noBodyData
}
guard let authToken = ServerMessage.token(from: body) else {
return .invalidMessageSize
}
guard deviceManager.authenticateRemote(authToken) else {
return .messageAuthenticationFailed
}
guard deviceManager.deviceIsConnected else {
return .deviceNotConnected
}
return .deviceConnected
}
func routes(_ app: Application) throws {
/**
Get the connection status of the device.
The request expects the authentication token of the remote in the body data of the POST request.
The request returns one byte of data, which is the raw value of a `MessageResult`.
Possible results are `noBodyData`, `invalidMessageSize`, `deviceNotConnected`, `deviceConnected`.
*/
app.post(RouteAPI.getDeviceStatus.path) { request in
let result = deviceStatus(request)
return Response(status: .ok, body: .init(data: result.encoded))
}
func routes(_ app: Application) {
/**
Post a message to the device for unlocking.
The expects a `ServerMessage` in the body data of the POST request, containing the valid remote authentication token and the message to send to the device.
The expects a `Message` in the body data of the POST request, containing the message to send to the device.
Expects a header ``RouteAPI.authenticationHeader`` with the hexencoded authentication token with binary length ``ServerMessage.authTokenSize``.
The request returns one or `Message.length+1` bytes of data, where the first byte is the raw value of a `MessageResult`,
and the optional following bytes contain the response message of the device. This request does not complete until either the device responds or the request times out. The timeout is specified by `KeyManagement.deviceTimeout`.
The request returns the ``ServerMessage.messageSize`` bytes of data constituting the device response,
or a status code corresponding to a ``MessageResult``.
This request does not complete until either the device responds or the request times out.
The timeout is specified by the configuration parameter `deviceTimeout`.
*/
app.post(RouteAPI.postMessage.path) { request async throws in
app.post(SesameRoute.postMessage.path) { request async throws in
do {
let result = try await messageTransmission(request)
return Response(status: .ok, body: .init(data: result))
guard let authString = request.headers.first(name: SesameHeader.authenticationHeader),
let authToken = Data(fromHexEncodedString: authString),
authToken.count == SesameHeader.serverAuthenticationTokenSize else {
throw MessageResult.missingOrInvalidAuthenticationHeaderFromRemote
}
guard let body = request.body.data,
let message = body.getData(at: 0, length: body.readableBytes) else {
throw MessageResult.noOrInvalidBodyDataFromRemote
}
let responseMessage = try await deviceManager.sendMessageToDevice(message, authToken: authToken, on: request.eventLoop)
return Response(status: .ok, body: .init(data: responseMessage))
} catch let error as MessageResult {
return Response(status: .ok, body: .init(data: error.encoded))
return Response(status: .init(statusCode: error.statusCode))
}
}
/**
Start a new websocket connection for the device to receive messages from the server
- Returns: Nothing
- Note: The first message from the device over the connection must be a valid auth token.
Start a new websocket connection for the device to receive messages from the server.
The request must contain a header ``RouteAPI.socketAuthenticationHeader`` with a valid authentication token.
*/
app.webSocket(RouteAPI.socket.path) { req, socket async in
await deviceManager.createNewDeviceConnection(socket)
app.webSocket(SesameRoute.socket.path) { request, socket async in
guard let authToken = request.headers.first(name: SesameHeader.authenticationHeader) else {
try? await socket.close()
return
}
await deviceManager.createNewDeviceConnection(socket: socket, auth: authToken)
}
}

View File

@ -1,13 +0,0 @@
import App
import Vapor
var env = Environment.production //.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer {
app.shutdown()
shutdown()
}
try configure(app)
try app.run()

View File

@ -1,13 +0,0 @@
@testable import App
import XCTVapor
final class AppTests: XCTestCase {
func testEncodingUInt32() {
let input: UInt32 = 123
let data = input.encoded
let output = UInt32(data: data)
XCTAssertEqual(input, output)
}
}