284 lines
20 KiB
Swift
284 lines
20 KiB
Swift
import Foundation
|
|
import Darwin
|
|
import UltraMeshCore
|
|
import UMNSystem
|
|
|
|
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)")
|
|
}
|
|
|
|
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)
|
|
packet[6] = next; packet[7] = 64
|
|
packet.replaceSubrange(8..<24, with: source.bytes); packet.replaceSubrange(24..<40, with: destination.bytes)
|
|
packet.append(payload); return packet
|
|
}
|
|
func tcpHeader(source: UInt16, destination: UInt16, flags: UInt8) -> Data {
|
|
var data = Data(repeating: 0, count: 20)
|
|
data[0] = UInt8(source >> 8); data[1] = UInt8(source & 255)
|
|
data[2] = UInt8(destination >> 8); data[3] = UInt8(destination & 255)
|
|
data[12] = 0x50; data[13] = flags; return data
|
|
}
|
|
func udpHeader(source: UInt16, destination: UInt16, payload: Data = Data()) -> Data {
|
|
let length = 8 + payload.count; var data = Data(repeating: 0, count: 8)
|
|
data[0] = UInt8(source >> 8); data[1] = UInt8(source & 255)
|
|
data[2] = UInt8(destination >> 8); data[3] = UInt8(destination & 255)
|
|
data[4] = UInt8(length >> 8); data[5] = UInt8(length & 255); data.append(payload); return data
|
|
}
|
|
func dnsQuery(_ name: String, type: UInt16) -> Data {
|
|
var data = Data([0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0])
|
|
for label in name.split(separator: ".") { data.append(UInt8(label.utf8.count)); data.append(contentsOf: label.utf8) }
|
|
data.append(0); data.append(UInt8(type >> 8)); data.append(UInt8(type & 255)); data.append(contentsOf: [0, 1]); return data
|
|
}
|
|
|
|
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 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)
|
|
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 replayID = UUID()
|
|
let nativeFrame = InnerFrame(kind: .ipv6Packet, payload: Data([1, 2]), sourceRecord: alice.record, replayID: replayID)
|
|
let nativeOpened = try bob.open(alice.seal(nativeFrame, to: bob.record), expectedSource: alice.record.address)
|
|
try check(nativeOpened.replayID == replayID, "signed native packet replay UUID round trip")
|
|
|
|
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")
|
|
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")
|
|
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 migrated = try JSONDecoder().decode(FirewallRule.self, from: Data("{\"port\":7000}".utf8))
|
|
try check(migrated.protocolKind == .overlay, "protocol-less firewall rule migrates to overlay")
|
|
firewall.allow(protocol: .tcp, port: 8080, source: nil)
|
|
try check(firewall.allows(protocol: .tcp, port: 8080, source: carol.record.address) &&
|
|
!firewall.allows(protocol: .udp, port: 8080, source: carol.record.address), "firewall protocols remain distinct")
|
|
|
|
let syn = ipv6Packet(source: alice.record.address, destination: bob.record.address, next: 6,
|
|
payload: tcpHeader(source: 50_000, destination: 8080, flags: 0x02))
|
|
let synInfo = try IPv6PacketParser.parse(syn)
|
|
try check(synInfo.transport == .tcp && synInfo.destinationPort == 8080, "TCP packet parsed")
|
|
let udp = ipv6Packet(source: alice.record.address, destination: bob.record.address, next: 17,
|
|
payload: udpHeader(source: 50_001, destination: 5353, payload: Data([1, 2, 3])))
|
|
try check(try IPv6PacketParser.parse(udp).transport == .udp, "UDP packet parsed")
|
|
let echo = ipv6Packet(source: alice.record.address, destination: bob.record.address, next: 58,
|
|
payload: Data([128, 0, 0, 0, 0, 1, 0, 1]))
|
|
try check(try IPv6PacketParser.parse(echo).icmpType == 128, "ICMPv6 echo parsed")
|
|
let extensionHeader = Data([6, 0, 0, 0, 0, 0, 0, 0]) + tcpHeader(source: 1, destination: 2, flags: 0x02)
|
|
let extended = ipv6Packet(source: alice.record.address, destination: bob.record.address, next: 0, payload: extensionHeader)
|
|
try check(try IPv6PacketParser.parse(extended).transportOffset == 48, "IPv6 extension header parsed safely")
|
|
var badOption = extensionHeader; badOption[2] = 5; badOption[3] = 10
|
|
var badOptionRejected = false
|
|
do { _ = try IPv6PacketParser.parse(ipv6Packet(source: alice.record.address, destination: bob.record.address,
|
|
next: 0, payload: badOption)) } catch { badOptionRejected = true }
|
|
try check(badOptionRejected, "malformed IPv6 option rejected")
|
|
var fragmentRejected = false
|
|
do { _ = try IPv6PacketParser.parse(ipv6Packet(source: alice.record.address, destination: bob.record.address,
|
|
next: 44, payload: Data(repeating: 0, count: 8))) } catch { fragmentRejected = true }
|
|
try check(fragmentRejected, "IPv6 fragments rejected")
|
|
var malformedRejected = false; var malformedUDP = udp; malformedUDP[44] = 0; malformedUDP[45] = 8
|
|
do { _ = try IPv6PacketParser.parse(malformedUDP) } catch { malformedRejected = true }
|
|
try check(malformedRejected, "malformed UDP length rejected")
|
|
try check(synInfo.source != carol.record.address, "packet source exposes spoof mismatch")
|
|
|
|
let packetFrame = InnerFrame(kind: .ipv6Packet, payload: syn, sourceRecord: alice.record, replayID: UUID())
|
|
let routedNative = RoutedPacket(source: alice.record.address, destination: carol.record.address,
|
|
sealed: try alice.seal(packetFrame, to: carol.record), trafficClass: .nativeIPv6)
|
|
try check(router.routes()[carol.record.address]?.nextHop == bob.record.address, "native packet selects three-node next hop")
|
|
var relayCannotDecrypt = false
|
|
do { _ = try bob.open(routedNative.sealed, expectedSource: alice.record.address) } catch { relayCannotDecrypt = true }
|
|
let destinationFrame = try carol.open(routedNative.sealed, expectedSource: alice.record.address)
|
|
try check(relayCannotDecrypt && destinationFrame.payload == syn, "native packet relays end-to-end encrypted")
|
|
var replayCache = Set<UUID>(); let packetReplayID = destinationFrame.replayID!
|
|
try check(replayCache.insert(packetReplayID).inserted && !replayCache.insert(packetReplayID).inserted,
|
|
"native replay UUID rejects duplicate")
|
|
var nativeBytes = 0
|
|
for _ in 0..<900 {
|
|
let large = ipv6Packet(source: alice.record.address, destination: carol.record.address, next: 17,
|
|
payload: udpHeader(source: 40_000, destination: 8080,
|
|
payload: Data(repeating: 0x5a, count: 1_160)))
|
|
_ = try IPv6PacketParser.parse(large); nativeBytes += large.count
|
|
}
|
|
try check(nativeBytes > 1_048_576, "native packet path exceeds legacy 1 MiB stream cap")
|
|
try check(router.ingest(try alice.makeLinkState(sequence: 2, neighbors: [carol.record.address])) &&
|
|
router.ingest(try carol.makeLinkState(sequence: 2, neighbors: [alice.record.address])), "route-change topology accepted")
|
|
try check(router.routes()[carol.record.address]?.nextHop == carol.record.address,
|
|
"retransmitted native packet uses current route")
|
|
|
|
let nativeRules = MeshFirewall(); let stateful = NativePacketFirewall(rules: nativeRules, maximumFlows: 4)
|
|
try check(stateful.allowOutbound(synInfo), "outbound TCP allowed and tracked")
|
|
let synAck = ipv6Packet(source: bob.record.address, destination: alice.record.address, next: 6,
|
|
payload: tcpHeader(source: 8080, destination: 50_000, flags: 0x12))
|
|
try check(stateful.allowInbound(try IPv6PacketParser.parse(synAck)), "TCP return packet admitted")
|
|
let unsolicited = ipv6Packet(source: bob.record.address, destination: alice.record.address, next: 6,
|
|
payload: tcpHeader(source: 9000, destination: 9001, flags: 0x02))
|
|
try check(!stateful.allowInbound(try IPv6PacketParser.parse(unsolicited)), "new inbound TCP denied by default")
|
|
nativeRules.allow(protocol: .tcp, port: 9001, source: bob.record.address)
|
|
try check(stateful.allowInbound(try IPv6PacketParser.parse(unsolicited)), "source-scoped inbound TCP admitted")
|
|
|
|
let aaaaResponse = try MeshDNSCodec.response(to: dnsQuery("alice.mesh", type: 28), aliases: ["alice": alice.record.address])
|
|
try check(aaaaResponse[7] == 1 && aaaaResponse.suffix(16) == alice.record.address.bytes, "DNS AAAA response")
|
|
let noData = try MeshDNSCodec.response(to: dnsQuery("alice.mesh", type: 1), aliases: ["alice": alice.record.address])
|
|
try check(noData[3] & 0x0f == 0 && noData[7] == 0, "DNS A query returns NODATA")
|
|
let notFound = try MeshDNSCodec.response(to: dnsQuery("missing.mesh", type: 28), aliases: [:])
|
|
try check(notFound[3] & 0x0f == 3, "DNS unknown alias returns NXDOMAIN")
|
|
var compressionRejected = false
|
|
var compressed = dnsQuery("x.mesh", type: 28); compressed.replaceSubrange(12..<20, with: Data([0xc0, 0x0c, 0, 28, 0, 1]))
|
|
do { _ = try MeshDNSCodec.response(to: compressed, aliases: [:]) } catch { compressionRejected = true }
|
|
try check(compressionRejected, "DNS compression loop rejected")
|
|
|
|
let desired: Set<MeshAddress> = [bob.record.address, carol.record.address]
|
|
try HelperRouteSet.validate(desired, local: alice.record.address)
|
|
let routeDiff = HelperRouteSet.diff(current: [bob.record.address], desired: desired)
|
|
try check(routeDiff.additions == [carol.record.address] && routeDiff.removals.isEmpty, "helper route-set diff is idempotent")
|
|
var localRouteRejected = false
|
|
do { try HelperRouteSet.validate([alice.record.address], local: alice.record.address) } catch { localRouteRejected = true }
|
|
try check(localRouteRejected, "helper rejects a route to the local identity")
|
|
|
|
var sockets = [Int32](repeating: -1, count: 2); var pipeFDs = [Int32](repeating: -1, count: 2)
|
|
guard socketpair(AF_UNIX, SOCK_STREAM, 0, &sockets) == 0, pipe(&pipeFDs) == 0 else { throw TestFailure("socket setup") }
|
|
defer { sockets.forEach { Darwin.close($0) }; pipeFDs.forEach { Darwin.close($0) } }
|
|
var peerUID: uid_t = 0, peerGID: gid_t = 0
|
|
try check(umn_get_peer_eid(sockets[0], &peerUID, &peerGID) == 0 && peerUID == getuid(), "helper peer UID authentication primitive")
|
|
let marker = Data("FD\n".utf8)
|
|
let fdSent = marker.withUnsafeBytes { umn_send_fd(sockets[0], pipeFDs[0], $0.baseAddress, marker.count) }
|
|
var receivedFD: Int32 = -1; var fdBuffer = [UInt8](repeating: 0, count: 8)
|
|
let fdCount = umn_recv_fd(sockets[1], &receivedFD, &fdBuffer, fdBuffer.count)
|
|
defer { if receivedFD >= 0 { Darwin.close(receivedFD) } }
|
|
try check(fdSent == 0 && fdCount == marker.count && receivedFD >= 0, "utun descriptor passing primitive")
|
|
|
|
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 == 2, case .keepalive = decoded.message { try check(true, "wire v2 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)
|
|
}
|