diff --git a/Package.swift b/Package.swift index 759dcc3..a3ba3cf 100644 --- a/Package.swift +++ b/Package.swift @@ -9,12 +9,15 @@ let package = Package( .executable(name: "umnd", targets: ["umnd"]), .executable(name: "umn", targets: ["umn"]), .executable(name: "umn-selftest", targets: ["umn-selftest"]), + .executable(name: "umn-helper", targets: ["umn-helper"]), ], targets: [ - .target(name: "UltraMeshCore"), - .executableTarget(name: "umnd", dependencies: ["UltraMeshCore"]), + .target(name: "UMNSystem", publicHeadersPath: "include"), + .target(name: "UltraMeshCore", dependencies: ["UMNSystem"]), + .executableTarget(name: "umnd", dependencies: ["UltraMeshCore", "UMNSystem"]), + .executableTarget(name: "umn-helper", dependencies: ["UltraMeshCore", "UMNSystem"]), .executableTarget(name: "umn", dependencies: ["UltraMeshCore"]), - .executableTarget(name: "umn-selftest", dependencies: ["UltraMeshCore"]), + .executableTarget(name: "umn-selftest", dependencies: ["UltraMeshCore", "UMNSystem"]), ], swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 2a43628..aad0d92 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ 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. +It installs a narrowly routed IPv6 `utun` interface. Ordinary applications can reach live mesh nodes directly while existing logical streams, text commands, `expose`, and the SOCKS5 proxy remain available for compatibility. ## Requirements and installation -- macOS 14 or newer +- macOS 14 or newer on a personally controlled laptop - Apple Command Line Tools with Swift 6 - Wi-Fi enabled on both Macs @@ -19,9 +19,23 @@ 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`. +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`. -Use `./scripts/uninstall.sh` to remove the binaries and LaunchAgent. It deliberately preserves the identity 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 identity, aliases, and firewall configuration so reinstalling keeps the same mesh address. + +## Native IPv6 + +Set a local alias, then use standard applications without proxy settings: + +```sh +umn alias set alice +ping6 alice.mesh +ssh alice.mesh +curl http://alice.mesh:8080/ +umn interface status +``` + +Only currently reachable, authenticated mesh addresses are installed as exact IPv6 `/128` routes. The interface never installs a default route or a broad ULA prefix. The local DNS server listens on TCP and UDP `127.0.0.1:53535`; `/etc/resolver/mesh` scopes only `.mesh` lookups to it. Aliases remain explicitly local and AAAA-only. For development, run `swift run umnd` in one terminal and `swift run umn status` in another. @@ -31,7 +45,7 @@ On the receiving laptop: ```sh umn address -umn firewall allow 7000 --from any +umn firewall allow overlay 7000 --from any umn text listen 7000 ``` @@ -63,7 +77,7 @@ 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 firewall allow overlay 80 --from any umn expose 80 --to 127.0.0.1:8080 ``` @@ -85,7 +99,9 @@ HTTPS is passed through unchanged. The web server remains responsible for its TL - 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. +- The endpoint firewall permits outbound native connections and their bounded TCP/UDP return state. New inbound TCP/UDP flows require explicit rules such as `umn firewall allow tcp 8080 --from any`; logical services use `overlay`. ICMPv6 echo is rate-limited and related errors are admitted conservatively. Transit traffic is independent of endpoint rules. +- Native packets are complete, signed, replay-protected IPv6 packets encrypted end-to-end. TCP retransmission and flow control are supplied by macOS; retransmissions use the current shortest-hop route. +- IPv6 fragments, multicast, malformed packets, unsafe routing headers, and unsupported next headers are dropped. The native peer queue is bounded and control traffic takes priority. - 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. diff --git a/Sources/UMNSystem/UMNSystem.c b/Sources/UMNSystem/UMNSystem.c new file mode 100644 index 0000000..e37f58b --- /dev/null +++ b/Sources/UMNSystem/UMNSystem.c @@ -0,0 +1,76 @@ +#include "UMNSystem.h" +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#include +#include +#endif + +int umn_utun_open(char *name, size_t name_capacity) { +#ifdef __APPLE__ + int fd = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL); + if (fd < 0) return -1; + struct ctl_info info; + memset(&info, 0, sizeof(info)); + strlcpy(info.ctl_name, UTUN_CONTROL_NAME, sizeof(info.ctl_name)); + if (ioctl(fd, CTLIOCGINFO, &info) < 0) { close(fd); return -1; } + struct sockaddr_ctl address; + memset(&address, 0, sizeof(address)); + address.sc_len = sizeof(address); address.sc_family = AF_SYSTEM; + address.ss_sysaddr = AF_SYS_CONTROL; address.sc_id = info.ctl_id; address.sc_unit = 0; + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) < 0) { close(fd); return -1; } + socklen_t length = (socklen_t)name_capacity; + if (getsockopt(fd, SYSPROTO_CONTROL, UTUN_OPT_IFNAME, name, &length) < 0) { close(fd); return -1; } + return fd; +#else + (void)name; (void)name_capacity; errno = ENOTSUP; return -1; +#endif +} + +int umn_get_peer_eid(int socket_fd, uid_t *uid, gid_t *gid) { +#ifdef __APPLE__ + return getpeereid(socket_fd, uid, gid); +#else + struct ucred credential; socklen_t size = sizeof(credential); + if (getsockopt(socket_fd, SOL_SOCKET, SO_PEERCRED, &credential, &size) < 0) return -1; + *uid = credential.uid; *gid = credential.gid; return 0; +#endif +} + +int umn_send_fd(int socket_fd, int passed_fd, const void *data, size_t length) { + struct iovec io = { .iov_base = (void *)data, .iov_len = length }; + char control[CMSG_SPACE(sizeof(int))]; memset(control, 0, sizeof(control)); + struct msghdr message; memset(&message, 0, sizeof(message)); + message.msg_iov = &io; message.msg_iovlen = 1; + message.msg_control = control; message.msg_controllen = sizeof(control); + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + header->cmsg_level = SOL_SOCKET; header->cmsg_type = SCM_RIGHTS; header->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(header), &passed_fd, sizeof(int)); + ssize_t result = sendmsg(socket_fd, &message, 0); + if (result < 0) return -1; + if ((size_t)result != length) { errno = EIO; return -1; } + return 0; +} + +ssize_t umn_recv_fd(int socket_fd, int *passed_fd, void *data, size_t capacity) { + struct iovec io = { .iov_base = data, .iov_len = capacity }; + char control[CMSG_SPACE(sizeof(int))]; memset(control, 0, sizeof(control)); + struct msghdr message; memset(&message, 0, sizeof(message)); + message.msg_iov = &io; message.msg_iovlen = 1; + message.msg_control = control; message.msg_controllen = sizeof(control); + ssize_t result = recvmsg(socket_fd, &message, 0); + if (result <= 0) return result; + *passed_fd = -1; + for (struct cmsghdr *header = CMSG_FIRSTHDR(&message); header; header = CMSG_NXTHDR(&message, header)) { + if (header->cmsg_level == SOL_SOCKET && header->cmsg_type == SCM_RIGHTS && header->cmsg_len >= CMSG_LEN(sizeof(int))) { + memcpy(passed_fd, CMSG_DATA(header), sizeof(int)); break; + } + } + if (*passed_fd < 0) { errno = EBADMSG; return -1; } + return result; +} diff --git a/Sources/UMNSystem/include/UMNSystem.h b/Sources/UMNSystem/include/UMNSystem.h new file mode 100644 index 0000000..85570a4 --- /dev/null +++ b/Sources/UMNSystem/include/UMNSystem.h @@ -0,0 +1,11 @@ +#ifndef UMN_SYSTEM_H +#define UMN_SYSTEM_H +#include +#include + +int umn_utun_open(char *name, size_t name_capacity); +int umn_get_peer_eid(int socket_fd, uid_t *uid, gid_t *gid); +int umn_send_fd(int socket_fd, int passed_fd, const void *data, size_t length); +ssize_t umn_recv_fd(int socket_fd, int *passed_fd, void *data, size_t capacity); + +#endif diff --git a/Sources/UltraMeshCore/Firewall.swift b/Sources/UltraMeshCore/Firewall.swift index 0862d8b..76b3062 100644 --- a/Sources/UltraMeshCore/Firewall.swift +++ b/Sources/UltraMeshCore/Firewall.swift @@ -1,9 +1,24 @@ import Foundation +public enum FirewallProtocol: String, Codable, CaseIterable, Sendable { + case overlay, tcp, udp +} + public struct FirewallRule: Codable, Hashable, Sendable { + public let protocolKind: FirewallProtocol public let port: UInt16 public let source: MeshAddress? - public init(port: UInt16, source: MeshAddress?) { self.port = port; self.source = source } + public init(protocolKind: FirewallProtocol = .overlay, port: UInt16, source: MeshAddress?) { + self.protocolKind = protocolKind; self.port = port; self.source = source + } + + private enum CodingKeys: String, CodingKey { case protocolKind = "protocol", port, source } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + protocolKind = try c.decodeIfPresent(FirewallProtocol.self, forKey: .protocolKind) ?? .overlay + port = try c.decode(UInt16.self, forKey: .port) + source = try c.decodeIfPresent(MeshAddress.self, forKey: .source) + } } public final class MeshFirewall: @unchecked Sendable { @@ -11,11 +26,20 @@ public final class MeshFirewall: @unchecked Sendable { private let lock = NSLock() public init(rules: Set = []) { self.rules = rules } - public func allows(port: UInt16, source: MeshAddress) -> Bool { + public func allows(protocol protocolKind: FirewallProtocol = .overlay, 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)) + return rules.contains(FirewallRule(protocolKind: protocolKind, port: port, source: nil)) || + rules.contains(FirewallRule(protocolKind: protocolKind, port: port, source: source)) + } + public func allow(protocol protocolKind: FirewallProtocol = .overlay, port: UInt16, source: MeshAddress?) { + lock.lock(); rules.insert(.init(protocolKind: protocolKind, port: port, source: source)); lock.unlock() + } + public func revoke(protocol protocolKind: FirewallProtocol = .overlay, port: UInt16, source: MeshAddress?) { + lock.lock(); rules.remove(.init(protocolKind: protocolKind, port: port, source: source)); lock.unlock() + } + public func allRules() -> [FirewallRule] { + lock.lock(); defer { lock.unlock() } + return rules.sorted { ($0.protocolKind.rawValue, $0.port, $0.source?.description ?? "") < + ($1.protocolKind.rawValue, $1.port, $1.source?.description ?? "") } } - public func allow(port: UInt16, source: MeshAddress?) { lock.lock(); rules.insert(.init(port: port, source: source)); lock.unlock() } - public func revoke(port: UInt16, source: MeshAddress?) { lock.lock(); rules.remove(.init(port: port, source: source)); lock.unlock() } - public func allRules() -> [FirewallRule] { lock.lock(); defer { lock.unlock() }; return rules.sorted { $0.port < $1.port } } } diff --git a/Sources/UltraMeshCore/Framing.swift b/Sources/UltraMeshCore/Framing.swift index 9e000ce..f4dcea5 100644 --- a/Sources/UltraMeshCore/Framing.swift +++ b/Sources/UltraMeshCore/Framing.swift @@ -25,7 +25,7 @@ public enum FrameCodec { } public enum IPCOperation: String, Codable, Sendable { - case status, address, peers, routes, ping + case status, address, peers, routes, ping, interfaceStatus case aliasSet, aliasRemove, aliasList case firewallAllow, firewallRevoke, firewallList case textSend, bindText, bindStream, openStream, streamData, streamClose @@ -39,6 +39,13 @@ public struct IPCMessage: Codable, Sendable { public var source: String? public var name: String? public var port: UInt16? + public var firewallProtocol: FirewallProtocol? + public var interfaceName: String? + public var interfaceAddress: String? + public var interfaceMTU: UInt16? + public var helperState: String? + public var synchronizedRouteCount: Int? + public var dnsState: String? public var streamID: UInt64? public var data: Data? public var ok: Bool? @@ -46,6 +53,6 @@ public struct IPCMessage: Codable, Sendable { public var values: [String]? public init(operation: IPCOperation, requestID: UUID = UUID()) { - self.version = 1; self.operation = operation; self.requestID = requestID + self.version = 2; self.operation = operation; self.requestID = requestID } } diff --git a/Sources/UltraMeshCore/HelperProtocol.swift b/Sources/UltraMeshCore/HelperProtocol.swift new file mode 100644 index 0000000..6703c4d --- /dev/null +++ b/Sources/UltraMeshCore/HelperProtocol.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct RouteSetDiff: Equatable, Sendable { + public let additions: Set + public let removals: Set +} + +public enum HelperRouteSet { + public static func validate(_ desired: Set, local: MeshAddress, maximum: Int = 31) throws { + guard desired.count <= maximum, !desired.contains(local) else { throw UMNError.invalidFrame } + } + public static func diff(current: Set, desired: Set) -> RouteSetDiff { + .init(additions: desired.subtracting(current), removals: current.subtracting(desired)) + } +} diff --git a/Sources/UltraMeshCore/IPv6Packet.swift b/Sources/UltraMeshCore/IPv6Packet.swift new file mode 100644 index 0000000..4222751 --- /dev/null +++ b/Sources/UltraMeshCore/IPv6Packet.swift @@ -0,0 +1,176 @@ +import Foundation + +public enum IPv6Transport: UInt8, Sendable { + case tcp = 6 + case udp = 17 + case icmpv6 = 58 +} + +public struct IPv6PacketInfo: Sendable { + public let source: MeshAddress + public let destination: MeshAddress + public let transport: IPv6Transport + public let sourcePort: UInt16? + public let destinationPort: UInt16? + public let tcpFlags: UInt8? + public let icmpType: UInt8? + public let transportOffset: Int +} + +public enum IPv6PacketParser { + public static let maximumPacketSize = 1280 + + public static func parse(_ packet: Data) throws -> IPv6PacketInfo { + guard packet.count >= 40, packet.count <= maximumPacketSize, packet[0] >> 4 == 6 else { throw UMNError.invalidFrame } + let payloadLength = Int(read16(packet, 4)) + guard payloadLength == packet.count - 40 else { throw UMNError.invalidFrame } + let source = try MeshAddress(bytes: packet.subdata(in: 8..<24)) + let destinationBytes = packet.subdata(in: 24..<40) + guard destinationBytes.first != 0xff else { throw UMNError.invalidFrame } + let destination = try MeshAddress(bytes: destinationBytes) + + var next = packet[6] + var offset = 40 + var extensions = 0 + while [UInt8(0), 43, 60].contains(next) { + guard extensions < 8, offset + 2 <= packet.count else { throw UMNError.invalidFrame } + let following = packet[offset] + let length: Int + length = (Int(packet[offset + 1]) + 1) * 8 + guard length >= 8, offset + length <= packet.count else { throw UMNError.invalidFrame } + if next == 43, packet[offset + 3] != 0 { throw UMNError.invalidFrame } + if next == 0 || next == 60 { try validateOptions(packet, range: (offset + 2)..<(offset + length)) } + next = following; offset += length; extensions += 1 + } + guard next != 44, let transport = IPv6Transport(rawValue: next) else { throw UMNError.invalidFrame } + + switch transport { + case .tcp: + guard offset + 20 <= packet.count else { throw UMNError.invalidFrame } + let headerLength = Int(packet[offset + 12] >> 4) * 4 + guard headerLength >= 20, offset + headerLength <= packet.count else { throw UMNError.invalidFrame } + return .init(source: source, destination: destination, transport: transport, + sourcePort: read16(packet, offset), destinationPort: read16(packet, offset + 2), + tcpFlags: packet[offset + 13], icmpType: nil, transportOffset: offset) + case .udp: + guard offset + 8 <= packet.count else { throw UMNError.invalidFrame } + let length = Int(read16(packet, offset + 4)) + guard length >= 8, offset + length == packet.count else { throw UMNError.invalidFrame } + return .init(source: source, destination: destination, transport: transport, + sourcePort: read16(packet, offset), destinationPort: read16(packet, offset + 2), + tcpFlags: nil, icmpType: nil, transportOffset: offset) + case .icmpv6: + guard offset + 8 <= packet.count else { throw UMNError.invalidFrame } + return .init(source: source, destination: destination, transport: transport, + sourcePort: nil, destinationPort: nil, tcpFlags: nil, + icmpType: packet[offset], transportOffset: offset) + } + } + + private static func read16(_ data: Data, _ offset: Int) -> UInt16 { + UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + } + private static func validateOptions(_ data: Data, range: Range) throws { + var index = range.lowerBound + while index < range.upperBound { + if data[index] == 0 { index += 1; continue } // Pad1 + guard index + 2 <= range.upperBound else { throw UMNError.invalidFrame } + let length = Int(data[index + 1]) + guard index + 2 + length <= range.upperBound else { throw UMNError.invalidFrame } + index += 2 + length + } + } +} + +public final class NativePacketFirewall: @unchecked Sendable { + private struct Flow: Hashable { + let kind: FirewallProtocol + let local: MeshAddress + let remote: MeshAddress + let localPort: UInt16 + let remotePort: UInt16 + } + private struct Entry { var lastSeen: Date; var established: Bool } + private let rules: MeshFirewall + private let lock = NSLock() + private var flows: [Flow: Entry] = [:] + private var echoWindows: [MeshAddress: (Date, Int)] = [:] + public let maximumFlows: Int + + public init(rules: MeshFirewall, maximumFlows: Int = 4096) { + self.rules = rules; self.maximumFlows = maximumFlows + } + + /// Records an OS-originated packet. Outbound connections are always permitted. + public func allowOutbound(_ info: IPv6PacketInfo, now: Date = Date()) -> Bool { + guard let kind = protocolKind(info), let localPort = info.sourcePort, let remotePort = info.destinationPort else { + return info.transport == .icmpv6 && [1, 2, 3, 4, 128, 129].contains(info.icmpType ?? 255) + } + lock.lock(); defer { lock.unlock() } + expireLocked(now) + let key = Flow(kind: kind, local: info.source, remote: info.destination, + localPort: localPort, remotePort: remotePort) + insert(key, entry: .init(lastSeen: now, established: kind == .udp || ((info.tcpFlags ?? 0) & 0x02) != 0)) + return true + } + + public func allowInbound(_ info: IPv6PacketInfo, packet: Data? = nil, now: Date = Date()) -> Bool { + if info.transport == .icmpv6 { return allowICMP(info, packet: packet, now: now) } + guard let kind = protocolKind(info), let remotePort = info.sourcePort, let localPort = info.destinationPort else { return false } + lock.lock(); defer { lock.unlock() } + expireLocked(now) + let key = Flow(kind: kind, local: info.destination, remote: info.source, + localPort: localPort, remotePort: remotePort) + if var entry = flows[key] { + entry.lastSeen = now + if kind == .tcp, ((info.tcpFlags ?? 0) & 0x12) == 0x12 { entry.established = true } + flows[key] = entry + if kind == .tcp, ((info.tcpFlags ?? 0) & 0x05) != 0 { flows.removeValue(forKey: key) } + return true + } + guard rules.allows(protocol: kind, port: localPort, source: info.source) else { return false } + // A new TCP flow must begin with SYN and not ACK. UDP is connectionless. + if kind == .tcp, ((info.tcpFlags ?? 0) & 0x12) != 0x02 { return false } + insert(key, entry: .init(lastSeen: now, established: kind == .udp)) + return true + } + + private func allowICMP(_ info: IPv6PacketInfo, packet: Data?, now: Date) -> Bool { + guard let type = info.icmpType else { return false } + if (1...4).contains(type), let packet, packet.count >= info.transportOffset + 8 + 48 { + var quoted = Data(packet[(info.transportOffset + 8)...]) + let actualPayload = quoted.count - 40 + quoted[4] = UInt8(actualPayload >> 8); quoted[5] = UInt8(actualPayload & 255) + guard let original = try? IPv6PacketParser.parse(quoted), + let kind = protocolKind(original), let localPort = original.sourcePort, + let remotePort = original.destinationPort else { return false } + lock.lock(); defer { lock.unlock() }; expireLocked(now) + return flows[Flow(kind: kind, local: original.source, remote: original.destination, + localPort: localPort, remotePort: remotePort)] != nil + } + guard type == 128 || type == 129 else { return false } + lock.lock(); defer { lock.unlock() } + let current = echoWindows[info.source] + if let current, now.timeIntervalSince(current.0) < 1 { + guard current.1 < 10 else { return false } + echoWindows[info.source] = (current.0, current.1 + 1) + } else { + guard echoWindows[info.source] != nil || echoWindows.count < 128 else { return false } + echoWindows[info.source] = (now, 1) + } + return true + } + + private func protocolKind(_ info: IPv6PacketInfo) -> FirewallProtocol? { + info.transport == .tcp ? .tcp : (info.transport == .udp ? .udp : nil) + } + private func insert(_ flow: Flow, entry: Entry) { + if flows[flow] == nil, flows.count >= maximumFlows, + let oldest = flows.min(by: { $0.value.lastSeen < $1.value.lastSeen })?.key { flows.removeValue(forKey: oldest) } + flows[flow] = entry + } + private func expireLocked(_ now: Date) { + flows = flows.filter { now.timeIntervalSince($0.value.lastSeen) <= ($0.key.kind == .tcp ? 300 : 60) } + echoWindows = echoWindows.filter { now.timeIntervalSince($0.value.0) < 60 } + } +} diff --git a/Sources/UltraMeshCore/MeshDNS.swift b/Sources/UltraMeshCore/MeshDNS.swift new file mode 100644 index 0000000..1f95aaa --- /dev/null +++ b/Sources/UltraMeshCore/MeshDNS.swift @@ -0,0 +1,56 @@ +import Foundation + +public enum MeshDNSCodec { + public static func response(to query: Data, aliases: [String: MeshAddress], ttl: UInt32 = 30) throws -> Data { + guard query.count >= 12, read16(query, 4) == 1 else { throw UMNError.invalidFrame } + var offset = 12; var visited = Set() + let labels = try decodeName(query, offset: &offset, visited: &visited) + guard offset + 4 <= query.count else { throw UMNError.invalidFrame } + let type = read16(query, offset), qclass = read16(query, offset + 2); offset += 4 + guard qclass == 1 else { throw UMNError.invalidFrame } + let question = query.subdata(in: 12.., depth: Int = 0) throws -> [String] { + guard depth < 16 else { throw UMNError.invalidFrame } + var labels: [String] = [] + while true { + guard offset < data.count else { throw UMNError.invalidFrame } + let length = Int(data[offset]); offset += 1 + if length == 0 { return labels } + if length & 0xc0 == 0xc0 { + guard offset < data.count else { throw UMNError.invalidFrame } + let pointer = ((length & 0x3f) << 8) | Int(data[offset]); offset += 1 + guard pointer >= 12, pointer < data.count, visited.insert(pointer).inserted else { throw UMNError.invalidFrame } + var nested = pointer + labels += try decodeName(data, offset: &nested, visited: &visited, depth: depth + 1) + return labels + } + guard length <= 63, offset + length <= data.count, + let label = String(data: data.subdata(in: offset..<(offset + length)), encoding: .utf8), + !label.isEmpty else { throw UMNError.invalidFrame } + labels.append(label); offset += length + } + } + private static func read16(_ data: Data, _ offset: Int) -> UInt16 { UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) } + private static func append16(_ value: UInt16, to data: inout Data) { data.append(UInt8(value >> 8)); data.append(UInt8(value & 255)) } + private static func append32(_ value: UInt32, to data: inout Data) { + data.append(UInt8(value >> 24)); data.append(UInt8((value >> 16) & 255)); data.append(UInt8((value >> 8) & 255)); data.append(UInt8(value & 255)) + } +} diff --git a/Sources/UltraMeshCore/Models.swift b/Sources/UltraMeshCore/Models.swift index 2439442..79f0021 100644 --- a/Sources/UltraMeshCore/Models.swift +++ b/Sources/UltraMeshCore/Models.swift @@ -102,7 +102,7 @@ public struct LinkState: Codable, Hashable, Sendable { } public enum PacketKind: String, Codable, Sendable { - case pingRequest, pingReply, text, streamOpen, streamData, streamAck, streamClose, streamReset, error + case pingRequest, pingReply, text, streamOpen, streamData, streamAck, streamClose, streamReset, ipv6Packet, error } public struct InnerFrame: Codable, Sendable { @@ -113,13 +113,15 @@ public struct InnerFrame: Codable, Sendable { public let acknowledgment: UInt64 public let payload: Data public let sourceRecord: NodeRecord + /// Required for ipv6Packet frames. It is covered by the end-to-end signature. + public let replayID: UUID? public init(kind: PacketKind, streamID: UInt64 = 0, port: UInt16? = nil, sequence: UInt64 = 0, acknowledgment: UInt64 = 0, - payload: Data = Data(), sourceRecord: NodeRecord) { + payload: Data = Data(), sourceRecord: NodeRecord, replayID: UUID? = nil) { self.kind = kind; self.streamID = streamID; self.port = port self.sequence = sequence; self.acknowledgment = acknowledgment - self.payload = payload; self.sourceRecord = sourceRecord + self.payload = payload; self.sourceRecord = sourceRecord; self.replayID = replayID } } @@ -134,16 +136,18 @@ public struct SealedPayload: Codable, Sendable { } public struct RoutedPacket: Codable, Sendable { + public enum TrafficClass: String, Codable, Sendable { case overlay, nativeIPv6 } public let id: UUID public let source: MeshAddress public let destination: MeshAddress public var hopLimit: UInt8 public let sealed: SealedPayload + public let trafficClass: TrafficClass public init(id: UUID = UUID(), source: MeshAddress, destination: MeshAddress, - hopLimit: UInt8 = 16, sealed: SealedPayload) { + hopLimit: UInt8 = 16, sealed: SealedPayload, trafficClass: TrafficClass = .overlay) { self.id = id; self.source = source; self.destination = destination - self.hopLimit = hopLimit; self.sealed = sealed + self.hopLimit = hopLimit; self.sealed = sealed; self.trafficClass = trafficClass } } @@ -178,7 +182,7 @@ public enum WireMessage: Codable, Sendable { } public struct WireEnvelope: Codable, Sendable { - public static let currentVersion: UInt16 = 1 + public static let currentVersion: UInt16 = 2 public let version: UInt16 public let message: WireMessage diff --git a/Sources/umn-helper/main.swift b/Sources/umn-helper/main.swift new file mode 100644 index 0000000..6299e1f --- /dev/null +++ b/Sources/umn-helper/main.swift @@ -0,0 +1,96 @@ +import Foundation +import Darwin +import UltraMeshCore +import UMNSystem + +private struct HelperOptions { + var uid: uid_t + var socket = "/var/run/ultramesh-helper.sock" + init() throws { + let args = Array(CommandLine.arguments.dropFirst()) + guard let uidIndex = args.firstIndex(of: "--uid"), uidIndex + 1 < args.count, + let parsed = UInt32(args[uidIndex + 1]) else { throw UMNError.message("umn-helper requires --uid") } + uid = parsed + if let index = args.firstIndex(of: "--socket"), index + 1 < args.count { socket = args[index + 1] } + } +} + +private func runCommand(_ executable: String, _ arguments: [String]) throws { + let process = Process(); process.executableURL = URL(fileURLWithPath: executable); process.arguments = arguments + process.standardOutput = FileHandle.nullDevice; process.standardError = FileHandle.standardError + try process.run(); process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw UMNError.message("\(executable) failed with status \(process.terminationStatus)") + } +} + +private func readLine(_ fd: Int32, limit: Int = 32_768) throws -> String? { + var bytes: [UInt8] = [] + while bytes.count < limit { + var byte: UInt8 = 0; let count = Darwin.read(fd, &byte, 1) + if count == 0 { return bytes.isEmpty ? nil : String(decoding: bytes, as: UTF8.self) } + if count < 0 { if errno == EINTR { continue }; throw POSIXError(.init(rawValue: errno) ?? .EIO) } + if byte == 10 { return String(decoding: bytes, as: UTF8.self) } + bytes.append(byte) + } + throw UMNError.invalidFrame +} + +private func handle(_ client: Int32, allowedUID: uid_t) { + var device: Int32 = -1; var interface = ""; var routes = Set() + defer { + for route in routes { try? runCommand("/sbin/route", ["-n", "delete", "-inet6", route.description]) } + if !interface.isEmpty { try? runCommand("/sbin/ifconfig", [interface, "down"]) } + if device >= 0 { Darwin.close(device) } + UnixIPC.close(client) + } + do { + var peerUID: uid_t = 0; var peerGID: gid_t = 0 + guard umn_get_peer_eid(client, &peerUID, &peerGID) == 0, peerUID == allowedUID else { + throw UMNError.message("helper authentication failed") + } + guard let hello = try readLine(client), hello.hasPrefix("UMN2 "), + let local = try? MeshAddress(String(hello.dropFirst(5))) else { throw UMNError.invalidFrame } + var name = [CChar](repeating: 0, count: Int(IFNAMSIZ)) + device = umn_utun_open(&name, name.count) + guard device >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + interface = String(cString: name) + try runCommand("/sbin/ifconfig", [interface, "inet6", local.description, "prefixlen", "128", "mtu", "1280", "up"]) + let ready = Data("READY \(interface) 1280\n".utf8) + let sent = ready.withUnsafeBytes { umn_send_fd(client, device, $0.baseAddress, ready.count) } + guard sent == 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + + while let line = try readLine(client) { + guard line.hasPrefix("ROUTES") else { throw UMNError.invalidFrame } + let fields = line.split(separator: " ").dropFirst() + var desired = Set() + for field in fields { + guard let address = try? MeshAddress(String(field)) else { throw UMNError.invalidFrame } + desired.insert(address) + } + try HelperRouteSet.validate(desired, local: local) + let diff = HelperRouteSet.diff(current: routes, desired: desired) + for address in diff.removals { + try runCommand("/sbin/route", ["-n", "delete", "-inet6", address.description]); routes.remove(address) + } + for address in diff.additions { + try runCommand("/sbin/route", ["-n", "add", "-inet6", address.description, "-interface", interface]); routes.insert(address) + } + } + } catch { + FileHandle.standardError.write(Data("umn-helper: \(error.localizedDescription)\n".utf8)) + } +} + +do { + guard geteuid() == 0 else { throw UMNError.message("umn-helper must run as root") } + let options = try HelperOptions() + let server = try UnixIPC.listen(path: options.socket) + guard chown(options.socket, options.uid, gid_t.max) == 0, chmod(options.socket, 0o600) == 0 else { + throw POSIXError(.init(rawValue: errno) ?? .EIO) + } + signal(SIGPIPE, SIG_IGN) + while true { let client = try UnixIPC.accept(server); handle(client, allowedUID: options.uid) } +} catch { + FileHandle.standardError.write(Data("umn-helper: \(error.localizedDescription)\n".utf8)); exit(1) +} diff --git a/Sources/umn-selftest/main.swift b/Sources/umn-selftest/main.swift index dfa73bf..e288bcf 100644 --- a/Sources/umn-selftest/main.swift +++ b/Sources/umn-selftest/main.swift @@ -1,5 +1,7 @@ import Foundation +import Darwin import UltraMeshCore +import UMNSystem struct TestFailure: Error, LocalizedError { let errorDescription: String? @@ -12,6 +14,31 @@ func check(_ condition: @autoclosure () throws -> Bool, _ name: String) throws { passed += 1; print("ok \(passed) - \(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") @@ -35,6 +62,10 @@ do { 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") @@ -53,11 +84,109 @@ do { 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(); 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 = [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 == 1, case .keepalive = decoded.message { try check(true, "wire frame round trip") } + 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 } diff --git a/Sources/umn/main.swift b/Sources/umn/main.swift index 39151e5..b5777ea 100644 --- a/Sources/umn/main.swift +++ b/Sources/umn/main.swift @@ -6,9 +6,9 @@ let args = Array(CommandLine.arguments.dropFirst()) func usage() -> Never { print(""" Ultra Mesh Network - umn status | address | peers | routes | ping + umn status | address | peers | routes | interface status | ping umn alias set
| remove | list - umn firewall allow|revoke --from | list + umn firewall allow|revoke --from | list umn text send | listen umn expose --to umn proxy [--listen 127.0.0.1:] @@ -44,6 +44,9 @@ do { case "address": request = IPCMessage(operation: .address) case "peers": request = IPCMessage(operation: .peers) case "routes": request = IPCMessage(operation: .routes) + case "interface": + guard args.count == 2, args[1] == "status" else { usage() } + request = IPCMessage(operation: .interfaceStatus) case "ping": guard args.count == 2 else { usage() } request = IPCMessage(operation: .ping); request.target = args[1] @@ -63,9 +66,10 @@ do { 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() } + guard args.count == 6, args[4] == "--from", ["allow", "revoke"].contains(args[1]), + let protocolKind = FirewallProtocol(rawValue: args[2]) else { usage() } request = IPCMessage(operation: args[1] == "allow" ? .firewallAllow : .firewallRevoke) - request.port = parsePort(args[2]); request.source = args[4] + request.firewallProtocol = protocolKind; request.port = parsePort(args[3]); request.source = args[5] } case "text": guard args.count >= 3 else { usage() } diff --git a/Sources/umnd/MeshDNSServer.swift b/Sources/umnd/MeshDNSServer.swift new file mode 100644 index 0000000..d28581b --- /dev/null +++ b/Sources/umnd/MeshDNSServer.swift @@ -0,0 +1,84 @@ +import Foundation +import Darwin +import UltraMeshCore + +final class MeshDNSServer { + static let port: UInt16 = 53535 + private let aliases: () -> [String: MeshAddress] + private var udp: Int32 = -1, tcp: Int32 = -1 + private var running = false + + init(aliases: @escaping () -> [String: MeshAddress]) { self.aliases = aliases } + + func start() throws { + udp = try bindSocket(type: SOCK_DGRAM); tcp = try bindSocket(type: SOCK_STREAM) + guard Darwin.listen(tcp, 32) == 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + running = true + DispatchQueue.global(qos: .utility).async { [weak self] in self?.udpLoop() } + DispatchQueue.global(qos: .utility).async { [weak self] in self?.tcpLoop() } + } + func stop() { + running = false + if udp >= 0 { Darwin.close(udp); udp = -1 } + if tcp >= 0 { Darwin.close(tcp); tcp = -1 } + } + + private func bindSocket(type: Int32) throws -> Int32 { + let fd = socket(AF_INET, type, 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)) + var address = sockaddr_in(); address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET); address.sin_port = Self.port.bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard result == 0 else { let code = errno; Darwin.close(fd); throw POSIXError(.init(rawValue: code) ?? .EIO) } + return fd + } + private func udpLoop() { + while running { + var bytes = [UInt8](repeating: 0, count: 4096); var peer = sockaddr_storage(); var length = socklen_t(MemoryLayout.size(ofValue: peer)) + let count = withUnsafeMutablePointer(to: &peer) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { recvfrom(udp, &bytes, bytes.count, 0, $0, &length) } + } + guard count > 0 else { if errno == EINTR { continue }; return } + guard let response = try? MeshDNSCodec.response(to: Data(bytes.prefix(count)), aliases: aliases()) else { continue } + response.withUnsafeBytes { body in + withUnsafePointer(to: &peer) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { _ = sendto(udp, body.baseAddress, response.count, 0, $0, length) } + } + } + } + } + private func tcpLoop() { + while running { + let client = Darwin.accept(tcp, nil, nil) + if client < 0 { if errno == EINTR { continue }; return } + DispatchQueue.global(qos: .utility).async { [weak self] in self?.handleTCP(client) } + } + } + private func handleTCP(_ fd: Int32) { + defer { Darwin.close(fd) } + guard let prefix = readExact(2, fd), prefix.count == 2 else { return } + let length = Int(prefix[0]) << 8 | Int(prefix[1]); guard length > 0, length <= 4096, + let query = readExact(length, fd), let response = try? MeshDNSCodec.response(to: query, aliases: aliases()) else { return } + var framed = Data([UInt8(response.count >> 8), UInt8(response.count & 255)]); framed.append(response) + framed.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var offset = 0 + while offset < framed.count { + let count = Darwin.write(fd, base.advanced(by: offset), framed.count - offset) + if count <= 0 { return } + offset += count + } + } + } + private func readExact(_ count: Int, _ fd: Int32) -> Data? { + var output = Data(); var buffer = [UInt8](repeating: 0, count: count) + while output.count < count { + let n = Darwin.read(fd, &buffer, count - output.count); if n <= 0 { return nil } + output.append(buffer, count: n) + } + return output + } +} diff --git a/Sources/umnd/MeshDaemon.swift b/Sources/umnd/MeshDaemon.swift index 0f598cb..1dffd86 100644 --- a/Sources/umnd/MeshDaemon.swift +++ b/Sources/umnd/MeshDaemon.swift @@ -24,6 +24,7 @@ final class MeshDaemon { private let identity: NodeIdentity private let router: LinkStateRouter private let firewall: MeshFirewall + private let nativeFirewall: NativePacketFirewall private let configURL: URL private var config: DaemonConfig private let ipc: IPCServer @@ -35,12 +36,19 @@ final class MeshDaemon { private var lsaSequence: UInt64 = 0 private var seenPackets = Set() private var seenOrder: [UUID] = [] + private var replayPackets = Set() + private var replayOrder: [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? + private var nativeInterface: NativeInterfaceClient? + private var synchronizedRoutes = Set() + private var nextInterfaceAttempt = Date.distantPast + private var dns: MeshDNSServer? + private var dnsState = "stopped" init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws { @@ -50,6 +58,7 @@ final class MeshDaemon { configURL = baseURL.appendingPathComponent("config.json") config = DaemonConfig.load(from: configURL) firewall = MeshFirewall(rules: config.firewall) + nativeFirewall = NativePacketFirewall(rules: 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) @@ -63,6 +72,8 @@ final class MeshDaemon { try startNetwork() ipc.onMessage = { [weak self] client, message in self?.queue.async { self?.handleIPC(client, message) } } try ipc.start() + startDNS() + attemptInterface() publishLocalState() let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 1, repeating: 1) @@ -74,6 +85,7 @@ final class MeshDaemon { func stop() { queue.sync { timer?.cancel(); browser?.cancel(); listener?.cancel() + nativeInterface?.disconnect(); dns?.stop() allSessions.values.forEach { $0.stop() }; ipc.stop() } } @@ -185,7 +197,8 @@ final class MeshDaemon { @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) + let packet = RoutedPacket(source: address, destination: remote.address, sealed: sealed, + trafficClass: inner.kind == .ipv6Packet ? .nativeIPv6 : .overlay) guard router.routes()[remote.address] != nil || peers[remote.address] != nil else { return false } forward(packet); return true } @@ -214,6 +227,12 @@ final class MeshDaemon { case .streamData: receiveStreamData(inner) case .streamAck: receiveStreamAck(inner) case .streamClose, .streamReset: receiveStreamClose(inner) + case .ipv6Packet: + guard let replayID = inner.replayID, markReplay(replayID), + let info = try? IPv6PacketParser.parse(inner.payload), + info.source == packet.source, info.destination == address, + nativeFirewall.allowInbound(info, packet: inner.payload) else { return } + _ = nativeInterface?.inject(inner.payload) case .error: receiveStreamClose(inner) } } @@ -304,9 +323,23 @@ final class MeshDaemon { response.ok = ok; response.message = text; response.values = values; response.streamID = streamID client.send(response) } + guard message.version == 2 else { reply(false, "incompatible CLI protocol version"); return } switch message.operation { case .status: - reply(true, "umnd running as \(address); \(peers.count) direct peer(s), \(router.routes().count) route(s)") + let state = nativeInterface?.status + reply(true, "umnd running as \(address); \(peers.count) direct peer(s), \(router.routes().count) route(s); " + + "interface \(state?.name ?? "unavailable") MTU 1280, helper \(state?.helperState ?? "disconnected"), " + + "\(state?.routeCount ?? 0) route(s) synchronized, DNS \(dnsState)") + case .interfaceStatus: + let state = nativeInterface?.status + let name = state?.name ?? "unavailable" + var response = IPCMessage(operation: message.operation, requestID: message.requestID) + response.ok = true; response.interfaceName = state?.name; response.interfaceAddress = address.description + response.interfaceMTU = 1280; response.helperState = state?.helperState ?? "disconnected" + response.synchronizedRouteCount = state?.routeCount ?? 0; response.dnsState = dnsState + response.message = "interface: \(name)\naddress: \(address)\nMTU: 1280\nhelper: \(response.helperState!)\n" + + "routes synchronized: \(response.synchronizedRouteCount!)\nDNS: \(dnsState)" + client.send(response) case .address: reply(true, address.description) case .peers: reply(true, values: peers.keys.sorted().map(\.description)) case .routes: @@ -323,14 +356,16 @@ final class MeshDaemon { 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")" } + let values = firewall.allRules().map { "allow \($0.protocolKind.rawValue) \($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 } + guard let protocolKind = message.firewallProtocol, let port = message.port, let sourceText = message.source else { + reply(false, "new firewall rules require overlay, tcp, or udp"); 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) } + if message.operation == .firewallAllow { firewall.allow(protocol: protocolKind, port: port, source: source) } + else { firewall.revoke(protocol: protocolKind, 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 } @@ -410,6 +445,51 @@ final class MeshDaemon { return true } + private func markReplay(_ id: UUID) -> Bool { + guard !replayPackets.contains(id) else { return false } + replayPackets.insert(id); replayOrder.append(id) + if replayOrder.count > 4096 { replayPackets.remove(replayOrder.removeFirst()) } + return true + } + + private func startDNS() { + let server = MeshDNSServer { [weak self] in + guard let self else { return [:] } + return self.queue.sync { self.config.aliases } + } + do { try server.start(); dns = server; dnsState = "listening on 127.0.0.1:53535" } + catch { dnsState = "unavailable (\(error.localizedDescription))"; log("DNS unavailable: \(error)") } + } + + private func attemptInterface() { + guard nativeInterface == nil, Date() >= nextInterfaceAttempt else { return } + nextInterfaceAttempt = Date().addingTimeInterval(5) + let socket = ProcessInfo.processInfo.environment["UMN_HELPER_SOCKET"] ?? "/var/run/ultramesh-helper.sock" + let client = NativeInterfaceClient(local: address, socketPath: socket, queue: queue) + client.onPacket = { [weak self] packet in self?.receiveOutboundIPv6(packet) } + client.onDisconnect = { [weak self] in self?.nativeInterface = nil; self?.synchronizedRoutes.removeAll() } + do { + try client.connect(); nativeInterface = client; synchronizeNativeRoutes(force: true) + if nativeInterface != nil { log("native interface connected") } + } + catch { client.disconnect(); log("native interface unavailable: \(error.localizedDescription)") } + } + + private func synchronizeNativeRoutes(force: Bool = false) { + guard let nativeInterface else { return } + let desired = Set(router.routes().keys.filter { router.record(for: $0) != nil }) + guard force || desired != synchronizedRoutes else { return } + do { try nativeInterface.setRoutes(desired); synchronizedRoutes = desired } + catch { log("route synchronization failed: \(error.localizedDescription)"); nativeInterface.disconnect() } + } + + private func receiveOutboundIPv6(_ packet: Data) { + guard let info = try? IPv6PacketParser.parse(packet), info.source == address, + let remote = router.record(for: info.destination), nativeFirewall.allowOutbound(info) else { return } + let frame = InnerFrame(kind: .ipv6Packet, payload: packet, sourceRecord: identity.record, replayID: UUID()) + _ = send(frame, to: remote) + } + private func allowPing(from source: MeshAddress) -> Bool { let now = Date() if let current = pingWindows[source], now.timeIntervalSince(current.0) < 1 { @@ -427,6 +507,7 @@ final class MeshDaemon { if Int(now.timeIntervalSince1970) % 5 == 0 { broadcast(.keepalive, excluding: nil) } if Int(now.timeIntervalSince1970) % 10 == 0 { publishLocalState() } router.expire(now: now) + attemptInterface(); synchronizeNativeRoutes() pingWindows = pingWindows.filter { now.timeIntervalSince($0.value.0) < 60 } let expiredPings = pendingPings.filter { now.timeIntervalSince($0.value.1) > 10 } for (id, pending) in expiredPings { diff --git a/Sources/umnd/NativeInterface.swift b/Sources/umnd/NativeInterface.swift new file mode 100644 index 0000000..8451b81 --- /dev/null +++ b/Sources/umnd/NativeInterface.swift @@ -0,0 +1,121 @@ +import Foundation +import Darwin +import UltraMeshCore +import UMNSystem + +struct NativeInterfaceStatus { + var name: String? + var address: MeshAddress + var mtu = 1280 + var helperState: String + var routeCount = 0 +} + +final class NativeInterfaceClient { + private let local: MeshAddress + private let socketPath: String + private let callbackQueue: DispatchQueue + private var helperFD: Int32 = -1 + private var deviceFD: Int32 = -1 + private var readSource: DispatchSourceRead? + private var helperSource: DispatchSourceRead? + private let writeLock = NSLock() + private(set) var status: NativeInterfaceStatus + var onPacket: ((Data) -> Void)? + var onDisconnect: (() -> Void)? + + init(local: MeshAddress, socketPath: String = "/var/run/ultramesh-helper.sock", queue: DispatchQueue) { + self.local = local; self.socketPath = socketPath; callbackQueue = queue + status = .init(address: local, helperState: "disconnected") + } + + func connect() throws { + guard helperFD < 0 else { return } + let socket = try UnixIPC.connect(path: socketPath) + do { + try writeAll(Data("UMN2 \(local)\n".utf8), fd: socket) + var receivedFD: Int32 = -1; var buffer = [UInt8](repeating: 0, count: 256) + let count = umn_recv_fd(socket, &receivedFD, &buffer, buffer.count) + guard count > 0, receivedFD >= 0 else { throw UMNError.invalidFrame } + var adopted = false + defer { if !adopted { Darwin.close(receivedFD) } } + var readyData = Data(buffer.prefix(count)) + while !readyData.contains(10), readyData.count < buffer.count { + var byte: UInt8 = 0 + let amount = Darwin.read(socket, &byte, 1) + guard amount == 1 else { throw UMNError.invalidFrame } + readyData.append(byte) + } + guard readyData.last == 10, let ready = String(data: readyData, encoding: .utf8) else { throw UMNError.invalidFrame } + let fields = ready.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ") + guard fields.count == 3, fields[0] == "READY", fields[1].hasPrefix("utun"), fields[2] == "1280" else { + throw UMNError.invalidFrame + } + helperFD = socket; deviceFD = receivedFD + adopted = true + status.name = String(fields[1]); status.helperState = "connected" + let source = DispatchSource.makeReadSource(fileDescriptor: receivedFD, queue: callbackQueue) + source.setEventHandler { [weak self] in self?.readPacket() } + source.setCancelHandler { Darwin.close(receivedFD) } + source.resume(); readSource = source + let helperMonitor = DispatchSource.makeReadSource(fileDescriptor: socket, queue: callbackQueue) + helperMonitor.setEventHandler { [weak self] in self?.helperBecameReadable() } + helperMonitor.resume(); helperSource = helperMonitor + } catch { UnixIPC.close(socket); throw error } + } + + func setRoutes(_ addresses: Set) throws { + guard helperFD >= 0 else { throw UMNError.message("helper disconnected") } + let line = "ROUTES" + addresses.sorted().map { " \($0)" }.joined() + "\n" + do { try writeAll(Data(line.utf8), fd: helperFD); status.routeCount = addresses.count } + catch { disconnect(); throw error } + } + + func inject(_ packet: Data) -> Bool { + guard deviceFD >= 0 else { return false } + var family = UInt32(AF_INET6).bigEndian + let framed = withUnsafeBytes(of: &family) { Data($0) } + packet + writeLock.lock(); defer { writeLock.unlock() } + return (try? writeAll(framed, fd: deviceFD)) != nil + } + + func disconnect() { + let wasConnected = helperFD >= 0 + readSource?.cancel(); readSource = nil; deviceFD = -1 + helperSource?.cancel(); helperSource = nil + if helperFD >= 0 { UnixIPC.close(helperFD); helperFD = -1 } + status.name = nil; status.helperState = "disconnected"; status.routeCount = 0 + if wasConnected { onDisconnect?() } + } + + private func readPacket() { + guard deviceFD >= 0 else { return } + var buffer = [UInt8](repeating: 0, count: IPv6PacketParser.maximumPacketSize + 4) + let count = Darwin.read(deviceFD, &buffer, buffer.count) + guard count > 4 else { if count <= 0 { disconnect() }; return } + let family = buffer.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + guard family == UInt32(AF_INET6) else { return } + onPacket?(Data(buffer[4..= 0 else { return } + var byte: UInt8 = 0 + _ = recv(helperFD, &byte, 1, MSG_PEEK) + // The protocol has no unsolicited helper messages, so data or EOF means + // the session is no longer trustworthy. + disconnect() + } + + private func writeAll(_ 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 count = Darwin.write(fd, base.advanced(by: offset), data.count - offset) + if count < 0 { if errno == EINTR { continue }; throw POSIXError(.init(rawValue: errno) ?? .EIO) } + offset += count + } + } + } +} diff --git a/Sources/umnd/PeerSession.swift b/Sources/umnd/PeerSession.swift index 2c90b5f..2e817e3 100644 --- a/Sources/umnd/PeerSession.swift +++ b/Sources/umnd/PeerSession.swift @@ -10,6 +10,12 @@ final class PeerSession { var onStop: ((PeerSession) -> Void)? private let queue: DispatchQueue private var stopped = false + private struct Pending { let data: Data; let bulk: Bool } + private var priorityQueue: [Pending] = [] + private var bulkQueue: [Pending] = [] + private var bulkBytes = 0 + private var sending = false + private static let maximumPendingBulkBytes = 512 * 1024 init(connection: NWConnection, outbound: Bool, queue: DispatchQueue) { self.connection = connection; self.outbound = outbound; self.queue = queue @@ -30,9 +36,13 @@ final class PeerSession { 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() } - }) + let bulk: Bool + if case .packet(let packet) = message { bulk = packet.trafficClass == .nativeIPv6 } else { bulk = false } + if bulk { + guard bulkBytes + data.count <= Self.maximumPendingBulkBytes else { return } + bulkBytes += data.count; bulkQueue.append(.init(data: data, bulk: true)) + } else { priorityQueue.append(.init(data: data, bulk: false)) } + pumpWrites() } func stop() { @@ -54,6 +64,22 @@ final class PeerSession { } } + private func pumpWrites() { + guard !stopped, !sending else { return } + let pending: Pending + if !priorityQueue.isEmpty { pending = priorityQueue.removeFirst() } + else if !bulkQueue.isEmpty { pending = bulkQueue.removeFirst(); bulkBytes -= pending.data.count } + else { return } + sending = true + connection.send(content: pending.data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + self.queue.async { + self.sending = false + if error != nil { self.stop() } else { self.pumpWrites() } + } + }) + } + 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 diff --git a/Sources/umnd/main.swift b/Sources/umnd/main.swift index 765187a..2c87128 100644 --- a/Sources/umnd/main.swift +++ b/Sources/umnd/main.swift @@ -2,6 +2,7 @@ import Foundation import UltraMeshCore do { + signal(SIGPIPE, SIG_IGN) let arguments = Array(CommandLine.arguments.dropFirst()) var stateURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/UltraMesh") diff --git a/docs/protocol.md b/docs/protocol.md index fc0c98b..0cca1b6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,10 +1,10 @@ -# Ultra Mesh Protocol v1 +# Ultra Mesh Protocol v2 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. +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 `2` and one `WireMessage`. Unknown versions terminate the peer session, so v1 peers fail closed. A node record contains: @@ -36,9 +36,11 @@ The sender serializes an `InnerFrame`, signs its bytes with Ed25519, and encrypt 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. +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 v2. -Inner frame kinds are ping request/reply, text, stream open/data/ack/close/reset, and error. Logical destination ports are inside the sealed payload. +Inner frame kinds are ping request/reply, text, stream open/data/ack/close/reset, `ipv6Packet`, and error. Logical destination ports are inside the sealed payload. An `ipv6Packet` contains the complete IPv6 packet and a signed UUID retained in a bounded destination replay cache. Routed packets mark native bulk traffic so neighbor queues can bound and deprioritize it behind hello, link-state, and keepalive messages. + +Native packets must use the authenticated outer source and destination as their IPv6 source and destination. Endpoints accept TCP, UDP, ICMPv6 echo and related errors, plus bounded standard extension-header chains. They reject fragments, multicast, malformed lengths, non-mesh addresses, unsafe routing headers, and unsupported next-header values. Relays route ciphertext and do not inspect the IP header. ## Streams and failure handling @@ -46,7 +48,7 @@ A stream uses a random 64-bit identifier. `streamOpen` occupies sequence zero; d 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: +Each endpoint enforces these resource bounds: - 1 MiB aggregate data per stream. - 32 simultaneous streams per node. @@ -57,6 +59,10 @@ New operations are live-route-only and are not written to a durable delivery que ## 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. +Ping terminates in the daemon and is always available. Firewall rules explicitly select `overlay`, `tcp`, or `udp`. Persisted rules without a protocol decode as `overlay`; new IPC mutations must specify one. A stream or text message is delivered only when an overlay allow rule matches its authenticated source and a local process currently binds the destination port. Outbound native flows are allowed, return TCP/UDP state is bounded and timed, and new inbound flows require a matching source/port rule. ICMPv6 echo has per-source rate limiting. 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. +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 `2`, and support control operations, interface diagnostics, service binding, and stream events. A binding is removed when its IPC connection closes. + +## Privileged interface protocol + +The root LaunchDaemon accepts only the installing UID (verified with `getpeereid`) on its mode-`0600` socket. After an `UMN2 ` greeting it creates an MTU-1280 `utun`, configures the local `/128`, and sends a duplicated descriptor using `SCM_RIGHTS`. Subsequent `ROUTES` messages replace a validated set of at most 31 mesh `/128` routes. Commands are invoked as fixed `/sbin/ifconfig` or `/sbin/route` argument arrays, never through a shell. EOF removes all routes and closes the interface. diff --git a/scripts/install.sh b/scripts/install.sh index b2527a4..9accb3f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,10 +7,17 @@ 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" +HELPER_BIN="/Library/PrivilegedHelperTools/com.ultramesh.umn-helper" +HELPER_PLIST="/Library/LaunchDaemons/com.ultramesh.umn-helper.plist" +HELPER_SOCKET="/var/run/ultramesh-helper.sock" +RESOLVER_PATH="/etc/resolver/mesh" +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "${TEMP_DIR}"' EXIT cd "${PROJECT_DIR}" swift build -c release --product umnd swift build -c release --product umn +swift build -c release --product umn-helper BUILD_DIR=$(swift build -c release --show-bin-path) mkdir -p "${BIN_DIR}" "${STATE_DIR}" "${LAUNCH_DIR}" @@ -18,6 +25,38 @@ 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 +codesign --force --sign - "${BUILD_DIR}/umn-helper" >/dev/null 2>&1 + +cat > "${TEMP_DIR}/helper.plist" < + + +Labelcom.ultramesh.umn-helper +ProgramArguments${HELPER_BIN}--uid${UID}--socket${HELPER_SOCKET} +RunAtLoadKeepAlive +StandardOutPath/var/log/ultramesh-helper.log +StandardErrorPath/var/log/ultramesh-helper.log + +EOF +cat > "${TEMP_DIR}/resolver" <<'EOF' +# Managed by Ultra Mesh Network +nameserver 127.0.0.1 +port 53535 +EOF +plutil -lint "${TEMP_DIR}/helper.plist" >/dev/null + +if [[ -e "${RESOLVER_PATH}" ]] && ! cmp -s "${TEMP_DIR}/resolver" "${RESOLVER_PATH}"; then + echo "Refusing to overwrite unmanaged ${RESOLVER_PATH}." >&2 + exit 1 +fi + +sudo -v +sudo launchctl bootout system "${HELPER_PLIST}" >/dev/null 2>&1 || true +sudo install -d -m 755 /Library/PrivilegedHelperTools /Library/LaunchDaemons /etc/resolver +sudo install -m 755 "${BUILD_DIR}/umn-helper" "${HELPER_BIN}" +sudo install -m 644 "${TEMP_DIR}/helper.plist" "${HELPER_PLIST}" +sudo install -m 644 "${TEMP_DIR}/resolver" "${RESOLVER_PATH}" +sudo launchctl bootstrap system "${HELPER_PLIST}" launchctl bootout "gui/${UID}" "${PLIST_PATH}" >/dev/null 2>&1 || true plutil -create xml1 "${PLIST_PATH}" @@ -32,9 +71,16 @@ plutil -insert StandardErrorPath -string "${STATE_DIR}/umnd.log" "${PLIST_PATH}" chmod 600 "${PLIST_PATH}" launchctl bootstrap "gui/${UID}" "${PLIST_PATH}" +for _ in {1..20}; do [[ -S "${HELPER_SOCKET}" ]] && [[ -S "${STATE_DIR}/umnd.sock" ]] && break; sleep 0.25; done + 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 +STATUS=$("${BIN_DIR}/umn" status) +echo "${STATUS}" +INTERFACE=$("${BIN_DIR}/umn" interface status) +echo "${INTERFACE}" +[[ "${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; } diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 90d372a..6198e28 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -3,11 +3,27 @@ set -euo pipefail BIN_DIR="${HOME}/.local/bin" PLIST_PATH="${HOME}/Library/LaunchAgents/com.ultramesh.umnd.plist" +HELPER_BIN="/Library/PrivilegedHelperTools/com.ultramesh.umn-helper" +HELPER_PLIST="/Library/LaunchDaemons/com.ultramesh.umn-helper.plist" +RESOLVER_PATH="/etc/resolver/mesh" +TEMP_RESOLVER=$(mktemp) +trap 'rm -f "${TEMP_RESOLVER}"' EXIT +cat > "${TEMP_RESOLVER}" <<'EOF' +# Managed by Ultra Mesh Network +nameserver 127.0.0.1 +port 53535 +EOF 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." +sudo -v +sudo launchctl bootout system "${HELPER_PLIST}" >/dev/null 2>&1 || true +[[ ! -e "${HELPER_PLIST}" ]] || sudo unlink "${HELPER_PLIST}" +[[ ! -e "${HELPER_BIN}" ]] || sudo unlink "${HELPER_BIN}" +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."