Compare commits
No commits in common. "master" and "1.0.0" have entirely different histories.
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,6 +2,3 @@
|
|||||||
Package.resolved
|
Package.resolved
|
||||||
.swiftpm
|
.swiftpm
|
||||||
.build
|
.build
|
||||||
Resources/config.json
|
|
||||||
Resources/logs
|
|
||||||
Resources/keys
|
|
||||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 Christoph Hagen
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
|
@ -4,25 +4,28 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "SesameServer",
|
name: "SesameServer",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v12)
|
.macOS(.v10_15)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
|
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.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/ClairvoyantBinaryCodable", from: "0.3.1"),
|
|
||||||
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.target(
|
||||||
name: "App",
|
name: "App",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor")
|
||||||
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
],
|
||||||
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
|
swiftSettings: [
|
||||||
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
|
// Enable better optimizations when building in Release configuration. Despite the use of
|
||||||
.product(name: "Crypto", package: "swift-crypto"),
|
// 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"),
|
||||||
|
])
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"port": 6003,
|
|
||||||
"keyFileName": "keys",
|
|
||||||
"deviceTimeout": 20,
|
|
||||||
"authenticationTokens" : [],
|
|
||||||
}
|
|
2
Resources/keys
Normal file
2
Resources/keys
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000
|
@ -1,5 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum Message {
|
|
||||||
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import RoutingKit
|
|
||||||
|
|
||||||
extension SesameRoute {
|
|
||||||
|
|
||||||
var path: PathComponent {
|
|
||||||
.init(stringLiteral: rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SignedMessage {
|
|
||||||
|
|
||||||
}
|
|
@ -40,3 +40,20 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
90
Sources/App/API/DeviceResponse.swift
Normal file
90
Sources/App/API/DeviceResponse.swift
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
import NIOCore
|
||||||
|
|
||||||
|
/**
|
||||||
|
Encapsulates a response from a device.
|
||||||
|
*/
|
||||||
|
struct DeviceResponse {
|
||||||
|
|
||||||
|
/// Shorthand property for a timeout event.
|
||||||
|
static var deviceTimedOut: DeviceResponse {
|
||||||
|
.init(event: .deviceTimedOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand property for a disconnected event.
|
||||||
|
static var deviceNotConnected: DeviceResponse {
|
||||||
|
.init(event: .deviceNotConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand property for a connected event.
|
||||||
|
static var deviceConnected: DeviceResponse {
|
||||||
|
.init(event: .deviceConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand property for an unexpected socket event.
|
||||||
|
static var unexpectedSocketEvent: DeviceResponse {
|
||||||
|
.init(event: .unexpectedSocketEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand property for an invalid message.
|
||||||
|
static var invalidMessageData: DeviceResponse {
|
||||||
|
.init(event: .invalidMessageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand property for missing body data.
|
||||||
|
static var noBodyData: DeviceResponse {
|
||||||
|
.init(event: .noBodyData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand property for a busy connection
|
||||||
|
static var operationInProgress: DeviceResponse {
|
||||||
|
.init(event: .operationInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The response to a key from the server
|
||||||
|
let event: MessageResult
|
||||||
|
|
||||||
|
/// The index of the next key to use
|
||||||
|
let response: Message?
|
||||||
|
|
||||||
|
/**
|
||||||
|
Decode a message from a buffer.
|
||||||
|
|
||||||
|
The buffer must contain `Message.length+1` bytes. The first byte denotes the event type,
|
||||||
|
the remaining bytes contain the message.
|
||||||
|
- Parameter buffer: The buffer where the message bytes are stored
|
||||||
|
*/
|
||||||
|
init?(_ buffer: ByteBuffer) {
|
||||||
|
guard let byte = buffer.getBytes(at: 0, length: 1) else {
|
||||||
|
print("No bytes received from device")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let event = MessageResult(rawValue: byte[0]) else {
|
||||||
|
print("Unknown response \(byte[0]) received from device")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.event = event
|
||||||
|
guard let data = buffer.getSlice(at: 1, length: Message.length) else {
|
||||||
|
self.response = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.response = Message(decodeFrom: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create a response from an event without a message from the device.
|
||||||
|
- Parameter event: The response from the device.
|
||||||
|
*/
|
||||||
|
init(event: MessageResult) {
|
||||||
|
self.event = event
|
||||||
|
self.response = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the reponse encoded in bytes.
|
||||||
|
var encoded: Data {
|
||||||
|
guard let message = response else {
|
||||||
|
return Data([event.rawValue])
|
||||||
|
}
|
||||||
|
return Data([event.rawValue]) + message.encoded
|
||||||
|
}
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Message {
|
|
||||||
|
|
||||||
/// The byte length of an encoded message content
|
|
||||||
static let size: Int = 2 + 2 * UInt32.byteSize
|
|
||||||
|
|
||||||
}
|
|
151
Sources/App/API/Message.swift
Normal file
151
Sources/App/API/Message.swift
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import Foundation
|
||||||
|
import NIOCore
|
||||||
|
|
||||||
|
#if canImport(CryptoKit)
|
||||||
|
import CryptoKit
|
||||||
|
#else
|
||||||
|
import Crypto
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
An authenticated message to or from the device.
|
||||||
|
*/
|
||||||
|
struct Message: Equatable, Hashable {
|
||||||
|
|
||||||
|
/// The message authentication code for the message (32 bytes)
|
||||||
|
let mac: Data
|
||||||
|
|
||||||
|
/// The message content
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create an authenticated message
|
||||||
|
- Parameter mac: The message authentication code
|
||||||
|
- Parameter content: The message content
|
||||||
|
*/
|
||||||
|
init(mac: Data, content: Content) {
|
||||||
|
self.mac = mac
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Message {
|
||||||
|
|
||||||
|
/**
|
||||||
|
The message content without authentication.
|
||||||
|
*/
|
||||||
|
struct Content: Equatable, Hashable {
|
||||||
|
|
||||||
|
/// The time of message creation, in UNIX time (seconds since 1970)
|
||||||
|
let time: UInt32
|
||||||
|
|
||||||
|
/// The counter of the message (for freshness)
|
||||||
|
let id: UInt32
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create new message content.
|
||||||
|
- Parameter time: The time of message creation,
|
||||||
|
- Parameter id: The counter of the message
|
||||||
|
*/
|
||||||
|
init(time: UInt32, id: UInt32) {
|
||||||
|
self.time = time
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Decode message content from data.
|
||||||
|
|
||||||
|
The data consists of two `UInt32` encoded in little endian format
|
||||||
|
- Warning: The sequence must contain at least 8 bytes, or the function will crash.
|
||||||
|
- Parameter data: The sequence containing the bytes.
|
||||||
|
*/
|
||||||
|
init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
|
||||||
|
self.time = UInt32(data: Data(data.prefix(MemoryLayout<UInt32>.size)))
|
||||||
|
self.id = UInt32(data: Data(data.dropFirst(MemoryLayout<UInt32>.size)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte length of an encoded message content
|
||||||
|
static var length: Int {
|
||||||
|
MemoryLayout<UInt32>.size * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message content encoded to data
|
||||||
|
var encoded: Data {
|
||||||
|
time.encoded + id.encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Message {
|
||||||
|
|
||||||
|
/// The length of a message in bytes
|
||||||
|
static var length: Int {
|
||||||
|
SHA256.byteCount + Content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Decode a message from a byte buffer.
|
||||||
|
The buffer must contain at least `Message.length` bytes, or it will return `nil`.
|
||||||
|
- Parameter buffer: The buffer containing the bytes.
|
||||||
|
*/
|
||||||
|
init?(decodeFrom buffer: ByteBuffer) {
|
||||||
|
guard let data = buffer.getBytes(at: 0, length: Message.length) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(decodeFrom: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message encoded to data
|
||||||
|
var encoded: Data {
|
||||||
|
mac + content.encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes: [UInt8] {
|
||||||
|
Array(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create a message from received bytes.
|
||||||
|
- Parameter data: The sequence of bytes
|
||||||
|
- Note: The sequence must contain at least `Message.length` bytes, or the function will crash.
|
||||||
|
*/
|
||||||
|
init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
|
||||||
|
let count = SHA256.byteCount
|
||||||
|
self.mac = Data(data.prefix(count))
|
||||||
|
self.content = .init(decodeFrom: Array(data.dropFirst(count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Check if the message contains a valid authentication code
|
||||||
|
- Parameter key: The key used to sign the message.
|
||||||
|
- Returns: `true`, if the message is valid.
|
||||||
|
*/
|
||||||
|
func isValid(using key: SymmetricKey) -> Bool {
|
||||||
|
HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Message.Content {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Calculate an authentication code for the message content.
|
||||||
|
- Parameter key: The key to use to sign the content.
|
||||||
|
- Returns: The new message signed with the key.
|
||||||
|
*/
|
||||||
|
func authenticate(using key: SymmetricKey) -> Message {
|
||||||
|
let mac = HMAC<SHA256>.authenticationCode(for: encoded, using: key)
|
||||||
|
return .init(mac: Data(mac.map { $0 }), content: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Calculate an authentication code for the message content and convert everything to data.
|
||||||
|
- Parameter key: The key to use to sign the content.
|
||||||
|
- Returns: The new message signed with the key, serialized to bytes.
|
||||||
|
*/
|
||||||
|
func authenticateAndSerialize(using key: SymmetricKey) -> Data {
|
||||||
|
let encoded = self.encoded
|
||||||
|
let mac = HMAC<SHA256>.authenticationCode(for: encoded, using: key)
|
||||||
|
return Data(mac.map { $0 }) + encoded
|
||||||
|
}
|
||||||
|
}
|
@ -5,226 +5,72 @@ import Foundation
|
|||||||
*/
|
*/
|
||||||
enum MessageResult: UInt8 {
|
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
|
||||||
|
|
||||||
/// An unexpected socket event occured while performing the exchange.
|
/// A socket event on the device was unexpected (not binary data)
|
||||||
case unexpectedSocketEvent = 2
|
case unexpectedSocketEvent = 2
|
||||||
|
|
||||||
/// The received message size is invalid.
|
/// The size of the payload (i.e. message) was invalid, or the data could not be read
|
||||||
case invalidMessageSizeFromRemote = 3
|
case invalidMessageData = 3
|
||||||
|
|
||||||
/// The message signature was incorrect.
|
/// The transmitted message could not be authenticated using the key
|
||||||
case invalidSignatureFromRemote = 4
|
case messageAuthenticationFailed = 4
|
||||||
|
|
||||||
/// The server challenge of the message did not match previous messages
|
/// The message time was not within the acceptable bounds
|
||||||
case invalidServerChallengeFromRemote = 5
|
case messageTimeMismatch = 5
|
||||||
|
|
||||||
/// The client challenge of the message did not match previous messages
|
/// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication)
|
||||||
case invalidClientChallengeFromRemote = 6
|
case messageCounterInvalid = 6
|
||||||
|
|
||||||
/// An unexpected or unsupported message type was received
|
/// The key was accepted by the device, and the door will be opened
|
||||||
case invalidMessageTypeFromRemote = 7
|
case messageAccepted = 7
|
||||||
|
|
||||||
/// A message is already being processed
|
|
||||||
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
|
/// Another message is being processed by the device
|
||||||
case noOrInvalidBodyDataFromRemote = 21
|
case operationInProgress = 14
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
|
|
||||||
|
/// The device is connected
|
||||||
|
case deviceConnected = 15
|
||||||
}
|
}
|
||||||
|
|
||||||
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 .invalidMessageSizeFromRemote:
|
case .invalidMessageData:
|
||||||
return "Invalid message data from remote"
|
return "Invalid message data"
|
||||||
case .invalidSignatureFromRemote:
|
case .messageAuthenticationFailed:
|
||||||
return "Message authentication failed"
|
return "Message authentication failed"
|
||||||
case .invalidServerChallengeFromRemote:
|
case .messageTimeMismatch:
|
||||||
return "Server challenge mismatch"
|
return "Message time invalid"
|
||||||
case .invalidClientChallengeFromRemote:
|
case .messageCounterInvalid:
|
||||||
return "Wrong client challenge sent"
|
return "Message counter invalid"
|
||||||
case .invalidMessageTypeFromRemote:
|
case .messageAccepted:
|
||||||
return "Message type from remote invalid"
|
return "Message accepted"
|
||||||
case .tooManyRequests:
|
case .noBodyData:
|
||||||
return "Device busy"
|
return "No body data included in the request"
|
||||||
case .invalidMessageResultFromRemote:
|
case .deviceNotConnected:
|
||||||
return "Invalid message result"
|
return "Device not connected"
|
||||||
case .invalidUrlParameter:
|
|
||||||
return "The url parameter could not be found"
|
|
||||||
|
|
||||||
case .noOrInvalidBodyDataFromRemote:
|
|
||||||
return "Invalid body data in server request"
|
|
||||||
case .invalidServerAuthenticationFromRemote:
|
|
||||||
return "Invalid server token"
|
|
||||||
case .deviceTimedOut:
|
case .deviceTimedOut:
|
||||||
return "The device did not respond"
|
return "The device did not respond"
|
||||||
case .deviceNotConnected:
|
case .operationInProgress:
|
||||||
return "Device not connected to server"
|
return "Another operation is in progress"
|
||||||
case .invalidMessageSizeFromDevice:
|
case .deviceConnected:
|
||||||
return "Invalid device message size"
|
return "The device is connected"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,10 @@ 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 SesameRoute: String {
|
enum RouteAPI: 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"
|
46
Sources/App/API/ServerMessage.swift
Normal file
46
Sources/App/API/ServerMessage.swift
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
import NIOCore
|
||||||
|
|
||||||
|
#if canImport(CryptoKit)
|
||||||
|
import CryptoKit
|
||||||
|
#else
|
||||||
|
import Crypto
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct ServerMessage {
|
||||||
|
|
||||||
|
static let authTokenSize = SHA256.byteCount
|
||||||
|
|
||||||
|
static let length = authTokenSize + Message.length
|
||||||
|
|
||||||
|
let authToken: Data
|
||||||
|
|
||||||
|
let message: Message
|
||||||
|
|
||||||
|
/**
|
||||||
|
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 let data = buffer.getBytes(at: 0, length: ServerMessage.length) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.authToken = Data(data.prefix(ServerMessage.authTokenSize))
|
||||||
|
self.message = Message(decodeFrom: Data(data.dropFirst(ServerMessage.authTokenSize)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded: Data {
|
||||||
|
authToken + message.encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
#if canImport(CryptoKit)
|
|
||||||
import CryptoKit
|
|
||||||
#else
|
|
||||||
import Crypto
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum SesameHeader {
|
|
||||||
|
|
||||||
static let authenticationHeader = "Authorization"
|
|
||||||
|
|
||||||
static let serverAuthenticationTokenSize = SHA256.byteCount
|
|
||||||
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@ -16,7 +16,4 @@ 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
|
|
||||||
}
|
}
|
@ -3,71 +3,11 @@ import Foundation
|
|||||||
struct Config {
|
struct Config {
|
||||||
|
|
||||||
/// The port where the server runs
|
/// The port where the server runs
|
||||||
let port: Int
|
static let port = 6003
|
||||||
|
|
||||||
/**
|
/// 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.
|
static let keyFileName = "keys"
|
||||||
|
|
||||||
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
|
/// The seconds to wait for a response from the device
|
||||||
let deviceTimeout: Int64
|
static let deviceTimeout: Int64 = 20
|
||||||
|
|
||||||
/// 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.
|
|
||||||
*/
|
|
||||||
let logPath: String?
|
|
||||||
|
|
||||||
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
|
|
||||||
guard let logPath else {
|
|
||||||
return resourcesDirectory.appendingPathComponent("logs")
|
|
||||||
}
|
|
||||||
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(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Config: Codable {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Config {
|
|
||||||
|
|
||||||
init(loadFrom url: URL) throws {
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
||||||
printAndFlush("No configuration file found at \(url.path)")
|
|
||||||
fatalError("No configuration file found")
|
|
||||||
}
|
|
||||||
let data: Data
|
|
||||||
do {
|
|
||||||
data = try Data(contentsOf: url)
|
|
||||||
} catch {
|
|
||||||
printAndFlush("Failed to read config data: \(error)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
self = try JSONDecoder().decode(Config.self, from: data)
|
|
||||||
} catch {
|
|
||||||
printAndFlush("Failed to decode config data: \(error)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import WebSocketKit
|
import WebSocketKit
|
||||||
import Vapor
|
import Vapor
|
||||||
import Clairvoyant
|
|
||||||
|
|
||||||
final class DeviceManager {
|
final class DeviceManager {
|
||||||
|
|
||||||
@ -14,144 +13,60 @@ final class DeviceManager {
|
|||||||
/// The authentication token of the remote
|
/// The authentication token of the remote
|
||||||
private let remoteKey: Data
|
private let remoteKey: Data
|
||||||
|
|
||||||
private let deviceTimeout: Int64
|
/// Indicate that the socket is fully initialized with an authorized device
|
||||||
|
var deviceIsAuthenticated = false
|
||||||
|
|
||||||
private let deviceConnectedMetric: Metric<Bool>
|
private var isOpeningNewConnection = false
|
||||||
|
|
||||||
private let messagesToDeviceMetric: Metric<Int>
|
|
||||||
|
|
||||||
let serverStatus: Metric<ServerStatus>
|
|
||||||
|
|
||||||
|
/// Indicator for device availability
|
||||||
var deviceIsConnected: Bool {
|
var deviceIsConnected: Bool {
|
||||||
guard let connection, !connection.isClosed else {
|
deviceIsAuthenticated && !(connection?.isClosed ?? true)
|
||||||
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: EventLoopPromise<DeviceResponse>?
|
||||||
private var receivedMessageData: Data?
|
|
||||||
|
|
||||||
var logger: Logger?
|
init(deviceKey: Data, remoteKey: Data) {
|
||||||
|
|
||||||
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.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 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: API
|
// MARK: API
|
||||||
|
|
||||||
func sendMessageToDevice(_ message: Data, authToken: Data, on eventLoop: EventLoop) async throws -> Data {
|
var deviceStatus: String {
|
||||||
guard message.count == SignedMessage.size else {
|
deviceIsConnected ? "1" : "0"
|
||||||
throw MessageResult.invalidMessageSizeFromRemote
|
|
||||||
}
|
|
||||||
guard SHA256.hash(data: authToken) == remoteKey else {
|
|
||||||
throw MessageResult.invalidServerAuthenticationFromRemote
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendMessageToDevice(_ message: Message, on eventLoop: EventLoop) -> EventLoopFuture<DeviceResponse> {
|
||||||
guard let socket = connection, !socket.isClosed else {
|
guard let socket = connection, !socket.isClosed else {
|
||||||
// Ensure that metric is updated
|
connection = nil
|
||||||
didCloseDeviceSocket()
|
return eventLoop.makeSucceededFuture(.deviceNotConnected)
|
||||||
throw MessageResult.deviceNotConnected
|
|
||||||
}
|
}
|
||||||
guard receivedMessageData == nil else {
|
guard requestInProgress == nil else {
|
||||||
throw MessageResult.tooManyRequests
|
return eventLoop.makeSucceededFuture(.operationInProgress)
|
||||||
}
|
}
|
||||||
// Indicate that a message is in transit
|
requestInProgress = eventLoop.makePromise(of: DeviceResponse.self)
|
||||||
receivedMessageData = Data()
|
socket.send(message.bytes, promise: nil)
|
||||||
do {
|
eventLoop.scheduleTask(in: .seconds(Config.deviceTimeout)) { [weak self] in
|
||||||
try await socket.send(Array(message))
|
guard let promise = self?.requestInProgress else {
|
||||||
} catch {
|
return
|
||||||
throw MessageResult.deviceNotConnected
|
|
||||||
}
|
}
|
||||||
startTimeoutForDeviceRequest(on: eventLoop)
|
self?.requestInProgress = nil
|
||||||
|
promise.succeed(.deviceTimedOut)
|
||||||
// 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
|
return requestInProgress!.futureResult
|
||||||
let result: Data = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
self.requestInProgress = continuation
|
|
||||||
}
|
|
||||||
await updateMessageCountMetric()
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTimeoutForDeviceRequest(on eventLoop: EventLoop) {
|
func authenticateDevice(hash: String) {
|
||||||
eventLoop.scheduleTask(in: .seconds(deviceTimeout)) { [weak self] in
|
guard let key = Data(fromHexEncodedString: hash),
|
||||||
guard let self else {
|
SHA256.hash(data: key) == self.deviceKey else {
|
||||||
log("[WARN] No reference to self after timeout of message")
|
print("Invalid device key")
|
||||||
|
_ = connection?.close()
|
||||||
|
deviceIsAuthenticated = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.resumeDeviceRequest(with: .deviceTimedOut)
|
print("Device authenticated")
|
||||||
}
|
deviceIsAuthenticated = true
|
||||||
}
|
|
||||||
|
|
||||||
private func resumeDeviceRequest(with data: Data) {
|
|
||||||
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) {
|
|
||||||
guard let receivedMessageData else {
|
|
||||||
self.requestInProgress = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.receivedMessageData = nil
|
|
||||||
guard let requestInProgress else {
|
|
||||||
log("[WARN] Request in progress (\(receivedMessageData.count) bytes), but no continuation found for result: \(result)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.requestInProgress = nil
|
|
||||||
requestInProgress.resume(throwing: result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func authenticateRemote(_ token: Data) -> Bool {
|
func authenticateRemote(_ token: Data) -> Bool {
|
||||||
@ -159,69 +74,42 @@ final class DeviceManager {
|
|||||||
return hash == remoteKey
|
return hash == remoteKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func processDeviceResponse(_ buffer: ByteBuffer) {
|
func processDeviceResponse(_ data: ByteBuffer) {
|
||||||
guard let data = buffer.getData(at: 0, length: buffer.readableBytes) else {
|
guard let promise = requestInProgress else {
|
||||||
log("[WARN] Failed to get data buffer received from device")
|
|
||||||
self.resumeDeviceRequest(with: .invalidMessageSizeFromDevice)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.resumeDeviceRequest(with: data)
|
defer { requestInProgress = nil }
|
||||||
|
promise.succeed(DeviceResponse(data) ?? .unexpectedSocketEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didCloseDeviceSocket() {
|
func didCloseDeviceSocket() {
|
||||||
connection = nil
|
guard !isOpeningNewConnection else {
|
||||||
Task {
|
|
||||||
await updateDeviceConnectionMetrics()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeDeviceConnection() async {
|
|
||||||
try? await connection?.close()
|
|
||||||
connection = nil
|
|
||||||
await updateDeviceConnectionMetrics()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
await removeDeviceConnection()
|
deviceIsAuthenticated = false
|
||||||
|
guard connection != nil else {
|
||||||
|
print("Socket closed, but no connection anyway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection = nil
|
||||||
|
print("Socket closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDeviceConnection() {
|
||||||
|
deviceIsAuthenticated = false
|
||||||
|
guard let socket = connection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? socket.close().wait()
|
||||||
|
connection = nil
|
||||||
|
print("Removed device connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewDeviceConnection(_ socket: WebSocket) {
|
||||||
|
isOpeningNewConnection = true
|
||||||
|
removeDeviceConnection()
|
||||||
connection = socket
|
connection = socket
|
||||||
socket.eventLoop.execute {
|
print("Socket connected")
|
||||||
socket.pingInterval = .seconds(10)
|
isOpeningNewConnection = false
|
||||||
|
|
||||||
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
|
|
||||||
guard let self else {
|
|
||||||
log("[WARN] No reference to self to process binary data on socket")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.processDeviceResponse(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onClose.whenComplete { [weak self] _ in
|
|
||||||
guard let self else {
|
|
||||||
log("[WARN] No reference to self to handle socket closing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.didCloseDeviceSocket()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await updateDeviceConnectionMetrics()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Vapor
|
|
||||||
import Clairvoyant
|
|
||||||
|
|
||||||
extension MultiThreadedEventLoopGroup: AsyncScheduler {
|
|
||||||
|
|
||||||
public func schedule(asyncJob: @escaping @Sendable () async throws -> Void) {
|
|
||||||
_ = any().makeFutureWithTask(asyncJob)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
#if os(Linux)
|
|
||||||
import Glibc
|
|
||||||
#else
|
|
||||||
import Darwin.C
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
func printAndFlush(_ message: String) {
|
|
||||||
print(message)
|
|
||||||
flushStdout()
|
|
||||||
}
|
|
||||||
|
|
||||||
func flushStdout() {
|
|
||||||
fflush(stdout)
|
|
||||||
}
|
|
@ -1,94 +1,43 @@
|
|||||||
import Vapor
|
import Vapor
|
||||||
import Clairvoyant
|
|
||||||
import ClairvoyantVapor
|
|
||||||
import ClairvoyantBinaryCodable
|
|
||||||
|
|
||||||
var deviceManager: DeviceManager!
|
var deviceManager: DeviceManager!
|
||||||
|
|
||||||
private var provider: VaporMetricProvider!
|
enum ServerError: Error {
|
||||||
|
case invalidAuthenticationFileContent
|
||||||
private var asyncScheduler = MultiThreadedEventLoopGroup(numberOfThreads: 2)
|
case invalidAuthenticationToken
|
||||||
|
}
|
||||||
private let df: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.dateStyle = .short
|
|
||||||
df.timeStyle = .short
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
|
|
||||||
// configures your application
|
// configures your application
|
||||||
public func configure(_ app: Application) async throws {
|
public func configure(_ app: Application) throws {
|
||||||
|
app.http.server.configuration.port = Config.port
|
||||||
|
|
||||||
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
||||||
|
let keyFile = storageFolder.appendingPathComponent(Config.keyFileName)
|
||||||
let configUrl = storageFolder.appendingPathComponent("config.json")
|
let authContent: [Data] = try String(contentsOf: keyFile)
|
||||||
let config = try Config(loadFrom: configUrl)
|
|
||||||
|
|
||||||
let logFolder = config.logURL(possiblyRelativeTo: storageFolder)
|
|
||||||
|
|
||||||
let monitor = MetricObserver(logFileFolder: logFolder, logMetricId: "sesame.log")
|
|
||||||
MetricObserver.standard = monitor
|
|
||||||
|
|
||||||
let status = Metric<ServerStatus>("sesame.status")
|
|
||||||
try await status.update(.initializing)
|
|
||||||
|
|
||||||
app.http.server.configuration.port = config.port
|
|
||||||
|
|
||||||
let keyFile = config.keyURL(possiblyRelativeTo: storageFolder)
|
|
||||||
|
|
||||||
let (deviceKey, remoteKey) = try loadKeys(at: keyFile)
|
|
||||||
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()
|
|
||||||
|
|
||||||
// Update the metric of the device and server status
|
|
||||||
await deviceManager.updateDeviceConnectionMetrics()
|
|
||||||
|
|
||||||
log("[\(df.string(from: Date()))] Server started")
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
let authContent: [Data] = try String(contentsOf: url)
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
.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 {
|
||||||
fatalError("Invalid key data: Failed to convert hex to binary.")
|
throw ServerError.invalidAuthenticationToken
|
||||||
}
|
}
|
||||||
guard key.count == SHA256.byteCount else {
|
guard key.count == SHA256.byteCount else {
|
||||||
fatalError("Invalid key data: Length should be \(SHA256.byteCount), not \(key.count)")
|
throw ServerError.invalidAuthenticationToken
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
guard authContent.count == 2 else {
|
guard authContent.count == 2 else {
|
||||||
fatalError("Invalid keys: Expected 2, found \(authContent.count)")
|
throw ServerError.invalidAuthenticationFileContent
|
||||||
}
|
|
||||||
return (deviceKey: authContent[0], remoteKey: authContent[1])
|
|
||||||
}
|
}
|
||||||
|
let deviceKey = authContent[0]
|
||||||
|
let remoteKey = authContent[1]
|
||||||
|
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey)
|
||||||
|
try routes(app)
|
||||||
|
|
||||||
func log(_ message: String) {
|
// Gracefully shut down by closing potentially open socket
|
||||||
guard let observer = MetricObserver.standard else {
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {
|
||||||
printAndFlush(message)
|
_ = app.server.onShutdown.always { _ in
|
||||||
return
|
deviceManager.removeDeviceConnection()
|
||||||
}
|
}
|
||||||
asyncScheduler.schedule {
|
|
||||||
await observer.log(message)
|
|
||||||
flushStdout()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,50 +0,0 @@
|
|||||||
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,48 +1,91 @@
|
|||||||
import Vapor
|
import Vapor
|
||||||
|
|
||||||
func routes(_ app: Application) {
|
extension RouteAPI {
|
||||||
|
|
||||||
|
var path: PathComponent {
|
||||||
|
.init(stringLiteral: rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathParameter: PathComponent {
|
||||||
|
.parameter(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func messageTransmission(_ req: Request) -> EventLoopFuture<DeviceResponse> {
|
||||||
|
guard let body = req.body.data else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.noBodyData)
|
||||||
|
}
|
||||||
|
guard let message = ServerMessage(decodeFrom: body) else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.invalidMessageData)
|
||||||
|
}
|
||||||
|
guard deviceManager.authenticateRemote(message.authToken) else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.invalidMessageData)
|
||||||
|
}
|
||||||
|
return deviceManager.sendMessageToDevice(message.message, on: req.eventLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceStatus(_ req: Request) -> EventLoopFuture<DeviceResponse> {
|
||||||
|
guard let body = req.body.data else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.noBodyData)
|
||||||
|
}
|
||||||
|
guard let authToken = ServerMessage.token(from: body) else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.invalidMessageData)
|
||||||
|
}
|
||||||
|
guard deviceManager.authenticateRemote(authToken) else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.invalidMessageData)
|
||||||
|
}
|
||||||
|
guard deviceManager.deviceIsConnected else {
|
||||||
|
return req.eventLoop.makeSucceededFuture(.deviceNotConnected)
|
||||||
|
}
|
||||||
|
return req.eventLoop.makeSucceededFuture(.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`, `invalidMessageData`, `deviceNotConnected`, `deviceConnected`.
|
||||||
|
*/
|
||||||
|
app.post(RouteAPI.getDeviceStatus.path) { req in
|
||||||
|
deviceStatus(req).map {
|
||||||
|
Response(status: .ok, body: .init(data: $0.encoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Post a message to the device for unlocking.
|
Post a message to the device for unlocking.
|
||||||
|
|
||||||
The expects a `Message` in the body data of the POST request, containing the message to send to the device.
|
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.
|
||||||
Expects a header ``RouteAPI.authenticationHeader`` with the hexencoded authentication token with binary length ``ServerMessage.authTokenSize``.
|
|
||||||
|
|
||||||
The request returns the ``ServerMessage.messageSize`` bytes of data constituting the device response,
|
The request returns one or `Message.length+1` bytes of data, where the first byte is the raw value of a `MessageResult`,
|
||||||
or a status code corresponding to 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`.
|
||||||
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(SesameRoute.postMessage.path) { request async throws in
|
app.post(RouteAPI.postMessage.path) { req in
|
||||||
do {
|
messageTransmission(req).map {
|
||||||
guard let authString = request.headers.first(name: SesameHeader.authenticationHeader),
|
Response(status: .ok, body: .init(data: $0.encoded))
|
||||||
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: .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
|
||||||
The request must contain a header ``RouteAPI.socketAuthenticationHeader`` with a valid authentication token.
|
- Note: The first message from the device over the connection must be a valid auth token.
|
||||||
*/
|
*/
|
||||||
app.webSocket(SesameRoute.socket.path) { request, socket async in
|
app.webSocket(RouteAPI.socket.path) { req, socket in
|
||||||
guard let authToken = request.headers.first(name: SesameHeader.authenticationHeader) else {
|
socket.onBinary { _, data in
|
||||||
try? await socket.close()
|
deviceManager.processDeviceResponse(data)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
await deviceManager.createNewDeviceConnection(socket: socket, auth: authToken)
|
socket.onText { _, text in
|
||||||
|
deviceManager.authenticateDevice(hash: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = socket.onClose.always { _ in
|
||||||
|
deviceManager.didCloseDeviceSocket()
|
||||||
|
}
|
||||||
|
deviceManager.createNewDeviceConnection(socket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
Sources/Run/main.swift
Normal file
9
Sources/Run/main.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import App
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
var env = try Environment.detect()
|
||||||
|
try LoggingSystem.bootstrap(from: &env)
|
||||||
|
let app = Application(env)
|
||||||
|
defer { app.shutdown() }
|
||||||
|
try configure(app)
|
||||||
|
try app.run()
|
53
Tests/AppTests/AppTests.swift
Normal file
53
Tests/AppTests/AppTests.swift
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncodingContent() {
|
||||||
|
let input = Message.Content(time: 1234567890, id: 23456789)
|
||||||
|
let data = Array(input.encoded)
|
||||||
|
let output = Message.Content(decodeFrom: data)
|
||||||
|
XCTAssertEqual(input, output)
|
||||||
|
let data2 = [42, 42] + data
|
||||||
|
let output2 = Message.Content(decodeFrom: data2[2...])
|
||||||
|
XCTAssertEqual(input, output2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncodingMessage() {
|
||||||
|
let input = Message(mac: Data(repeating: 42, count: 32),
|
||||||
|
content: Message.Content(time: 1234567890, id: 23456789))
|
||||||
|
let data = input.encoded
|
||||||
|
let buffer = ByteBuffer(data: data)
|
||||||
|
let output = Message(decodeFrom: buffer)
|
||||||
|
XCTAssertEqual(input, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSigning() throws {
|
||||||
|
let key = SymmetricKey(size: .bits256)
|
||||||
|
let content = Message.Content(time: 1234567890, id: 23456789)
|
||||||
|
let input = content.authenticate(using: key)
|
||||||
|
XCTAssertTrue(input.isValid(using: key))
|
||||||
|
|
||||||
|
let data = content.authenticateAndSerialize(using: key)
|
||||||
|
let decoded = Message(decodeFrom: ByteBuffer(data: data))
|
||||||
|
XCTAssertNotNil(decoded)
|
||||||
|
XCTAssertTrue(decoded!.isValid(using: key))
|
||||||
|
XCTAssertEqual(decoded!, input)
|
||||||
|
XCTAssertEqual(content, input.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMessageTransmission() throws {
|
||||||
|
let app = Application(.testing)
|
||||||
|
defer { app.shutdown() }
|
||||||
|
try configure(app)
|
||||||
|
|
||||||
|
// How to open a socket via request?
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user