inital commit

This commit is contained in:
Owen Qwen
2026-08-31 15:55:09 -05:00
commit e71b3ada71
22 changed files with 1783 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.build/
.DS_Store
*.log
+20
View File
@@ -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]
)
+94
View File
@@ -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 <receiver-mesh-address>
umn text send <receiver-mesh-address> 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 <mesh-address>
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 <host-mesh-address>
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).
+79
View File
@@ -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
}
}
+21
View File
@@ -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 } }
}
+51
View File
@@ -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
}
}
+188
View File
@@ -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
}
}
+78
View File
@@ -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))
})
}
}
+114
View File
@@ -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)
}
}
+71
View File
@@ -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)
}
+118
View File
@@ -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 }
}
+100
View File
@@ -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
}
+97
View File
@@ -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)
}
+19
View File
@@ -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)
}
}
+60
View File
@@ -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()
}
}
}
}
+452
View File
@@ -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))
}
}
+67
View File
@@ -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) }
}
}
}
+29
View File
@@ -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)
}
+62
View File
@@ -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.
+40
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR=${0:A:h}
cd "${SCRIPT_DIR:h}"
swift build
swift run umn-selftest
+13
View File
@@ -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."