diff --git a/app/vpn/relayclient.cpp b/app/vpn/relayclient.cpp new file mode 100644 index 0000000..70914b0 --- /dev/null +++ b/app/vpn/relayclient.cpp @@ -0,0 +1,265 @@ +// relayclient.cpp + +#include "relayclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +RelayClient::RelayClient(QObject *parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) + , m_settings(new QSettings(QStringLiteral("WildDragon"), QStringLiteral("DragonMoonlight"), this)) +{ +} + +RelayClient::~RelayClient() = default; + +// ─── URL helpers ────────────────────────────────────────────────────────────── + +QUrl RelayClient::apiUrl(const QString &path) const +{ + QUrl u = m_relayUrl; + u.setPath(u.path() + path); + return u; +} + +// ─── Auth-header injection ──────────────────────────────────────────────────── + +QNetworkReply *RelayClient::authedGet(const QString &path) +{ + QNetworkRequest req(apiUrl(path)); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + m_jwt.toUtf8()); + return m_nam->get(req); +} + +QNetworkReply *RelayClient::authedPost(const QString &path, const QByteArray &body) +{ + QNetworkRequest req(apiUrl(path)); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + m_jwt.toUtf8()); + req.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json")); + return m_nam->post(req, body); +} + +QNetworkReply *RelayClient::authedDelete(const QString &path) +{ + QNetworkRequest req(apiUrl(path)); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + m_jwt.toUtf8()); + return m_nam->deleteResource(req); +} + +// ─── login() ───────────────────────────────────────────────────────────────── + +void RelayClient::login(const QUrl &relayUrl, const QString &username, const QString &password) +{ + m_relayUrl = relayUrl; + m_username = username; + m_jwt.clear(); + + QNetworkRequest req(apiUrl(QStringLiteral("/api/auth/login"))); + req.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json")); + + QJsonObject body; + body[QStringLiteral("username")] = username; + body[QStringLiteral("password")] = password; + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + + QNetworkReply *reply = m_nam->post(req, payload); + connect(reply, &QNetworkReply::finished, this, [this, reply, username, password]() { + reply->deleteLater(); + onLoginReply(reply, username, password); + }); +} + +void RelayClient::onLoginReply(QNetworkReply *reply, const QString &username, + const QString & /*password*/) +{ + if (reply->error() != QNetworkReply::NoError) { + emit loginFailed(reply->errorString()); + return; + } + + const QJsonObject json = QJsonDocument::fromJson(reply->readAll()).object(); + const QString token = json[QStringLiteral("token")].toString(); + if (token.isEmpty()) { + const QString err = json[QStringLiteral("error")].toString(QStringLiteral("Unknown error")); + emit loginFailed(err); + return; + } + + m_jwt = token; + m_username = username; + persistJwt(); + emit loginSucceeded(); +} + +// ─── provisionVPN() ─────────────────────────────────────────────────────────── + +void RelayClient::provisionVPN(const QString &deviceName) +{ + // Use the machine's host name if the caller didn't provide one. + const QString device = deviceName.isEmpty() + ? QSysInfo::machineHostName() + : deviceName; + + QJsonObject body; + body[QStringLiteral("device_name")] = device; + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + + QNetworkReply *reply = authedPost(QStringLiteral("/api/vpn/peer"), payload); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + onProvisionReply(reply); + }); +} + +void RelayClient::onProvisionReply(QNetworkReply *reply) +{ + if (reply->error() != QNetworkReply::NoError) { + emit vpnProvisionFailed(reply->errorString()); + return; + } + + const QJsonObject json = QJsonDocument::fromJson(reply->readAll()).object(); + const QString conf = json[QStringLiteral("conf")].toString(); + if (conf.isEmpty()) { + const QString err = json[QStringLiteral("error")].toString(QStringLiteral("No conf in response")); + emit vpnProvisionFailed(err); + return; + } + + m_vpnPeerId = json[QStringLiteral("id")].toInt(-1); + + // Persist peer ID so we can revoke it even after a restart. + m_settings->setValue(QStringLiteral("vpn/peer_id"), m_vpnPeerId); + m_settings->setValue(QStringLiteral("vpn/relay_url"), m_relayUrl.toString()); + + WireGuardConfig cfg = WireGuardConfig::fromConf(conf); + if (!cfg.isValid()) { + emit vpnProvisionFailed(QStringLiteral("Received malformed .conf from server")); + return; + } + + emit vpnProvisioned(cfg); +} + +// ─── revokeVPN() ───────────────────────────────────────────────────────────── + +void RelayClient::revokeVPN() +{ + if (m_vpnPeerId < 0) { + // Try to load from settings (post-restart cleanup). + m_vpnPeerId = m_settings->value(QStringLiteral("vpn/peer_id"), -1).toInt(); + } + if (m_vpnPeerId < 0) { + emit vpnRevoked(); // nothing to do + return; + } + + const QString path = QStringLiteral("/api/vpn/peer/") + QString::number(m_vpnPeerId); + QNetworkReply *reply = authedDelete(path); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + onRevokeReply(reply); + }); +} + +void RelayClient::onRevokeReply(QNetworkReply *reply) +{ + if (reply->error() != QNetworkReply::NoError) + qWarning() << "[RelayClient] revokeVPN error:" << reply->errorString(); + + m_vpnPeerId = -1; + m_settings->remove(QStringLiteral("vpn/peer_id")); + emit vpnRevoked(); +} + +// ─── fetchHosts() ───────────────────────────────────────────────────────────── + +void RelayClient::fetchHosts() +{ + QNetworkReply *reply = authedGet(QStringLiteral("/api/hosts")); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + onHostsReply(reply); + }); +} + +void RelayClient::onHostsReply(QNetworkReply *reply) +{ + if (reply->error() != QNetworkReply::NoError) { + emit hostsFetchFailed(reply->errorString()); + return; + } + + QList hosts; + const QJsonArray arr = QJsonDocument::fromJson(reply->readAll()).array(); + for (const QJsonValue &v : arr) { + const QJsonObject obj = v.toObject(); + RelayHost h; + h.name = obj[QStringLiteral("name")].toString(); + h.ip = obj[QStringLiteral("ip")].toString(); + h.port = obj[QStringLiteral("port")].toInt(47984); + if (!h.ip.isEmpty()) + hosts << h; + } + emit hostsReady(hosts); +} + +// ─── Persistence ────────────────────────────────────────────────────────────── + +void RelayClient::persistJwt() +{ + // NOTE: QSettings on macOS stores in ~/Library/Preferences. + // The JWT has a limited lifetime — don't rely on it across sessions; + // the app will re-authenticate on next launch using saved credentials. + m_settings->setValue(QStringLiteral("auth/relay_url"), m_relayUrl.toString()); + m_settings->setValue(QStringLiteral("auth/username"), m_username); + // We do NOT persist the JWT itself or the password. +} + +void RelayClient::loadJwt() +{ + // Intentionally empty — re-auth on each launch for security. +} + +void RelayClient::saveServer(const QString &label, const QUrl &url, + const QString &username, const QString &password) +{ + m_settings->beginGroup(QStringLiteral("servers/") + label); + m_settings->setValue(QStringLiteral("url"), url.toString()); + m_settings->setValue(QStringLiteral("username"), username); + // password stored in the system keychain would be better; plain QSettings + // as a baseline — replace with QKeychain in production. + m_settings->setValue(QStringLiteral("password"), password); + m_settings->endGroup(); +} + +QList RelayClient::savedServers() const +{ + QList list; + const QStringList groups = m_settings->childGroups(); + for (const QString &g : groups) { + if (!g.startsWith(QStringLiteral("servers/"))) continue; + const QString label = g.mid(8); + m_settings->beginGroup(g); + SavedServer s; + s.label = label; + s.url = QUrl(m_settings->value(QStringLiteral("url")).toString()); + s.username = m_settings->value(QStringLiteral("username")).toString(); + s.password = m_settings->value(QStringLiteral("password")).toString(); + m_settings->endGroup(); + list << s; + } + return list; +} + +void RelayClient::removeServer(const QString &label) +{ + m_settings->remove(QStringLiteral("servers/") + label); +}