Files
Frametop--frametop/stream/host.cpp
T
DeeJanuzandClaude Opus 5.5 9240dcc9d3 ft-stream: unpair over HTTPS, and say when the host kept the client
gs_unpair asks over plain HTTP. Vibepollo answers that with 200 but only
removes a client that asks over HTTPS with its own certificate, so
"ft-layout remote remove" said "unpair: done" and the client stayed
paired (dody still had frametop-1..3, 2026-10-21). Unpair now asks over
HTTPS and reads the host's <unpaired>: "the host kept this client" when
it says 0, "not paired" when there's nothing to remove. A host that
doesn't say counts as done, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:23:28 -06:00

431 lines
19 KiB
C++

// See host.h.
#include "host.h"
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#include <curl/curl.h>
#include <openssl/evp.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <random>
#include <thread>
#include <vector>
extern "C" {
void http_cleanup(void); // in http.c, not in http.h
}
namespace ftstream {
namespace {
// What a Frametop client may do on a Vibepollo host: list apps, view and launch them, and
// send mouse and keyboard input (crypto::PERM in Vibepollo's crypto.h).
constexpr uint32_t kPermMouse = 1u << 11, kPermKeyboard = 1u << 12;
constexpr uint32_t kPermList = 1u << 24, kPermView = 1u << 25, kPermLaunch = 1u << 26;
constexpr uint32_t kFrametopPerm = kPermList | kPermView | kPermLaunch | kPermMouse | kPermKeyboard;
constexpr const char *kNoId = "0123456789ABCDEF"; // libgamestream's id without uniqueid.dat
// Percent-encodes a query value (device ids are {GUID}, display names \\.\DISPLAYn).
std::string UrlEncode(const std::string &v) {
std::string out;
for (unsigned char c : v) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out += char(c);
else {
char b[4];
std::snprintf(b, sizeof b, "%%%02X", c);
out += b;
}
}
return out;
}
std::string ReadUniqueId(const std::string &dir) {
char buf[17];
std::strcpy(buf, kNoId);
FILE *f = std::fopen((dir + "/uniqueid.dat").c_str(), "rb");
if (f) {
if (std::fread(buf, 1, 16, f) != 16) std::strcpy(buf, kNoId);
std::fclose(f);
}
return buf;
}
// libgamestream gives every client the same id unless uniqueid.dat exists; hosts track
// pairing by it, so each client gets a random one first.
void NewUniqueId(const std::string &dir) {
if (ReadUniqueId(dir) != kNoId) return;
for (size_t at = dir.find('/', 1);; at = dir.find('/', at + 1)) {
mkdir(dir.substr(0, at).c_str(), 0700);
if (at == std::string::npos) break;
}
std::random_device rd;
char id[17];
std::snprintf(id, sizeof id, "%08X%08X", rd(), rd());
FILE *f = std::fopen((dir + "/uniqueid.dat").c_str(), "wb");
if (f) std::fwrite(id, 1, 16, f), std::fclose(f);
}
// The attribute `name` of the response's root element.
std::string Attribute(const char *xml, size_t len, const char *name) {
const std::string s(xml, len), key = std::string(name) + "=\"";
const size_t at = s.find(key);
if (at == std::string::npos) return "";
const size_t end = s.find('"', at + key.size());
return s.substr(at + key.size(), end - at - key.size());
}
size_t Append(char *data, size_t size, size_t n, void *out) {
static_cast<std::string *>(out)->append(data, size * n);
return size * n;
}
std::string Base64(const std::string &v) {
std::string out(4 * ((v.size() + 2) / 3) + 1, '\0');
const int n = EVP_EncodeBlock(reinterpret_cast<unsigned char *>(&out[0]),
reinterpret_cast<const unsigned char *>(v.data()), int(v.size()));
out.resize(size_t(n));
return out;
}
// This end's address toward host:port, as the host sees it (a UDP connect sends nothing).
std::string LocalAddress(const std::string &host, int port) {
addrinfo hints{}, *res = nullptr;
hints.ai_socktype = SOCK_DGRAM;
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0) return "";
std::string out;
const int s = socket(res->ai_family, SOCK_DGRAM | SOCK_CLOEXEC, 0);
sockaddr_storage me{};
socklen_t len = sizeof me;
char text[INET6_ADDRSTRLEN] = "";
if (s >= 0 && connect(s, res->ai_addr, res->ai_addrlen) == 0 &&
getsockname(s, reinterpret_cast<sockaddr *>(&me), &len) == 0 &&
getnameinfo(reinterpret_cast<sockaddr *>(&me), len, text, sizeof text, nullptr, 0, NI_NUMERICHOST) == 0)
out = text;
if (s >= 0) close(s);
freeaddrinfo(res);
return out;
}
} // namespace
std::string KeyDir(const std::string &id) {
const char *home = std::getenv("HOME");
return std::string(home ? home : ".") + "/.local/share/frametop-stream/" + id;
}
std::string TokenFile(const std::string &host) { return KeyDir("hosts") + "/" + host + ".token"; }
bool SplitPort(std::string *host, int *port) {
*port = 0;
const size_t colon = host->rfind(':');
// One colon: HOST:PORT. More are an IPv6 address, with a port only as [ADDRESS]:PORT.
if (colon == std::string::npos || (host->find(':') != colon && (*host)[0] != '[')) return true;
const std::string digits = host->substr(colon + 1);
if (digits.empty() || digits.size() > 5 || !std::all_of(digits.begin(), digits.end(), ::isdigit)) return false;
*port = std::atoi(digits.c_str());
host->resize(colon);
if (host->size() > 1 && (*host)[0] == '[' && host->back() == ']') *host = host->substr(1, host->size() - 2);
return *port > 0 && *port < 65535;
}
bool WebApi::Load(const std::string &host, const std::string &file) {
std::ifstream f(file);
if (!(f >> token)) return false;
base = "https://" + host + ":" + std::to_string(kGameStreamPort + 1);
// The Web UI's public key as Remote Displays saw it when signing in (HOST.pin next to
// HOST.token): another one is refused. Without one, any (a host from make-frametop-token).
const std::string pinFile = file.size() > 6 && file.compare(file.size() - 6, 6, ".token") == 0
? file.substr(0, file.size() - 6) + ".pin" : "";
std::ifstream p(pinFile);
if (!(p >> pin)) pin.clear();
return true;
}
bool WebApi::LoadLogin(const std::string &host, int port, FILE *in) {
char user[256], pass[256];
if (!std::fgets(user, sizeof user, in) || !std::fgets(pass, sizeof pass, in)) return false;
user[std::strcspn(user, "\r\n")] = pass[std::strcspn(pass, "\r\n")] = '\0';
if (!*user) return false;
login = std::string(user) + ":" + pass;
std::memset(pass, 0, sizeof pass);
base = "https://" + host + ":" + std::to_string((port ? port : kGameStreamPort) + 1);
// Every instance on a host shares the Web UI certificate Remote Displays pinned (the Mac's
// host setup gives them one), so one pin per address.
std::ifstream p(KeyDir("hosts") + "/" + host + ".pin");
if (!(p >> pin)) pin.clear();
return true;
}
long WebApi::Call(const char *method, const std::string &path, const nlohmann::json *body, nlohmann::json *out) {
CURL *c = curl_easy_init();
if (!c) return -1;
std::string text;
const std::string payload = body ? body->dump() : "";
curl_slist *headers = curl_slist_append(
nullptr, (login.empty() ? "Authorization: Bearer " + token : "Authorization: Basic " + Base64(login)).c_str());
if (body) headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(c, CURLOPT_URL, (base + path).c_str());
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, method);
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
if (body) curl_easy_setopt(c, CURLOPT_POSTFIELDS, payload.c_str());
// The Web UI's certificate is self-signed: its public key is pinned instead (Load).
curl_easy_setopt(c, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(c, CURLOPT_SSL_VERIFYHOST, 0L);
if (!pin.empty()) curl_easy_setopt(c, CURLOPT_PINNEDPUBLICKEY, pin.c_str());
curl_easy_setopt(c, CURLOPT_TIMEOUT, 15L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, Append);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &text);
long code = -1;
if (curl_easy_perform(c) == CURLE_OK) curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(headers);
curl_easy_cleanup(c);
*out = nlohmann::json::parse(text, nullptr, false);
return code;
}
bool WebApi::Clients(std::map<std::string, std::pair<std::string, uint32_t>> *clients) {
nlohmann::json r;
const long code = Call("GET", "/api/clients/list", nullptr, &r);
if (code != 200 || !r.is_object() || !r.contains("named_certs"))
return std::fprintf(stderr, "web api: clients/list: %ld\n", code), false;
for (const auto &c : r["named_certs"]) (*clients)[c.value("uuid", "")] = {c.value("name", ""), c.value("perm", 0u)};
return true;
}
// Vibepollo's update replaces the whole record, so every field it takes is given.
bool WebApi::Grant(const std::string &uuid, const std::string &name) {
const nlohmann::json body = {{"uuid", uuid},
{"name", name},
{"perm", kFrametopPerm},
{"display_mode", ""},
{"output_name_override", ""},
{"virtual_display_mode", ""},
{"virtual_display_layout", ""},
{"always_use_virtual_display", false},
{"enable_legacy_ordering", true},
{"allow_client_commands", false},
{"do", nlohmann::json::array()},
{"undo", nlohmann::json::array()}};
nlohmann::json r;
const long code = Call("POST", "/api/clients/update", &body, &r);
if (code != 200 || !r.is_object() || !r.value("status", false))
return std::fprintf(stderr, "web api: clients/update: %ld\n", code), false;
return true;
}
// libgamestream keeps one curl handle whose connections stay open (it forbids reuse only
// on FreeBSD). Vibepollo closes an HTTPS connection with a blocking TLS shutdown on its only
// HTTPS thread, so a connection left idle here freezes its API for every client until this
// process exits. So every request is followed by dropping the handle, and with it the
// connection.
void Client::DropConnection() {
http_cleanup();
http_init(dir.c_str(), 0);
}
bool Client::Connect(const std::string &h, const std::string &name, int httpPort) {
host = h, id = name, dir = KeyDir(id), port = httpPort;
const mode_t old = umask(077); // the client's private key
NewUniqueId(dir);
const int r = gs_init(&server, host.data(), (unsigned short)port, dir.c_str(), 0, true);
umask(old);
if (r != GS_OK) return std::fprintf(stderr, "can't reach %s: %s\n", host.c_str(), gs_error ? gs_error : "?"), false;
DropConnection();
uniqueId = ReadUniqueId(dir);
return true;
}
bool Client::Pair(WebApi *api) {
std::random_device rd;
char pin[5];
std::snprintf(pin, sizeof pin, "%04u", rd() % 10000);
if (!api) {
std::printf("PIN %s: enter it in the host's Web UI (PIN pairing), with the device name %s\n", pin, id.c_str());
std::fflush(stdout);
if (gs_pair(&server, pin) != GS_OK) return std::fprintf(stderr, "pairing failed: %s\n", gs_error ? gs_error : "?"), false;
std::printf("paired. Now allow it to launch apps and use the mouse and keyboard in the Web UI (Clients).\n");
return true;
}
if (!api->login.empty()) return PairByLogin(*api, pin);
std::map<std::string, std::pair<std::string, uint32_t>> before, after;
if (!api->Clients(&before)) return false;
// gs_pair waits for the PIN, so it runs beside the request that gives it.
std::atomic<int> result{GS_FAILED};
std::atomic<bool> done{false};
std::thread pairing([&] { result = gs_pair(&server, pin), done = true; });
bool given = false;
for (int i = 0; i < 40 && !given && !done; ++i) {
usleep(250'000);
const nlohmann::json body = {{"pin", pin}, {"name", id}};
nlohmann::json r;
given = api->Call("POST", "/api/pin", &body, &r) == 200 && r.is_object() && r.value("status", false);
}
if (!given) {
std::fprintf(stderr, "the host took no PIN for this pairing (token scopes?)\n");
std::fflush(stderr);
std::_Exit(1); // gs_pair is still waiting
}
pairing.join();
DropConnection();
if (result != GS_OK) return std::fprintf(stderr, "pairing failed: %s\n", gs_error ? gs_error : "?"), false;
if (!api->Clients(&after)) return false;
std::string uuid;
for (const auto &[u, c] : after)
if (!before.count(u) && c.first == id) uuid = u;
if (uuid.empty()) return std::fprintf(stderr, "paired, but the host lists no new client named %s\n", id.c_str()), false;
if (!api->Grant(uuid, id)) return false;
std::printf("paired as %s (host id %s), allowed to launch and to use the mouse and keyboard\n", id.c_str(), uuid.c_str());
return true;
}
// Sunshine: gs_pair's request waits on the host until it's approved with its PIN. The host
// lists waiting requests with an id (GET /api/pin); this one is libgamestream's device name
// from this end's address.
bool Client::PairByLogin(WebApi &api, char *pin) {
// The login first: a request left waiting by a failed try blocks this client's next one
// until the host drops it.
nlohmann::json r;
const long first = api.Call("GET", "/api/pin", nullptr, &r);
if (first == 401) return std::fprintf(stderr, "wrong user name or password\n"), false;
if (first != 200) return std::fprintf(stderr, "the host's Web UI answered %ld\n", first), false;
std::atomic<int> result{GS_FAILED};
std::atomic<bool> done{false};
std::thread pairing([&] { result = gs_pair(&server, pin), done = true; });
const std::string me = LocalAddress(host, port ? port : kGameStreamPort);
bool given = false;
long code = 0;
for (int i = 0; i < 40 && !given && !done; ++i) {
usleep(250'000);
code = api.Call("GET", "/api/pin", nullptr, &r);
if (code != 200 || !r.is_object() || !r.contains("pairings") || !r["pairings"].is_array()) continue;
std::vector<std::string> ours, mine;
for (const auto &p : r["pairings"]) {
if (!p.is_object() || p.value("name", "") != "roth") continue; // libgamestream's devicename
ours.push_back(p.value("id", ""));
if (p.value("address", "") == me) mine.push_back(p.value("id", ""));
}
const std::vector<std::string> &pick = mine.size() == 1 ? mine : ours;
if (pick.size() != 1) continue;
const nlohmann::json body = {{"pairing_id", pick[0]}, {"pin", pin}, {"name", id}};
given = api.Call("POST", "/api/pin", &body, &r) == 200 && r.is_object() && r.value("status", false);
}
if (!given && done) { // the host turned the request down before it could be approved
pairing.join();
DropConnection();
return std::fprintf(stderr, "pairing failed: %s\n", gs_error ? gs_error : "?"), false;
}
if (!given) {
std::fprintf(stderr, "the host showed no pairing request to approve (%ld)\n", code);
std::fflush(stderr);
std::_Exit(1); // gs_pair is still waiting
}
pairing.join();
DropConnection();
if (result != GS_OK) return std::fprintf(stderr, "pairing failed: %s\n", gs_error ? gs_error : "?"), false;
std::printf("paired as %s\n", id.c_str());
return true;
}
bool Client::GrantByName(WebApi &api) {
std::map<std::string, std::pair<std::string, uint32_t>> clients;
if (!api.Clients(&clients)) return false;
std::vector<std::string> named;
for (const auto &[u, c] : clients)
if (c.first == id) named.push_back(u);
if (named.size() != 1) return std::fprintf(stderr, "the host has %zu clients named %s\n", named.size(), id.c_str()), false;
if (!api.Grant(named[0], id)) return false;
std::printf("%s may now launch and use the mouse and keyboard (perm was %u)\n", id.c_str(), clients[named[0]].second);
return true;
}
// Over HTTPS with this client's certificate: Vibepollo removes a client only when it asks itself
// that way. gs_unpair's plain-HTTP request got a 200 and the client stayed paired (2026-10-21).
// A host that doesn't say whether it did ("unpaired") counts as done, as before.
bool Client::Unpair() {
if (!server.paired) return std::printf("unpair: not paired\n"), true;
std::random_device rd;
char url[4096];
std::snprintf(url, sizeof url, "https://%s:%u/unpair?uniqueid=%s&uuid=%08x-0000-4000-8000-%012x",
server.serverInfo.address, server.httpsPort, uniqueId.c_str(), rd(), rd());
PHTTP_DATA data = http_create_data();
if (!data) return false;
bool ok = false;
std::string why = "failed";
if (http_request(url, data) == GS_OK) {
char *unpaired = nullptr;
ok = xml_search(data->memory, data->size, const_cast<char *>("unpaired"), &unpaired) != GS_OK ||
std::strcmp(unpaired, "1") == 0;
if (!ok) why = "the host kept this client";
std::free(unpaired);
} else if (gs_error) {
why = gs_error;
}
http_free_data(data);
DropConnection();
std::printf("unpair: %s\n", ok ? "done" : why.c_str());
return ok;
}
int Client::FindApp(const std::string &want) {
if (want == "monitor") return kRemoteMonitor;
if (want.rfind("display:", 0) == 0) return display = want.substr(8), kFrametopDisplay;
if (!want.empty() && std::all_of(want.begin(), want.end(), ::isdigit)) return std::atoi(want.c_str());
const std::string name = want == "primary" ? kPrimaryApp : want;
PAPP_LIST list = nullptr;
const int listed = gs_applist(&server, &list);
DropConnection();
if (listed != GS_OK) return std::fprintf(stderr, "can't list the host's apps: %s\n", gs_error), 0;
for (PAPP_LIST a = list; a; a = a->next)
if (name == a->name) return a->id;
std::fprintf(stderr, "the host has no app named \"%s\"\n", name.c_str());
return 0;
}
// As gs_start_app does it, but without its mode check and with the status code kept.
int Client::Launch(STREAM_CONFIGURATION &cfg, int appId, bool resume, std::string *message) {
std::random_device rd;
for (auto &b : cfg.remoteInputAesKey) b = char(rd());
std::memset(cfg.remoteInputAesIv, 0, sizeof cfg.remoteInputAesIv);
uint32_t rikeyid = rd();
std::memcpy(cfg.remoteInputAesIv, &rikeyid, sizeof rikeyid);
char rikey[33];
for (int i = 0; i < 16; ++i) std::snprintf(rikey + 2 * i, 3, "%02x", uint8_t(cfg.remoteInputAesKey[i]));
const std::string extra = appId == kFrametopDisplay ? "&frametopDisplay=" + UrlEncode(display) : "";
char url[4096];
std::snprintf(url, sizeof url,
"https://%s:%u/%s?uniqueid=%s&uuid=%08x-0000-4000-8000-%012x&appid=%d&mode=%dx%dx%d&additionalStates=1"
"&sops=0&rikey=%s&rikeyid=%d&localAudioPlayMode=1&surroundAudioInfo=%d&remoteControllersBitmap=0&gcmap=0%s%s",
server.serverInfo.address, server.httpsPort, resume ? "resume" : "launch", uniqueId.c_str(), rd(), rd(),
appId, cfg.width, cfg.height, cfg.fps, rikey, int(htonl(rikeyid)),
SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(cfg.audioConfiguration), LiGetLaunchUrlQueryParameters(),
extra.c_str());
PHTTP_DATA data = http_create_data();
if (!data) return -1;
int code = -1;
if (http_request(url, data) == GS_OK) {
const std::string c = Attribute(data->memory, data->size, "status_code");
code = c.empty() ? -1 : std::atoi(c.c_str());
*message = Attribute(data->memory, data->size, "status_message");
char *session = nullptr;
if (code == 200 && xml_search(data->memory, data->size, const_cast<char *>("sessionUrl0"), &session) == GS_OK)
server.serverInfo.rtspSessionUrl = session;
} else {
*message = gs_error ? gs_error : "request failed";
}
http_free_data(data);
DropConnection();
return code;
}
} // namespace ftstream