This commit is contained in:
Owen Qwen
2026-08-31 17:21:07 -05:00
parent 5f99d9b137
commit ef61d8dd07
6 changed files with 259 additions and 21 deletions
+9 -2
View File
@@ -19,9 +19,15 @@ umn status
umn address
```
The installer builds locally, asks for administrator authorization once, installs a minimal root LaunchDaemon for `utun` and `/128` route management, installs the scoped `.mesh` resolver, then starts `umnd` as a per-user LaunchAgent. Peer networking, keys, packet parsing, firewall state, and DNS remain in the unprivileged daemon. If prompted, allow local-network access. Identity and configuration live in `~/Library/Application Support/UltraMesh`.
The installer builds locally, asks for administrator authorization once, installs a minimal root LaunchDaemon for `utun` and `/128` route management, installs the scoped `.mesh` resolver, then starts `umnd` as a per-user LaunchAgent. Peer networking, keys, packet parsing, firewall state, and DNS remain in the unprivileged daemon. If prompted, allow local-network access. All persistent state lives in `~/Library/Application Support/UltraMesh`. The cryptographic identity in `identity.plist` determines the machine's mesh IPv6, and the same value is recorded as plain text in `address`; both files are mode `0600`.
Use `./scripts/uninstall.sh` to remove both daemons, the interface/routes, binaries, and installer-managed resolver. It deliberately preserves identity, aliases, and firewall configuration so reinstalling keeps the same mesh address.
Use `./scripts/uninstall.sh` to remove both daemons, the interface/routes, binaries, and installer-managed resolver. It deliberately preserves the entire state directory so reinstalling keeps the same mesh address. Do not edit or separately replace `identity.plist` or `address`: if either is corrupt, missing from an established identity/address pair, or mismatched, startup fails instead of silently assigning a new address.
To verify the persistent address after installation or a restart:
```sh
test "$(umn address)" = "$(tr -d '[:space:]' < "$HOME/Library/Application Support/UltraMesh/address")"
```
## Native IPv6
@@ -96,6 +102,7 @@ HTTPS is passed through unchanged. The web server remains responsible for its TL
## 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.
- The local mesh IPv6 is derived only from the persistent signing identity. Wi-Fi path changes, peer-to-peer fallback, route updates, `utun` recreation, reboots, and reinstalls do not select or alter it.
- 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.
+154 -9
View File
@@ -1,7 +1,14 @@
import Foundation
import CryptoKit
import Darwin
public final class NodeIdentity: @unchecked Sendable {
private struct StoredIdentity: Codable {
let version: Int
let signing: Data
let agreement: Data
}
public static let currentVersion = 1
public let signingKey: Curve25519.Signing.PrivateKey
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
@@ -16,23 +23,161 @@ public final class NodeIdentity: @unchecked Sendable {
agreementPublicKey: agreementKey.publicKey.rawRepresentation)
}
private static func writeAtomicallyWithoutReplacing(_ data: Data, to url: URL) throws {
let temporaryURL = url.deletingLastPathComponent()
.appendingPathComponent(".\(url.lastPathComponent).\(UUID().uuidString).tmp")
let descriptor = Darwin.open(temporaryURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
guard descriptor >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
defer {
Darwin.close(descriptor)
Darwin.unlink(temporaryURL.path)
}
try data.withUnsafeBytes { bytes in
guard let base = bytes.baseAddress else { return }
var offset = 0
while offset < data.count {
let written = Darwin.write(descriptor, base.advanced(by: offset), data.count - offset)
if written < 0 {
if errno == EINTR { continue }
throw POSIXError(.init(rawValue: errno) ?? .EIO)
}
offset += written
}
}
guard Darwin.fsync(descriptor) == 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
// A hard link publishes the fully-written same-filesystem temporary file in
// one operation and fails with EEXIST instead of replacing existing state.
guard Darwin.link(temporaryURL.path, url.path) == 0 else {
throw POSIXError(.init(rawValue: errno) ?? .EIO)
}
}
private static func itemExists(at url: URL) throws -> Bool {
var status = stat()
if Darwin.lstat(url.path, &status) == 0 { return true }
let code = errno
if code == ENOENT || code == ENOTDIR { return false }
throw POSIXError(.init(rawValue: code) ?? .EIO)
}
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 }
let fileManager = FileManager.default
func load() throws -> NodeIdentity {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw UMNError.message("Cannot read the UltraMesh identity at \(url.path). Restore access to this file; it was not replaced.")
}
let value: StoredIdentity
do {
value = try PropertyListDecoder().decode(StoredIdentity.self, from: data)
} catch {
throw UMNError.message("The UltraMesh identity at \(url.path) is corrupt. Restore it from backup or explicitly reset UltraMesh state; it was not replaced.")
}
guard value.version == currentVersion else {
throw UMNError.message("The UltraMesh identity at \(url.path) uses unsupported version \(value.version). Upgrade UltraMesh or restore a compatible identity; it was not replaced.")
}
do {
return try NodeIdentity(signingKey: .init(rawRepresentation: value.signing),
agreementKey: .init(rawRepresentation: value.agreement))
} catch {
throw UMNError.message("The UltraMesh identity at \(url.path) contains invalid cryptographic keys. Restore it from backup or explicitly reset UltraMesh state; it was not replaced.")
}
}
let exists: Bool
do {
exists = try itemExists(at: url)
} catch {
throw UMNError.message("Cannot inspect the UltraMesh identity path at \(url.path): \(error.localizedDescription). No new identity was created.")
}
if exists { return try load() }
let identity = try NodeIdentity()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
do {
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
} catch {
throw UMNError.message("Cannot create the UltraMesh state directory at \(url.deletingLastPathComponent().path): \(error.localizedDescription)")
}
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
let data = try encoder.encode(Stored(version: currentVersion,
let data = try encoder.encode(StoredIdentity(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)
do {
try writeAtomicallyWithoutReplacing(data, to: url)
} catch {
// Another daemon may have won the first-run race. Its complete identity is
// authoritative; never overwrite it with the identity generated above.
if (try? itemExists(at: url)) == true { return try load() }
throw UMNError.message("Cannot create the UltraMesh identity at \(url.path): \(error.localizedDescription)")
}
do {
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
throw UMNError.message("The UltraMesh identity was created at \(url.path), but its permissions could not be restricted to 0600: \(error.localizedDescription)")
}
return identity
}
/// Loads the persistent identity in a state directory and creates or verifies
/// its derived, human-readable address sidecar.
public static func loadOrCreate(in directory: URL) throws -> NodeIdentity {
let fileManager = FileManager.default
let identityURL = directory.appendingPathComponent("identity.plist")
let addressURL = directory.appendingPathComponent("address")
let identityExists: Bool
let addressExists: Bool
do {
identityExists = try itemExists(at: identityURL)
addressExists = try itemExists(at: addressURL)
} catch {
throw UMNError.message("Cannot inspect UltraMesh state in \(directory.path): \(error.localizedDescription). No state was changed.")
}
guard identityExists || !addressExists else {
throw UMNError.message("Found \(addressURL.path) without \(identityURL.path). Restore the matching identity or explicitly reset UltraMesh state; no new identity was created.")
}
let identity = try loadOrCreate(at: identityURL)
func verifyAddress() throws {
let data: Data
do {
data = try Data(contentsOf: addressURL)
} catch {
throw UMNError.message("Cannot read the stored UltraMesh address at \(addressURL.path). Restore access to this file; it was not replaced.")
}
guard let stored = String(data: data, encoding: .utf8) else {
throw UMNError.message("The stored UltraMesh address at \(addressURL.path) is not UTF-8 text. Restore the matching record or explicitly reset UltraMesh state; it was not replaced.")
}
let text = stored.trimmingCharacters(in: .whitespacesAndNewlines)
guard let address = try? MeshAddress(text), address == identity.record.address else {
throw UMNError.message("The stored UltraMesh address at \(addressURL.path) does not match the persistent identity. Restore the matching identity/address pair or explicitly reset UltraMesh state; neither file was replaced.")
}
}
if addressExists {
try verifyAddress()
} else {
let data = Data("\(identity.record.address)\n".utf8)
do {
try writeAtomicallyWithoutReplacing(data, to: addressURL)
} catch {
// As with identity creation, tolerate only a concurrent complete write.
if (try? itemExists(at: addressURL)) == true { try verifyAddress() }
else {
throw UMNError.message("Cannot create the stored UltraMesh address at \(addressURL.path): \(error.localizedDescription)")
}
}
}
do {
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: addressURL.path)
} catch {
throw UMNError.message("Cannot restrict the stored UltraMesh address at \(addressURL.path) to mode 0600: \(error.localizedDescription)")
}
return identity
}
+86 -3
View File
@@ -14,6 +14,14 @@ func check(_ condition: @autoclosure () throws -> Bool, _ name: String) throws {
passed += 1; print("ok \(passed) - \(name)")
}
func rejects(_ name: String, _ operation: () throws -> Void) throws {
do { try operation() } catch {
passed += 1; print("ok \(passed) - \(name)")
return
}
throw TestFailure(name)
}
func ipv6Packet(source: MeshAddress, destination: MeshAddress, next: UInt8, payload: Data) -> Data {
var packet = Data(repeating: 0, count: 40)
packet[0] = 0x60; packet[4] = UInt8(payload.count >> 8); packet[5] = UInt8(payload.count & 255)
@@ -47,11 +55,84 @@ do {
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 addressURL = directory.appendingPathComponent("address")
let stored1 = try NodeIdentity.loadOrCreate(in: directory)
let stored2 = try NodeIdentity.loadOrCreate(in: directory)
try check(stored1.record == stored2.record, "identity and address persist across daemon-style reloads")
let attributes = try FileManager.default.attributesOfItem(atPath: identityURL.path)
try check((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, "identity permissions")
let addressAttributes = try FileManager.default.attributesOfItem(atPath: addressURL.path)
try check((addressAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, "address permissions")
let storedAddressText = try String(contentsOf: addressURL, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
try check(try MeshAddress(storedAddressText) == stored1.record.address, "stored address is valid IPv6 matching identity")
let legacyDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: legacyDirectory) }
let legacyIdentity = try NodeIdentity.loadOrCreate(at: legacyDirectory.appendingPathComponent("identity.plist"))
try check(!FileManager.default.fileExists(atPath: legacyDirectory.appendingPathComponent("address").path),
"legacy path API creates only identity")
let upgradedIdentity = try NodeIdentity.loadOrCreate(in: legacyDirectory)
try check(upgradedIdentity.record == legacyIdentity.record &&
FileManager.default.fileExists(atPath: legacyDirectory.appendingPathComponent("address").path),
"existing installation gains address sidecar without identity change")
let corruptDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: corruptDirectory) }
try FileManager.default.createDirectory(at: corruptDirectory, withIntermediateDirectories: true)
let corruptURL = corruptDirectory.appendingPathComponent("identity.plist")
let corruptBytes = Data("not an identity".utf8)
try corruptBytes.write(to: corruptURL)
try rejects("corrupt identity fails without replacement") { _ = try NodeIdentity.loadOrCreate(in: corruptDirectory) }
try check(try Data(contentsOf: corruptURL) == corruptBytes, "corrupt identity remains unchanged")
let incompatibleDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: incompatibleDirectory) }
try FileManager.default.createDirectory(at: incompatibleDirectory, withIntermediateDirectories: true)
let incompatibleURL = incompatibleDirectory.appendingPathComponent("identity.plist")
let incompatibleBytes = try PropertyListSerialization.data(
fromPropertyList: ["version": 999, "signing": Data(repeating: 1, count: 32),
"agreement": Data(repeating: 2, count: 32)], format: .binary, options: 0)
try incompatibleBytes.write(to: incompatibleURL)
try rejects("incompatible identity version fails without replacement") {
_ = try NodeIdentity.loadOrCreate(in: incompatibleDirectory)
}
try check(try Data(contentsOf: incompatibleURL) == incompatibleBytes, "incompatible identity remains unchanged")
let orphanDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: orphanDirectory) }
try FileManager.default.createDirectory(at: orphanDirectory, withIntermediateDirectories: true)
let orphanAddressURL = orphanDirectory.appendingPathComponent("address")
let orphanBytes = Data("\(alice.record.address)\n".utf8)
try orphanBytes.write(to: orphanAddressURL)
try rejects("address without identity fails without creating identity") {
_ = try NodeIdentity.loadOrCreate(in: orphanDirectory)
}
try check(!FileManager.default.fileExists(atPath: orphanDirectory.appendingPathComponent("identity.plist").path) &&
(try Data(contentsOf: orphanAddressURL)) == orphanBytes, "orphan address state remains unchanged")
let mismatchDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: mismatchDirectory) }
let mismatchIdentity = try NodeIdentity.loadOrCreate(in: mismatchDirectory)
let mismatchAddressURL = mismatchDirectory.appendingPathComponent("address")
let mismatchedBytes = Data("\(bob.record.address)\n".utf8)
try mismatchedBytes.write(to: mismatchAddressURL, options: .atomic)
try rejects("address and identity mismatch fails") { _ = try NodeIdentity.loadOrCreate(in: mismatchDirectory) }
try check(try Data(contentsOf: mismatchAddressURL) == mismatchedBytes &&
mismatchIdentity.record.address != bob.record.address, "mismatched address remains unchanged")
let unreadableDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: unreadableDirectory) }
_ = try NodeIdentity.loadOrCreate(in: unreadableDirectory)
let unreadableURL = unreadableDirectory.appendingPathComponent("identity.plist")
let unreadableBytes = try Data(contentsOf: unreadableURL)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadableURL.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadableURL.path) }
try rejects("unreadable identity fails without replacement") {
_ = try NodeIdentity.loadOrCreate(in: unreadableDirectory)
}
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadableURL.path)
try check(try Data(contentsOf: unreadableURL) == unreadableBytes, "unreadable identity remains unchanged")
let original = InnerFrame(kind: .text, port: 7000, payload: Data("hello".utf8), sourceRecord: alice.record)
let sealed = try alice.seal(original, to: bob.record)
@@ -76,6 +157,8 @@ do {
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")
try check((try NodeIdentity.loadOrCreate(in: directory)).record.address == stored1.record.address,
"route and peer topology changes do not alter persistent local address")
let firewall = MeshFirewall()
try check(!firewall.allows(port: 80, source: alice.record.address), "firewall defaults to deny")
+1 -1
View File
@@ -53,7 +53,7 @@ final class MeshDaemon {
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"))
identity = try NodeIdentity.loadOrCreate(in: baseURL)
router = LinkStateRouter(local: identity.record.address)
configURL = baseURL.appendingPathComponent("config.json")
config = DaemonConfig.load(from: configURL)
+3
View File
@@ -82,5 +82,8 @@ STATUS=$("${BIN_DIR}/umn" status)
echo "${STATUS}"
INTERFACE=$("${BIN_DIR}/umn" interface status)
echo "${INTERFACE}"
ADDRESS=$("${BIN_DIR}/umn" address)
STORED_ADDRESS=$(tr -d '[:space:]' < "${STATE_DIR}/address")
[[ "${ADDRESS}" == "${STORED_ADDRESS}" ]] || { echo "identity address health check failed: umn address does not match ${STATE_DIR}/address." >&2; exit 1; }
[[ "${STATUS}" == *"DNS listening"* ]] || { echo "DNS health check failed (port 53535 may be busy)." >&2; exit 1; }
[[ "${INTERFACE}" == *"helper: connected"* ]] || { echo "native interface health check failed." >&2; exit 1; }
+1 -1
View File
@@ -26,4 +26,4 @@ sudo launchctl bootout system "${HELPER_PLIST}" >/dev/null 2>&1 || true
if [[ -e "${RESOLVER_PATH}" ]] && cmp -s "${TEMP_RESOLVER}" "${RESOLVER_PATH}"; then sudo unlink "${RESOLVER_PATH}"; fi
echo "Binaries, LaunchAgent, LaunchDaemon, routes, interface, and managed resolver removed."
echo "Identity and configuration were preserved in ~/Library/Application Support/UltraMesh."
echo "All persistent state, including identity and address, was preserved in ~/Library/Application Support/UltraMesh."