Compare commits

..

7 Commits

Author SHA1 Message Date
Christoph Hagen
9dd0045c4b Update API with device id 2023-08-08 15:17:59 +02:00
Christoph Hagen
23fd5055cd Move to newer metrics version 2023-02-17 00:09:51 +01:00
Christoph Hagen
e96b85b1cc Log more metrics 2023-02-06 21:57:42 +01:00
Christoph Hagen
b3c58ce4c7 Improve logging 2023-02-06 21:44:56 +01:00
Christoph Hagen
790662a1ec Remove empty keys file 2023-01-31 19:16:38 +01:00
Christoph Hagen
21a4f4ecae Add server status 2023-01-31 19:10:57 +01:00
Christoph Hagen
52cb76d4c8 Read config from file 2023-01-31 19:10:33 +01:00
12 changed files with 199 additions and 47 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
Package.resolved Package.resolved
.swiftpm .swiftpm
.build .build
Resources/config.json

View File

@@ -4,16 +4,19 @@ import PackageDescription
let package = Package( let package = Package(
name: "SesameServer", name: "SesameServer",
platforms: [ platforms: [
.macOS(.v10_15) .macOS(.v12)
], ],
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.git", from: "0.5.0"),
], ],
targets: [ targets: [
.target( .target(
name: "App", name: "App",
dependencies: [ dependencies: [
.product(name: "Vapor", package: "vapor") .product(name: "Vapor", package: "vapor"),
.product(name: "Clairvoyant", package: "Clairvoyant"),
], ],
swiftSettings: [ swiftSettings: [
// Enable better optimizations when building in Release configuration. Despite the use of // Enable better optimizations when building in Release configuration. Despite the use of

View File

@@ -0,0 +1,6 @@
{
"port": 6003,
"keyFileName": "keys",
"deviceTimeout": 20,
"authenticationTokens" : [],
}

View File

@@ -1,2 +0,0 @@
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000

View File

@@ -54,13 +54,13 @@ struct DeviceResponse {
the remaining bytes contain the message. the remaining bytes contain the message.
- Parameter buffer: The buffer where the message bytes are stored - Parameter buffer: The buffer where the message bytes are stored
*/ */
init?(_ buffer: ByteBuffer) { init?(_ buffer: ByteBuffer, request: String) {
guard let byte = buffer.getBytes(at: 0, length: 1) else { guard let byte = buffer.getBytes(at: 0, length: 1) else {
print("No bytes received from device") log("\(request): No bytes received from device")
return nil return nil
} }
guard let event = MessageResult(rawValue: byte[0]) else { guard let event = MessageResult(rawValue: byte[0]) else {
print("Unknown response \(byte[0]) received from device") log("\(request): Unknown response \(byte[0]) received from device")
return nil return nil
} }
self.event = event self.event = event

View File

@@ -29,6 +29,14 @@ struct Message: Equatable, Hashable {
} }
} }
extension Message: Codable {
enum CodingKeys: Int, CodingKey {
case mac = 1
case content = 2
}
}
extension Message { extension Message {
/** /**
@@ -42,14 +50,17 @@ extension Message {
/// The counter of the message (for freshness) /// The counter of the message (for freshness)
let id: UInt32 let id: UInt32
let deviceId: UInt8?
/** /**
Create new message content. Create new message content.
- Parameter time: The time of message creation, - Parameter time: The time of message creation,
- Parameter id: The counter of the message - Parameter id: The counter of the message
*/ */
init(time: UInt32, id: UInt32) { init(time: UInt32, id: UInt32, device: UInt8) {
self.time = time self.time = time
self.id = id self.id = id
self.deviceId = device
} }
/** /**
@@ -61,20 +72,29 @@ extension Message {
*/ */
init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 { init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
self.time = UInt32(data: Data(data.prefix(MemoryLayout<UInt32>.size))) self.time = UInt32(data: Data(data.prefix(MemoryLayout<UInt32>.size)))
self.id = UInt32(data: Data(data.dropFirst(MemoryLayout<UInt32>.size))) self.id = UInt32(data: Data(data.dropLast().suffix(MemoryLayout<UInt32>.size)))
self.deviceId = data.suffix(1).last!
} }
/// The byte length of an encoded message content /// The byte length of an encoded message content
static var length: Int { static var length: Int {
MemoryLayout<UInt32>.size * 2 MemoryLayout<UInt32>.size * 2 + 1
} }
/// The message content encoded to data /// The message content encoded to data
var encoded: Data { var encoded: Data {
time.encoded + id.encoded time.encoded + id.encoded + Data([deviceId ?? 0])
} }
} }
}
extension Message.Content: Codable {
enum CodingKeys: Int, CodingKey {
case time = 1
case id = 2
case deviceId = 3
}
} }
extension Message { extension Message {
@@ -96,6 +116,14 @@ extension Message {
self.init(decodeFrom: data) self.init(decodeFrom: data)
} }
init?(decodeFrom data: Data, index: inout Int) {
guard index + Message.length <= data.count else {
return nil
}
self.init(decodeFrom: data.advanced(by: index))
index += Message.length
}
/// The message encoded to data /// The message encoded to data
var encoded: Data { var encoded: Data {
mac + content.encoded mac + content.encoded

View File

@@ -26,6 +26,9 @@ enum MessageResult: UInt8 {
/// The key was accepted by the device, and the door will be opened /// The key was accepted by the device, and the door will be opened
case messageAccepted = 7 case messageAccepted = 7
/// The device id is invalid
case messageDeviceInvalid = 8
/// The request did not contain body data with the key /// The request did not contain body data with the key
case noBodyData = 10 case noBodyData = 10
@@ -61,6 +64,8 @@ extension MessageResult: CustomStringConvertible {
return "Message counter invalid" return "Message counter invalid"
case .messageAccepted: case .messageAccepted:
return "Message accepted" return "Message accepted"
case .messageDeviceInvalid:
return "Invalid device ID"
case .noBodyData: case .noBodyData:
return "No body data included in the request" return "No body data included in the request"
case .deviceNotConnected: case .deviceNotConnected:

View File

@@ -3,11 +3,41 @@ import Foundation
struct Config { struct Config {
/// The port where the server runs /// The port where the server runs
static let port = 6003 let port: Int
/// The name of the file in the `Resources` folder containing the device authentication token /// The name of the file in the `Resources` folder containing the device authentication token
static let keyFileName = "keys" let keyFileName: String
/// The seconds to wait for a response from the device /// The seconds to wait for a response from the device
static let deviceTimeout: Int64 = 20 let deviceTimeout: Int64
/// The authentication tokens to use for monitoring of the service
let authenticationTokens: Set<String>
}
extension Config: Codable {
}
extension Config {
init(loadFrom url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else {
log("No configuration file found at \(url.path)")
fatalError("No configuration file found")
}
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
log("Failed to read config data: \(error)")
throw error
}
do {
self = try JSONDecoder().decode(Config.self, from: data)
} catch {
log("Failed to decode config data: \(error)")
throw error
}
}
} }

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import WebSocketKit import WebSocketKit
import Vapor import Vapor
import Clairvoyant
final class DeviceManager { final class DeviceManager {
@@ -17,6 +18,12 @@ final class DeviceManager {
var deviceIsAuthenticated = false var deviceIsAuthenticated = false
private var isOpeningNewConnection = false private var isOpeningNewConnection = false
private let deviceTimeout: Int64
private let deviceConnectedMetric: Metric<Bool>
private let messagesToDeviceMetric: Metric<Int>
/// Indicator for device availability /// Indicator for device availability
var deviceIsConnected: Bool { var deviceIsConnected: Bool {
@@ -26,9 +33,31 @@ final class DeviceManager {
/// 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: EventLoopPromise<DeviceResponse>? private var requestInProgress: EventLoopPromise<DeviceResponse>?
init(deviceKey: Data, remoteKey: Data) { init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64) async {
self.deviceKey = deviceKey self.deviceKey = deviceKey
self.remoteKey = remoteKey self.remoteKey = remoteKey
self.deviceTimeout = deviceTimeout
self.deviceConnectedMetric = try! await .init(
"sesame.connected",
name: "Device connected",
description: "Shows if the device is connected via WebSocket")
self.messagesToDeviceMetric = try! await .init(
"sesame.messages",
name: "Forwarded Messages",
description: "The number of messages transmitted to the device")
}
private func updateDeviceConnectionMetric() {
Task {
try? await deviceConnectedMetric.update(deviceIsConnected)
}
}
private func updateMessageCountMetric() {
Task {
let lastValue = await messagesToDeviceMetric.lastValue()?.value ?? 0
try? await messagesToDeviceMetric.update(lastValue + 1)
}
} }
// MARK: API // MARK: API
@@ -45,27 +74,30 @@ final class DeviceManager {
guard requestInProgress == nil else { guard requestInProgress == nil else {
return eventLoop.makeSucceededFuture(.operationInProgress) return eventLoop.makeSucceededFuture(.operationInProgress)
} }
requestInProgress = eventLoop.makePromise(of: DeviceResponse.self) let result = eventLoop.makePromise(of: DeviceResponse.self)
self.requestInProgress = result
socket.send(message.bytes, promise: nil) socket.send(message.bytes, promise: nil)
eventLoop.scheduleTask(in: .seconds(Config.deviceTimeout)) { [weak self] in updateMessageCountMetric()
eventLoop.scheduleTask(in: .seconds(deviceTimeout)) { [weak self] in
guard let promise = self?.requestInProgress else { guard let promise = self?.requestInProgress else {
return return
} }
self?.requestInProgress = nil self?.requestInProgress = nil
promise.succeed(.deviceTimedOut) promise.succeed(.deviceTimedOut)
} }
return requestInProgress!.futureResult return result.futureResult
} }
func authenticateDevice(hash: String) { func authenticateDevice(hash: String) {
defer { updateDeviceConnectionMetric() }
guard let key = Data(fromHexEncodedString: hash), guard let key = Data(fromHexEncodedString: hash),
SHA256.hash(data: key) == self.deviceKey else { SHA256.hash(data: key) == self.deviceKey else {
print("Invalid device key") log("Invalid device key")
_ = connection?.close() _ = connection?.close()
deviceIsAuthenticated = false deviceIsAuthenticated = false
return return
} }
print("Device authenticated") log("Device authenticated")
deviceIsAuthenticated = true deviceIsAuthenticated = true
} }
@@ -79,37 +111,40 @@ final class DeviceManager {
return return
} }
defer { requestInProgress = nil } defer { requestInProgress = nil }
promise.succeed(DeviceResponse(data) ?? .unexpectedSocketEvent) promise.succeed(DeviceResponse(data, request: RouteAPI.socket.rawValue) ?? .unexpectedSocketEvent)
} }
func didCloseDeviceSocket() { func didCloseDeviceSocket() {
defer { updateDeviceConnectionMetric() }
guard !isOpeningNewConnection else { guard !isOpeningNewConnection else {
return return
} }
deviceIsAuthenticated = false deviceIsAuthenticated = false
guard connection != nil else { guard connection != nil else {
print("Socket closed, but no connection anyway") log("Socket closed, but no connection anyway")
return return
} }
connection = nil connection = nil
print("Socket closed") log("Socket closed")
} }
func removeDeviceConnection() { func removeDeviceConnection() {
defer { updateDeviceConnectionMetric() }
deviceIsAuthenticated = false deviceIsAuthenticated = false
guard let socket = connection else { guard let socket = connection else {
return return
} }
try? socket.close().wait() try? socket.close().wait()
connection = nil connection = nil
print("Removed device connection") log("Removed device connection")
} }
func createNewDeviceConnection(_ socket: WebSocket) { func createNewDeviceConnection(_ socket: WebSocket) {
defer { updateDeviceConnectionMetric() }
isOpeningNewConnection = true isOpeningNewConnection = true
removeDeviceConnection() removeDeviceConnection()
connection = socket connection = socket
print("Socket connected") log("Socket connected")
isOpeningNewConnection = false isOpeningNewConnection = false
} }
} }

View File

@@ -1,4 +1,5 @@
import Vapor import Vapor
import Clairvoyant
var deviceManager: DeviceManager! var deviceManager: DeviceManager!
@@ -8,12 +9,51 @@ enum ServerError: Error {
} }
// configures your application // configures your application
public func configure(_ app: Application) throws { public func configure(_ app: Application) async 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 logFolder = storageFolder.appendingPathComponent("logs")
let authContent: [Data] = try String(contentsOf: keyFile)
let accessManager = AccessTokenManager([])
let monitor = await MetricObserver(
logFolder: logFolder,
accessManager: accessManager,
logMetricId: "sesame.log")
MetricObserver.standard = monitor
let status = try await Metric<ServerStatus>("sesame.status")
try await status.update(.initializing)
await monitor.registerRoutes(app)
let configUrl = storageFolder.appendingPathComponent("config.json")
let config = try Config(loadFrom: configUrl)
config.authenticationTokens.map { $0.data(using: .utf8)! }.forEach(accessManager.add)
app.http.server.configuration.port = config.port
let keyFile = storageFolder.appendingPathComponent(config.keyFileName)
let (deviceKey, remoteKey) = try loadKeys(at: keyFile)
deviceManager = await DeviceManager(
deviceKey: deviceKey,
remoteKey: remoteKey,
deviceTimeout: config.deviceTimeout)
try routes(app)
// Gracefully shut down by closing potentially open socket
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {
_ = app.server.onShutdown.always { _ in
deviceManager.removeDeviceConnection()
}
}
try await status.update(.nominal)
}
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) }
@@ -29,15 +69,15 @@ public func configure(_ app: Application) throws {
guard authContent.count == 2 else { guard authContent.count == 2 else {
throw ServerError.invalidAuthenticationFileContent throw ServerError.invalidAuthenticationFileContent
} }
let deviceKey = authContent[0] return (deviceKey: authContent[0], remoteKey: authContent[1])
let remoteKey = authContent[1] }
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey)
try routes(app) func log(_ message: String) {
guard let observer = MetricObserver.standard else {
// Gracefully shut down by closing potentially open socket print(message)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) { return
_ = app.server.onShutdown.always { _ in }
deviceManager.removeDeviceConnection() Task {
} await observer.log(message)
} }
} }

View File

@@ -1,9 +1,15 @@
import App import App
import Vapor import Vapor
var env = try Environment.detect() var env = Environment.production //.detect()
try LoggingSystem.bootstrap(from: &env) try LoggingSystem.bootstrap(from: &env)
let app = Application(env) let app = Application(env)
defer { app.shutdown() } defer { app.shutdown() }
try configure(app)
private let semaphore = DispatchSemaphore(value: 0)
Task {
try await configure(app)
semaphore.signal()
}
semaphore.wait()
try app.run() try app.run()

View File

@@ -11,7 +11,7 @@ final class AppTests: XCTestCase {
} }
func testEncodingContent() { func testEncodingContent() {
let input = Message.Content(time: 1234567890, id: 23456789) let input = Message.Content(time: 1234567890, id: 23456789, device: 0)
let data = Array(input.encoded) let data = Array(input.encoded)
let output = Message.Content(decodeFrom: data) let output = Message.Content(decodeFrom: data)
XCTAssertEqual(input, output) XCTAssertEqual(input, output)
@@ -22,7 +22,7 @@ final class AppTests: XCTestCase {
func testEncodingMessage() { func testEncodingMessage() {
let input = Message(mac: Data(repeating: 42, count: 32), let input = Message(mac: Data(repeating: 42, count: 32),
content: Message.Content(time: 1234567890, id: 23456789)) content: Message.Content(time: 1234567890, id: 23456789, device: 0))
let data = input.encoded let data = input.encoded
let buffer = ByteBuffer(data: data) let buffer = ByteBuffer(data: data)
let output = Message(decodeFrom: buffer) let output = Message(decodeFrom: buffer)
@@ -31,7 +31,7 @@ final class AppTests: XCTestCase {
func testSigning() throws { func testSigning() throws {
let key = SymmetricKey(size: .bits256) let key = SymmetricKey(size: .bits256)
let content = Message.Content(time: 1234567890, id: 23456789) let content = Message.Content(time: 1234567890, id: 23456789, device: 0)
let input = content.authenticate(using: key) let input = content.authenticate(using: key)
XCTAssertTrue(input.isValid(using: key)) XCTAssertTrue(input.isValid(using: key))
@@ -43,10 +43,10 @@ final class AppTests: XCTestCase {
XCTAssertEqual(content, input.content) XCTAssertEqual(content, input.content)
} }
func testMessageTransmission() throws { func testMessageTransmission() async throws {
let app = Application(.testing) let app = Application(.testing)
defer { app.shutdown() } defer { app.shutdown() }
try configure(app) try await configure(app)
// How to open a socket via request? // How to open a socket via request?
} }