From 1711a0bb5a1b55adf64e973f3a087b4a72c29091 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Wed, 6 May 2026 19:00:51 -0400 Subject: [PATCH] =?UTF-8?q?vpn:=20macOS=20TunnelManager=20=E2=80=94=20utun?= =?UTF-8?q?=20+=20boringtun,=20no=20root=20required=20for=20TUN=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/vpn/tunnelmanager_mac.mm | 532 +++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 app/vpn/tunnelmanager_mac.mm diff --git a/app/vpn/tunnelmanager_mac.mm b/app/vpn/tunnelmanager_mac.mm new file mode 100644 index 0000000..0b76aad --- /dev/null +++ b/app/vpn/tunnelmanager_mac.mm @@ -0,0 +1,532 @@ +// 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. + +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#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 ──────────────────────────────────────────────────────────── + +struct MacTunnelPriv { + wireguard_tunnel *wg = nullptr; + int tunFd = -1; ///< utun file descriptor + int udpFd = -1; ///< UDP socket connected to WG server + 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::thread thdTunToUdp; + std::thread thdUdpToTun; + std::thread thdTicker; + + // Saved for route cleanup + WireGuardConfig cfg; + std::string savedGateway; ///< Original default gateway IP string +}; + +// ─── 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) { + if (ch == '"' || ch == '\\') escaped += '\\'; + escaped += ch; + } + + NSString *script = [NSString stringWithFormat: + @"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]; + + @try { + [task launch]; + [task waitUntilExit]; + } @catch (NSException *e) { + errOut = QString::fromNSString(e.reason); + return false; + } + + if (task.terminationStatus != 0) { + NSData *data = [[errPipe fileHandleForReading] readDataToEndOfFile]; + errOut = QString::fromUtf8(reinterpret_cast(data.bytes), + static_cast(data.length)); + return false; + } + 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"); + if (!f) return {}; + char buf[512] = {}; + 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 ───────────────────────────────────────────────────────────── + +/// 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); + if (fd < 0) { qWarning() << "[DragonVPN] socket(PF_SYSTEM):" << strerror(errno); return -1; } + + struct ctl_info info {}; + ::strlcpy(info.ctl_name, UTUN_CONTROL_NAME, sizeof(info.ctl_name)); + if (::ioctl(fd, CTLIOCGINFO, &info) < 0) { + qWarning() << "[DragonVPN] ioctl(CTLIOCGINFO):" << strerror(errno); + ::close(fd); return -1; + } + + struct sockaddr_ctl addr {}; + addr.sc_len = sizeof(addr); + 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 + + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + qWarning() << "[DragonVPN] connect(utun):" << strerror(errno); + ::close(fd); return -1; + } + + socklen_t namelen = IFNAMSIZ; + if (::getsockopt(fd, SYSPROTO_CONTROL, UTUN_OPT_IFNAME, tunName, &namelen) < 0) + ::snprintf(tunName, IFNAMSIZ, "utun?"); + + return fd; +} + +// ─── 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(); + + // 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 { + script += " && route add -net " + net + " -interface " + tun; + } + } + + 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(); + QString ignored; + + std::string script; + bool first = true; + auto append = [&](const std::string &cmd) { + if (!first) script += " ; "; + script += cmd; + first = false; + }; + + if (!epHost.empty()) + append("route delete -host " + epHost); + + for (const QString &cidr : p->cfg.allowedIPs) { + const std::string net = cidr.trimmed().toStdString(); + if (net == "0.0.0.0/0") { + append("route delete -net 0.0.0.0/1"); + append("route delete -net 128.0.0.0/1"); + } else { + append("route delete -net " + net); + } + } + + if (!script.empty()) + runAsAdmin(script, ignored); +} + +// ─── 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 + + while (p->running) { + // Wait for data on tunFd or the wake pipe using select(). + fd_set rfds; + FD_ZERO(&rfds); + FD_SET(p->tunFd, &rfds); + FD_SET(p->wakePipeR, &rfds); + 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; + + ssize_t n = ::read(p->tunFd, plain.data(), plain.size()); + if (n <= 4) continue; // error or empty AF header + + // 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); + + wireguard_result res = wireguard_write( + p->wg, ipPkt, ipLen, + enc.data(), static_cast(enc.size())); + + if (res.op == WRITE_TO_NETWORK && res.size > 0) + ::send(p->udpFd, enc.data(), res.size, 0); + else if (res.op == WIREGUARD_ERROR) + qWarning() << "[DragonVPN] wireguard_write error"; + } +} + +/// 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 plain(kBufSize); + + while (p->running) { + fd_set rfds; + FD_ZERO(&rfds); + FD_SET(p->udpFd, &rfds); + FD_SET(p->wakePipeR, &rfds); + 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; + + ssize_t n = ::recv(p->udpFd, enc.data(), enc.size(), 0); + if (n <= 0) continue; + + wireguard_result res = wireguard_read( + p->wg, enc.data(), static_cast(n), + plain.data(), static_cast(plain.size())); + + 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]; + iov[0].iov_base = const_cast(&af); + iov[0].iov_len = 4; + iov[1].iov_base = plain.data(); + iov[1].iov_len = res.size; + ::writev(p->tunFd, iov, 2); + + } 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) { + qWarning() << "[DragonVPN] wireguard_read error"; + } + } +} + +/// 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. + fd_set rfds; + FD_ZERO(&rfds); + FD_SET(p->wakePipeR, &rfds); + struct timeval tv { 0, 100000 }; // 100 ms + ::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 (res.op == WRITE_TO_NETWORK && res.size > 0) + ::send(p->udpFd, buf.data(), res.size, 0); + } +} + +// ─── TunnelManager platform implementation ──────────────────────────────────── + +TunnelManager::TunnelManager(QObject *parent) : QObject(parent) {} + +TunnelManager::~TunnelManager() { stop(); } + +bool TunnelManager::start(const WireGuardConfig &cfg) +{ + if (!cfg.isValid()) { + m_error = QStringLiteral("Invalid WireGuard configuration"); + emit tunnelError(m_error); + return false; + } + + if (m_running) stop(); + + return platformStart(cfg); +} + +void TunnelManager::stop() +{ + if (!m_running) return; + m_running = false; + platformStop(); + m_localAddr.clear(); + emit disconnected(); +} + +bool TunnelManager::platformStart(const WireGuardConfig &cfg) +{ + auto *p = new MacTunnelPriv; + m_priv = p; + p->cfg = cfg; + + // ── 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); + + if (!p->wg) { + m_error = QStringLiteral("boringtun: failed to create tunnel (bad key material?)"); + delete p; m_priv = nullptr; + emit tunnelError(m_error); + return false; + } + + // ── 2. Open utun ────────────────────────────────────────────────────── + p->tunFd = openUtun(p->tunName); + if (p->tunFd < 0) { + m_error = QStringLiteral("Failed to open utun device"); + tunnel_free(p->wg); + delete p; m_priv = nullptr; + emit tunnelError(m_error); + return false; + } + 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); + ::close(p->tunFd); + tunnel_free(p->wg); + delete p; m_priv = nullptr; + emit tunnelError(m_error); + return false; + } + + struct sockaddr_in serverAddr {}; + serverAddr.sin_family = AF_INET; + serverAddr.sin_port = htons(cfg.endpointPort()); + if (::inet_pton(AF_INET, cfg.endpointHost().toUtf8().constData(), + &serverAddr.sin_addr) != 1) { + m_error = QStringLiteral("Invalid endpoint host: ") + cfg.endpointHost(); + ::close(p->udpFd); ::close(p->tunFd); + tunnel_free(p->wg); + delete p; m_priv = nullptr; + emit tunnelError(m_error); + return false; + } + + if (::connect(p->udpFd, + reinterpret_cast(&serverAddr), + sizeof(serverAddr)) < 0) { + m_error = QString("Failed to connect UDP socket to %1:%2 — %3") + .arg(cfg.endpointHost()) + .arg(cfg.endpointPort()) + .arg(strerror(errno)); + ::close(p->udpFd); ::close(p->tunFd); + tunnel_free(p->wg); + delete p; m_priv = nullptr; + emit tunnelError(m_error); + return false; + } + + // ── 4. Self-pipe for clean thread shutdown ──────────────────────────── + int pipeFds[2]; + if (::pipe(pipeFds) < 0) { + m_error = QStringLiteral("Failed to create wake pipe"); + ::close(p->udpFd); ::close(p->tunFd); + tunnel_free(p->wg); + delete p; m_priv = nullptr; + emit tunnelError(m_error); + return false; + } + 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); + + // ── 7. Initiate first handshake ─────────────────────────────────────── + { + std::vector hsBuf(kBufSize + 32); + wireguard_result res = wireguard_force_handshake( + p->wg, hsBuf.data(), static_cast(hsBuf.size())); + 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; + return true; +} + +void TunnelManager::platformStop() +{ + auto *p = static_cast(m_priv); + if (!p) return; + + 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->wg) { tunnel_free(p->wg); p->wg = nullptr; } + + delete p; + m_priv = nullptr; + qDebug() << "[DragonVPN] Tunnel stopped"; +}