Compare commits
25 Commits
ab2a14e00b
...
master
Author | SHA1 | Date | |
---|---|---|---|
db5311074a | |||
e0e2a1cb06 | |||
c9d13bb150 | |||
7968f64581 | |||
ad1c959ead | |||
1a1eeb6547 | |||
5e72137d0e | |||
b9f1827b63 | |||
4489092a6f | |||
160c9a1a97 | |||
d9bd0c6e30 | |||
9feab409ab | |||
1917d1d10e | |||
17d7a8e6c4 | |||
c25c4f3dc6 | |||
2e11023096 | |||
7652bb24a3 | |||
e76029270a | |||
ac22fcd4eb | |||
b2b3c74586 | |||
eb10ae6626 | |||
f4864127f8 | |||
6117ae8305 | |||
18fd850413 | |||
f60cb3c05a |
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@ Package.resolved
|
|||||||
.swiftpm
|
.swiftpm
|
||||||
.build
|
.build
|
||||||
Resources/config.json
|
Resources/config.json
|
||||||
|
Resources/logs
|
||||||
|
Resources/keys
|
||||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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:
|
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:
|
||||||
|
|
||||||
|
@ -11,27 +11,18 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/christophhagen/Clairvoyant", from: "0.13.0"),
|
.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/ClairvoyantVapor", from: "0.5.0"),
|
||||||
.package(url: "https://github.com/christophhagen/ClairvoyantBinaryCodable", from: "0.3.1"),
|
.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: [
|
targets: [
|
||||||
.target(
|
.executableTarget(
|
||||||
name: "App",
|
name: "App",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
||||||
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
|
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
|
||||||
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
|
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
|
||||||
],
|
.product(name: "Crypto", package: "swift-crypto"),
|
||||||
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))
|
|
||||||
]
|
]
|
||||||
),
|
)
|
||||||
.executableTarget(name: "Run", dependencies: [.target(name: "App")]),
|
|
||||||
.testTarget(name: "AppTests", dependencies: [
|
|
||||||
.target(name: "App"),
|
|
||||||
.product(name: "XCTVapor", package: "vapor"),
|
|
||||||
])
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
5
Sources/App/API Extensions/Message.swift
Normal file
5
Sources/App/API Extensions/Message.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Message {
|
||||||
|
|
||||||
|
}
|
9
Sources/App/API Extensions/SesameRoute+Path.swift
Normal file
9
Sources/App/API Extensions/SesameRoute+Path.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
import RoutingKit
|
||||||
|
|
||||||
|
extension SesameRoute {
|
||||||
|
|
||||||
|
var path: PathComponent {
|
||||||
|
.init(stringLiteral: rawValue)
|
||||||
|
}
|
||||||
|
}
|
5
Sources/App/API Extensions/SignedMessage.swift
Normal file
5
Sources/App/API Extensions/SignedMessage.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SignedMessage {
|
||||||
|
|
||||||
|
}
|
17
Sources/App/API/Extensions/Data+Coding.swift
Normal file
17
Sources/App/API/Extensions/Data+Coding.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -16,4 +16,7 @@ extension UInt32 {
|
|||||||
var encoded: Data {
|
var encoded: Data {
|
||||||
Data(from: CFSwapInt32HostToLittle(self))
|
Data(from: CFSwapInt32HostToLittle(self))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The size of a `UInt32` when converted to data
|
||||||
|
static let byteSize = MemoryLayout<UInt32>.size
|
||||||
}
|
}
|
8
Sources/App/API/Message+Size.swift
Normal file
8
Sources/App/API/Message+Size.swift
Normal 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
|
||||||
|
|
||||||
|
}
|
@ -3,98 +3,228 @@ import Foundation
|
|||||||
/**
|
/**
|
||||||
A result from sending a key to the device.
|
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
|
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
|
case unexpectedSocketEvent = 2
|
||||||
|
|
||||||
/// The size of the payload (i.e. message) was invalid
|
/// The received message size is invalid.
|
||||||
case invalidMessageSize = 3
|
case invalidMessageSizeFromRemote = 3
|
||||||
|
|
||||||
/// The transmitted message could not be authenticated using the key
|
/// The message signature was incorrect.
|
||||||
case messageAuthenticationFailed = 4
|
case invalidSignatureFromRemote = 4
|
||||||
|
|
||||||
/// The message time was not within the acceptable bounds
|
/// The server challenge of the message did not match previous messages
|
||||||
case messageTimeMismatch = 5
|
case invalidServerChallengeFromRemote = 5
|
||||||
|
|
||||||
/// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication)
|
/// The client challenge of the message did not match previous messages
|
||||||
case messageCounterInvalid = 6
|
case invalidClientChallengeFromRemote = 6
|
||||||
|
|
||||||
/// The key was accepted by the device, and the door will be opened
|
/// An unexpected or unsupported message type was received
|
||||||
case messageAccepted = 7
|
case invalidMessageTypeFromRemote = 7
|
||||||
|
|
||||||
/// The device id is invalid
|
/// A message is already being processed
|
||||||
case messageDeviceInvalid = 8
|
case tooManyRequests = 8
|
||||||
|
|
||||||
|
/// The received message result was not ``messageAccepted``
|
||||||
/// The request did not contain body data with the key
|
case invalidMessageResultFromRemote = 9
|
||||||
case noBodyData = 10
|
|
||||||
|
/// An invalid Url parameter was set sending a message to the device over a local connection
|
||||||
/// The device is not connected
|
case invalidUrlParameter = 10
|
||||||
case deviceNotConnected = 12
|
|
||||||
|
// MARK: Server status
|
||||||
/// The device did not respond within the timeout
|
|
||||||
case deviceTimedOut = 13
|
/// The body data posting a message was missing or of wrong length
|
||||||
|
case noOrInvalidBodyDataFromRemote = 21
|
||||||
/// Another message is being processed by the device
|
|
||||||
case operationInProgress = 14
|
/// The authentication token for the server was invalid
|
||||||
|
case invalidServerAuthenticationFromRemote = 22
|
||||||
/// The device is connected
|
|
||||||
case deviceConnected = 15
|
/// The request took too long to complete
|
||||||
|
case deviceTimedOut = 23
|
||||||
case invalidUrlParameter = 20
|
|
||||||
|
/// The device is not connected to the server via web socket
|
||||||
case invalidResponseAuthentication = 21
|
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 {
|
extension MessageResult: CustomStringConvertible {
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .messageAccepted:
|
||||||
|
return "Message accepted"
|
||||||
case .textReceived:
|
case .textReceived:
|
||||||
return "The device received unexpected text"
|
return "The device received unexpected text"
|
||||||
case .unexpectedSocketEvent:
|
case .unexpectedSocketEvent:
|
||||||
return "Unexpected socket event for the device"
|
return "Unexpected socket event for the device"
|
||||||
case .invalidMessageSize:
|
case .invalidMessageSizeFromRemote:
|
||||||
return "Invalid message data"
|
return "Invalid message data from remote"
|
||||||
case .messageAuthenticationFailed:
|
case .invalidSignatureFromRemote:
|
||||||
return "Message authentication failed"
|
return "Message authentication failed"
|
||||||
case .messageTimeMismatch:
|
case .invalidServerChallengeFromRemote:
|
||||||
return "Message time invalid"
|
return "Server challenge mismatch"
|
||||||
case .messageCounterInvalid:
|
case .invalidClientChallengeFromRemote:
|
||||||
return "Message counter invalid"
|
return "Wrong client challenge sent"
|
||||||
case .messageAccepted:
|
case .invalidMessageTypeFromRemote:
|
||||||
return "Message accepted"
|
return "Message type from remote invalid"
|
||||||
case .messageDeviceInvalid:
|
case .tooManyRequests:
|
||||||
return "Invalid device ID"
|
return "Device busy"
|
||||||
case .noBodyData:
|
case .invalidMessageResultFromRemote:
|
||||||
return "No body data included in the request"
|
return "Invalid message result"
|
||||||
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 .invalidUrlParameter:
|
case .invalidUrlParameter:
|
||||||
return "The url parameter could not be found"
|
return "The url parameter could not be found"
|
||||||
case .invalidResponseAuthentication:
|
|
||||||
return "The response could not be authenticated"
|
case .noOrInvalidBodyDataFromRemote:
|
||||||
case .invalidDeviceResponse:
|
return "Invalid body data in server request"
|
||||||
return "The device responded with invalid data"
|
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 {
|
extension MessageResult {
|
||||||
|
|
||||||
var encoded: Data {
|
var encoded: Data {
|
||||||
Data([rawValue])
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
14
Sources/App/API/SesameHeader.swift
Normal file
14
Sources/App/API/SesameHeader.swift
Normal 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
|
||||||
|
|
||||||
|
}
|
@ -3,10 +3,7 @@ import Foundation
|
|||||||
/**
|
/**
|
||||||
The active urls on the server, for the device and the remote to connect
|
The active urls on the server, for the device and the remote to connect
|
||||||
*/
|
*/
|
||||||
enum RouteAPI: String {
|
enum SesameRoute: String {
|
||||||
|
|
||||||
/// Check the device status
|
|
||||||
case getDeviceStatus = "status"
|
|
||||||
|
|
||||||
/// Send a message to the server, to relay to the device
|
/// Send a message to the server, to relay to the device
|
||||||
case postMessage = "message"
|
case postMessage = "message"
|
15
Sources/App/API/SignedMessage+Size.swift
Normal file
15
Sources/App/API/SignedMessage+Size.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,11 @@ struct Config {
|
|||||||
/// The port where the server runs
|
/// The port where the server runs
|
||||||
let port: Int
|
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
|
let keyFileName: String
|
||||||
|
|
||||||
/// The seconds to wait for a response from the device
|
/// 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
|
/// The authentication tokens to use for monitoring of the service
|
||||||
let authenticationTokens: Set<String>
|
let authenticationTokens: Set<String>
|
||||||
|
|
||||||
/// The path to the folder where the metric logs are stored
|
/**
|
||||||
///
|
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
|
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?
|
let logPath: String?
|
||||||
|
|
||||||
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
|
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
|
||||||
guard let logPath else {
|
guard let logPath else {
|
||||||
return resourcesDirectory.appendingPathComponent("logs")
|
return resourcesDirectory.appendingPathComponent("logs")
|
||||||
}
|
}
|
||||||
guard !logPath.hasPrefix("/") else {
|
return Config.url(logPath, possiblyRelativeTo: resourcesDirectory)
|
||||||
return .init(fileURLWithPath: logPath)
|
}
|
||||||
|
|
||||||
|
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,21 +53,21 @@ extension Config {
|
|||||||
|
|
||||||
init(loadFrom url: URL) throws {
|
init(loadFrom url: URL) throws {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
log("No configuration file found at \(url.path)")
|
printAndFlush("No configuration file found at \(url.path)")
|
||||||
fatalError("No configuration file found")
|
fatalError("No configuration file found")
|
||||||
}
|
}
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try Data(contentsOf: url)
|
data = try Data(contentsOf: url)
|
||||||
} catch {
|
} catch {
|
||||||
log("Failed to read config data: \(error)")
|
printAndFlush("Failed to read config data: \(error)")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
self = try JSONDecoder().decode(Config.self, from: data)
|
self = try JSONDecoder().decode(Config.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
log("Failed to decode config data: \(error)")
|
printAndFlush("Failed to decode config data: \(error)")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,74 +3,63 @@ import WebSocketKit
|
|||||||
import Vapor
|
import Vapor
|
||||||
import Clairvoyant
|
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 {
|
final class DeviceManager {
|
||||||
|
|
||||||
/// The connection to the device
|
/// The connection to the device
|
||||||
private var connection: WebSocket?
|
private var connection: WebSocket?
|
||||||
|
|
||||||
/// The authentication token of the device for the socket connection
|
/// The authentication token of the device for the socket connection
|
||||||
private let deviceKey: Data
|
private let deviceKey: Data
|
||||||
|
|
||||||
/// The authentication token of the remote
|
/// The authentication token of the remote
|
||||||
private let remoteKey: Data
|
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 deviceTimeout: Int64
|
||||||
|
|
||||||
private let deviceStateMetric: Metric<DeviceState>
|
private let deviceConnectedMetric: Metric<Bool>
|
||||||
|
|
||||||
private let messagesToDeviceMetric: Metric<Int>
|
private let messagesToDeviceMetric: Metric<Int>
|
||||||
|
|
||||||
var deviceState: DeviceState {
|
let serverStatus: Metric<ServerStatus>
|
||||||
guard let connection, !connection.isClosed else {
|
|
||||||
return .disconnected
|
|
||||||
}
|
|
||||||
guard deviceIsAuthenticated else {
|
|
||||||
return .connected
|
|
||||||
}
|
|
||||||
return .authenticated
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicator for device availability
|
|
||||||
var deviceIsConnected: Bool {
|
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
|
/// A promise to finish the request once the device responds or times out
|
||||||
private var requestInProgress: CheckedContinuation<Data, Error>?
|
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.deviceKey = deviceKey
|
||||||
self.remoteKey = remoteKey
|
self.remoteKey = remoteKey
|
||||||
self.deviceTimeout = deviceTimeout
|
self.deviceTimeout = deviceTimeout
|
||||||
self.deviceStateMetric = .init(
|
self.deviceConnectedMetric = .init(
|
||||||
"sesame.device",
|
"sesame.connected",
|
||||||
name: "Device status",
|
name: "Device connection",
|
||||||
description: "Shows if the device is connected and authenticated via WebSocket")
|
description: "Shows if the device is connected via WebSocket")
|
||||||
self.messagesToDeviceMetric = .init(
|
self.messagesToDeviceMetric = .init(
|
||||||
"sesame.messages",
|
"sesame.messages",
|
||||||
name: "Forwarded Messages",
|
name: "Forwarded Messages",
|
||||||
description: "The number of messages transmitted to the device")
|
description: "The number of messages transmitted to the device")
|
||||||
|
self.serverStatus = serverStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDeviceConnectionMetric() async {
|
func updateDeviceConnectionMetrics() async {
|
||||||
_ = try? await deviceStateMetric.update(deviceState)
|
let isConnected = deviceIsConnected
|
||||||
|
_ = try? await serverStatus.update(isConnected ? .nominal : .reducedFunctionality)
|
||||||
|
_ = try? await deviceConnectedMetric.update(isConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMessageCountMetric() async {
|
private func updateMessageCountMetric() async {
|
||||||
let lastValue = await messagesToDeviceMetric.lastValue()?.value ?? 0
|
let lastValue = await messagesToDeviceMetric.lastValue()?.value ?? 0
|
||||||
_ = try? await messagesToDeviceMetric.update(lastValue + 1)
|
_ = try? await messagesToDeviceMetric.update(lastValue + 1)
|
||||||
@ -78,61 +67,91 @@ final class DeviceManager {
|
|||||||
|
|
||||||
// MARK: API
|
// MARK: API
|
||||||
|
|
||||||
private var deviceStatus: String {
|
func sendMessageToDevice(_ message: Data, authToken: Data, on eventLoop: EventLoop) async throws -> Data {
|
||||||
"\(deviceState.rawValue)"
|
guard message.count == SignedMessage.size else {
|
||||||
}
|
throw MessageResult.invalidMessageSizeFromRemote
|
||||||
|
}
|
||||||
func sendMessageToDevice(_ message: Data, on eventLoop: EventLoop) async throws -> Data {
|
guard SHA256.hash(data: authToken) == remoteKey else {
|
||||||
|
throw MessageResult.invalidServerAuthenticationFromRemote
|
||||||
|
}
|
||||||
guard let socket = connection, !socket.isClosed else {
|
guard let socket = connection, !socket.isClosed else {
|
||||||
connection = nil
|
// Ensure that metric is updated
|
||||||
|
didCloseDeviceSocket()
|
||||||
throw MessageResult.deviceNotConnected
|
throw MessageResult.deviceNotConnected
|
||||||
}
|
}
|
||||||
guard requestInProgress == nil else {
|
guard receivedMessageData == nil else {
|
||||||
throw MessageResult.operationInProgress
|
throw MessageResult.tooManyRequests
|
||||||
}
|
}
|
||||||
|
// Indicate that a message is in transit
|
||||||
|
receivedMessageData = Data()
|
||||||
do {
|
do {
|
||||||
try await socket.send(Array(message))
|
try await socket.send(Array(message))
|
||||||
await updateMessageCountMetric()
|
|
||||||
} catch {
|
} catch {
|
||||||
throw MessageResult.deviceNotConnected
|
throw MessageResult.deviceNotConnected
|
||||||
}
|
}
|
||||||
startTimeoutForDeviceRequest(on: eventLoop)
|
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
|
let result: Data = try await withCheckedThrowingContinuation { continuation in
|
||||||
self.requestInProgress = continuation
|
self.requestInProgress = continuation
|
||||||
}
|
}
|
||||||
|
await updateMessageCountMetric()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTimeoutForDeviceRequest(on eventLoop: EventLoop) {
|
private func startTimeoutForDeviceRequest(on eventLoop: EventLoop) {
|
||||||
eventLoop.scheduleTask(in: .seconds(deviceTimeout)) { [weak self] in
|
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) {
|
private func resumeDeviceRequest(with data: Data) {
|
||||||
requestInProgress?.resume(returning: data)
|
guard let receivedMessageData else {
|
||||||
requestInProgress = nil
|
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) {
|
private func resumeDeviceRequest(with result: MessageResult) {
|
||||||
requestInProgress?.resume(throwing: result)
|
guard let receivedMessageData else {
|
||||||
requestInProgress = nil
|
self.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()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let connection, !connection.isClosed else {
|
self.receivedMessageData = nil
|
||||||
await updateDeviceConnectionMetric()
|
guard let requestInProgress else {
|
||||||
|
log("[WARN] Request in progress (\(receivedMessageData.count) bytes), but no continuation found for result: \(result)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceIsAuthenticated = true
|
self.requestInProgress = nil
|
||||||
await updateDeviceConnectionMetric()
|
requestInProgress.resume(throwing: result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authenticateRemote(_ token: Data) -> Bool {
|
func authenticateRemote(_ token: Data) -> Bool {
|
||||||
@ -142,40 +161,67 @@ final class DeviceManager {
|
|||||||
|
|
||||||
func processDeviceResponse(_ buffer: ByteBuffer) {
|
func processDeviceResponse(_ buffer: ByteBuffer) {
|
||||||
guard let data = buffer.getData(at: 0, length: buffer.readableBytes) else {
|
guard let data = buffer.getData(at: 0, length: buffer.readableBytes) else {
|
||||||
log("Failed to get data buffer received from device")
|
log("[WARN] Failed to get data buffer received from device")
|
||||||
self.resumeDeviceRequest(with: .invalidDeviceResponse)
|
self.resumeDeviceRequest(with: .invalidMessageSizeFromDevice)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.resumeDeviceRequest(with: data)
|
self.resumeDeviceRequest(with: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didCloseDeviceSocket() {
|
func didCloseDeviceSocket() {
|
||||||
deviceIsAuthenticated = false
|
|
||||||
connection = nil
|
connection = nil
|
||||||
|
Task {
|
||||||
|
await updateDeviceConnectionMetrics()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeDeviceConnection() async {
|
func removeDeviceConnection() async {
|
||||||
try? await connection?.close()
|
try? await connection?.close()
|
||||||
connection = nil
|
connection = nil
|
||||||
deviceIsAuthenticated = false
|
await updateDeviceConnectionMetrics()
|
||||||
await updateDeviceConnectionMetric()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
await removeDeviceConnection()
|
||||||
|
|
||||||
|
connection = socket
|
||||||
socket.eventLoop.execute {
|
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
|
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.whenComplete { [weak self] _ in
|
||||||
}
|
guard let self else {
|
||||||
|
log("[WARN] No reference to self to handle socket closing")
|
||||||
_ = socket.onClose.always { [weak self] _ in
|
return
|
||||||
self?.didCloseDeviceSocket()
|
}
|
||||||
|
self.didCloseDeviceSocket()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection = socket
|
await updateDeviceConnectionMetrics()
|
||||||
await updateDeviceConnectionMetric()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
Sources/App/Print.swift
Normal file
16
Sources/App/Print.swift
Normal 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)
|
||||||
|
}
|
@ -16,13 +16,8 @@ private let df: DateFormatter = {
|
|||||||
return df
|
return df
|
||||||
}()
|
}()
|
||||||
|
|
||||||
enum ServerError: Error {
|
|
||||||
case invalidAuthenticationFileContent
|
|
||||||
case invalidAuthenticationToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// configures your application
|
// configures your application
|
||||||
public func configure(_ app: Application) throws {
|
public func configure(_ app: Application) async throws {
|
||||||
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
||||||
|
|
||||||
let configUrl = storageFolder.appendingPathComponent("config.json")
|
let configUrl = storageFolder.appendingPathComponent("config.json")
|
||||||
@ -34,45 +29,37 @@ public func configure(_ app: Application) throws {
|
|||||||
MetricObserver.standard = monitor
|
MetricObserver.standard = monitor
|
||||||
|
|
||||||
let status = Metric<ServerStatus>("sesame.status")
|
let status = Metric<ServerStatus>("sesame.status")
|
||||||
asyncScheduler.schedule {
|
try await status.update(.initializing)
|
||||||
_ = try await status.update(.initializing)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.http.server.configuration.port = config.port
|
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)
|
let (deviceKey, remoteKey) = try loadKeys(at: keyFile)
|
||||||
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey, deviceTimeout: config.deviceTimeout)
|
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey, deviceTimeout: config.deviceTimeout, serverStatus: status)
|
||||||
|
deviceManager.logger = app.logger
|
||||||
try routes(app)
|
routes(app)
|
||||||
|
|
||||||
provider = .init(observer: monitor, accessManager: config.authenticationTokens)
|
provider = .init(observer: monitor, accessManager: config.authenticationTokens)
|
||||||
provider.asyncScheduler = asyncScheduler
|
provider.asyncScheduler = asyncScheduler
|
||||||
provider.registerRoutes(app)
|
provider.registerRoutes(app)
|
||||||
monitor.saveCurrentListOfMetricsToLogFolder()
|
monitor.saveCurrentListOfMetricsToLogFolder()
|
||||||
|
|
||||||
asyncScheduler.schedule {
|
// Update the metric of the device and server status
|
||||||
_ = try await status.update(.nominal)
|
await deviceManager.updateDeviceConnectionMetrics()
|
||||||
await deviceManager.updateDeviceConnectionMetric()
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[\(df.string(from: Date()))] Server started")
|
log("[\(df.string(from: Date()))] Server started")
|
||||||
|
|
||||||
// Gracefully shut down by closing potentially open socket
|
|
||||||
// Must be done after app is running, otherwise error is thrown
|
|
||||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {
|
|
||||||
_ = app.server.onShutdown.always { _ in
|
|
||||||
print("[\(df.string(from: Date()))] Server shutdown")
|
|
||||||
asyncScheduler.schedule {
|
|
||||||
await deviceManager.removeDeviceConnection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func shutdown() {
|
public func shutdown() async {
|
||||||
try? asyncScheduler.syncShutdownGracefully()
|
// 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) {
|
private func loadKeys(at url: URL) throws -> (deviceKey: Data, remoteKey: Data) {
|
||||||
@ -82,25 +69,26 @@ private func loadKeys(at url: URL) throws -> (deviceKey: Data, remoteKey: Data)
|
|||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.map {
|
.map {
|
||||||
guard let key = Data(fromHexEncodedString: $0) else {
|
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 {
|
guard key.count == SHA256.byteCount else {
|
||||||
throw ServerError.invalidAuthenticationToken
|
fatalError("Invalid key data: Length should be \(SHA256.byteCount), not \(key.count)")
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
guard authContent.count == 2 else {
|
guard authContent.count == 2 else {
|
||||||
throw ServerError.invalidAuthenticationFileContent
|
fatalError("Invalid keys: Expected 2, found \(authContent.count)")
|
||||||
}
|
}
|
||||||
return (deviceKey: authContent[0], remoteKey: authContent[1])
|
return (deviceKey: authContent[0], remoteKey: authContent[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(_ message: String) {
|
func log(_ message: String) {
|
||||||
guard let observer = MetricObserver.standard else {
|
guard let observer = MetricObserver.standard else {
|
||||||
print(message)
|
printAndFlush(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
asyncScheduler.schedule {
|
asyncScheduler.schedule {
|
||||||
await observer.log(message)
|
await observer.log(message)
|
||||||
|
flushStdout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
50
Sources/App/entrypoint.swift
Normal file
50
Sources/App/entrypoint.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,83 +1,48 @@
|
|||||||
import Vapor
|
import Vapor
|
||||||
|
|
||||||
extension RouteAPI {
|
func routes(_ app: Application) {
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Post a message to the device for unlocking.
|
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`,
|
The request returns the ``ServerMessage.messageSize`` bytes of data constituting the device response,
|
||||||
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`.
|
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 {
|
do {
|
||||||
let result = try await messageTransmission(request)
|
guard let authString = request.headers.first(name: SesameHeader.authenticationHeader),
|
||||||
return Response(status: .ok, body: .init(data: result))
|
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 {
|
} 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
|
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.
|
The request must contain a header ``RouteAPI.socketAuthenticationHeader`` with a valid authentication token.
|
||||||
*/
|
*/
|
||||||
app.webSocket(RouteAPI.socket.path) { req, socket async in
|
app.webSocket(SesameRoute.socket.path) { request, socket async in
|
||||||
await deviceManager.createNewDeviceConnection(socket)
|
guard let authToken = request.headers.first(name: SesameHeader.authenticationHeader) else {
|
||||||
|
try? await socket.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await deviceManager.createNewDeviceConnection(socket: socket, auth: authToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
Reference in New Issue
Block a user