inital commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public final class NodeIdentity: @unchecked Sendable {
|
||||
public static let currentVersion = 1
|
||||
public let signingKey: Curve25519.Signing.PrivateKey
|
||||
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
|
||||
public let record: NodeRecord
|
||||
|
||||
public init(signingKey: Curve25519.Signing.PrivateKey = .init(),
|
||||
agreementKey: Curve25519.KeyAgreement.PrivateKey = .init()) throws {
|
||||
self.signingKey = signingKey; self.agreementKey = agreementKey
|
||||
let signing = signingKey.publicKey.rawRepresentation
|
||||
self.record = NodeRecord(address: try MeshAddress.derive(from: signing),
|
||||
signingPublicKey: signing,
|
||||
agreementPublicKey: agreementKey.publicKey.rawRepresentation)
|
||||
}
|
||||
|
||||
public static func loadOrCreate(at url: URL) throws -> NodeIdentity {
|
||||
struct Stored: Codable { let version: Int; let signing: Data; let agreement: Data }
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: url) {
|
||||
let value = try decoder.decode(Stored.self, from: data)
|
||||
guard value.version == currentVersion else { throw UMNError.invalidIdentity }
|
||||
return try NodeIdentity(signingKey: .init(rawRepresentation: value.signing),
|
||||
agreementKey: .init(rawRepresentation: value.agreement))
|
||||
}
|
||||
let identity = try NodeIdentity()
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
|
||||
let data = try encoder.encode(Stored(version: currentVersion,
|
||||
signing: identity.signingKey.rawRepresentation,
|
||||
agreement: identity.agreementKey.rawRepresentation))
|
||||
try data.write(to: url, options: .atomic)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
return identity
|
||||
}
|
||||
|
||||
public func sign(_ data: Data) throws -> Data { try signingKey.signature(for: data) }
|
||||
|
||||
public func makeLinkState(sequence: UInt64, neighbors: [MeshAddress]) throws -> LinkState {
|
||||
let unsigned = LinkState(origin: record, sequence: sequence, neighbors: neighbors, signature: Data())
|
||||
return LinkState(origin: record, sequence: sequence, neighbors: neighbors,
|
||||
signature: try sign(unsigned.signingBytes()))
|
||||
}
|
||||
|
||||
public func seal(_ inner: InnerFrame, to destination: NodeRecord) throws -> SealedPayload {
|
||||
guard destination.validate() else { throw UMNError.invalidIdentity }
|
||||
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
|
||||
let innerData = try encoder.encode(inner)
|
||||
struct Signed: Codable { let inner: Data; let signature: Data }
|
||||
let signed = try encoder.encode(Signed(inner: innerData, signature: sign(innerData)))
|
||||
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
|
||||
let remote = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: destination.agreementPublicKey)
|
||||
let secret = try ephemeral.sharedSecretFromKeyAgreement(with: remote)
|
||||
let key = secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: Data("umn-e2e-v1".utf8),
|
||||
sharedInfo: destination.address.bytes, outputByteCount: 32)
|
||||
let box = try ChaChaPoly.seal(signed, using: key)
|
||||
return SealedPayload(ephemeralPublicKey: ephemeral.publicKey.rawRepresentation,
|
||||
combinedCiphertext: box.combined)
|
||||
}
|
||||
|
||||
public func open(_ payload: SealedPayload, expectedSource: MeshAddress) throws -> InnerFrame {
|
||||
struct Signed: Codable { let inner: Data; let signature: Data }
|
||||
let ephemeral = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: payload.ephemeralPublicKey)
|
||||
let secret = try agreementKey.sharedSecretFromKeyAgreement(with: ephemeral)
|
||||
let key = secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: Data("umn-e2e-v1".utf8),
|
||||
sharedInfo: record.address.bytes, outputByteCount: 32)
|
||||
let box = try ChaChaPoly.SealedBox(combined: payload.combinedCiphertext)
|
||||
let data = try ChaChaPoly.open(box, using: key)
|
||||
let decoder = PropertyListDecoder()
|
||||
let signed = try decoder.decode(Signed.self, from: data)
|
||||
let inner = try decoder.decode(InnerFrame.self, from: signed.inner)
|
||||
guard inner.sourceRecord.address == expectedSource, inner.sourceRecord.validate() else { throw UMNError.invalidIdentity }
|
||||
let keyVerify = try Curve25519.Signing.PublicKey(rawRepresentation: inner.sourceRecord.signingPublicKey)
|
||||
guard keyVerify.isValidSignature(signed.signature, for: signed.inner) else { throw UMNError.invalidIdentity }
|
||||
return inner
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
public struct FirewallRule: Codable, Hashable, Sendable {
|
||||
public let port: UInt16
|
||||
public let source: MeshAddress?
|
||||
public init(port: UInt16, source: MeshAddress?) { self.port = port; self.source = source }
|
||||
}
|
||||
|
||||
public final class MeshFirewall: @unchecked Sendable {
|
||||
private var rules: Set<FirewallRule>
|
||||
private let lock = NSLock()
|
||||
public init(rules: Set<FirewallRule> = []) { self.rules = rules }
|
||||
|
||||
public func allows(port: UInt16, source: MeshAddress) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return rules.contains(FirewallRule(port: port, source: nil)) || rules.contains(FirewallRule(port: port, source: source))
|
||||
}
|
||||
public func allow(port: UInt16, source: MeshAddress?) { lock.lock(); rules.insert(.init(port: port, source: source)); lock.unlock() }
|
||||
public func revoke(port: UInt16, source: MeshAddress?) { lock.lock(); rules.remove(.init(port: port, source: source)); lock.unlock() }
|
||||
public func allRules() -> [FirewallRule] { lock.lock(); defer { lock.unlock() }; return rules.sorted { $0.port < $1.port } }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
public enum FrameCodec {
|
||||
public static let maximumFrameSize = 1_200_000
|
||||
|
||||
public static func encode<T: Encodable>(_ value: T) throws -> Data {
|
||||
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
|
||||
let body = try encoder.encode(value)
|
||||
guard body.count <= maximumFrameSize else { throw UMNError.invalidFrame }
|
||||
var size = UInt32(body.count).bigEndian
|
||||
return withUnsafeBytes(of: &size) { Data($0) } + body
|
||||
}
|
||||
|
||||
public static func decode<T: Decodable>(_ type: T.Type, body: Data) throws -> T {
|
||||
guard body.count <= maximumFrameSize else { throw UMNError.invalidFrame }
|
||||
return try PropertyListDecoder().decode(type, from: body)
|
||||
}
|
||||
|
||||
public static func bodyLength(from prefix: Data) throws -> Int {
|
||||
guard prefix.count == 4 else { throw UMNError.invalidFrame }
|
||||
let value = prefix.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
guard value > 0, value <= maximumFrameSize else { throw UMNError.invalidFrame }
|
||||
return Int(value)
|
||||
}
|
||||
}
|
||||
|
||||
public enum IPCOperation: String, Codable, Sendable {
|
||||
case status, address, peers, routes, ping
|
||||
case aliasSet, aliasRemove, aliasList
|
||||
case firewallAllow, firewallRevoke, firewallList
|
||||
case textSend, bindText, bindStream, openStream, streamData, streamClose
|
||||
}
|
||||
|
||||
public struct IPCMessage: Codable, Sendable {
|
||||
public var version: UInt16
|
||||
public var operation: IPCOperation
|
||||
public var requestID: UUID
|
||||
public var target: String?
|
||||
public var source: String?
|
||||
public var name: String?
|
||||
public var port: UInt16?
|
||||
public var streamID: UInt64?
|
||||
public var data: Data?
|
||||
public var ok: Bool?
|
||||
public var message: String?
|
||||
public var values: [String]?
|
||||
|
||||
public init(operation: IPCOperation, requestID: UUID = UUID()) {
|
||||
self.version = 1; self.operation = operation; self.requestID = requestID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public enum UMNError: Error, LocalizedError {
|
||||
case invalidAddress, invalidFrame, invalidIdentity, cryptoFailure
|
||||
case noRoute, denied, unbound, transferLimit, timeout
|
||||
case message(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAddress: "invalid mesh address"
|
||||
case .invalidFrame: "invalid protocol frame"
|
||||
case .invalidIdentity: "identity verification failed"
|
||||
case .cryptoFailure: "cryptographic operation failed"
|
||||
case .noRoute: "no route to destination"
|
||||
case .denied: "destination firewall denied the connection"
|
||||
case .unbound: "destination port has no bound service"
|
||||
case .transferLimit: "the 1 MiB stream limit was exceeded"
|
||||
case .timeout: "operation timed out"
|
||||
case .message(let value): value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshAddress: Hashable, Codable, Comparable, CustomStringConvertible, Sendable {
|
||||
public let bytes: Data
|
||||
|
||||
public init(bytes: Data) throws {
|
||||
guard bytes.count == 16, bytes.first == 0xfd else { throw UMNError.invalidAddress }
|
||||
self.bytes = bytes
|
||||
}
|
||||
|
||||
public init(_ text: String) throws {
|
||||
var raw = in6_addr()
|
||||
guard inet_pton(AF_INET6, text, &raw) == 1 else { throw UMNError.invalidAddress }
|
||||
let data = withUnsafeBytes(of: &raw) { Data($0) }
|
||||
try self.init(bytes: data)
|
||||
}
|
||||
|
||||
public static func derive(from signingPublicKey: Data) throws -> MeshAddress {
|
||||
let digest = Data(SHA256.hash(data: signingPublicKey))
|
||||
return try MeshAddress(bytes: Data([0xfd]) + digest.prefix(15))
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
var raw = in6_addr()
|
||||
_ = withUnsafeMutableBytes(of: &raw) { bytes.copyBytes(to: $0) }
|
||||
var output = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
|
||||
guard inet_ntop(AF_INET6, &raw, &output, socklen_t(INET6_ADDRSTRLEN)) != nil else {
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
return String(cString: output)
|
||||
}
|
||||
|
||||
public static func < (lhs: MeshAddress, rhs: MeshAddress) -> Bool {
|
||||
lhs.bytes.lexicographicallyPrecedes(rhs.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeRecord: Codable, Hashable, Sendable {
|
||||
public let address: MeshAddress
|
||||
public let signingPublicKey: Data
|
||||
public let agreementPublicKey: Data
|
||||
|
||||
public init(address: MeshAddress, signingPublicKey: Data, agreementPublicKey: Data) {
|
||||
self.address = address
|
||||
self.signingPublicKey = signingPublicKey
|
||||
self.agreementPublicKey = agreementPublicKey
|
||||
}
|
||||
|
||||
public func validate() -> Bool {
|
||||
guard signingPublicKey.count == 32, agreementPublicKey.count == 32,
|
||||
let derived = try? MeshAddress.derive(from: signingPublicKey) else { return false }
|
||||
return derived == address
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinkState: Codable, Hashable, Sendable {
|
||||
public let origin: NodeRecord
|
||||
public let sequence: UInt64
|
||||
public let neighbors: [MeshAddress]
|
||||
public let signature: Data
|
||||
|
||||
public init(origin: NodeRecord, sequence: UInt64, neighbors: [MeshAddress], signature: Data) {
|
||||
self.origin = origin
|
||||
self.sequence = sequence
|
||||
self.neighbors = neighbors.sorted()
|
||||
self.signature = signature
|
||||
}
|
||||
|
||||
public func signingBytes() throws -> Data {
|
||||
struct Unsigned: Codable { let origin: NodeRecord; let sequence: UInt64; let neighbors: [MeshAddress] }
|
||||
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
|
||||
return try encoder.encode(Unsigned(origin: origin, sequence: sequence, neighbors: neighbors.sorted()))
|
||||
}
|
||||
|
||||
public func validate() -> Bool {
|
||||
guard origin.validate(), let key = try? Curve25519.Signing.PublicKey(rawRepresentation: origin.signingPublicKey),
|
||||
let bytes = try? signingBytes() else { return false }
|
||||
return key.isValidSignature(signature, for: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PacketKind: String, Codable, Sendable {
|
||||
case pingRequest, pingReply, text, streamOpen, streamData, streamAck, streamClose, streamReset, error
|
||||
}
|
||||
|
||||
public struct InnerFrame: Codable, Sendable {
|
||||
public let kind: PacketKind
|
||||
public let streamID: UInt64
|
||||
public let port: UInt16?
|
||||
public let sequence: UInt64
|
||||
public let acknowledgment: UInt64
|
||||
public let payload: Data
|
||||
public let sourceRecord: NodeRecord
|
||||
|
||||
public init(kind: PacketKind, streamID: UInt64 = 0, port: UInt16? = nil,
|
||||
sequence: UInt64 = 0, acknowledgment: UInt64 = 0,
|
||||
payload: Data = Data(), sourceRecord: NodeRecord) {
|
||||
self.kind = kind; self.streamID = streamID; self.port = port
|
||||
self.sequence = sequence; self.acknowledgment = acknowledgment
|
||||
self.payload = payload; self.sourceRecord = sourceRecord
|
||||
}
|
||||
}
|
||||
|
||||
public struct SealedPayload: Codable, Sendable {
|
||||
public let ephemeralPublicKey: Data
|
||||
public let combinedCiphertext: Data
|
||||
|
||||
public init(ephemeralPublicKey: Data, combinedCiphertext: Data) {
|
||||
self.ephemeralPublicKey = ephemeralPublicKey
|
||||
self.combinedCiphertext = combinedCiphertext
|
||||
}
|
||||
}
|
||||
|
||||
public struct RoutedPacket: Codable, Sendable {
|
||||
public let id: UUID
|
||||
public let source: MeshAddress
|
||||
public let destination: MeshAddress
|
||||
public var hopLimit: UInt8
|
||||
public let sealed: SealedPayload
|
||||
|
||||
public init(id: UUID = UUID(), source: MeshAddress, destination: MeshAddress,
|
||||
hopLimit: UInt8 = 16, sealed: SealedPayload) {
|
||||
self.id = id; self.source = source; self.destination = destination
|
||||
self.hopLimit = hopLimit; self.sealed = sealed
|
||||
}
|
||||
}
|
||||
|
||||
public enum WireMessage: Codable, Sendable {
|
||||
case hello(NodeRecord)
|
||||
case linkState(LinkState)
|
||||
case packet(RoutedPacket)
|
||||
case keepalive
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case type, payload }
|
||||
private enum Kind: String, Codable { case hello, linkState, packet, keepalive }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
switch try c.decode(Kind.self, forKey: .type) {
|
||||
case .hello: self = .hello(try c.decode(NodeRecord.self, forKey: .payload))
|
||||
case .linkState: self = .linkState(try c.decode(LinkState.self, forKey: .payload))
|
||||
case .packet: self = .packet(try c.decode(RoutedPacket.self, forKey: .payload))
|
||||
case .keepalive: self = .keepalive
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .hello(let value): try c.encode(Kind.hello, forKey: .type); try c.encode(value, forKey: .payload)
|
||||
case .linkState(let value): try c.encode(Kind.linkState, forKey: .type); try c.encode(value, forKey: .payload)
|
||||
case .packet(let value): try c.encode(Kind.packet, forKey: .type); try c.encode(value, forKey: .payload)
|
||||
case .keepalive: try c.encode(Kind.keepalive, forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct WireEnvelope: Codable, Sendable {
|
||||
public static let currentVersion: UInt16 = 1
|
||||
public let version: UInt16
|
||||
public let message: WireMessage
|
||||
|
||||
public init(message: WireMessage, version: UInt16 = currentVersion) {
|
||||
self.version = version; self.message = message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
public struct Route: Equatable, Sendable {
|
||||
public let destination: MeshAddress
|
||||
public let nextHop: MeshAddress
|
||||
public let hopCount: Int
|
||||
}
|
||||
|
||||
public final class LinkStateRouter: @unchecked Sendable {
|
||||
private struct Entry { let state: LinkState; var receivedAt: Date }
|
||||
private var database: [MeshAddress: Entry] = [:]
|
||||
private let local: MeshAddress
|
||||
private let lock = NSLock()
|
||||
|
||||
public init(local: MeshAddress) { self.local = local }
|
||||
|
||||
@discardableResult
|
||||
public func ingest(_ state: LinkState, now: Date = Date()) -> Bool {
|
||||
guard state.validate(), state.neighbors.count <= 32 else { return false }
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
if let existing = database[state.origin.address], existing.state.sequence >= state.sequence { return false }
|
||||
if database[state.origin.address] == nil, database.count >= 32 { return false }
|
||||
database[state.origin.address] = Entry(state: state, receivedAt: now)
|
||||
return true
|
||||
}
|
||||
|
||||
public func expire(olderThan seconds: TimeInterval = 30, now: Date = Date()) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
database = database.filter { now.timeIntervalSince($0.value.receivedAt) <= seconds || $0.key == local }
|
||||
}
|
||||
|
||||
public func record(for address: MeshAddress) -> NodeRecord? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return database[address]?.state.origin
|
||||
}
|
||||
|
||||
public func allRecords() -> [NodeRecord] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return database.values.map(\.state.origin)
|
||||
}
|
||||
|
||||
public func allStates() -> [LinkState] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return database.values.map(\.state)
|
||||
}
|
||||
|
||||
public func routes() -> [MeshAddress: Route] {
|
||||
lock.lock(); let snapshot = database; lock.unlock()
|
||||
var graph: [MeshAddress: Set<MeshAddress>] = [:]
|
||||
for entry in snapshot.values {
|
||||
let origin = entry.state.origin.address
|
||||
for neighbor in entry.state.neighbors {
|
||||
graph[origin, default: []].insert(neighbor)
|
||||
graph[neighbor, default: []].insert(origin)
|
||||
}
|
||||
}
|
||||
var distance: [MeshAddress: Int] = [local: 0]
|
||||
var firstHop: [MeshAddress: MeshAddress] = [:]
|
||||
var queue = [local]
|
||||
while !queue.isEmpty {
|
||||
let current = queue.removeFirst()
|
||||
guard let base = distance[current], base < 16 else { continue }
|
||||
for neighbor in (graph[current] ?? []).sorted() {
|
||||
let candidateFirst = current == local ? neighbor : firstHop[current]!
|
||||
let candidateDistance = base + 1
|
||||
if distance[neighbor] == nil || candidateDistance < distance[neighbor]! ||
|
||||
(candidateDistance == distance[neighbor]! && candidateFirst < firstHop[neighbor]!) {
|
||||
distance[neighbor] = candidateDistance; firstHop[neighbor] = candidateFirst
|
||||
queue.append(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: distance.compactMap { address, hops in
|
||||
guard address != local, let hop = firstHop[address] else { return nil }
|
||||
return (address, Route(destination: address, nextHop: hop, hopCount: hops))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
public enum UnixIPC {
|
||||
public static let defaultSocketPath = ProcessInfo.processInfo.environment["UMN_SOCKET"] ??
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/UltraMesh/umnd.sock").path
|
||||
|
||||
public static func connect(path: String = defaultSocketPath) throws -> Int32 {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
do {
|
||||
var address = try unixAddress(path: path)
|
||||
let result = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
Darwin.connect(fd, $0, unixAddressLength(path: path))
|
||||
}
|
||||
}
|
||||
guard result == 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
return fd
|
||||
} catch {
|
||||
Darwin.close(fd); throw error
|
||||
}
|
||||
}
|
||||
|
||||
public static func listen(path: String = defaultSocketPath) throws -> Int32 {
|
||||
try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
unlink(path)
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
var address = try unixAddress(path: path)
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
Darwin.bind(fd, $0, unixAddressLength(path: path))
|
||||
}
|
||||
}
|
||||
guard bound == 0, Darwin.listen(fd, 64) == 0 else {
|
||||
let code = errno; Darwin.close(fd); throw POSIXError(.init(rawValue: code) ?? .EIO)
|
||||
}
|
||||
chmod(path, 0o600)
|
||||
return fd
|
||||
}
|
||||
|
||||
public static func accept(_ server: Int32) throws -> Int32 {
|
||||
let fd = Darwin.accept(server, nil, nil)
|
||||
guard fd >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
return fd
|
||||
}
|
||||
|
||||
public static func send(_ message: IPCMessage, to fd: Int32) throws {
|
||||
try writeAll(FrameCodec.encode(message), to: fd)
|
||||
}
|
||||
|
||||
public static func receive(from fd: Int32) throws -> IPCMessage? {
|
||||
guard let prefix = try readExact(4, from: fd) else { return nil }
|
||||
let count = try FrameCodec.bodyLength(from: prefix)
|
||||
guard let body = try readExact(count, from: fd) else { throw UMNError.invalidFrame }
|
||||
return try FrameCodec.decode(IPCMessage.self, body: body)
|
||||
}
|
||||
|
||||
public static func close(_ fd: Int32) { Darwin.shutdown(fd, SHUT_RDWR); Darwin.close(fd) }
|
||||
|
||||
private static func readExact(_ count: Int, from fd: Int32) throws -> Data? {
|
||||
var output = Data(); output.reserveCapacity(count)
|
||||
var buffer = [UInt8](repeating: 0, count: min(16_384, count))
|
||||
while output.count < count {
|
||||
let wanted = min(buffer.count, count - output.count)
|
||||
let result = Darwin.read(fd, &buffer, wanted)
|
||||
if result == 0 { return output.isEmpty ? nil : output }
|
||||
if result < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw POSIXError(.init(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
output.append(buffer, count: result)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func writeAll(_ data: Data, to fd: Int32) throws {
|
||||
try data.withUnsafeBytes { raw in
|
||||
guard let base = raw.baseAddress else { return }
|
||||
var offset = 0
|
||||
while offset < data.count {
|
||||
let result = Darwin.write(fd, base.advanced(by: offset), data.count - offset)
|
||||
if result < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw POSIXError(.init(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
offset += result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func unixAddress(path: String) throws -> sockaddr_un {
|
||||
guard path.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path) else {
|
||||
throw UMNError.message("IPC socket path is too long")
|
||||
}
|
||||
var address = sockaddr_un(); address.sun_family = sa_family_t(AF_UNIX)
|
||||
let capacity = MemoryLayout.size(ofValue: address.sun_path)
|
||||
withUnsafeMutablePointer(to: &address.sun_path) { pointer in
|
||||
pointer.withMemoryRebound(to: CChar.self, capacity: capacity) { destination in
|
||||
path.withCString { source in
|
||||
_ = strncpy(destination, source, capacity - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
private static func unixAddressLength(path: String) -> socklen_t {
|
||||
socklen_t(MemoryLayout<sa_family_t>.size + path.utf8.count + 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import UltraMeshCore
|
||||
|
||||
struct TestFailure: Error, LocalizedError {
|
||||
let errorDescription: String?
|
||||
init(_ message: String) { errorDescription = message }
|
||||
}
|
||||
|
||||
var passed = 0
|
||||
func check(_ condition: @autoclosure () throws -> Bool, _ name: String) throws {
|
||||
guard try condition() else { throw TestFailure(name) }
|
||||
passed += 1; print("ok \(passed) - \(name)")
|
||||
}
|
||||
|
||||
do {
|
||||
let alice = try NodeIdentity(); let bob = try NodeIdentity(); let carol = try NodeIdentity()
|
||||
try check(try MeshAddress(alice.record.address.description) == alice.record.address, "mesh address round trip")
|
||||
try check(alice.record.address.bytes.first == 0xfd, "mesh address uses fd prefix")
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let identityURL = directory.appendingPathComponent("identity.plist")
|
||||
let stored1 = try NodeIdentity.loadOrCreate(at: identityURL)
|
||||
let stored2 = try NodeIdentity.loadOrCreate(at: identityURL)
|
||||
try check(stored1.record == stored2.record, "identity persists")
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: identityURL.path)
|
||||
try check((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, "identity permissions")
|
||||
|
||||
let original = InnerFrame(kind: .text, port: 7000, payload: Data("hello".utf8), sourceRecord: alice.record)
|
||||
let sealed = try alice.seal(original, to: bob.record)
|
||||
let opened = try bob.open(sealed, expectedSource: alice.record.address)
|
||||
try check(opened.payload == Data("hello".utf8), "end-to-end encryption round trip")
|
||||
var corrupted = sealed.combinedCiphertext; corrupted[corrupted.startIndex] ^= 1
|
||||
let tampered = SealedPayload(ephemeralPublicKey: sealed.ephemeralPublicKey, combinedCiphertext: corrupted)
|
||||
var rejected = false
|
||||
do { _ = try bob.open(tampered, expectedSource: alice.record.address) } catch { rejected = true }
|
||||
try check(rejected, "tampered ciphertext rejected")
|
||||
|
||||
let router = LinkStateRouter(local: alice.record.address)
|
||||
try check(router.ingest(try alice.makeLinkState(sequence: 1, neighbors: [bob.record.address])), "local topology accepted")
|
||||
try check(router.ingest(try bob.makeLinkState(sequence: 1, neighbors: [alice.record.address, carol.record.address])), "relay topology accepted")
|
||||
try check(router.ingest(try carol.makeLinkState(sequence: 1, neighbors: [bob.record.address])), "remote topology accepted")
|
||||
let route = router.routes()[carol.record.address]
|
||||
try check(route?.nextHop == bob.record.address && route?.hopCount == 2, "three-node route uses relay")
|
||||
try check(!router.ingest(try carol.makeLinkState(sequence: 1, neighbors: [])), "stale topology rejected")
|
||||
let forged = LinkState(origin: carol.record, sequence: 2, neighbors: [], signature: Data(repeating: 0, count: 64))
|
||||
try check(!router.ingest(forged), "forged topology rejected")
|
||||
|
||||
let firewall = MeshFirewall()
|
||||
try check(!firewall.allows(port: 80, source: alice.record.address), "firewall defaults to deny")
|
||||
firewall.allow(port: 80, source: alice.record.address)
|
||||
try check(firewall.allows(port: 80, source: alice.record.address), "scoped firewall rule allows source")
|
||||
try check(!firewall.allows(port: 80, source: bob.record.address), "scoped firewall rule denies other source")
|
||||
firewall.allow(port: 443, source: nil)
|
||||
try check(firewall.allows(port: 443, source: bob.record.address), "any-source firewall rule")
|
||||
|
||||
let framed = try FrameCodec.encode(WireEnvelope(message: .keepalive))
|
||||
try check(try FrameCodec.bodyLength(from: framed.prefix(4)) == framed.count - 4, "frame length prefix")
|
||||
let decoded = try FrameCodec.decode(WireEnvelope.self, body: framed.dropFirst(4))
|
||||
if decoded.version == 1, case .keepalive = decoded.message { try check(true, "wire frame round trip") }
|
||||
else { throw TestFailure("wire frame round trip") }
|
||||
var zeroRejected = false
|
||||
do { _ = try FrameCodec.bodyLength(from: Data([0, 0, 0, 0])) } catch { zeroRejected = true }
|
||||
try check(zeroRejected, "zero-length frame rejected")
|
||||
|
||||
print("1..\(passed)")
|
||||
print("all core self-tests passed")
|
||||
} catch {
|
||||
fputs("not ok - \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import UltraMeshCore
|
||||
|
||||
final class StreamBridge {
|
||||
let streamID: UInt64
|
||||
let socket: Int32
|
||||
private let daemon: DaemonConnection
|
||||
private let writeLock = NSLock()
|
||||
|
||||
init(streamID: UInt64, socket: Int32, daemon: DaemonConnection) {
|
||||
self.streamID = streamID; self.socket = socket; self.daemon = daemon
|
||||
}
|
||||
deinit { Darwin.shutdown(socket, SHUT_RDWR); Darwin.close(socket) }
|
||||
|
||||
func startReading() {
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
while let data = try SocketIO.readSome(self.socket) {
|
||||
var request = IPCMessage(operation: .streamData)
|
||||
request.streamID = self.streamID; request.data = data
|
||||
try self.daemon.send(request)
|
||||
}
|
||||
} catch {}
|
||||
var close = IPCMessage(operation: .streamClose); close.streamID = self.streamID
|
||||
try? self.daemon.send(close)
|
||||
}
|
||||
}
|
||||
|
||||
func write(_ data: Data) {
|
||||
writeLock.lock(); defer { writeLock.unlock() }; try? SocketIO.write(data, fd: socket)
|
||||
}
|
||||
}
|
||||
|
||||
func runExpose(meshPort: UInt16, host: String, localPort: UInt16) throws -> Never {
|
||||
let daemon = try DaemonConnection()
|
||||
var bind = IPCMessage(operation: .bindStream); bind.port = meshPort
|
||||
guard printResponse(try daemon.request(bind)) else { exit(1) }
|
||||
print("forwarding mesh port \(meshPort) to \(host):\(localPort); press Ctrl-C to stop")
|
||||
var bridges: [UInt64: StreamBridge] = [:]
|
||||
while let event = try daemon.receive() {
|
||||
guard let id = event.streamID else { continue }
|
||||
switch event.operation {
|
||||
case .openStream:
|
||||
do {
|
||||
let socket = try SocketIO.connect(host: host, port: localPort)
|
||||
let bridge = StreamBridge(streamID: id, socket: socket, daemon: daemon)
|
||||
bridges[id] = bridge; bridge.startReading()
|
||||
} catch {
|
||||
var close = IPCMessage(operation: .streamClose); close.streamID = id
|
||||
try? daemon.send(close)
|
||||
}
|
||||
case .streamData: if let data = event.data { bridges[id]?.write(data) }
|
||||
case .streamClose: bridges.removeValue(forKey: id)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
throw UMNError.message("daemon disconnected")
|
||||
}
|
||||
|
||||
func runSOCKS(port: UInt16) throws -> Never {
|
||||
let server = try SocketIO.listenLoopback(port: port)
|
||||
print("SOCKS5 proxy listening on 127.0.0.1:\(port); press Ctrl-C to stop")
|
||||
while true {
|
||||
let client = Darwin.accept(server, nil, nil)
|
||||
if client < 0 { if errno == EINTR { continue }; throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
DispatchQueue.global(qos: .userInitiated).async { handleSOCKSClient(client) }
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSOCKSClient(_ client: Int32) {
|
||||
defer { Darwin.shutdown(client, SHUT_RDWR); Darwin.close(client) }
|
||||
do {
|
||||
guard let greeting = try SocketIO.readExact(2, fd: client), greeting[0] == 5 else { return }
|
||||
_ = try SocketIO.readExact(Int(greeting[1]), fd: client)
|
||||
try SocketIO.write(Data([5, 0]), fd: client)
|
||||
guard let header = try SocketIO.readExact(4, fd: client), header[0] == 5, header[1] == 1 else { return }
|
||||
let target: String
|
||||
switch header[3] {
|
||||
case 3:
|
||||
guard let length = try SocketIO.readExact(1, fd: client)?.first,
|
||||
let name = try SocketIO.readExact(Int(length), fd: client),
|
||||
let value = String(data: name, encoding: .utf8) else { return }
|
||||
target = value
|
||||
case 4:
|
||||
guard let bytes = try SocketIO.readExact(16, fd: client) else { return }
|
||||
var raw = in6_addr(); _ = withUnsafeMutableBytes(of: &raw) { bytes.copyBytes(to: $0) }
|
||||
var output = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
|
||||
guard inet_ntop(AF_INET6, &raw, &output, socklen_t(INET6_ADDRSTRLEN)) != nil else { return }
|
||||
target = String(cString: output)
|
||||
default:
|
||||
try SocketIO.write(Data([5, 8, 0, 1, 0, 0, 0, 0, 0, 0]), fd: client); return
|
||||
}
|
||||
guard let portData = try SocketIO.readExact(2, fd: client) else { return }
|
||||
let port = UInt16(portData[0]) << 8 | UInt16(portData[1])
|
||||
let daemon = try DaemonConnection()
|
||||
var open = IPCMessage(operation: .openStream); open.target = target; open.port = port
|
||||
let response = try daemon.request(open)
|
||||
guard response.ok == true, let id = response.streamID else {
|
||||
try SocketIO.write(Data([5, 4, 0, 1, 0, 0, 0, 0, 0, 0]), fd: client); return
|
||||
}
|
||||
try SocketIO.write(Data([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]), fd: client)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
while let data = try SocketIO.readSome(client) {
|
||||
var packet = IPCMessage(operation: .streamData); packet.streamID = id; packet.data = data
|
||||
try daemon.send(packet)
|
||||
}
|
||||
} catch {}
|
||||
var close = IPCMessage(operation: .streamClose); close.streamID = id; try? daemon.send(close)
|
||||
}
|
||||
while let event = try daemon.receive() {
|
||||
if event.operation == .streamData, event.streamID == id, let data = event.data { try SocketIO.write(data, fd: client) }
|
||||
if event.operation == .streamClose, event.streamID == id { return }
|
||||
}
|
||||
} catch { return }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import UltraMeshCore
|
||||
|
||||
final class DaemonConnection {
|
||||
let fd: Int32
|
||||
private let lock = NSLock()
|
||||
|
||||
init() throws { fd = try UnixIPC.connect() }
|
||||
deinit { UnixIPC.close(fd) }
|
||||
|
||||
func send(_ message: IPCMessage) throws {
|
||||
lock.lock(); defer { lock.unlock() }; try UnixIPC.send(message, to: fd)
|
||||
}
|
||||
func receive() throws -> IPCMessage? { try UnixIPC.receive(from: fd) }
|
||||
func request(_ message: IPCMessage) throws -> IPCMessage {
|
||||
try send(message)
|
||||
guard let response = try receive() else { throw UMNError.message("daemon disconnected") }
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
enum SocketIO {
|
||||
static func listenLoopback(port: UInt16) throws -> Int32 {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
var yes: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size(ofValue: yes)))
|
||||
var address = sockaddr_in(); address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET); address.sin_port = port.bigEndian
|
||||
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
let result = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard result == 0, Darwin.listen(fd, 64) == 0 else {
|
||||
let code = errno; close(fd); throw POSIXError(.init(rawValue: code) ?? .EIO)
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
static func connect(host: String, port: UInt16) throws -> Int32 {
|
||||
var hints = addrinfo(ai_flags: 0, ai_family: AF_UNSPEC, ai_socktype: SOCK_STREAM,
|
||||
ai_protocol: IPPROTO_TCP, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil)
|
||||
var info: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(host, String(port), &hints, &info) == 0, let first = info else {
|
||||
throw UMNError.message("cannot resolve local target")
|
||||
}
|
||||
defer { freeaddrinfo(info) }
|
||||
var cursor: UnsafeMutablePointer<addrinfo>? = first
|
||||
while let item = cursor {
|
||||
let fd = socket(item.pointee.ai_family, item.pointee.ai_socktype, item.pointee.ai_protocol)
|
||||
if fd >= 0, Darwin.connect(fd, item.pointee.ai_addr, item.pointee.ai_addrlen) == 0 { return fd }
|
||||
if fd >= 0 { close(fd) }; cursor = item.pointee.ai_next
|
||||
}
|
||||
throw UMNError.message("cannot connect to local target")
|
||||
}
|
||||
|
||||
static func readExact(_ count: Int, fd: Int32) throws -> Data? {
|
||||
var data = Data(); var buffer = [UInt8](repeating: 0, count: count)
|
||||
while data.count < count {
|
||||
let n = Darwin.read(fd, &buffer, count - data.count)
|
||||
if n == 0 { return data.isEmpty ? nil : data }
|
||||
if n < 0 { if errno == EINTR { continue }; throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
data.append(buffer, count: n)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func readSome(_ fd: Int32, maximum: Int = 32_768) throws -> Data? {
|
||||
var buffer = [UInt8](repeating: 0, count: maximum)
|
||||
let n = Darwin.read(fd, &buffer, maximum)
|
||||
if n == 0 { return nil }
|
||||
if n < 0 {
|
||||
if errno == EINTR { return try readSome(fd, maximum: maximum) }
|
||||
throw POSIXError(.init(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
return Data(buffer.prefix(n))
|
||||
}
|
||||
|
||||
static func write(_ data: Data, fd: Int32) throws {
|
||||
try data.withUnsafeBytes { bytes in
|
||||
guard let base = bytes.baseAddress else { return }; var offset = 0
|
||||
while offset < data.count {
|
||||
let n = Darwin.write(fd, base.advanced(by: offset), data.count - offset)
|
||||
if n < 0 { if errno == EINTR { continue }; throw POSIXError(.init(rawValue: errno) ?? .EIO) }
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func printResponse(_ response: IPCMessage) -> Bool {
|
||||
if let message = response.message { print(message) }
|
||||
response.values?.forEach { print($0) }
|
||||
if response.ok != true, response.message == nil { fputs("operation failed\n", stderr) }
|
||||
return response.ok == true
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import UltraMeshCore
|
||||
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
|
||||
func usage() -> Never {
|
||||
print("""
|
||||
Ultra Mesh Network
|
||||
umn status | address | peers | routes | ping <target>
|
||||
umn alias set <name> <address> | remove <name> | list
|
||||
umn firewall allow|revoke <port> --from <address|any> | list
|
||||
umn text send <target> <port> <message> | listen <port>
|
||||
umn expose <mesh-port> --to <host:port>
|
||||
umn proxy [--listen 127.0.0.1:<port>]
|
||||
""")
|
||||
exit(2)
|
||||
}
|
||||
|
||||
func parsePort(_ value: String) -> UInt16 {
|
||||
guard let port = UInt16(value), port > 0 else { usage() }
|
||||
return port
|
||||
}
|
||||
|
||||
do {
|
||||
guard let command = args.first else { usage() }
|
||||
if command == "expose" {
|
||||
guard args.count == 4, args[2] == "--to", let split = args[3].lastIndex(of: ":") else { usage() }
|
||||
let host = String(args[3][..<split])
|
||||
let localPort = parsePort(String(args[3][args[3].index(after: split)...]))
|
||||
try runExpose(meshPort: parsePort(args[1]), host: host, localPort: localPort)
|
||||
}
|
||||
if command == "proxy" {
|
||||
var listenPort: UInt16 = 1080
|
||||
if args.count == 3, args[1] == "--listen", let split = args[2].lastIndex(of: ":") {
|
||||
listenPort = parsePort(String(args[2][args[2].index(after: split)...]))
|
||||
} else if args.count != 1 { usage() }
|
||||
try runSOCKS(port: listenPort)
|
||||
}
|
||||
|
||||
let daemon = try DaemonConnection()
|
||||
var request: IPCMessage
|
||||
switch command {
|
||||
case "status": request = IPCMessage(operation: .status)
|
||||
case "address": request = IPCMessage(operation: .address)
|
||||
case "peers": request = IPCMessage(operation: .peers)
|
||||
case "routes": request = IPCMessage(operation: .routes)
|
||||
case "ping":
|
||||
guard args.count == 2 else { usage() }
|
||||
request = IPCMessage(operation: .ping); request.target = args[1]
|
||||
case "alias":
|
||||
guard args.count >= 2 else { usage() }
|
||||
switch args[1] {
|
||||
case "list": request = IPCMessage(operation: .aliasList)
|
||||
case "set":
|
||||
guard args.count == 4 else { usage() }
|
||||
request = IPCMessage(operation: .aliasSet); request.name = args[2]; request.target = args[3]
|
||||
case "remove":
|
||||
guard args.count == 3 else { usage() }
|
||||
request = IPCMessage(operation: .aliasRemove); request.name = args[2]
|
||||
default: usage()
|
||||
}
|
||||
case "firewall":
|
||||
guard args.count >= 2 else { usage() }
|
||||
if args[1] == "list" { request = IPCMessage(operation: .firewallList) }
|
||||
else {
|
||||
guard args.count == 5, args[3] == "--from", ["allow", "revoke"].contains(args[1]) else { usage() }
|
||||
request = IPCMessage(operation: args[1] == "allow" ? .firewallAllow : .firewallRevoke)
|
||||
request.port = parsePort(args[2]); request.source = args[4]
|
||||
}
|
||||
case "text":
|
||||
guard args.count >= 3 else { usage() }
|
||||
if args[1] == "listen" {
|
||||
request = IPCMessage(operation: .bindText); request.port = parsePort(args[2])
|
||||
guard printResponse(try daemon.request(request)) else { exit(1) }
|
||||
while let event = try daemon.receive() {
|
||||
if event.operation == .textSend, let data = event.data,
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
print("[\(event.source ?? "unknown")] \(text)")
|
||||
}
|
||||
}
|
||||
throw UMNError.message("daemon disconnected")
|
||||
} else if args[1] == "send" {
|
||||
guard args.count >= 5 else { usage() }
|
||||
request = IPCMessage(operation: .textSend); request.target = args[2]
|
||||
request.port = parsePort(args[3]); request.data = Data(args.dropFirst(4).joined(separator: " ").utf8)
|
||||
} else { usage() }
|
||||
default: usage()
|
||||
}
|
||||
let result = try daemon.request(request)
|
||||
exit(printResponse(result) ? 0 : 1)
|
||||
} catch {
|
||||
fputs("umn: \(error.localizedDescription)\n", stderr)
|
||||
if (error as NSError).domain == NSPOSIXErrorDomain {
|
||||
fputs("Is umnd running? Install it or start with `swift run umnd`.\n", stderr)
|
||||
}
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import UltraMeshCore
|
||||
|
||||
struct DaemonConfig: Codable {
|
||||
var aliases: [String: MeshAddress] = [:]
|
||||
var firewall: Set<FirewallRule> = []
|
||||
var lsaSequence: UInt64?
|
||||
|
||||
static func load(from url: URL) -> DaemonConfig {
|
||||
guard let data = try? Data(contentsOf: url), let value = try? JSONDecoder().decode(Self.self, from: data) else { return .init() }
|
||||
return value
|
||||
}
|
||||
|
||||
func save(to url: URL) throws {
|
||||
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(self).write(to: url, options: .atomic)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import UltraMeshCore
|
||||
|
||||
final class IPCClient {
|
||||
let fd: Int32
|
||||
private let writeLock = NSLock()
|
||||
private var closed = false
|
||||
var onClose: ((IPCClient) -> Void)?
|
||||
init(fd: Int32) { self.fd = fd }
|
||||
|
||||
func send(_ message: IPCMessage) {
|
||||
writeLock.lock()
|
||||
guard !closed else { writeLock.unlock(); return }
|
||||
do { try UnixIPC.send(message, to: fd); writeLock.unlock() }
|
||||
catch {
|
||||
closed = true; writeLock.unlock()
|
||||
UnixIPC.close(fd); onClose?(self)
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
writeLock.lock()
|
||||
guard !closed else { writeLock.unlock(); return }
|
||||
closed = true; writeLock.unlock()
|
||||
UnixIPC.close(fd); onClose?(self)
|
||||
}
|
||||
}
|
||||
|
||||
final class IPCServer {
|
||||
private let path: String
|
||||
private var server: Int32 = -1
|
||||
private var running = true
|
||||
var onMessage: ((IPCClient, IPCMessage) -> Void)?
|
||||
|
||||
init(path: String = UnixIPC.defaultSocketPath) { self.path = path }
|
||||
|
||||
func start() throws {
|
||||
server = try UnixIPC.listen(path: path)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.acceptLoop() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
running = false
|
||||
if server >= 0 { UnixIPC.close(server) }
|
||||
unlink(path)
|
||||
}
|
||||
|
||||
private func acceptLoop() {
|
||||
while running {
|
||||
guard let fd = try? UnixIPC.accept(server) else { if running { continue }; return }
|
||||
let client = IPCClient(fd: fd)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
while let message = try? UnixIPC.receive(from: fd) {
|
||||
self?.onMessage?(client, message)
|
||||
}
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import UltraMeshCore
|
||||
|
||||
private final class StreamState {
|
||||
struct Pending { var frame: InnerFrame; var lastSent: Date; var attempts: Int }
|
||||
let id: UInt64
|
||||
let remote: NodeRecord
|
||||
weak var client: IPCClient?
|
||||
var nextSequence: UInt64 = 1
|
||||
var expectedSequence: UInt64 = 1
|
||||
var pending: [UInt64: Pending] = [:]
|
||||
var queued: [Data] = []
|
||||
var closing = false
|
||||
var transferred = 0
|
||||
var lastProgress = Date()
|
||||
init(id: UInt64, remote: NodeRecord, client: IPCClient) {
|
||||
self.id = id; self.remote = remote; self.client = client
|
||||
}
|
||||
}
|
||||
|
||||
final class MeshDaemon {
|
||||
private let queue = DispatchQueue(label: "com.ultramesh.daemon")
|
||||
private let identity: NodeIdentity
|
||||
private let router: LinkStateRouter
|
||||
private let firewall: MeshFirewall
|
||||
private let configURL: URL
|
||||
private var config: DaemonConfig
|
||||
private let ipc: IPCServer
|
||||
private var listener: NWListener?
|
||||
private var browser: NWBrowser?
|
||||
private var pendingEndpoints = Set<String>()
|
||||
private var peers: [MeshAddress: PeerSession] = [:]
|
||||
private var allSessions: [ObjectIdentifier: PeerSession] = [:]
|
||||
private var lsaSequence: UInt64 = 0
|
||||
private var seenPackets = Set<UUID>()
|
||||
private var seenOrder: [UUID] = []
|
||||
private var textBindings: [UInt16: IPCClient] = [:]
|
||||
private var streamBindings: [UInt16: IPCClient] = [:]
|
||||
private var streams: [UInt64: StreamState] = [:]
|
||||
private var pendingPings: [UUID: (IPCClient, Date)] = [:]
|
||||
private var pingWindows: [MeshAddress: (Date, Int)] = [:]
|
||||
private var timer: DispatchSourceTimer?
|
||||
|
||||
init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws {
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
identity = try NodeIdentity.loadOrCreate(at: baseURL.appendingPathComponent("identity.plist"))
|
||||
router = LinkStateRouter(local: identity.record.address)
|
||||
configURL = baseURL.appendingPathComponent("config.json")
|
||||
config = DaemonConfig.load(from: configURL)
|
||||
firewall = MeshFirewall(rules: config.firewall)
|
||||
ipc = IPCServer(path: socketPath ?? baseURL.appendingPathComponent("umnd.sock").path)
|
||||
let wallClockBase = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
lsaSequence = max((config.lsaSequence ?? 0) &+ 1_000_000, wallClockBase)
|
||||
config.lsaSequence = lsaSequence
|
||||
try config.save(to: configURL)
|
||||
}
|
||||
|
||||
var address: MeshAddress { identity.record.address }
|
||||
|
||||
func start() throws {
|
||||
try startNetwork()
|
||||
ipc.onMessage = { [weak self] client, message in self?.queue.async { self?.handleIPC(client, message) } }
|
||||
try ipc.start()
|
||||
publishLocalState()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1)
|
||||
timer.setEventHandler { [weak self] in self?.tick() }
|
||||
timer.resume(); self.timer = timer
|
||||
log("node \(address) started")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.sync {
|
||||
timer?.cancel(); browser?.cancel(); listener?.cancel()
|
||||
allSessions.values.forEach { $0.stop() }; ipc.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func startNetwork() throws {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.includePeerToPeer = true
|
||||
let listener = try NWListener(using: parameters)
|
||||
listener.service = .init(name: "umn-\(address.description.suffix(8))", type: "_umn._tcp")
|
||||
listener.newConnectionHandler = { [weak self] connection in self?.queue.async { self?.addSession(connection, outbound: false) } }
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
if case .failed(let error) = state { self?.log("listener failed: \(error)") }
|
||||
}
|
||||
listener.start(queue: queue); self.listener = listener
|
||||
|
||||
let browseParameters = NWParameters.tcp
|
||||
browseParameters.includePeerToPeer = true
|
||||
let browser = NWBrowser(for: .bonjour(type: "_umn._tcp", domain: nil), using: browseParameters)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
guard let self else { return }
|
||||
self.queue.async {
|
||||
for result in results {
|
||||
let key = String(describing: result.endpoint)
|
||||
guard !self.pendingEndpoints.contains(key) else { continue }
|
||||
self.pendingEndpoints.insert(key)
|
||||
let connection = NWConnection(to: result.endpoint, using: browseParameters)
|
||||
self.addSession(connection, outbound: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
if case .failed(let error) = state { self?.log("browser failed: \(error)") }
|
||||
}
|
||||
browser.start(queue: queue); self.browser = browser
|
||||
}
|
||||
|
||||
private func addSession(_ connection: NWConnection, outbound: Bool) {
|
||||
let session = PeerSession(connection: connection, outbound: outbound, queue: queue)
|
||||
allSessions[ObjectIdentifier(session)] = session
|
||||
session.onMessage = { [weak self] peer, message in self?.queue.async { self?.handle(peer, message) } }
|
||||
session.onStop = { [weak self] peer in self?.queue.async { self?.remove(peer) } }
|
||||
session.start(local: identity.record)
|
||||
}
|
||||
|
||||
private func remove(_ session: PeerSession) {
|
||||
allSessions.removeValue(forKey: ObjectIdentifier(session))
|
||||
pendingEndpoints.remove(String(describing: session.connection.endpoint))
|
||||
if let remote = session.remote, peers[remote.address] === session {
|
||||
peers.removeValue(forKey: remote.address); log("peer \(remote.address) disconnected"); publishLocalState()
|
||||
let endpoint = session.connection.endpoint
|
||||
queue.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
guard let self, self.peers[remote.address] == nil else { return }
|
||||
let parameters = NWParameters.tcp; parameters.includePeerToPeer = true
|
||||
self.addSession(NWConnection(to: endpoint, using: parameters), outbound: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ session: PeerSession, _ message: WireMessage) {
|
||||
switch message {
|
||||
case .hello(let record):
|
||||
guard record.validate(), record.address != address else { session.stop(); return }
|
||||
guard peers[record.address] != nil || peers.count < 31 else { session.stop(); return }
|
||||
session.remote = record
|
||||
let shouldBeOutbound = address < record.address
|
||||
if session.outbound != shouldBeOutbound, let existing = peers[record.address], existing.outbound == shouldBeOutbound {
|
||||
session.stop(); return
|
||||
}
|
||||
if let existing = peers[record.address], existing !== session {
|
||||
if existing.outbound == shouldBeOutbound { session.stop(); return }
|
||||
existing.stop()
|
||||
}
|
||||
peers[record.address] = session
|
||||
log("peer \(record.address) connected over \(session.outbound ? "outbound" : "inbound") path")
|
||||
for state in currentLinkStates() { session.send(.linkState(state)) }
|
||||
publishLocalState()
|
||||
case .linkState(let state):
|
||||
guard session.remote != nil, router.ingest(state) else { return }
|
||||
broadcast(.linkState(state), excluding: session)
|
||||
case .packet(var packet):
|
||||
guard session.remote != nil, markSeen(packet.id) else { return }
|
||||
if packet.destination == address { receiveLocal(packet) }
|
||||
else if packet.hopLimit > 1 {
|
||||
packet.hopLimit -= 1; forward(packet)
|
||||
}
|
||||
case .keepalive: break
|
||||
}
|
||||
}
|
||||
|
||||
private func publishLocalState() {
|
||||
lsaSequence &+= 1
|
||||
guard let state = try? identity.makeLinkState(sequence: lsaSequence, neighbors: Array(peers.keys)) else { return }
|
||||
_ = router.ingest(state); broadcast(.linkState(state), excluding: nil)
|
||||
}
|
||||
|
||||
private func currentLinkStates() -> [LinkState] {
|
||||
router.allStates()
|
||||
}
|
||||
|
||||
private func broadcast(_ message: WireMessage, excluding: PeerSession?) {
|
||||
for peer in peers.values where peer !== excluding { peer.send(message) }
|
||||
}
|
||||
|
||||
private func forward(_ packet: RoutedPacket) {
|
||||
guard let route = router.routes()[packet.destination], let peer = peers[route.nextHop] else { return }
|
||||
peer.send(.packet(packet))
|
||||
}
|
||||
|
||||
@discardableResult private func send(_ inner: InnerFrame, to remote: NodeRecord) -> Bool {
|
||||
guard remote.address != address,
|
||||
let sealed = try? identity.seal(inner, to: remote) else { return false }
|
||||
let packet = RoutedPacket(source: address, destination: remote.address, sealed: sealed)
|
||||
guard router.routes()[remote.address] != nil || peers[remote.address] != nil else { return false }
|
||||
forward(packet); return true
|
||||
}
|
||||
|
||||
private func receiveLocal(_ packet: RoutedPacket) {
|
||||
guard let inner = try? identity.open(packet.sealed, expectedSource: packet.source) else { return }
|
||||
switch inner.kind {
|
||||
case .pingRequest:
|
||||
guard allowPing(from: packet.source) else { return }
|
||||
let reply = InnerFrame(kind: .pingReply, streamID: inner.streamID, payload: inner.payload, sourceRecord: identity.record)
|
||||
_ = send(reply, to: inner.sourceRecord)
|
||||
case .pingReply:
|
||||
guard let idString = String(data: inner.payload, encoding: .utf8), let id = UUID(uuidString: idString),
|
||||
let pending = pendingPings.removeValue(forKey: id) else { return }
|
||||
var response = IPCMessage(operation: .ping, requestID: id); response.ok = true
|
||||
response.message = String(format: "reply from %@: %.1f ms", packet.source.description,
|
||||
Date().timeIntervalSince(pending.1) * 1000)
|
||||
pending.0.send(response)
|
||||
case .text:
|
||||
guard let port = inner.port, firewall.allows(port: port, source: packet.source),
|
||||
let client = textBindings[port] else { return }
|
||||
var event = IPCMessage(operation: .textSend); event.ok = true
|
||||
event.source = packet.source.description; event.port = port; event.data = inner.payload
|
||||
client.send(event)
|
||||
case .streamOpen: receiveStreamOpen(inner, from: packet.source)
|
||||
case .streamData: receiveStreamData(inner)
|
||||
case .streamAck: receiveStreamAck(inner)
|
||||
case .streamClose, .streamReset: receiveStreamClose(inner)
|
||||
case .error: receiveStreamClose(inner)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveStreamOpen(_ inner: InnerFrame, from source: MeshAddress) {
|
||||
if let existing = streams[inner.streamID] {
|
||||
sendAck(for: existing, sequence: 0); return
|
||||
}
|
||||
guard streams.count < 32,
|
||||
streams.values.filter({ $0.remote.address == source }).count < 8,
|
||||
let port = inner.port,
|
||||
firewall.allows(port: port, source: source), let client = streamBindings[port] else {
|
||||
let reset = InnerFrame(kind: .streamReset, streamID: inner.streamID,
|
||||
payload: Data("denied or unbound".utf8), sourceRecord: identity.record)
|
||||
_ = send(reset, to: inner.sourceRecord); return
|
||||
}
|
||||
let state = StreamState(id: inner.streamID, remote: inner.sourceRecord, client: client)
|
||||
streams[inner.streamID] = state
|
||||
var event = IPCMessage(operation: .openStream); event.ok = true; event.streamID = inner.streamID
|
||||
event.source = source.description; event.port = port; client.send(event)
|
||||
sendAck(for: state, sequence: 0)
|
||||
}
|
||||
|
||||
private func receiveStreamData(_ inner: InnerFrame) {
|
||||
guard let state = streams[inner.streamID] else { return }
|
||||
if inner.sequence == state.expectedSequence {
|
||||
guard state.transferred + inner.payload.count <= 1_048_576 else { reset(state, reason: "1 MiB limit exceeded"); return }
|
||||
state.transferred += inner.payload.count; state.expectedSequence &+= 1; state.lastProgress = Date()
|
||||
var event = IPCMessage(operation: .streamData); event.streamID = state.id; event.data = inner.payload; event.ok = true
|
||||
state.client?.send(event)
|
||||
}
|
||||
sendAck(for: state, sequence: state.expectedSequence - 1)
|
||||
}
|
||||
|
||||
private func receiveStreamAck(_ inner: InnerFrame) {
|
||||
guard let state = streams[inner.streamID] else { return }
|
||||
state.pending = state.pending.filter { $0.key > inner.acknowledgment }
|
||||
state.lastProgress = Date()
|
||||
pump(state)
|
||||
finishIfDrained(state)
|
||||
}
|
||||
|
||||
private func receiveStreamClose(_ inner: InnerFrame) {
|
||||
guard let state = streams.removeValue(forKey: inner.streamID) else { return }
|
||||
var event = IPCMessage(operation: .streamClose); event.streamID = state.id; event.data = inner.payload
|
||||
event.ok = inner.kind == .streamClose; state.client?.send(event)
|
||||
}
|
||||
|
||||
private func sendAck(for state: StreamState, sequence: UInt64) {
|
||||
let ack = InnerFrame(kind: .streamAck, streamID: state.id, acknowledgment: sequence, sourceRecord: identity.record)
|
||||
_ = send(ack, to: state.remote)
|
||||
}
|
||||
|
||||
private func reset(_ state: StreamState, reason: String) {
|
||||
let frame = InnerFrame(kind: .streamReset, streamID: state.id, payload: Data(reason.utf8), sourceRecord: identity.record)
|
||||
_ = send(frame, to: state.remote); streams.removeValue(forKey: state.id)
|
||||
var event = IPCMessage(operation: .streamClose); event.streamID = state.id; event.ok = false; event.message = reason
|
||||
state.client?.send(event)
|
||||
}
|
||||
|
||||
private func sendReliable(_ frame: InnerFrame, state: StreamState) -> Bool {
|
||||
guard send(frame, to: state.remote) else { return false }
|
||||
state.pending[frame.sequence] = .init(frame: frame, lastSent: Date(), attempts: 0)
|
||||
return true
|
||||
}
|
||||
|
||||
private func pump(_ state: StreamState) {
|
||||
var inFlightBytes = state.pending.values.reduce(0) { $0 + $1.frame.payload.count }
|
||||
while let data = state.queued.first, inFlightBytes + data.count <= 65_536 {
|
||||
state.queued.removeFirst()
|
||||
let frame = InnerFrame(kind: .streamData, streamID: state.id, sequence: state.nextSequence,
|
||||
payload: data, sourceRecord: identity.record)
|
||||
state.nextSequence &+= 1
|
||||
guard sendReliable(frame, state: state) else { state.queued.insert(data, at: 0); return }
|
||||
inFlightBytes += data.count
|
||||
}
|
||||
}
|
||||
|
||||
private func finishIfDrained(_ state: StreamState) {
|
||||
guard state.closing, state.queued.isEmpty, state.pending.isEmpty else { return }
|
||||
let frame = InnerFrame(kind: .streamClose, streamID: state.id, sequence: state.nextSequence, sourceRecord: identity.record)
|
||||
_ = send(frame, to: state.remote); streams.removeValue(forKey: state.id)
|
||||
}
|
||||
|
||||
private func handleIPC(_ client: IPCClient, _ message: IPCMessage) {
|
||||
func reply(_ ok: Bool, _ text: String? = nil, values: [String]? = nil, streamID: UInt64? = nil) {
|
||||
var response = IPCMessage(operation: message.operation, requestID: message.requestID)
|
||||
response.ok = ok; response.message = text; response.values = values; response.streamID = streamID
|
||||
client.send(response)
|
||||
}
|
||||
switch message.operation {
|
||||
case .status:
|
||||
reply(true, "umnd running as \(address); \(peers.count) direct peer(s), \(router.routes().count) route(s)")
|
||||
case .address: reply(true, address.description)
|
||||
case .peers: reply(true, values: peers.keys.sorted().map(\.description))
|
||||
case .routes:
|
||||
let values = router.routes().values.sorted { $0.destination < $1.destination }
|
||||
.map { "\($0.destination) via \($0.nextHop) (\($0.hopCount) hop\($0.hopCount == 1 ? "" : "s"))" }
|
||||
reply(true, values: values)
|
||||
case .aliasList:
|
||||
reply(true, values: config.aliases.sorted { $0.key < $1.key }.map { "\($0.key).mesh \($0.value)" })
|
||||
case .aliasSet:
|
||||
guard let name = message.name?.lowercased(), validAlias(name), let target = message.target,
|
||||
let value = try? MeshAddress(target) else { reply(false, "invalid alias or address"); return }
|
||||
config.aliases[name] = value; saveConfig(); reply(true, "\(name).mesh -> \(value)")
|
||||
case .aliasRemove:
|
||||
guard let name = message.name?.lowercased() else { reply(false, "missing alias"); return }
|
||||
config.aliases.removeValue(forKey: name); saveConfig(); reply(true)
|
||||
case .firewallList:
|
||||
let values = firewall.allRules().map { "allow \($0.port) from \($0.source?.description ?? "any")" }
|
||||
reply(true, values: values)
|
||||
case .firewallAllow, .firewallRevoke:
|
||||
guard let port = message.port, let sourceText = message.source else { reply(false, "missing port or source"); return }
|
||||
let source: MeshAddress?
|
||||
if sourceText == "any" { source = nil } else if let parsed = resolve(sourceText) { source = parsed } else { reply(false, "invalid source"); return }
|
||||
if message.operation == .firewallAllow { firewall.allow(port: port, source: source) }
|
||||
else { firewall.revoke(port: port, source: source) }
|
||||
config.firewall = Set(firewall.allRules()); saveConfig(); reply(true)
|
||||
case .ping:
|
||||
guard let target = message.target, let remote = record(for: target) else { reply(false, "no route to destination"); return }
|
||||
let id = message.requestID
|
||||
let inner = InnerFrame(kind: .pingRequest, streamID: UInt64.random(in: 1...UInt64.max),
|
||||
payload: Data(id.uuidString.utf8), sourceRecord: identity.record)
|
||||
guard send(inner, to: remote) else { reply(false, "no route to destination"); return }
|
||||
pendingPings[id] = (client, Date())
|
||||
case .textSend:
|
||||
guard let target = message.target, let remote = record(for: target), let port = message.port,
|
||||
let data = message.data, data.count <= 4096, String(data: data, encoding: .utf8) != nil else {
|
||||
reply(false, "invalid destination, port, or UTF-8 message (maximum 4 KiB)"); return
|
||||
}
|
||||
let inner = InnerFrame(kind: .text, port: port, payload: data, sourceRecord: identity.record)
|
||||
let sent = send(inner, to: remote)
|
||||
reply(sent, sent ? "sent" : "no route to destination")
|
||||
case .bindText:
|
||||
guard let port = message.port, textBindings[port] == nil else { reply(false, "port is already bound"); return }
|
||||
textBindings[port] = client; bindCleanup(client: client); reply(true, "listening on mesh port \(port)")
|
||||
case .bindStream:
|
||||
guard let port = message.port, streamBindings[port] == nil else { reply(false, "port is already bound"); return }
|
||||
streamBindings[port] = client; bindCleanup(client: client); reply(true, "bound mesh port \(port)")
|
||||
case .openStream:
|
||||
guard streams.count < 32, let target = message.target, let remote = record(for: target), let port = message.port else {
|
||||
reply(false, "no route to destination"); return
|
||||
}
|
||||
var id = UInt64.random(in: 1...UInt64.max); while streams[id] != nil { id = UInt64.random(in: 1...UInt64.max) }
|
||||
let state = StreamState(id: id, remote: remote, client: client); streams[id] = state
|
||||
let open = InnerFrame(kind: .streamOpen, streamID: id, port: port, sequence: 0, sourceRecord: identity.record)
|
||||
guard sendReliable(open, state: state) else { streams.removeValue(forKey: id); reply(false, "no route"); return }
|
||||
bindCleanup(client: client); reply(true, "opening", streamID: id)
|
||||
case .streamData:
|
||||
guard let id = message.streamID, let state = streams[id], state.client === client, let data = message.data,
|
||||
state.transferred + data.count <= 1_048_576 else { reply(false, "unknown stream or 1 MiB limit exceeded"); return }
|
||||
state.transferred += data.count
|
||||
state.queued.append(data); pump(state)
|
||||
case .streamClose:
|
||||
guard let id = message.streamID, let state = streams[id], state.client === client else { return }
|
||||
state.closing = true; finishIfDrained(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindCleanup(client: IPCClient) {
|
||||
client.onClose = { [weak self, weak client] _ in
|
||||
guard let self, let client else { return }
|
||||
self.queue.async {
|
||||
self.textBindings = self.textBindings.filter { $0.value !== client }
|
||||
self.streamBindings = self.streamBindings.filter { $0.value !== client }
|
||||
let ids = self.streams.filter { $0.value.client === client }.map(\.key)
|
||||
for id in ids { if let state = self.streams[id] { self.reset(state, reason: "local service disconnected") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func record(for text: String) -> NodeRecord? {
|
||||
guard let target = resolve(text) else { return nil }
|
||||
if let peer = peers[target]?.remote { return peer }
|
||||
return router.record(for: target)
|
||||
}
|
||||
|
||||
private func resolve(_ text: String) -> MeshAddress? {
|
||||
if let value = try? MeshAddress(text) { return value }
|
||||
let name = text.lowercased().hasSuffix(".mesh") ? String(text.lowercased().dropLast(5)) : text.lowercased()
|
||||
return config.aliases[name]
|
||||
}
|
||||
|
||||
private func validAlias(_ value: String) -> Bool {
|
||||
!value.isEmpty && value.count <= 63 && value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" }
|
||||
}
|
||||
|
||||
private func saveConfig() { try? config.save(to: configURL) }
|
||||
|
||||
private func markSeen(_ id: UUID) -> Bool {
|
||||
guard !seenPackets.contains(id) else { return false }
|
||||
seenPackets.insert(id); seenOrder.append(id)
|
||||
if seenOrder.count > 4096 { seenPackets.remove(seenOrder.removeFirst()) }
|
||||
return true
|
||||
}
|
||||
|
||||
private func allowPing(from source: MeshAddress) -> Bool {
|
||||
let now = Date()
|
||||
if let current = pingWindows[source], now.timeIntervalSince(current.0) < 1 {
|
||||
guard current.1 < 10 else { return false }
|
||||
pingWindows[source] = (current.0, current.1 + 1)
|
||||
} else {
|
||||
guard pingWindows[source] != nil || pingWindows.count < 128 else { return false }
|
||||
pingWindows[source] = (now, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = Date()
|
||||
if Int(now.timeIntervalSince1970) % 5 == 0 { broadcast(.keepalive, excluding: nil) }
|
||||
if Int(now.timeIntervalSince1970) % 10 == 0 { publishLocalState() }
|
||||
router.expire(now: now)
|
||||
pingWindows = pingWindows.filter { now.timeIntervalSince($0.value.0) < 60 }
|
||||
let expiredPings = pendingPings.filter { now.timeIntervalSince($0.value.1) > 10 }
|
||||
for (id, pending) in expiredPings {
|
||||
var response = IPCMessage(operation: .ping, requestID: id); response.ok = false; response.message = "ping timed out"
|
||||
pending.0.send(response); pendingPings.removeValue(forKey: id)
|
||||
}
|
||||
for state in Array(streams.values) {
|
||||
if now.timeIntervalSince(state.lastProgress) > 60 { reset(state, reason: "stream timed out"); continue }
|
||||
for (sequence, pending) in Array(state.pending) {
|
||||
let delay = min(5.0, 0.5 * pow(2.0, Double(min(pending.attempts, 4))))
|
||||
if now.timeIntervalSince(pending.lastSent) >= delay {
|
||||
_ = send(pending.frame, to: state.remote)
|
||||
state.pending[sequence] = .init(frame: pending.frame, lastSent: now, attempts: pending.attempts + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n"
|
||||
FileHandle.standardError.write(Data(line.utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import UltraMeshCore
|
||||
|
||||
final class PeerSession {
|
||||
let connection: NWConnection
|
||||
let outbound: Bool
|
||||
var remote: NodeRecord?
|
||||
var onMessage: ((PeerSession, WireMessage) -> Void)?
|
||||
var onStop: ((PeerSession) -> Void)?
|
||||
private let queue: DispatchQueue
|
||||
private var stopped = false
|
||||
|
||||
init(connection: NWConnection, outbound: Bool, queue: DispatchQueue) {
|
||||
self.connection = connection; self.outbound = outbound; self.queue = queue
|
||||
}
|
||||
|
||||
func start(local: NodeRecord) {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.send(.hello(local)); self.receiveMessage()
|
||||
case .failed, .cancelled: self.stop()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
|
||||
func send(_ message: WireMessage) {
|
||||
guard !stopped, let data = try? FrameCodec.encode(WireEnvelope(message: message)) else { return }
|
||||
connection.send(content: data, completion: .contentProcessed { [weak self] error in
|
||||
if error != nil { self?.stop() }
|
||||
})
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !stopped else { return }; stopped = true
|
||||
connection.cancel(); onStop?(self)
|
||||
}
|
||||
|
||||
private func receiveMessage() {
|
||||
receiveExactly(4, accumulated: Data()) { [weak self] prefix in
|
||||
guard let self, let prefix,
|
||||
let size = try? FrameCodec.bodyLength(from: prefix) else { self?.stop(); return }
|
||||
self.receiveExactly(size, accumulated: Data()) { [weak self] body in
|
||||
guard let self, let body,
|
||||
let envelope = try? FrameCodec.decode(WireEnvelope.self, body: body),
|
||||
envelope.version == WireEnvelope.currentVersion else { self?.stop(); return }
|
||||
self.onMessage?(self, envelope.message)
|
||||
self.receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveExactly(_ count: Int, accumulated: Data, completion: @escaping (Data?) -> Void) {
|
||||
if accumulated.count == count { completion(accumulated); return }
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: count - accumulated.count) { [weak self] data, _, complete, error in
|
||||
guard self != nil, error == nil, let data, !data.isEmpty else { completion(nil); return }
|
||||
let joined = accumulated + data
|
||||
if joined.count == count { completion(joined) }
|
||||
else if complete { completion(nil) }
|
||||
else { self?.receiveExactly(count, accumulated: joined, completion: completion) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import UltraMeshCore
|
||||
|
||||
do {
|
||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||
var stateURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/UltraMesh")
|
||||
var socketPath: String?
|
||||
var index = 0
|
||||
while index < arguments.count {
|
||||
guard index + 1 < arguments.count else { throw UMNError.message("missing value for \(arguments[index])") }
|
||||
if arguments[index] == "--state-dir" { stateURL = URL(fileURLWithPath: arguments[index + 1]) }
|
||||
else if arguments[index] == "--socket" { socketPath = arguments[index + 1] }
|
||||
else { throw UMNError.message("unknown option \(arguments[index])") }
|
||||
index += 2
|
||||
}
|
||||
let daemon = try MeshDaemon(baseURL: stateURL, socketPath: socketPath)
|
||||
try daemon.start()
|
||||
signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
||||
let term = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
||||
source.setEventHandler { daemon.stop(); exit(0) }
|
||||
term.setEventHandler { daemon.stop(); exit(0) }
|
||||
source.resume(); term.resume()
|
||||
dispatchMain()
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("umnd: \(error.localizedDescription)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user