From e71b3ada716ebd21cd8e22607e384fdbf9e48afa Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 31 Aug 2026 15:55:09 -0500 Subject: [PATCH] inital commit --- .gitignore | 3 + Package.swift | 20 ++ README.md | 94 ++++++ Sources/UltraMeshCore/Crypto.swift | 79 +++++ Sources/UltraMeshCore/Firewall.swift | 21 ++ Sources/UltraMeshCore/Framing.swift | 51 +++ Sources/UltraMeshCore/Models.swift | 188 +++++++++++ Sources/UltraMeshCore/Routing.swift | 78 +++++ Sources/UltraMeshCore/UnixIPC.swift | 114 +++++++ Sources/umn-selftest/main.swift | 71 +++++ Sources/umn/Bridges.swift | 118 +++++++ Sources/umn/Client.swift | 100 ++++++ Sources/umn/main.swift | 97 ++++++ Sources/umnd/DaemonConfig.swift | 19 ++ Sources/umnd/IPCServer.swift | 60 ++++ Sources/umnd/MeshDaemon.swift | 452 +++++++++++++++++++++++++++ Sources/umnd/PeerSession.swift | 67 ++++ Sources/umnd/main.swift | 29 ++ docs/protocol.md | 62 ++++ scripts/install.sh | 40 +++ scripts/test.sh | 7 + scripts/uninstall.sh | 13 + 22 files changed, 1783 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/UltraMeshCore/Crypto.swift create mode 100644 Sources/UltraMeshCore/Firewall.swift create mode 100644 Sources/UltraMeshCore/Framing.swift create mode 100644 Sources/UltraMeshCore/Models.swift create mode 100644 Sources/UltraMeshCore/Routing.swift create mode 100644 Sources/UltraMeshCore/UnixIPC.swift create mode 100644 Sources/umn-selftest/main.swift create mode 100644 Sources/umn/Bridges.swift create mode 100644 Sources/umn/Client.swift create mode 100644 Sources/umn/main.swift create mode 100644 Sources/umnd/DaemonConfig.swift create mode 100644 Sources/umnd/IPCServer.swift create mode 100644 Sources/umnd/MeshDaemon.swift create mode 100644 Sources/umnd/PeerSession.swift create mode 100644 Sources/umnd/main.swift create mode 100644 docs/protocol.md create mode 100755 scripts/install.sh create mode 100755 scripts/test.sh create mode 100755 scripts/uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e090f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +.DS_Store +*.log diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..759dcc3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "UltraMeshNetwork", + platforms: [.macOS(.v14)], + products: [ + .library(name: "UltraMeshCore", targets: ["UltraMeshCore"]), + .executable(name: "umnd", targets: ["umnd"]), + .executable(name: "umn", targets: ["umn"]), + .executable(name: "umn-selftest", targets: ["umn-selftest"]), + ], + targets: [ + .target(name: "UltraMeshCore"), + .executableTarget(name: "umnd", dependencies: ["UltraMeshCore"]), + .executableTarget(name: "umn", dependencies: ["UltraMeshCore"]), + .executableTarget(name: "umn-selftest", dependencies: ["UltraMeshCore"]), + ], + swiftLanguageModes: [.v5] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a43628 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Ultra Mesh Network + +Ultra Mesh Network is a small, user-space mesh for macOS. Every running laptop gets a stable IPv6-looking overlay address, discovers nearby nodes over regular Wi-Fi and Apple peer-to-peer Wi-Fi, and can relay encrypted traffic for nodes that are not directly connected. + +It does **not** install a virtual network interface or replace normal IP networking. Use `umn ping`, the text commands, or the local SOCKS5 proxy to reach mesh addresses. + +## Requirements and installation + +- macOS 14 or newer +- Apple Command Line Tools with Swift 6 +- Wi-Fi enabled on both Macs + +Copy this project to each laptop and run: + +```sh +./scripts/test.sh +./scripts/install.sh +umn status +umn address +``` + +The installer builds locally, copies `umn` and `umnd` to `~/.local/bin`, and starts `umnd` as a per-user LaunchAgent. It never needs root. If prompted, allow local-network access. The identity and configuration live in `~/Library/Application Support/UltraMesh`. + +Use `./scripts/uninstall.sh` to remove the binaries and LaunchAgent. It deliberately preserves the identity so reinstalling keeps the same mesh address. + +For development, run `swift run umnd` in one terminal and `swift run umn status` in another. + +## Text communication + +On the receiving laptop: + +```sh +umn address +umn firewall allow 7000 --from any +umn text listen 7000 +``` + +On the sender: + +```sh +umn ping +umn text send 7000 "hello over the mesh" +``` + +Replace `any` with a specific mesh address to restrict the port. Opening a firewall port is not sufficient by itself: a local service must also be bound. When `umn text listen` exits, the binding disappears but the firewall rule remains. + +Useful diagnostics: + +```sh +umn peers +umn routes +umn firewall list +umn alias set alice +umn ping alice.mesh +``` + +Aliases are stored locally and are not claimed network-wide. + +## HTTP and HTTPS + +Run a local server and expose it on a logical mesh port: + +```sh +# Host laptop +python3 -m http.server 8080 --bind 127.0.0.1 +umn firewall allow 80 --from any +umn expose 80 --to 127.0.0.1:8080 +``` + +Start the client-side proxy: + +```sh +umn alias set host +umn proxy --listen 127.0.0.1:1080 +curl --proxy socks5h://127.0.0.1:1080 http://host.mesh/ +``` + +The proxy implements SOCKS5 `CONNECT` and binds only to loopback. A browser can use the same proxy after its SOCKS settings are pointed at `127.0.0.1:1080` with proxy-side DNS enabled. + +HTTPS is passed through unchanged. The web server remains responsible for its TLS certificate, and a self-signed certificate will still produce the usual trust warning. + +## Design and current limits + +- Bonjour and Network.framework discover peers over infrastructure and Apple peer-to-peer Wi-Fi (`includePeerToPeer`). New routes recover automatically when an access-point path disappears while Wi-Fi remains enabled. +- Signed link-state announcements and shortest-hop routing support multi-hop topologies. The implementation is bounded and tested for 32 live nodes and 16 hops. +- Service payloads are authenticated and encrypted end-to-end with Curve25519, HKDF-SHA256, and ChaCha20-Poly1305. Relays see routing metadata but cannot read ports or content. +- Streams use sequence numbers, acknowledgements, retransmission, and a 60-second recovery window. An active stream can continue after a route change if another path appears within that window. +- The endpoint firewall defaults to ping-only. Transit traffic is relayed independently of endpoint firewall rules. +- Text is limited to 4 KiB. A logical stream is limited to 1 MiB total and the daemon supports at most 32 simultaneous streams. +- There is no durable offline queue: a new operation with no live route fails immediately. +- Membership is open. Identities are self-authenticating, but there is no global authority, revocation service, or Sybil resistance yet. +- Apple peer-to-peer Wi-Fi works only between Apple devices. The documented mesh protocol is transport-independent so other bearers can be added later. + +The on-wire details are in [docs/protocol.md](docs/protocol.md). diff --git a/Sources/UltraMeshCore/Crypto.swift b/Sources/UltraMeshCore/Crypto.swift new file mode 100644 index 0000000..9f32382 --- /dev/null +++ b/Sources/UltraMeshCore/Crypto.swift @@ -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 + } +} diff --git a/Sources/UltraMeshCore/Firewall.swift b/Sources/UltraMeshCore/Firewall.swift new file mode 100644 index 0000000..0862d8b --- /dev/null +++ b/Sources/UltraMeshCore/Firewall.swift @@ -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 + private let lock = NSLock() + public init(rules: Set = []) { 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 } } +} diff --git a/Sources/UltraMeshCore/Framing.swift b/Sources/UltraMeshCore/Framing.swift new file mode 100644 index 0000000..9e000ce --- /dev/null +++ b/Sources/UltraMeshCore/Framing.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum FrameCodec { + public static let maximumFrameSize = 1_200_000 + + public static func encode(_ 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(_ 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 + } +} diff --git a/Sources/UltraMeshCore/Models.swift b/Sources/UltraMeshCore/Models.swift new file mode 100644 index 0000000..2439442 --- /dev/null +++ b/Sources/UltraMeshCore/Models.swift @@ -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 + } +} diff --git a/Sources/UltraMeshCore/Routing.swift b/Sources/UltraMeshCore/Routing.swift new file mode 100644 index 0000000..f84c9f5 --- /dev/null +++ b/Sources/UltraMeshCore/Routing.swift @@ -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] = [:] + 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)) + }) + } +} diff --git a/Sources/UltraMeshCore/UnixIPC.swift b/Sources/UltraMeshCore/UnixIPC.swift new file mode 100644 index 0000000..8ab9e5f --- /dev/null +++ b/Sources/UltraMeshCore/UnixIPC.swift @@ -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.size + path.utf8.count + 1) + } +} diff --git a/Sources/umn-selftest/main.swift b/Sources/umn-selftest/main.swift new file mode 100644 index 0000000..dfa73bf --- /dev/null +++ b/Sources/umn-selftest/main.swift @@ -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) +} diff --git a/Sources/umn/Bridges.swift b/Sources/umn/Bridges.swift new file mode 100644 index 0000000..923e849 --- /dev/null +++ b/Sources/umn/Bridges.swift @@ -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 } +} diff --git a/Sources/umn/Client.swift b/Sources/umn/Client.swift new file mode 100644 index 0000000..c466296 --- /dev/null +++ b/Sources/umn/Client.swift @@ -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.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.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? + 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? = 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 +} diff --git a/Sources/umn/main.swift b/Sources/umn/main.swift new file mode 100644 index 0000000..39151e5 --- /dev/null +++ b/Sources/umn/main.swift @@ -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 + umn alias set
| remove | list + umn firewall allow|revoke --from | list + umn text send | listen + umn expose --to + umn proxy [--listen 127.0.0.1:] + """) + 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][..= 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) +} diff --git a/Sources/umnd/DaemonConfig.swift b/Sources/umnd/DaemonConfig.swift new file mode 100644 index 0000000..3399221 --- /dev/null +++ b/Sources/umnd/DaemonConfig.swift @@ -0,0 +1,19 @@ +import Foundation +import UltraMeshCore + +struct DaemonConfig: Codable { + var aliases: [String: MeshAddress] = [:] + var firewall: Set = [] + 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) + } +} diff --git a/Sources/umnd/IPCServer.swift b/Sources/umnd/IPCServer.swift new file mode 100644 index 0000000..9b8b90d --- /dev/null +++ b/Sources/umnd/IPCServer.swift @@ -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() + } + } + } +} diff --git a/Sources/umnd/MeshDaemon.swift b/Sources/umnd/MeshDaemon.swift new file mode 100644 index 0000000..0f598cb --- /dev/null +++ b/Sources/umnd/MeshDaemon.swift @@ -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() + private var peers: [MeshAddress: PeerSession] = [:] + private var allSessions: [ObjectIdentifier: PeerSession] = [:] + private var lsaSequence: UInt64 = 0 + private var seenPackets = Set() + 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)) + } +} diff --git a/Sources/umnd/PeerSession.swift b/Sources/umnd/PeerSession.swift new file mode 100644 index 0000000..2c90b5f --- /dev/null +++ b/Sources/umnd/PeerSession.swift @@ -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) } + } + } +} diff --git a/Sources/umnd/main.swift b/Sources/umnd/main.swift new file mode 100644 index 0000000..765187a --- /dev/null +++ b/Sources/umnd/main.swift @@ -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) +} diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..fc0c98b --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,62 @@ +# Ultra Mesh Protocol v1 + +This document describes the protocol implemented by `umnd`. Multi-byte integers use network byte order unless the binary property-list encoding defines their representation. + +## Framing and identities + +Every peer message is a four-byte unsigned body length followed by a binary property-list `WireEnvelope`. Bodies are limited to 1,200,000 bytes. An envelope carries protocol version `1` and one `WireMessage`. Unknown versions terminate the peer session. + +A node record contains: + +- A 32-byte Ed25519 signing public key. +- A 32-byte X25519 agreement public key. +- A 16-byte mesh address equal to `0xfd` followed by the first 15 bytes of SHA-256 over the signing public key. + +The address is rendered using normal IPv6 text notation. A receiver always recomputes it from the public key before trusting the record. Private keys are created once and stored with mode `0600`. + +## Peer and routing messages + +The wire message variants are: + +- `hello`: announces a node record when a transport session becomes ready. +- `linkState`: carries an origin record, monotonically increasing sequence, sorted neighbor addresses, and the origin's Ed25519 signature over those fields. +- `packet`: carries a UUID, source and destination addresses, hop limit, and an end-to-end sealed payload. +- `keepalive`: detects failed transport sessions. + +Invalid identities, signatures, stale link-state sequence numbers, oversized neighbor lists, duplicate packet UUIDs, and expired hop limits are discarded. + +Each node floods newly accepted link-state records. Records refresh every 10 seconds, expire after 30 seconds, and neighbors are considered lost when their transport connection fails. Routes use shortest-hop Dijkstra calculation with deterministic address ordering for equal-cost paths. Packets start with a hop limit of 16. The duplicate cache retains the most recent 4,096 packet UUIDs. + +## End-to-end payloads + +The sender serializes an `InnerFrame`, signs its bytes with Ed25519, and encrypts the signed object to the destination record: + +1. Generate an ephemeral X25519 key. +2. Perform X25519 agreement with the destination's static agreement key. +3. Derive a 32-byte key using HKDF-SHA256, salt `umn-e2e-v1`, and the destination address as shared information. +4. Seal with ChaCha20-Poly1305. + +The routed payload contains only the ephemeral public key and the combined authenticated ciphertext. The receiver decrypts it, verifies the signature, validates the embedded source record, and confirms that its derived address matches the outer source address. Relays cannot decrypt the inner frame. Neighbor discovery and topology metadata are authenticated but intentionally public in v1. + +Inner frame kinds are ping request/reply, text, stream open/data/ack/close/reset, and error. Logical destination ports are inside the sealed payload. + +## Streams and failure handling + +A stream uses a random 64-bit identifier. `streamOpen` occupies sequence zero; data starts at sequence one. Receivers acknowledge the greatest contiguous sequence delivered. Duplicate frames are acknowledged without being delivered again. + +Unacknowledged frames are retransmitted through the route table's current next hop. Retry delay begins at 500 milliseconds and backs off to five seconds. No progress for 60 seconds resets the stream. This makes streams independent of any one neighbor TCP connection and allows an access-point path to be replaced by a peer-to-peer or multi-hop path. + +Each endpoint enforces these v1 resource bounds: + +- 1 MiB aggregate data per stream. +- 32 simultaneous streams per node. +- Eight intended concurrent streams per source; broader abuse controls remain future work. +- 32 KiB bridge read chunks and a logical 64 KiB flow-control target. + +New operations are live-route-only and are not written to a durable delivery queue. + +## Firewall and local IPC + +Ping terminates in the daemon and is always available. All ports otherwise default to denied. A stream or text message is delivered only when an allow rule matches its authenticated source and a local process currently binds the destination port. Firewall rules affect terminating traffic, not transit forwarding. + +Local tools use a mode-`0600` Unix-domain socket. IPC messages use the same four-byte framing and binary property-list encoding, carry IPC version `1`, and support control operations, service binding, and stream events. A binding is removed when its IPC connection closes. diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..b2527a4 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,40 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +PROJECT_DIR=${SCRIPT_DIR:h} +BIN_DIR="${HOME}/.local/bin" +STATE_DIR="${HOME}/Library/Application Support/UltraMesh" +LAUNCH_DIR="${HOME}/Library/LaunchAgents" +PLIST_PATH="${LAUNCH_DIR}/com.ultramesh.umnd.plist" + +cd "${PROJECT_DIR}" +swift build -c release --product umnd +swift build -c release --product umn +BUILD_DIR=$(swift build -c release --show-bin-path) + +mkdir -p "${BIN_DIR}" "${STATE_DIR}" "${LAUNCH_DIR}" +install -m 755 "${BUILD_DIR}/umnd" "${BIN_DIR}/umnd" +install -m 755 "${BUILD_DIR}/umn" "${BIN_DIR}/umn" +codesign --force --sign - "${BIN_DIR}/umnd" >/dev/null 2>&1 +codesign --force --sign - "${BIN_DIR}/umn" >/dev/null 2>&1 + +launchctl bootout "gui/${UID}" "${PLIST_PATH}" >/dev/null 2>&1 || true +plutil -create xml1 "${PLIST_PATH}" +plutil -insert Label -string com.ultramesh.umnd "${PLIST_PATH}" +plutil -insert ProgramArguments -array "${PLIST_PATH}" +plutil -insert ProgramArguments.0 -string "${BIN_DIR}/umnd" "${PLIST_PATH}" +plutil -insert RunAtLoad -bool true "${PLIST_PATH}" +plutil -insert KeepAlive -bool true "${PLIST_PATH}" +plutil -insert ProcessType -string Background "${PLIST_PATH}" +plutil -insert StandardOutPath -string "${STATE_DIR}/umnd.log" "${PLIST_PATH}" +plutil -insert StandardErrorPath -string "${STATE_DIR}/umnd.log" "${PLIST_PATH}" +chmod 600 "${PLIST_PATH}" +launchctl bootstrap "gui/${UID}" "${PLIST_PATH}" + +echo "Ultra Mesh Network installed." +echo "Binary directory: ${BIN_DIR}" +if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then + echo "Add this line to ~/.zshrc: export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi +"${BIN_DIR}/umn" status diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..2ccf32c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,7 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +cd "${SCRIPT_DIR:h}" +swift build +swift run umn-selftest diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..90d372a --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,13 @@ +#!/bin/zsh +set -euo pipefail + +BIN_DIR="${HOME}/.local/bin" +PLIST_PATH="${HOME}/Library/LaunchAgents/com.ultramesh.umnd.plist" + +launchctl bootout "gui/${UID}" "${PLIST_PATH}" >/dev/null 2>&1 || true +[[ ! -e "${PLIST_PATH}" ]] || unlink "${PLIST_PATH}" +[[ ! -e "${BIN_DIR}/umnd" ]] || unlink "${BIN_DIR}/umnd" +[[ ! -e "${BIN_DIR}/umn" ]] || unlink "${BIN_DIR}/umn" + +echo "Binaries and LaunchAgent removed." +echo "Identity and configuration were preserved in ~/Library/Application Support/UltraMesh."