From 41b431a11c5700ae63e69b759779c7adf3d02b71 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 7 May 2026 00:17:12 -0400 Subject: [PATCH] Mac tunnelmanager: align with new size_t-based FFI; first-packet tunnelUp() signal --- app/vpn/tunnelmanager_mac.mm | 227 ++++++++++++++--------------------- 1 file changed, 92 insertions(+), 135 deletions(-) diff --git a/app/vpn/tunnelmanager_mac.mm b/app/vpn/tunnelmanager_mac.mm index 0b76aad..e6765ba 100644 --- a/app/vpn/tunnelmanager_mac.mm +++ b/app/vpn/tunnelmanager_mac.mm @@ -1,28 +1,11 @@ -// tunnelmanager_mac.mm — macOS implementation of TunnelManager. +// tunnelmanager_mac.mm - macOS implementation of TunnelManager. // -// Architecture -// ──────────── -// • TUN device : utun (com.apple.net.utun_control) — no root needed to open. -// • WireGuard : boringtun (Rust static lib via boringtun_ffi.h). -// • Routing : ifconfig + route commands via osascript "with administrator -// privileges" — macOS shows a one-time auth dialog. -// • Threading : tunToUdp thread, udpToTun thread, ticker thread. -// A self-pipe is used to unblock select() on shutdown. -// -// Privilege model -// ─────────────── -// Opening the utun fd: no root. -// ifconfig (set IP + MTU): needs admin → osascript dialog. -// route add: needs admin → same osascript dialog (batched into one call). -// Subsequent reconnects reuse the saved original-gateway so the dialog -// should only appear once per session. -// -// Packet framing on macOS utun -// ───────────────────────────── -// Every packet on the utun fd is prefixed by a 4-byte address-family word -// in host byte order (AF_INET = 2, AF_INET6 = 30 on macOS). We strip this -// prefix before handing packets to boringtun, and prepend it before writing -// decrypted packets back to the TUN device. +// TUN device : utun (com.apple.net.utun_control) - no root needed to open. +// WireGuard : boringtun (Rust static lib via boringtun_ffi.h). +// Routing : ifconfig + route commands via osascript "with administrator +// privileges" - macOS shows a one-time auth dialog. +// Threading : tunToUdp thread, udpToTun thread, ticker thread. +// A self-pipe is used to unblock select() on shutdown. #import @@ -31,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -47,48 +31,43 @@ #include #include +#include #include #include "tunnelmanager.h" #include "wireguardconfig.h" #include "boringtun_ffi.h" -// ─── Constants ──────────────────────────────────────────────────────────────── - static constexpr size_t kMTU = 1420; -static constexpr size_t kBufSize = kMTU + 64; // MTU + WireGuard overhead -static constexpr uint32_t kAF_INET_HOST = AF_INET; // 2 on macOS (host order) -static constexpr uint32_t kAF_INET6_HOST = AF_INET6; // 30 on macOS - -// ─── MacTunnelPriv ──────────────────────────────────────────────────────────── +static constexpr size_t kBufSize = kMTU + 64; +static constexpr uint32_t kAF_INET_HOST = AF_INET; +static constexpr uint32_t kAF_INET6_HOST = AF_INET6; struct MacTunnelPriv { wireguard_tunnel *wg = nullptr; - int tunFd = -1; ///< utun file descriptor - int udpFd = -1; ///< UDP socket connected to WG server + int tunFd = -1; + int udpFd = -1; char tunName[IFNAMSIZ] = {}; - // Self-pipe for signalling threads to stop (write end closed on stop()). int wakePipeR = -1; int wakePipeW = -1; - std::atomic running {false}; + std::atomic running {false}; + std::atomic gotFirstPkt {false}; std::thread thdTunToUdp; std::thread thdUdpToTun; std::thread thdTicker; - // Saved for route cleanup WireGuardConfig cfg; - std::string savedGateway; ///< Original default gateway IP string + std::string savedGateway; + + QPointer owner; }; -// ─── Admin helper ───────────────────────────────────────────────────────────── +// Admin helper -/// Run a shell command with administrator privileges via osascript. -/// macOS shows a standard auth dialog if the user is not already elevated. static bool runAsAdmin(const std::string &shellCmd, QString &errOut) { - // Escape double-quotes and backslashes for the AppleScript string literal. std::string escaped; escaped.reserve(shellCmd.size()); for (char ch : shellCmd) { @@ -100,12 +79,12 @@ static bool runAsAdmin(const std::string &shellCmd, QString &errOut) @"do shell script \"%s\" with administrator privileges", escaped.c_str()]; - NSTask *task = [[NSTask alloc] init]; - NSPipe *errPipe = [NSPipe pipe]; - task.launchPath = @"/usr/bin/osascript"; - task.arguments = @[@"-e", script]; - task.standardError = errPipe; - task.standardOutput = [NSPipe pipe]; + NSTask *task = [[NSTask alloc] init]; + NSPipe *errPipe = [NSPipe pipe]; + task.launchPath = @"/usr/bin/osascript"; + task.arguments = @[@"-e", script]; + task.standardError = errPipe; + task.standardOutput = [NSPipe pipe]; @try { [task launch]; @@ -124,8 +103,6 @@ static bool runAsAdmin(const std::string &shellCmd, QString &errOut) return true; } -/// Run a shell command as the current user (no privilege elevation). -/// Returns the stdout as a trimmed string, or "" on failure. static std::string runAsUser(const std::string &shellCmd) { FILE *f = popen(shellCmd.c_str(), "r"); @@ -134,17 +111,13 @@ static std::string runAsUser(const std::string &shellCmd) std::string out; while (fgets(buf, sizeof(buf), f)) out += buf; pclose(f); - // Trim trailing whitespace while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) out.pop_back(); return out; } -// ─── utun helpers ───────────────────────────────────────────────────────────── +// utun helpers -/// Open a utun device. No root required. -/// @param[out] tunName Filled with the interface name, e.g. "utun3". -/// @return File descriptor on success, -1 on failure. static int openUtun(char tunName[IFNAMSIZ]) { int fd = ::socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL); @@ -162,7 +135,7 @@ static int openUtun(char tunName[IFNAMSIZ]) addr.sc_family = AF_SYSTEM; addr.ss_sysaddr = AF_SYS_CONTROL; addr.sc_id = info.ctl_id; - addr.sc_unit = 0; // 0 = kernel assigns next available unit + addr.sc_unit = 0; if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { qWarning() << "[DragonVPN] connect(utun):" << strerror(errno); @@ -176,38 +149,28 @@ static int openUtun(char tunName[IFNAMSIZ]) return fd; } -// ─── Routing ────────────────────────────────────────────────────────────────── +// Routing -/// Configure IP address, MTU, and routes on the utun interface. -/// Requires one macOS administrator auth dialog. static bool configureInterface(MacTunnelPriv *p, const WireGuardConfig &cfg, QString &errOut) { - const std::string tun = p->tunName; - const std::string localIP = cfg.localIP().toStdString(); - const std::string epHost = cfg.endpointHost().toStdString(); + const std::string tun = p->tunName; + const std::string localIP = cfg.localIP().toStdString(); + const std::string epHost = cfg.endpointHost().toStdString(); - // Discover current default gateway before we touch routing. p->savedGateway = runAsUser( "route -n get default 2>/dev/null | awk '/gateway:/ { print $2; exit }'"); - // Build a single multi-command script to minimise auth prompts. std::string script; - - // 1. Assign IP (point-to-point to itself is the canonical utun style) script += "ifconfig " + tun + " " + localIP + " " + localIP + " up"; script += " && ifconfig " + tun + " mtu " + std::to_string(kMTU); - // 2. If endpoint resolves to a real IP, pin it to the original gateway so - // the encrypted packets can still reach the server after we add routes. if (!epHost.empty() && !p->savedGateway.empty()) { script += " && route add -host " + epHost + " " + p->savedGateway; } - // 3. Add routes for each AllowedIPs entry via the utun interface. for (const QString &cidr : cfg.allowedIPs) { const std::string net = cidr.trimmed().toStdString(); if (net == "0.0.0.0/0") { - // Two /1 routes override the default without replacing it. script += " && route add -net 0.0.0.0/1 -interface " + tun; script += " && route add -net 128.0.0.0/1 -interface " + tun; } else { @@ -218,7 +181,6 @@ static bool configureInterface(MacTunnelPriv *p, const WireGuardConfig &cfg, QSt return runAsAdmin(script, errOut); } -/// Remove routes added in configureInterface. Best-effort; never fatal. static void teardownRoutes(MacTunnelPriv *p) { const std::string epHost = p->cfg.endpointHost().toStdString(); @@ -249,17 +211,14 @@ static void teardownRoutes(MacTunnelPriv *p) runAsAdmin(script, ignored); } -// ─── I/O threads ────────────────────────────────────────────────────────────── +// I/O threads -/// TUN → UDP: read plaintext IP packets from utun, encrypt with boringtun, -/// send encrypted datagrams to the WireGuard server. static void tunToUdpThread(MacTunnelPriv *p) { - std::vector plain(kBufSize + 4); // +4 for AF header - std::vector enc (kBufSize + 32); // +32 for WG overhead + std::vector plain(kBufSize + 4); + std::vector enc (kBufSize + 64); - while (p->running) { - // Wait for data on tunFd or the wake pipe using select(). + while (p->running.load()) { fd_set rfds; FD_ZERO(&rfds); FD_SET(p->tunFd, &rfds); @@ -267,18 +226,17 @@ static void tunToUdpThread(MacTunnelPriv *p) int maxFd = std::max(p->tunFd, p->wakePipeR) + 1; if (::select(maxFd, &rfds, nullptr, nullptr, nullptr) < 0) break; - if (!p->running || FD_ISSET(p->wakePipeR, &rfds)) break; + if (!p->running.load() || FD_ISSET(p->wakePipeR, &rfds)) break; ssize_t n = ::read(p->tunFd, plain.data(), plain.size()); - if (n <= 4) continue; // error or empty AF header + if (n <= 4) continue; - // Strip the 4-byte AF header; the rest is a raw IP packet. - const uint8_t *ipPkt = plain.data() + 4; - const uint32_t ipLen = static_cast(n - 4); + const uint8_t *ipPkt = plain.data() + 4; + const size_t ipLen = static_cast(n - 4); + size_t encLen = enc.size(); wireguard_result res = wireguard_write( - p->wg, ipPkt, ipLen, - enc.data(), static_cast(enc.size())); + p->wg, ipPkt, ipLen, enc.data(), &encLen); if (res.op == WRITE_TO_NETWORK && res.size > 0) ::send(p->udpFd, enc.data(), res.size, 0); @@ -287,14 +245,12 @@ static void tunToUdpThread(MacTunnelPriv *p) } } -/// UDP → TUN: receive encrypted datagrams from the WireGuard server, -/// decrypt with boringtun, write plaintext IP packets to utun. static void udpToTunThread(MacTunnelPriv *p) { - std::vector enc (kBufSize + 32); + std::vector enc (kBufSize + 64); std::vector plain(kBufSize); - while (p->running) { + while (p->running.load()) { fd_set rfds; FD_ZERO(&rfds); FD_SET(p->udpFd, &rfds); @@ -302,17 +258,17 @@ static void udpToTunThread(MacTunnelPriv *p) int maxFd = std::max(p->udpFd, p->wakePipeR) + 1; if (::select(maxFd, &rfds, nullptr, nullptr, nullptr) < 0) break; - if (!p->running || FD_ISSET(p->wakePipeR, &rfds)) break; + if (!p->running.load() || FD_ISSET(p->wakePipeR, &rfds)) break; ssize_t n = ::recv(p->udpFd, enc.data(), enc.size(), 0); if (n <= 0) continue; + size_t plainLen = plain.size(); wireguard_result res = wireguard_read( - p->wg, enc.data(), static_cast(n), - plain.data(), static_cast(plain.size())); + p->wg, enc.data(), static_cast(n), + plain.data(), &plainLen); if (res.op == WRITE_TO_TUNNEL_IPV4 || res.op == WRITE_TO_TUNNEL_IPV6) { - // Prepend 4-byte AF header (host byte order on macOS). const uint32_t af = (res.op == WRITE_TO_TUNNEL_IPV4) ? kAF_INET_HOST : kAF_INET6_HOST; struct iovec iov[2]; @@ -322,8 +278,15 @@ static void udpToTunThread(MacTunnelPriv *p) iov[1].iov_len = res.size; ::writev(p->tunFd, iov, 2); + if (!p->gotFirstPkt.exchange(true) && p->owner) { + const QString localIP = p->cfg.localIP(); + QPointer owner = p->owner; + QMetaObject::invokeMethod(owner.data(), [owner, localIP]() { + if (owner) emit owner->tunnelUp(localIP); + }, Qt::QueuedConnection); + } + } else if (res.op == WRITE_TO_NETWORK && res.size > 0) { - // boringtun needs to send a handshake response or keepalive ACK. ::send(p->udpFd, plain.data(), res.size, 0); } else if (res.op == WIREGUARD_ERROR) { @@ -332,30 +295,26 @@ static void udpToTunThread(MacTunnelPriv *p) } } -/// Ticker: drives boringtun's internal timer (keepalives, handshake retries). -/// Call every 100 ms. static void tickerThread(MacTunnelPriv *p) { std::vector buf(kBufSize + 32); - while (p->running) { - // Sleep 100 ms with early-exit via wake pipe. + while (p->running.load()) { fd_set rfds; FD_ZERO(&rfds); FD_SET(p->wakePipeR, &rfds); - struct timeval tv { 0, 100000 }; // 100 ms + struct timeval tv { 0, 100000 }; ::select(p->wakePipeR + 1, &rfds, nullptr, nullptr, &tv); - if (!p->running) break; - - wireguard_result res = wireguard_tick( - p->wg, buf.data(), static_cast(buf.size())); + if (!p->running.load()) break; + size_t bufLen = buf.size(); + wireguard_result res = wireguard_tick(p->wg, buf.data(), &bufLen); if (res.op == WRITE_TO_NETWORK && res.size > 0) ::send(p->udpFd, buf.data(), res.size, 0); } } -// ─── TunnelManager platform implementation ──────────────────────────────────── +// TunnelManager platform implementation TunnelManager::TunnelManager(QObject *parent) : QObject(parent) {} @@ -380,7 +339,7 @@ void TunnelManager::stop() m_running = false; platformStop(); m_localAddr.clear(); - emit disconnected(); + emit tunnelDown(); } bool TunnelManager::platformStart(const WireGuardConfig &cfg) @@ -388,14 +347,19 @@ bool TunnelManager::platformStart(const WireGuardConfig &cfg) auto *p = new MacTunnelPriv; m_priv = p; p->cfg = cfg; + p->owner = this; - // ── 1. Create boringtun instance ───────────────────────────────────── - p->wg = new_tunnel( - cfg.privateKey.toUtf8().constData(), - cfg.peerPublicKey.toUtf8().constData(), - cfg.presharedKey.isEmpty() ? nullptr : cfg.presharedKey.toUtf8().constData(), - cfg.persistentKeepalive, - /*index=*/0); + { + const QByteArray priv = cfg.privateKey.toUtf8(); + const QByteArray pubk = cfg.peerPublicKey.toUtf8(); + const QByteArray psk = cfg.presharedKey.toUtf8(); + p->wg = new_tunnel( + priv.constData(), + pubk.constData(), + psk.isEmpty() ? nullptr : psk.constData(), + cfg.persistentKeepalive, + /*log_level=*/1); + } if (!p->wg) { m_error = QStringLiteral("boringtun: failed to create tunnel (bad key material?)"); @@ -404,7 +368,6 @@ bool TunnelManager::platformStart(const WireGuardConfig &cfg) return false; } - // ── 2. Open utun ────────────────────────────────────────────────────── p->tunFd = openUtun(p->tunName); if (p->tunFd < 0) { m_error = QStringLiteral("Failed to open utun device"); @@ -415,7 +378,6 @@ bool TunnelManager::platformStart(const WireGuardConfig &cfg) } qDebug() << "[DragonVPN] Opened" << p->tunName; - // ── 3. Create UDP socket → WireGuard server ─────────────────────────── p->udpFd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (p->udpFd < 0) { m_error = QStringLiteral("Failed to create UDP socket: ") + strerror(errno); @@ -442,7 +404,7 @@ bool TunnelManager::platformStart(const WireGuardConfig &cfg) if (::connect(p->udpFd, reinterpret_cast(&serverAddr), sizeof(serverAddr)) < 0) { - m_error = QString("Failed to connect UDP socket to %1:%2 — %3") + m_error = QString("Failed to connect UDP socket to %1:%2 - %3") .arg(cfg.endpointHost()) .arg(cfg.endpointPort()) .arg(strerror(errno)); @@ -453,7 +415,6 @@ bool TunnelManager::platformStart(const WireGuardConfig &cfg) return false; } - // ── 4. Self-pipe for clean thread shutdown ──────────────────────────── int pipeFds[2]; if (::pipe(pipeFds) < 0) { m_error = QStringLiteral("Failed to create wake pipe"); @@ -466,39 +427,38 @@ bool TunnelManager::platformStart(const WireGuardConfig &cfg) p->wakePipeR = pipeFds[0]; p->wakePipeW = pipeFds[1]; - // ── 5. Configure routing (triggers macOS auth dialog once) ──────────── QString routeErr; if (!configureInterface(p, cfg, routeErr)) { qWarning() << "[DragonVPN] Route setup failed (non-fatal):" << routeErr; - // Non-fatal: the tunnel will still encrypt/decrypt; only routing is absent. - // The user can manually add routes or the dialog may have been dismissed. } m_localAddr = cfg.localIP(); - // ── 6. Start I/O threads ────────────────────────────────────────────── - p->running = true; - m_running = true; - p->thdTunToUdp = std::thread(tunToUdpThread, p); - p->thdUdpToTun = std::thread(udpToTunThread, p); - p->thdTicker = std::thread(tickerThread, p); + p->running = true; + m_running = true; + p->thdTunToUdp = std::thread(tunToUdpThread, p); + p->thdUdpToTun = std::thread(udpToTunThread, p); + p->thdTicker = std::thread(tickerThread, p); - // ── 7. Initiate first handshake ─────────────────────────────────────── { std::vector hsBuf(kBufSize + 32); + size_t hsBufLen = hsBuf.size(); wireguard_result res = wireguard_force_handshake( - p->wg, hsBuf.data(), static_cast(hsBuf.size())); + p->wg, hsBuf.data(), &hsBufLen); if (res.op == WRITE_TO_NETWORK && res.size > 0) ::send(p->udpFd, hsBuf.data(), res.size, 0); } - // Emit connected() after a short delay — in production, wire this to a - // "first packet received" callback from udpToTunThread instead. - QTimer::singleShot(800, this, [this]() { - if (m_running) emit connected(); + qDebug() << "[DragonVPN] Tunnel up, local IP" << m_localAddr; + + QPointer owner(this); + QTimer::singleShot(1500, this, [owner]() { + if (!owner || !owner->m_running) return; + auto *priv = static_cast(owner->m_priv); + if (priv && !priv->gotFirstPkt.load()) + emit owner->tunnelUp(owner->localAddress()); }); - qDebug() << "[DragonVPN] Tunnel up, local IP" << m_localAddr; return true; } @@ -509,20 +469,17 @@ void TunnelManager::platformStop() p->running = false; - // Unblock all select() calls by closing the write end of the wake pipe. if (p->wakePipeW >= 0) { ::close(p->wakePipeW); p->wakePipeW = -1; } - // Give threads a moment, then hard-close fds to unblock any lingering I/O. if (p->thdTunToUdp.joinable()) p->thdTunToUdp.join(); if (p->thdUdpToTun.joinable()) p->thdUdpToTun.join(); if (p->thdTicker.joinable()) p->thdTicker.join(); - // Tear down routing before closing the interface. teardownRoutes(p); if (p->wakePipeR >= 0) { ::close(p->wakePipeR); p->wakePipeR = -1; } if (p->udpFd >= 0) { ::close(p->udpFd); p->udpFd = -1; } - if (p->tunFd >= 0) { ::close(p->tunFd); p->tunFd = -1; } + if (p->tunFd >= 0) { ::close(p->tunFd); p->tunFd = -1; } if (p->wg) { tunnel_free(p->wg); p->wg = nullptr; }