more
This commit is contained in:
@@ -19,9 +19,15 @@ umn status
|
|||||||
umn address
|
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
|
## Native IPv6
|
||||||
|
|
||||||
@@ -96,6 +102,7 @@ HTTPS is passed through unchanged. The web server remains responsible for its TL
|
|||||||
## Design and current limits
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Darwin
|
||||||
|
|
||||||
public final class NodeIdentity: @unchecked Sendable {
|
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 static let currentVersion = 1
|
||||||
public let signingKey: Curve25519.Signing.PrivateKey
|
public let signingKey: Curve25519.Signing.PrivateKey
|
||||||
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
|
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
@@ -16,23 +23,161 @@ public final class NodeIdentity: @unchecked Sendable {
|
|||||||
agreementPublicKey: agreementKey.publicKey.rawRepresentation)
|
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 {
|
public static func loadOrCreate(at url: URL) throws -> NodeIdentity {
|
||||||
struct Stored: Codable { let version: Int; let signing: Data; let agreement: Data }
|
let fileManager = FileManager.default
|
||||||
let decoder = PropertyListDecoder()
|
|
||||||
if let data = try? Data(contentsOf: url) {
|
func load() throws -> NodeIdentity {
|
||||||
let value = try decoder.decode(Stored.self, from: data)
|
let data: Data
|
||||||
guard value.version == currentVersion else { throw UMNError.invalidIdentity }
|
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),
|
return try NodeIdentity(signingKey: .init(rawRepresentation: value.signing),
|
||||||
agreementKey: .init(rawRepresentation: value.agreement))
|
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()
|
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 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,
|
signing: identity.signingKey.rawRepresentation,
|
||||||
agreement: identity.agreementKey.rawRepresentation))
|
agreement: identity.agreementKey.rawRepresentation))
|
||||||
try data.write(to: url, options: .atomic)
|
do {
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
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
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ func check(_ condition: @autoclosure () throws -> Bool, _ name: String) throws {
|
|||||||
passed += 1; print("ok \(passed) - \(name)")
|
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 {
|
func ipv6Packet(source: MeshAddress, destination: MeshAddress, next: UInt8, payload: Data) -> Data {
|
||||||
var packet = Data(repeating: 0, count: 40)
|
var packet = Data(repeating: 0, count: 40)
|
||||||
packet[0] = 0x60; packet[4] = UInt8(payload.count >> 8); packet[5] = UInt8(payload.count & 255)
|
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)
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
let identityURL = directory.appendingPathComponent("identity.plist")
|
let identityURL = directory.appendingPathComponent("identity.plist")
|
||||||
let stored1 = try NodeIdentity.loadOrCreate(at: identityURL)
|
let addressURL = directory.appendingPathComponent("address")
|
||||||
let stored2 = try NodeIdentity.loadOrCreate(at: identityURL)
|
let stored1 = try NodeIdentity.loadOrCreate(in: directory)
|
||||||
try check(stored1.record == stored2.record, "identity persists")
|
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)
|
let attributes = try FileManager.default.attributesOfItem(atPath: identityURL.path)
|
||||||
try check((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, "identity permissions")
|
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 original = InnerFrame(kind: .text, port: 7000, payload: Data("hello".utf8), sourceRecord: alice.record)
|
||||||
let sealed = try alice.seal(original, to: bob.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")
|
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))
|
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(!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()
|
let firewall = MeshFirewall()
|
||||||
try check(!firewall.allows(port: 80, source: alice.record.address), "firewall defaults to deny")
|
try check(!firewall.allows(port: 80, source: alice.record.address), "firewall defaults to deny")
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ final class MeshDaemon {
|
|||||||
init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser
|
init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws {
|
.appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws {
|
||||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
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)
|
router = LinkStateRouter(local: identity.record.address)
|
||||||
configURL = baseURL.appendingPathComponent("config.json")
|
configURL = baseURL.appendingPathComponent("config.json")
|
||||||
config = DaemonConfig.load(from: configURL)
|
config = DaemonConfig.load(from: configURL)
|
||||||
|
|||||||
@@ -82,5 +82,8 @@ STATUS=$("${BIN_DIR}/umn" status)
|
|||||||
echo "${STATUS}"
|
echo "${STATUS}"
|
||||||
INTERFACE=$("${BIN_DIR}/umn" interface status)
|
INTERFACE=$("${BIN_DIR}/umn" interface status)
|
||||||
echo "${INTERFACE}"
|
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; }
|
[[ "${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; }
|
[[ "${INTERFACE}" == *"helper: connected"* ]] || { echo "native interface health check failed." >&2; exit 1; }
|
||||||
|
|||||||
@@ -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
|
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 "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."
|
||||||
|
|||||||
Reference in New Issue
Block a user