dragonmoonlight/app/vpn/tunnelmanager_mac.mm

489 lines
15 KiB
Text

// tunnelmanager_mac.mm - macOS implementation of TunnelManager.
//
// 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 <Foundation/Foundation.h>
#include <sys/socket.h>
#include <sys/kern_control.h>
#include <sys/ioctl.h>
#include <sys/sys_domain.h>
#include <sys/select.h>
#include <sys/uio.h>
#include <net/if_utun.h>
#include <net/if.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <atomic>
#include <thread>
#include <vector>
#include <string>
#include <cstring>
#include <cerrno>
#include <QDebug>
#include <QHostInfo>
#include <QPointer>
#include <QTimer>
#include "tunnelmanager.h"
#include "wireguardconfig.h"
#include "boringtun_ffi.h"
static constexpr size_t kMTU = 1420;
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;
int udpFd = -1;
char tunName[IFNAMSIZ] = {};
int wakePipeR = -1;
int wakePipeW = -1;
std::atomic<bool> running {false};
std::atomic<bool> gotFirstPkt {false};
std::thread thdTunToUdp;
std::thread thdUdpToTun;
std::thread thdTicker;
WireGuardConfig cfg;
std::string savedGateway;
QPointer<TunnelManager> owner;
};
// Admin helper
static bool runAsAdmin(const std::string &shellCmd, QString &errOut)
{
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<const char *>(data.bytes),
static_cast<int>(data.length));
return false;
}
return true;
}
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);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' '))
out.pop_back();
return out;
}
// utun helpers
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;
if (::connect(fd, reinterpret_cast<struct sockaddr *>(&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
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();
p->savedGateway = runAsUser(
"route -n get default 2>/dev/null | awk '/gateway:/ { print $2; exit }'");
std::string script;
script += "ifconfig " + tun + " " + localIP + " " + localIP + " up";
script += " && ifconfig " + tun + " mtu " + std::to_string(kMTU);
if (!epHost.empty() && !p->savedGateway.empty()) {
script += " && route add -host " + epHost + " " + p->savedGateway;
}
for (const QString &cidr : cfg.allowedIPs) {
const std::string net = cidr.trimmed().toStdString();
if (net == "0.0.0.0/0") {
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);
}
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
static void tunToUdpThread(MacTunnelPriv *p)
{
std::vector<uint8_t> plain(kBufSize + 4);
std::vector<uint8_t> enc (kBufSize + 64);
while (p->running.load()) {
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.load() || FD_ISSET(p->wakePipeR, &rfds)) break;
ssize_t n = ::read(p->tunFd, plain.data(), plain.size());
if (n <= 4) continue;
const uint8_t *ipPkt = plain.data() + 4;
const size_t ipLen = static_cast<size_t>(n - 4);
size_t encLen = enc.size();
wireguard_result res = wireguard_write(
p->wg, ipPkt, ipLen, enc.data(), &encLen);
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";
}
}
static void udpToTunThread(MacTunnelPriv *p)
{
std::vector<uint8_t> enc (kBufSize + 64);
std::vector<uint8_t> plain(kBufSize);
while (p->running.load()) {
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.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<size_t>(n),
plain.data(), &plainLen);
if (res.op == WRITE_TO_TUNNEL_IPV4 || res.op == WRITE_TO_TUNNEL_IPV6) {
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<uint32_t *>(&af);
iov[0].iov_len = 4;
iov[1].iov_base = plain.data();
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<TunnelManager> 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) {
::send(p->udpFd, plain.data(), res.size, 0);
} else if (res.op == WIREGUARD_ERROR) {
qWarning() << "[DragonVPN] wireguard_read error";
}
}
}
static void tickerThread(MacTunnelPriv *p)
{
std::vector<uint8_t> buf(kBufSize + 32);
while (p->running.load()) {
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(p->wakePipeR, &rfds);
struct timeval tv { 0, 100000 };
::select(p->wakePipeR + 1, &rfds, nullptr, nullptr, &tv);
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::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 tunnelDown();
}
bool TunnelManager::platformStart(const WireGuardConfig &cfg)
{
auto *p = new MacTunnelPriv;
m_priv = p;
p->cfg = cfg;
p->owner = this;
{
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?)");
delete p; m_priv = nullptr;
emit tunnelError(m_error);
return false;
}
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;
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<struct sockaddr *>(&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;
}
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];
QString routeErr;
if (!configureInterface(p, cfg, routeErr)) {
qWarning() << "[DragonVPN] Route setup failed (non-fatal):" << routeErr;
}
m_localAddr = cfg.localIP();
p->running = true;
m_running = true;
p->thdTunToUdp = std::thread(tunToUdpThread, p);
p->thdUdpToTun = std::thread(udpToTunThread, p);
p->thdTicker = std::thread(tickerThread, p);
{
std::vector<uint8_t> hsBuf(kBufSize + 32);
size_t hsBufLen = hsBuf.size();
wireguard_result res = wireguard_force_handshake(
p->wg, hsBuf.data(), &hsBufLen);
if (res.op == WRITE_TO_NETWORK && res.size > 0)
::send(p->udpFd, hsBuf.data(), res.size, 0);
}
qDebug() << "[DragonVPN] Tunnel up, local IP" << m_localAddr;
QPointer<TunnelManager> owner(this);
QTimer::singleShot(1500, this, [owner]() {
if (!owner || !owner->m_running) return;
auto *priv = static_cast<MacTunnelPriv *>(owner->m_priv);
if (priv && !priv->gotFirstPkt.load())
emit owner->tunnelUp(owner->localAddress());
});
return true;
}
void TunnelManager::platformStop()
{
auto *p = static_cast<MacTunnelPriv *>(m_priv);
if (!p) return;
p->running = false;
if (p->wakePipeW >= 0) { ::close(p->wakePipeW); p->wakePipeW = -1; }
if (p->thdTunToUdp.joinable()) p->thdTunToUdp.join();
if (p->thdUdpToTun.joinable()) p->thdUdpToTun.join();
if (p->thdTicker.joinable()) p->thdTicker.join();
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";
}