// 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"; }