This commit is contained in:
Owen Qwen
2026-08-31 16:44:54 -05:00
parent e71b3ada71
commit 5f99d9b137
21 changed files with 1045 additions and 47 deletions
+6 -3
View File
@@ -9,12 +9,15 @@ let package = Package(
.executable(name: "umnd", targets: ["umnd"]), .executable(name: "umnd", targets: ["umnd"]),
.executable(name: "umn", targets: ["umn"]), .executable(name: "umn", targets: ["umn"]),
.executable(name: "umn-selftest", targets: ["umn-selftest"]), .executable(name: "umn-selftest", targets: ["umn-selftest"]),
.executable(name: "umn-helper", targets: ["umn-helper"]),
], ],
targets: [ targets: [
.target(name: "UltraMeshCore"), .target(name: "UMNSystem", publicHeadersPath: "include"),
.executableTarget(name: "umnd", dependencies: ["UltraMeshCore"]), .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", dependencies: ["UltraMeshCore"]),
.executableTarget(name: "umn-selftest", dependencies: ["UltraMeshCore"]), .executableTarget(name: "umn-selftest", dependencies: ["UltraMeshCore", "UMNSystem"]),
], ],
swiftLanguageModes: [.v5] swiftLanguageModes: [.v5]
) )
+23 -7
View File
@@ -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. 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 ## Requirements and installation
- macOS 14 or newer - macOS 14 or newer on a personally controlled laptop
- Apple Command Line Tools with Swift 6 - Apple Command Line Tools with Swift 6
- Wi-Fi enabled on both Macs - Wi-Fi enabled on both Macs
@@ -19,9 +19,23 @@ umn status
umn address 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 <mesh-address>
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. 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 ```sh
umn address umn address
umn firewall allow 7000 --from any umn firewall allow overlay 7000 --from any
umn text listen 7000 umn text listen 7000
``` ```
@@ -63,7 +77,7 @@ Run a local server and expose it on a logical mesh port:
```sh ```sh
# Host laptop # Host laptop
python3 -m http.server 8080 --bind 127.0.0.1 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 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. - Signed link-state announcements and shortest-hop routing support multi-hop topologies. The implementation is bounded and tested for 32 live nodes and 16 hops.
- Service payloads are authenticated and encrypted end-to-end with Curve25519, HKDF-SHA256, and ChaCha20-Poly1305. Relays see routing metadata but cannot read ports or content. - Service payloads are authenticated and encrypted end-to-end with Curve25519, HKDF-SHA256, and ChaCha20-Poly1305. Relays see routing metadata but cannot read ports or content.
- Streams use sequence numbers, acknowledgements, retransmission, and a 60-second recovery window. An active stream can continue after a route change if another path appears within that window. - Streams use sequence numbers, acknowledgements, retransmission, and a 60-second recovery window. An active stream can continue after a route change if another path appears within that window.
- 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. - 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. - 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. - Membership is open. Identities are self-authenticating, but there is no global authority, revocation service, or Sybil resistance yet.
+76
View File
@@ -0,0 +1,76 @@
#include "UMNSystem.h"
#include <errno.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <unistd.h>
#ifdef __APPLE__
#include <net/if.h>
#include <net/if_utun.h>
#include <sys/kern_control.h>
#include <sys/sys_domain.h>
#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;
}
+11
View File
@@ -0,0 +1,11 @@
#ifndef UMN_SYSTEM_H
#define UMN_SYSTEM_H
#include <stddef.h>
#include <sys/types.h>
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
+30 -6
View File
@@ -1,9 +1,24 @@
import Foundation import Foundation
public enum FirewallProtocol: String, Codable, CaseIterable, Sendable {
case overlay, tcp, udp
}
public struct FirewallRule: Codable, Hashable, Sendable { public struct FirewallRule: Codable, Hashable, Sendable {
public let protocolKind: FirewallProtocol
public let port: UInt16 public let port: UInt16
public let source: MeshAddress? 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 { public final class MeshFirewall: @unchecked Sendable {
@@ -11,11 +26,20 @@ public final class MeshFirewall: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
public init(rules: Set<FirewallRule> = []) { self.rules = rules } public init(rules: Set<FirewallRule> = []) { 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() } 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 } }
} }
+9 -2
View File
@@ -25,7 +25,7 @@ public enum FrameCodec {
} }
public enum IPCOperation: String, Codable, Sendable { public enum IPCOperation: String, Codable, Sendable {
case status, address, peers, routes, ping case status, address, peers, routes, ping, interfaceStatus
case aliasSet, aliasRemove, aliasList case aliasSet, aliasRemove, aliasList
case firewallAllow, firewallRevoke, firewallList case firewallAllow, firewallRevoke, firewallList
case textSend, bindText, bindStream, openStream, streamData, streamClose case textSend, bindText, bindStream, openStream, streamData, streamClose
@@ -39,6 +39,13 @@ public struct IPCMessage: Codable, Sendable {
public var source: String? public var source: String?
public var name: String? public var name: String?
public var port: UInt16? 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 streamID: UInt64?
public var data: Data? public var data: Data?
public var ok: Bool? public var ok: Bool?
@@ -46,6 +53,6 @@ public struct IPCMessage: Codable, Sendable {
public var values: [String]? public var values: [String]?
public init(operation: IPCOperation, requestID: UUID = UUID()) { 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
} }
} }
@@ -0,0 +1,15 @@
import Foundation
public struct RouteSetDiff: Equatable, Sendable {
public let additions: Set<MeshAddress>
public let removals: Set<MeshAddress>
}
public enum HelperRouteSet {
public static func validate(_ desired: Set<MeshAddress>, local: MeshAddress, maximum: Int = 31) throws {
guard desired.count <= maximum, !desired.contains(local) else { throw UMNError.invalidFrame }
}
public static func diff(current: Set<MeshAddress>, desired: Set<MeshAddress>) -> RouteSetDiff {
.init(additions: desired.subtracting(current), removals: current.subtracting(desired))
}
}
+176
View File
@@ -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<Int>) 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 }
}
}
+56
View File
@@ -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<Int>()
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..<offset)
let fqdn = labels.joined(separator: ".").lowercased()
let aliasName = fqdn.hasSuffix(".mesh") ? String(fqdn.dropLast(5)) : ""
let address = aliases[aliasName]
let answer = address != nil && type == 28
let rcode: UInt16 = address == nil ? 3 : 0
var output = Data()
append16(read16(query, 0), to: &output)
append16(0x8400 | rcode, to: &output) // response + authoritative
append16(1, to: &output); append16(answer ? 1 : 0, to: &output)
append16(0, to: &output); append16(0, to: &output); output.append(question)
if answer, let address {
append16(0xc00c, to: &output); append16(28, to: &output); append16(1, to: &output)
append32(ttl, to: &output); append16(16, to: &output); output.append(address.bytes)
}
guard output.count <= 512 else { throw UMNError.invalidFrame }
return output
}
private static func decodeName(_ data: Data, offset: inout Int, visited: inout Set<Int>, 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))
}
}
+10 -6
View File
@@ -102,7 +102,7 @@ public struct LinkState: Codable, Hashable, Sendable {
} }
public enum PacketKind: String, Codable, 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 { public struct InnerFrame: Codable, Sendable {
@@ -113,13 +113,15 @@ public struct InnerFrame: Codable, Sendable {
public let acknowledgment: UInt64 public let acknowledgment: UInt64
public let payload: Data public let payload: Data
public let sourceRecord: NodeRecord 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, public init(kind: PacketKind, streamID: UInt64 = 0, port: UInt16? = nil,
sequence: UInt64 = 0, acknowledgment: UInt64 = 0, 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.kind = kind; self.streamID = streamID; self.port = port
self.sequence = sequence; self.acknowledgment = acknowledgment 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 struct RoutedPacket: Codable, Sendable {
public enum TrafficClass: String, Codable, Sendable { case overlay, nativeIPv6 }
public let id: UUID public let id: UUID
public let source: MeshAddress public let source: MeshAddress
public let destination: MeshAddress public let destination: MeshAddress
public var hopLimit: UInt8 public var hopLimit: UInt8
public let sealed: SealedPayload public let sealed: SealedPayload
public let trafficClass: TrafficClass
public init(id: UUID = UUID(), source: MeshAddress, destination: MeshAddress, 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.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 struct WireEnvelope: Codable, Sendable {
public static let currentVersion: UInt16 = 1 public static let currentVersion: UInt16 = 2
public let version: UInt16 public let version: UInt16
public let message: WireMessage public let message: WireMessage
+96
View File
@@ -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<MeshAddress>()
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<MeshAddress>()
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)
}
+130 -1
View File
@@ -1,5 +1,7 @@
import Foundation import Foundation
import Darwin
import UltraMeshCore import UltraMeshCore
import UMNSystem
struct TestFailure: Error, LocalizedError { struct TestFailure: Error, LocalizedError {
let errorDescription: String? let errorDescription: String?
@@ -12,6 +14,31 @@ func check(_ condition: @autoclosure () throws -> Bool, _ name: String) throws {
passed += 1; print("ok \(passed) - \(name)") 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 { do {
let alice = try NodeIdentity(); let bob = try NodeIdentity(); let carol = try NodeIdentity() 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(try MeshAddress(alice.record.address.description) == alice.record.address, "mesh address round trip")
@@ -35,6 +62,10 @@ do {
var rejected = false var rejected = false
do { _ = try bob.open(tampered, expectedSource: alice.record.address) } catch { rejected = true } do { _ = try bob.open(tampered, expectedSource: alice.record.address) } catch { rejected = true }
try check(rejected, "tampered ciphertext rejected") 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) 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 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") try check(!firewall.allows(port: 80, source: bob.record.address), "scoped firewall rule denies other source")
firewall.allow(port: 443, source: nil) firewall.allow(port: 443, source: nil)
try check(firewall.allows(port: 443, source: bob.record.address), "any-source firewall rule") 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)) let framed = try FrameCodec.encode(WireEnvelope(message: .keepalive))
try check(try FrameCodec.bodyLength(from: framed.prefix(4)) == framed.count - 4, "frame length prefix") 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)) 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") } else { throw TestFailure("wire frame round trip") }
var zeroRejected = false var zeroRejected = false
do { _ = try FrameCodec.bodyLength(from: Data([0, 0, 0, 0])) } catch { zeroRejected = true } do { _ = try FrameCodec.bodyLength(from: Data([0, 0, 0, 0])) } catch { zeroRejected = true }
+8 -4
View File
@@ -6,9 +6,9 @@ let args = Array(CommandLine.arguments.dropFirst())
func usage() -> Never { func usage() -> Never {
print(""" print("""
Ultra Mesh Network Ultra Mesh Network
umn status | address | peers | routes | ping <target> umn status | address | peers | routes | interface status | ping <target>
umn alias set <name> <address> | remove <name> | list umn alias set <name> <address> | remove <name> | list
umn firewall allow|revoke <port> --from <address|any> | list umn firewall allow|revoke <overlay|tcp|udp> <port> --from <address|any> | list
umn text send <target> <port> <message> | listen <port> umn text send <target> <port> <message> | listen <port>
umn expose <mesh-port> --to <host:port> umn expose <mesh-port> --to <host:port>
umn proxy [--listen 127.0.0.1:<port>] umn proxy [--listen 127.0.0.1:<port>]
@@ -44,6 +44,9 @@ do {
case "address": request = IPCMessage(operation: .address) case "address": request = IPCMessage(operation: .address)
case "peers": request = IPCMessage(operation: .peers) case "peers": request = IPCMessage(operation: .peers)
case "routes": request = IPCMessage(operation: .routes) case "routes": request = IPCMessage(operation: .routes)
case "interface":
guard args.count == 2, args[1] == "status" else { usage() }
request = IPCMessage(operation: .interfaceStatus)
case "ping": case "ping":
guard args.count == 2 else { usage() } guard args.count == 2 else { usage() }
request = IPCMessage(operation: .ping); request.target = args[1] request = IPCMessage(operation: .ping); request.target = args[1]
@@ -63,9 +66,10 @@ do {
guard args.count >= 2 else { usage() } guard args.count >= 2 else { usage() }
if args[1] == "list" { request = IPCMessage(operation: .firewallList) } if args[1] == "list" { request = IPCMessage(operation: .firewallList) }
else { 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 = 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": case "text":
guard args.count >= 3 else { usage() } guard args.count >= 3 else { usage() }
+84
View File
@@ -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<Int32>.size))
var address = sockaddr_in(); address.sin_len = UInt8(MemoryLayout<sockaddr_in>.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<sockaddr_in>.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
}
}
+87 -6
View File
@@ -24,6 +24,7 @@ final class MeshDaemon {
private let identity: NodeIdentity private let identity: NodeIdentity
private let router: LinkStateRouter private let router: LinkStateRouter
private let firewall: MeshFirewall private let firewall: MeshFirewall
private let nativeFirewall: NativePacketFirewall
private let configURL: URL private let configURL: URL
private var config: DaemonConfig private var config: DaemonConfig
private let ipc: IPCServer private let ipc: IPCServer
@@ -35,12 +36,19 @@ final class MeshDaemon {
private var lsaSequence: UInt64 = 0 private var lsaSequence: UInt64 = 0
private var seenPackets = Set<UUID>() private var seenPackets = Set<UUID>()
private var seenOrder: [UUID] = [] private var seenOrder: [UUID] = []
private var replayPackets = Set<UUID>()
private var replayOrder: [UUID] = []
private var textBindings: [UInt16: IPCClient] = [:] private var textBindings: [UInt16: IPCClient] = [:]
private var streamBindings: [UInt16: IPCClient] = [:] private var streamBindings: [UInt16: IPCClient] = [:]
private var streams: [UInt64: StreamState] = [:] private var streams: [UInt64: StreamState] = [:]
private var pendingPings: [UUID: (IPCClient, Date)] = [:] private var pendingPings: [UUID: (IPCClient, Date)] = [:]
private var pingWindows: [MeshAddress: (Date, Int)] = [:] private var pingWindows: [MeshAddress: (Date, Int)] = [:]
private var timer: DispatchSourceTimer? private var timer: DispatchSourceTimer?
private var nativeInterface: NativeInterfaceClient?
private var synchronizedRoutes = Set<MeshAddress>()
private var nextInterfaceAttempt = Date.distantPast
private var dns: MeshDNSServer?
private var dnsState = "stopped"
init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws { .appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws {
@@ -50,6 +58,7 @@ final class MeshDaemon {
configURL = baseURL.appendingPathComponent("config.json") configURL = baseURL.appendingPathComponent("config.json")
config = DaemonConfig.load(from: configURL) config = DaemonConfig.load(from: configURL)
firewall = MeshFirewall(rules: config.firewall) firewall = MeshFirewall(rules: config.firewall)
nativeFirewall = NativePacketFirewall(rules: firewall)
ipc = IPCServer(path: socketPath ?? baseURL.appendingPathComponent("umnd.sock").path) ipc = IPCServer(path: socketPath ?? baseURL.appendingPathComponent("umnd.sock").path)
let wallClockBase = UInt64(Date().timeIntervalSince1970 * 1000) let wallClockBase = UInt64(Date().timeIntervalSince1970 * 1000)
lsaSequence = max((config.lsaSequence ?? 0) &+ 1_000_000, wallClockBase) lsaSequence = max((config.lsaSequence ?? 0) &+ 1_000_000, wallClockBase)
@@ -63,6 +72,8 @@ final class MeshDaemon {
try startNetwork() try startNetwork()
ipc.onMessage = { [weak self] client, message in self?.queue.async { self?.handleIPC(client, message) } } ipc.onMessage = { [weak self] client, message in self?.queue.async { self?.handleIPC(client, message) } }
try ipc.start() try ipc.start()
startDNS()
attemptInterface()
publishLocalState() publishLocalState()
let timer = DispatchSource.makeTimerSource(queue: queue) let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 1, repeating: 1) timer.schedule(deadline: .now() + 1, repeating: 1)
@@ -74,6 +85,7 @@ final class MeshDaemon {
func stop() { func stop() {
queue.sync { queue.sync {
timer?.cancel(); browser?.cancel(); listener?.cancel() timer?.cancel(); browser?.cancel(); listener?.cancel()
nativeInterface?.disconnect(); dns?.stop()
allSessions.values.forEach { $0.stop() }; ipc.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 { @discardableResult private func send(_ inner: InnerFrame, to remote: NodeRecord) -> Bool {
guard remote.address != address, guard remote.address != address,
let sealed = try? identity.seal(inner, to: remote) else { return false } 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 } guard router.routes()[remote.address] != nil || peers[remote.address] != nil else { return false }
forward(packet); return true forward(packet); return true
} }
@@ -214,6 +227,12 @@ final class MeshDaemon {
case .streamData: receiveStreamData(inner) case .streamData: receiveStreamData(inner)
case .streamAck: receiveStreamAck(inner) case .streamAck: receiveStreamAck(inner)
case .streamClose, .streamReset: receiveStreamClose(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) case .error: receiveStreamClose(inner)
} }
} }
@@ -304,9 +323,23 @@ final class MeshDaemon {
response.ok = ok; response.message = text; response.values = values; response.streamID = streamID response.ok = ok; response.message = text; response.values = values; response.streamID = streamID
client.send(response) client.send(response)
} }
guard message.version == 2 else { reply(false, "incompatible CLI protocol version"); return }
switch message.operation { switch message.operation {
case .status: 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 .address: reply(true, address.description)
case .peers: reply(true, values: peers.keys.sorted().map(\.description)) case .peers: reply(true, values: peers.keys.sorted().map(\.description))
case .routes: case .routes:
@@ -323,14 +356,16 @@ final class MeshDaemon {
guard let name = message.name?.lowercased() else { reply(false, "missing alias"); return } guard let name = message.name?.lowercased() else { reply(false, "missing alias"); return }
config.aliases.removeValue(forKey: name); saveConfig(); reply(true) config.aliases.removeValue(forKey: name); saveConfig(); reply(true)
case .firewallList: 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) reply(true, values: values)
case .firewallAllow, .firewallRevoke: 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? let source: MeshAddress?
if sourceText == "any" { source = nil } else if let parsed = resolve(sourceText) { source = parsed } else { reply(false, "invalid source"); return } 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) } if message.operation == .firewallAllow { firewall.allow(protocol: protocolKind, port: port, source: source) }
else { firewall.revoke(port: port, source: source) } else { firewall.revoke(protocol: protocolKind, port: port, source: source) }
config.firewall = Set(firewall.allRules()); saveConfig(); reply(true) config.firewall = Set(firewall.allRules()); saveConfig(); reply(true)
case .ping: case .ping:
guard let target = message.target, let remote = record(for: target) else { reply(false, "no route to destination"); return } 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 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 { private func allowPing(from source: MeshAddress) -> Bool {
let now = Date() let now = Date()
if let current = pingWindows[source], now.timeIntervalSince(current.0) < 1 { 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) % 5 == 0 { broadcast(.keepalive, excluding: nil) }
if Int(now.timeIntervalSince1970) % 10 == 0 { publishLocalState() } if Int(now.timeIntervalSince1970) % 10 == 0 { publishLocalState() }
router.expire(now: now) router.expire(now: now)
attemptInterface(); synchronizeNativeRoutes()
pingWindows = pingWindows.filter { now.timeIntervalSince($0.value.0) < 60 } pingWindows = pingWindows.filter { now.timeIntervalSince($0.value.0) < 60 }
let expiredPings = pendingPings.filter { now.timeIntervalSince($0.value.1) > 10 } let expiredPings = pendingPings.filter { now.timeIntervalSince($0.value.1) > 10 }
for (id, pending) in expiredPings { for (id, pending) in expiredPings {
+121
View File
@@ -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<MeshAddress>) 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..<count]))
}
private func helperBecameReadable() {
guard helperFD >= 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
}
}
}
}
+29 -3
View File
@@ -10,6 +10,12 @@ final class PeerSession {
var onStop: ((PeerSession) -> Void)? var onStop: ((PeerSession) -> Void)?
private let queue: DispatchQueue private let queue: DispatchQueue
private var stopped = false 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) { init(connection: NWConnection, outbound: Bool, queue: DispatchQueue) {
self.connection = connection; self.outbound = outbound; self.queue = queue self.connection = connection; self.outbound = outbound; self.queue = queue
@@ -30,9 +36,13 @@ final class PeerSession {
func send(_ message: WireMessage) { func send(_ message: WireMessage) {
guard !stopped, let data = try? FrameCodec.encode(WireEnvelope(message: message)) else { return } guard !stopped, let data = try? FrameCodec.encode(WireEnvelope(message: message)) else { return }
connection.send(content: data, completion: .contentProcessed { [weak self] error in let bulk: Bool
if error != nil { self?.stop() } 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() { 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) { private func receiveExactly(_ count: Int, accumulated: Data, completion: @escaping (Data?) -> Void) {
if accumulated.count == count { completion(accumulated); return } if accumulated.count == count { completion(accumulated); return }
connection.receive(minimumIncompleteLength: 1, maximumLength: count - accumulated.count) { [weak self] data, _, complete, error in connection.receive(minimumIncompleteLength: 1, maximumLength: count - accumulated.count) { [weak self] data, _, complete, error in
+1
View File
@@ -2,6 +2,7 @@ import Foundation
import UltraMeshCore import UltraMeshCore
do { do {
signal(SIGPIPE, SIG_IGN)
let arguments = Array(CommandLine.arguments.dropFirst()) let arguments = Array(CommandLine.arguments.dropFirst())
var stateURL = FileManager.default.homeDirectoryForCurrentUser var stateURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/UltraMesh") .appendingPathComponent("Library/Application Support/UltraMesh")
+13 -7
View File
@@ -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. 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 ## 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: 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. 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. 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 ## 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. 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. - 1 MiB aggregate data per stream.
- 32 simultaneous streams per node. - 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 ## 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 <local-address>` 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.
+47 -1
View File
@@ -7,10 +7,17 @@ BIN_DIR="${HOME}/.local/bin"
STATE_DIR="${HOME}/Library/Application Support/UltraMesh" STATE_DIR="${HOME}/Library/Application Support/UltraMesh"
LAUNCH_DIR="${HOME}/Library/LaunchAgents" LAUNCH_DIR="${HOME}/Library/LaunchAgents"
PLIST_PATH="${LAUNCH_DIR}/com.ultramesh.umnd.plist" 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}" cd "${PROJECT_DIR}"
swift build -c release --product umnd swift build -c release --product umnd
swift build -c release --product umn swift build -c release --product umn
swift build -c release --product umn-helper
BUILD_DIR=$(swift build -c release --show-bin-path) BUILD_DIR=$(swift build -c release --show-bin-path)
mkdir -p "${BIN_DIR}" "${STATE_DIR}" "${LAUNCH_DIR}" 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" install -m 755 "${BUILD_DIR}/umn" "${BIN_DIR}/umn"
codesign --force --sign - "${BIN_DIR}/umnd" >/dev/null 2>&1 codesign --force --sign - "${BIN_DIR}/umnd" >/dev/null 2>&1
codesign --force --sign - "${BIN_DIR}/umn" >/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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.ultramesh.umn-helper</string>
<key>ProgramArguments</key><array><string>${HELPER_BIN}</string><string>--uid</string><string>${UID}</string><string>--socket</string><string>${HELPER_SOCKET}</string></array>
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/var/log/ultramesh-helper.log</string>
<key>StandardErrorPath</key><string>/var/log/ultramesh-helper.log</string>
</dict></plist>
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 launchctl bootout "gui/${UID}" "${PLIST_PATH}" >/dev/null 2>&1 || true
plutil -create xml1 "${PLIST_PATH}" plutil -create xml1 "${PLIST_PATH}"
@@ -32,9 +71,16 @@ plutil -insert StandardErrorPath -string "${STATE_DIR}/umnd.log" "${PLIST_PATH}"
chmod 600 "${PLIST_PATH}" chmod 600 "${PLIST_PATH}"
launchctl bootstrap "gui/${UID}" "${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 "Ultra Mesh Network installed."
echo "Binary directory: ${BIN_DIR}" echo "Binary directory: ${BIN_DIR}"
if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then
echo "Add this line to ~/.zshrc: export PATH=\"\$HOME/.local/bin:\$PATH\"" echo "Add this line to ~/.zshrc: export PATH=\"\$HOME/.local/bin:\$PATH\""
fi 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; }
+17 -1
View File
@@ -3,11 +3,27 @@ set -euo pipefail
BIN_DIR="${HOME}/.local/bin" BIN_DIR="${HOME}/.local/bin"
PLIST_PATH="${HOME}/Library/LaunchAgents/com.ultramesh.umnd.plist" 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 launchctl bootout "gui/${UID}" "${PLIST_PATH}" >/dev/null 2>&1 || true
[[ ! -e "${PLIST_PATH}" ]] || unlink "${PLIST_PATH}" [[ ! -e "${PLIST_PATH}" ]] || unlink "${PLIST_PATH}"
[[ ! -e "${BIN_DIR}/umnd" ]] || unlink "${BIN_DIR}/umnd" [[ ! -e "${BIN_DIR}/umnd" ]] || unlink "${BIN_DIR}/umnd"
[[ ! -e "${BIN_DIR}/umn" ]] || unlink "${BIN_DIR}/umn" [[ ! -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." echo "Identity and configuration were preserved in ~/Library/Application Support/UltraMesh."