// Copyright (c) 2024 Robin Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of // the Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #include "Updater.hpp" #include "util.hpp" #include "json.hpp" #include "config.hpp" #include #include #include #include #include #include "Lv2Log.hpp" #include "SysExec.hpp" #include "json_variant.hpp" #include "ss.hpp" #include "Lv2Log.hpp" #include #include "UpdaterSecurity.hpp" #include "SysExec.hpp" #include "ofstream_synced.hpp" #include #include "TemporaryFile.hpp" #include using namespace pipedal; namespace fs = std::filesystem; #undef TEST_UPDATE #ifndef DEBUG #undef TEST_UPDATE // do NOT leat this leak into a production build! #endif #include "GithubResponseHeaders.hpp" class pipedal::UpdaterImpl : public Updater { public: UpdaterImpl(const std::filesystem::path &workingDirectory); UpdaterImpl() : UpdaterImpl("/var/pipedal/updates") {} virtual ~UpdaterImpl() noexcept; virtual void SetUpdateListener(UpdateListener &&listener) override; virtual void Start() override; virtual void Stop() override; virtual void CheckNow() override; virtual UpdatePolicyT GetUpdatePolicy() override; virtual void SetUpdatePolicy(UpdatePolicyT updatePolicy) override; virtual void ForceUpdateCheck() override; virtual void DownloadUpdate(const std::string &url, std::filesystem::path *file, std::filesystem::path *signatureFile) override; virtual UpdateStatus GetCurrentStatus() const override; virtual UpdateStatus GetReleaseGeneratorStatus() override; private: std::filesystem::path workingDirectory; std::filesystem::path updateStatusCacheFile; std::filesystem::path githubResponseHeaderFilename; GithubResponseHeaders githubResponseHeaders; using clock = std::chrono::steady_clock; void SetCachedUpdateStatus(UpdateStatus &updateStatus); UpdateStatus GetCachedUpdateStatus(); void RetryAfter(clock::duration delay); template void RetryAfter(std::chrono::duration duration) { RetryAfter(std::chrono::duration_cast(duration)); } void SaveRetryTime(const std::chrono::system_clock::time_point &time); clock::time_point LoadRetryTime(); std::string GetUpdateFilename(const std::string &url); std::string GetSignatureUrl(const std::string &url); UpdatePolicyT updatePolicy = UpdatePolicyT::ReleaseOrBeta; using UpdateReleasePredicate = std::function; UpdateRelease getUpdateRelease( const std::vector &githubReleases, const std::string ¤tVersion, const UpdateReleasePredicate &predicate); UpdateStatus cachedUpdateStatus; bool stopped = false; int event_reader = -1; int event_writer = -1; void ThreadProc(); UpdateStatus DoUpdate(bool forReleaseGenerator); void CheckForUpdate(bool useCache); UpdateListener listener; std::unique_ptr thread; std::mutex mutex; bool hasInfo = false; UpdateStatus currentResult; static clock::duration updateRate; clock::time_point updateRetryTime; }; Updater::ptr Updater::Create() { return std::make_unique(); } Updater::ptr Updater::Create(const std::filesystem::path &workingDirectory) { return std::make_unique(workingDirectory); } static constexpr uint64_t CLOSE_EVENT = 0; static constexpr uint64_t CHECK_NOW_EVENT = 1; static constexpr uint64_t UNCACHED_CHECK_NOW_EVENT = 2; UpdaterImpl::clock::duration UpdaterImpl::updateRate = std::chrono::duration_cast(std::chrono::days(1)); static std::chrono::system_clock::duration CACHE_DURATION = std::chrono::duration_cast(std::chrono::minutes(30)); std::mutex cacheMutex; UpdateStatus UpdaterImpl::GetCachedUpdateStatus() { std::lock_guard lock{cacheMutex}; try { if (std::filesystem::exists(updateStatusCacheFile)) { std::ifstream f{updateStatusCacheFile}; if (f.is_open()) { json_reader reader(f); UpdateStatus status; reader.read(&status); // cached curruent version might come from a different version. status.UpdateForCurrentVersion(); return status; } } } catch (const std::exception &e) { Lv2Log::error(SS("Unable to read cached UpdateStatus. " << e.what())); } return UpdateStatus(); } void UpdaterImpl::SetCachedUpdateStatus(UpdateStatus &updateStatus) { std::lock_guard lock{cacheMutex}; updateStatus.LastUpdateTime(std::chrono::system_clock::now()); try { pipedal::ofstream_synced f{updateStatusCacheFile}; json_writer writer{f}; writer.write(updateStatus); } catch (const std::exception &e) { Lv2Log::error(SS("Unable to write cached UpdateStatus. " << e.what())); } } UpdaterImpl::UpdaterImpl(const std::filesystem::path &workingDirectory) : workingDirectory(workingDirectory), updateStatusCacheFile(workingDirectory / "updateStatus.json"), githubResponseHeaderFilename(workingDirectory / "githubHeaders.json") { this->githubResponseHeaders.Load(githubResponseHeaderFilename); cachedUpdateStatus = GetCachedUpdateStatus(); this->updatePolicy = cachedUpdateStatus.UpdatePolicy(); currentResult = cachedUpdateStatus; } void UpdaterImpl::Start() { #if !ENABLE_AUTO_UPDATE return; #endif int fds[2]; int rc = pipe(fds); if (rc != 0) { throw std::runtime_error("Updater: cant create event pipe."); } this->event_reader = fds[0]; this->event_writer = fds[1]; this->thread = std::make_unique([this]() { ThreadProc(); }); CheckNow(); } UpdaterImpl::~UpdaterImpl() { try { Stop(); } catch (const std::exception & /*ignored*/) { } } void UpdaterImpl::Stop() { if (stopped) { return; } #if !ENABLE_AUTO_UPDATE return; #endif stopped = true; if (event_writer != -1) { uint64_t value = CLOSE_EVENT; auto _ = write(this->event_writer, &value, sizeof(uint64_t)); } if (thread) { thread->join(); thread = nullptr; } if (event_reader != -1) { close(event_reader); } if (event_writer != -1) { close(event_writer); } } void UpdaterImpl::CheckNow() { uint64_t value = CHECK_NOW_EVENT; auto _ = write(this->event_writer, &value, sizeof(uint64_t)); } void UpdaterImpl::SetUpdateListener(UpdateListener &&listener) { std::lock_guard lock{mutex}; this->listener = listener; if (hasInfo) { listener(currentResult); } } static void LogNextStartTime(const std::chrono::steady_clock::time_point &clockTime) { using namespace std::chrono; // convert to system_clock; auto delay = clockTime - steady_clock::now(); auto systemTime = system_clock::now() + duration_cast(delay); auto t = system_clock::to_time_t(systemTime); Lv2Log::info(SS("Updater: Next update check at " << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S"))); } void UpdaterImpl::ThreadProc() { SetThreadName("UpdateMonitor"); struct pollfd pfd; pfd.fd = this->event_reader; pfd.events = POLLIN; auto lastRetryTime = clock::time_point(); this->updateRetryTime = LoadRetryTime(); try { while (true) { if (updateRetryTime != lastRetryTime) // has retry time changed? { lastRetryTime = updateRetryTime; if (updateRetryTime > clock::now()) { LogNextStartTime(updateRetryTime); } } auto clockDelay = this->updateRetryTime - clock::now(); auto delayMs = std::chrono::duration_cast(clockDelay).count(); if (delayMs > std::numeric_limits::max()) // could be as little as 32 seconds on a 16-bit int system. { delayMs = std::numeric_limits::max(); } int ret = poll(&pfd, 1, (int)delayMs); // 1000 ms timeout if (ret == -1) { Lv2Log::error("Updater: Poll error."); break; } else if (ret == 0) { if (clock::now() >= this->updateRetryTime) { CheckForUpdate(true); } } else { // Event occurred uint64_t value; ssize_t s = read(event_reader, &value, sizeof(uint64_t)); if (s == sizeof(uint64_t)) { if (value == CHECK_NOW_EVENT) { CheckForUpdate(true); } else if (value == UNCACHED_CHECK_NOW_EVENT) { CheckForUpdate(false); } else { break; } } } } } catch (const std::exception &e) { Lv2Log::error(SS("Updater: Service thread terminated abnormally: " << e.what())); } } GithubAsset::GithubAsset(json_variant &v) { auto o = v.as_object(); this->name = o->at("name").as_string(); this->browser_download_url = o->at("browser_download_url").as_string(); this->updated_at = o->at("updated_at").as_string(); } GithubRelease::GithubRelease(json_variant &v) { auto o = v.as_object(); this->name = o->at("name").as_string(); this->draft = o->at("draft").as_bool(); this->prerelease = o->at("prerelease").as_bool(); this->body = o->at("body").as_string(); auto assets = o->at("assets").as_array(); for (size_t i = 0; i < assets->size(); ++i) { auto &el = assets->at(i); this->assets.push_back(GithubAsset(el)); } this->published_at = o->at("published_at").as_string(); } static std::string justTheVersion(const std::string &assetName) { // eg. pipedal_1.2.41_arm64.deb auto t = split(assetName, '_'); if (t.size() != 3) { throw std::runtime_error("Unable to parse version."); } return t[1]; } static int compareVersions(const std::string &l, const std::string &r) { std::stringstream sl(l); std::stringstream sr(r); int majorL = -1, majorR = 1, minorL = -1, minorR = -1, buildL = -1, buildR = -1; sl >> majorL; sr >> majorR; if (majorL != majorR) { return (majorL < majorR) ? -1 : 1; } char discard; sl >> discard >> minorL; sr >> discard >> minorR; if (minorL != minorR) { return minorL < minorR ? -1 : 1; } sl >> discard >> buildL; sr >> discard >> buildR; if (buildL != buildR) { return buildL < buildR ? -1 : 1; } return 0; } void UpdateRelease::UpdateForCurrentVersion(const std::string¤tVersion) { if (compareVersions(currentVersion,this->UpgradeVersion()) >= 0) { this->updateAvailable_ = false; } } bool UpdateStatus::UpdateAvailable() const { if (!isValid_) return false; switch (this->UpdatePolicy()) { case UpdatePolicyT::Development: return this->devRelease_.UpdateAvailable(); break; case UpdatePolicyT::ReleaseOnly: return this->releaseOnlyRelease_.UpdateAvailable(); case UpdatePolicyT::ReleaseOrBeta: this->releaseOrBetaRelease_.UpdateAvailable(); default: case UpdatePolicyT::Disabled: return false; } } static std::string normalizeReleaseName(const std::string &releaseName) { // e.g. "PiPedal 1.2.34 Release" -> "PiPedal v1.2.34-Release" if (releaseName.empty()) return ""; std::string result = releaseName; auto nPos = result.find(' '); if (nPos == std::string::npos) { return result; } ++nPos; if (nPos >= result.length()) { return result; } char c = releaseName[nPos]; if (c >= '0' && c <= '9') { result.insert(result.begin() + nPos, 'v'); } nPos = result.find(' ', nPos); if (nPos != std::string::npos) { result.at(nPos) = '-'; } return result; } UpdateRelease UpdaterImpl::getUpdateRelease( const std::vector &githubReleases, const std::string ¤tVersion, const UpdateReleasePredicate &predicate) { for (const auto &githubRelease : githubReleases) { auto *asset = githubRelease.GetDownloadForCurrentArchitecture(); if (!asset) continue; auto *pgpKey = githubRelease.GetGpgKeyForAsset(asset->name); if (!pgpKey) { continue; } if (!predicate(githubRelease)) continue; UpdateRelease updateRelease; updateRelease.upgradeVersion_ = justTheVersion(asset->name); updateRelease.updateAvailable_ = compareVersions(currentVersion, updateRelease.upgradeVersion_) < 0; updateRelease.upgradeVersionDisplayName_ = normalizeReleaseName(githubRelease.name); updateRelease.assetName_ = asset->name; updateRelease.updateUrl_ = asset->browser_download_url; updateRelease.gpgSignatureUrl_ = pgpKey->browser_download_url; return updateRelease; } return UpdateRelease(); } static void CheckUpdateHttpResponse(std::string errorCode) { if (errorCode.starts_with("%")) { errorCode = errorCode.substr(1); } int code = -999; { std::istringstream ss{errorCode}; ss >> code; } if (code == -999) { throw std::runtime_error(SS("Invalid curl response: " << errorCode)); } if (code == 200) { return; } if (code == 0) { throw std::runtime_error("PiPedal server can't reach the internet."); } { std::string message = SS("HTTP error " << code << ""); throw std::runtime_error(message); } } static std::chrono::system_clock::time_point http_date_to_time_point(const std::string &http_date) { std::tm tm = {}; std::istringstream ss(http_date); ss >> std::get_time(&tm, "%a, %d %b %Y %H:%M:%S GMT"); if (ss.fail()) { throw std::runtime_error("Failed to parse HTTP date: " + http_date); } auto tt = timegm(&tm); return std::chrono::system_clock::from_time_t(tt); } JSON_MAP_BEGIN(GithubResponseHeaders) JSON_MAP_REFERENCE(GithubResponseHeaders, date) JSON_MAP_REFERENCE(GithubResponseHeaders, ratelimit_reset) JSON_MAP_REFERENCE(GithubResponseHeaders, ratelimit_limit) JSON_MAP_REFERENCE(GithubResponseHeaders, ratelimit_remaining) JSON_MAP_REFERENCE(GithubResponseHeaders, ratelimit_used) JSON_MAP_REFERENCE(GithubResponseHeaders, ratelimit_resource) JSON_MAP_END(); void GithubResponseHeaders::Load(const std::filesystem::path &FILENAME) { std::ifstream f(FILENAME); if (f.is_open()) { try { json_reader reader(f); reader.read(this); } catch (const std::exception &e) { Lv2Log::error(SS("Invalid file format: " << FILENAME << ".")); *this = GithubResponseHeaders(); } } } void GithubResponseHeaders::Save(const std::filesystem::path &FILENAME) { std::filesystem::create_directories(FILENAME.parent_path()); ofstream_synced f(FILENAME); if (!f.is_open()) { Lv2Log::error(SS("Can't write to " << FILENAME << ".")); } else { json_writer writer(f); writer.write(this); } } GithubResponseHeaders::GithubResponseHeaders(const std::filesystem::path path) { // HTTP/2 403 // date: Fri, 13 Sep 2024 17:31:22 GMT //... // x-ratelimit-limit: 60 // x-ratelimit-remaining: 0 // x-ratelimit-reset: 1726250807 // x-ratelimit-resource: core // x-ratelimit-used: 60 std::ifstream f{path}; if (!f.is_open()) { throw std::runtime_error(SS("Can't open file " << path << ".")); } std::string http; f >> http >> code_; std::string line; std::getline(f, line); while (true) { std::getline(f, line); auto trimPos = line.find_last_not_of("\r\n"); if (trimPos != std::string::npos) { line = line.substr(0, trimPos + 1); } if (!f) break; auto pos = line.find(':'); if (pos != std::string::npos) { std::string tag = line.substr(0, pos); ++pos; while (pos < line.length() && line[pos] == ' ') { ++pos; } std::string value = line.substr(pos); uint64_t *pResult = nullptr; if (tag == "date") { this->date_ = http_date_to_time_point(value); } else if (tag == "x-ratelimit-limit") { pResult = &ratelimit_limit_; } else if (tag == "x-ratelimit-remaining") { pResult = &ratelimit_remaining_; } else if (tag == "x-ratelimit-reset") { std::istringstream ss{value}; uint64_t reset = 0; ss >> reset; auto secs = std::chrono::seconds(reset); // value is seconds in UTC Unix epoch. this->ratelimit_reset_ = std::chrono::system_clock::time_point(secs); } else if (tag == "x-ratelimit-resource") { ratelimit_resource_ = value; } else if (tag == "x-ratelimit-used") { pResult = &ratelimit_used_; } if (pResult) { std::istringstream ss{value}; ss >> *pResult; } } } } UpdateStatus UpdaterImpl::GetReleaseGeneratorStatus() { UpdateStatus result = DoUpdate(true); return result; } void UpdaterImpl::CheckForUpdate(bool useCache) { UpdateStatus updateResult; // if we've used up too many actual github requests, used the cached version even if the request is non-cached. if (githubResponseHeaders.ratelimit_remaining_ < 3 * githubResponseHeaders.ratelimit_limit_ / 4) { useCache = true; } if (useCache) { if (clock::now() < this->updateRetryTime) { std::lock_guard lock{mutex}; { this->currentResult = cachedUpdateStatus; this->currentResult.UpdatePolicy(this->updatePolicy); if (listener) { listener(this->currentResult); } return; } } } try { updateResult = DoUpdate(false); } catch (const std::exception &e) { Lv2Log::error(SS("Updater: Failed to fetch update info. " << e.what())); updateResult.errorMessage_ = e.what(); updateResult.isValid_ = false; updateResult.isOnline_ = false; } { std::lock_guard lock{mutex}; updateResult.UpdatePolicy(this->updatePolicy); if (updateResult.isOnline_ && updateResult.isValid_) { this->currentResult = updateResult; } SetCachedUpdateStatus(this->currentResult); if (listener) { listener(this->currentResult); } } } UpdateStatus UpdaterImpl::DoUpdate(bool forReleaseGenerator) { UpdateStatus updateResult; if (forReleaseGenerator) { updateResult.currentVersion_ = "0.0.0"; } // set default next retry time. github failures may postpone even further. Lv2Log::info("Checking for updates."); // const std::string responseOption = "-w \"%{response_code}\""; #ifdef WIN32 responseOption = "-w \"%%{response_code}\""; // windows shell requires doubling of the %%. #endif TemporaryFile headerFile{workingDirectory}; std::string args = SS("-s -L " << GITHUB_RELEASES_URL << " -D " << headerFile.str()); updateResult.errorMessage_ = ""; auto result = sysExecForOutput("curl", args); if (result.exitCode != EXIT_SUCCESS) { RetryAfter(std::chrono::duration_cast(std::chrono::hours(4))); throw std::runtime_error("Server has no internet access."); } else { // hard throttling for github. RetryAfter(std::chrono::duration_cast(std::chrono::hours(8))); GithubResponseHeaders githubHeaders{headerFile.Path()}; this->githubResponseHeaders = githubHeaders; this->githubResponseHeaders.Save(githubResponseHeaderFilename); if (githubHeaders.code_ != 200) { if ( githubHeaders.ratelimit_limit_ != 0 && githubHeaders.ratelimit_limit_ == githubHeaders.ratelimit_used_) { std::time_t time = std::chrono::system_clock::to_time_t(githubHeaders.ratelimit_reset_); std::string strTime = std::ctime(&time); this->RetryAfter(githubHeaders.ratelimit_reset_ - githubHeaders.date_); throw std::runtime_error(SS("Github API rate limit exceeded. Retrying at " << strTime)); } } if (result.output.length() == 0) { throw std::runtime_error("Server has no internet access."); } std::stringstream ss(result.output); json_reader reader(ss); json_variant vResult(reader); if (vResult.is_object()) { // an HTML error. updateResult.isOnline_ = false; auto o = vResult.as_object(); std::string message = "Unknown error."; if (o->at("message").is_string()) { message = o->at("message").as_string(); } throw std::runtime_error(SS("Github Service error: " << message)); } else if (!vResult.is_array()) { throw std::runtime_error("Invalid file format error."); } else { json_variant::array_ptr vArray = vResult.as_array(); std::vector releases; for (size_t i = 0; i < vArray->size(); ++i) { auto &el = vArray->at(i); GithubRelease release{el}; if (!release.draft && release.GetDownloadForCurrentArchitecture() != nullptr) { if (release.name.find("Experimental") == std::string::npos // experimental releases do not participate in auto-updates (not even for dev stream) && release.name.find("Testing") == std::string::npos && release.name.find("Retracted") == std::string::npos ) { releases.push_back(std::move(release)); } } } std::sort( releases.begin(), releases.end(), [](const GithubRelease &left, const GithubRelease &right) { return left.published_at > right.published_at; // latest date first. }); updateResult.releaseOnlyRelease_ = getUpdateRelease( releases, updateResult.currentVersion_, [](const GithubRelease &githubRelease) { return !githubRelease.prerelease && githubRelease.name.find("Release") != std::string::npos; }); updateResult.releaseOrBetaRelease_ = getUpdateRelease( releases, updateResult.currentVersion_, [](const GithubRelease &githubRelease) { return !githubRelease.prerelease && (githubRelease.name.find("Release") != std::string::npos || githubRelease.name.find("Beta") != std::string::npos); }); updateResult.devRelease_ = getUpdateRelease( releases, updateResult.currentVersion_, [](const GithubRelease &githubRelease) { return true; }); #ifdef TEST_UPDATE updateResult.releaseOrBetaRelease_.upgradeVersionDisplayName_ = "PiPedal v1.2.41-Beta"; updateResult.devRelease_.upgradeVersionDisplayName_ = "PiPedal v1.2.39-Experimental"; updateResult.devRelease_.upgradeVersion_ = "1.2.39"; updateResult.devRelease_.updateAvailable_ = false; #endif updateResult.isValid_ = true; updateResult.isOnline_ = true; } } return updateResult; } bool UpdateRelease::operator==(const UpdateRelease &other) const { return (updateAvailable_ == other.updateAvailable_) && (upgradeVersion_ == other.upgradeVersion_) && (upgradeVersionDisplayName_ == other.upgradeVersionDisplayName_) && (assetName_ == other.assetName_) && (updateUrl_ == other.updateUrl_); } bool UpdateStatus::operator==(const UpdateStatus &other) const { return (lastUpdateTime_ == other.lastUpdateTime_) && (isValid_ == other.isValid_) && (errorMessage_ == other.errorMessage_) && (isOnline_ == other.isOnline_) && (currentVersion_ == other.currentVersion_) && (currentVersionDisplayName_ == other.currentVersionDisplayName_) && (updatePolicy_ == other.updatePolicy_) && (releaseOnlyRelease_ == other.releaseOnlyRelease_) && (releaseOrBetaRelease_ == other.releaseOrBetaRelease_) && (devRelease_ == other.devRelease_); } UpdatePolicyT UpdaterImpl::GetUpdatePolicy() { std::lock_guard lock{mutex}; return updatePolicy; } void UpdaterImpl::SetUpdatePolicy(UpdatePolicyT updatePolicy) { std::lock_guard lock{mutex}; if (updatePolicy == this->updatePolicy) return; this->updatePolicy = updatePolicy; this->currentResult.UpdatePolicy(updatePolicy); SetCachedUpdateStatus(this->currentResult); if (listener) { listener(currentResult); } } void UpdaterImpl::ForceUpdateCheck() { uint64_t value = UNCACHED_CHECK_NOW_EVENT; auto _ = write(this->event_writer, &value, sizeof(uint64_t)); } UpdateStatus::UpdateStatus() { currentVersion_ = PROJECT_VER; currentVersionDisplayName_ = PROJECT_DISPLAY_VERSION; #ifdef TEST_UPDATE // uncomment this line to test upgrading. currentVersion_ = "1.2.39"; currentVersionDisplayName_ = "PiPedal 1.2.39-Debug"; #endif } void UpdateStatus::UpdateForCurrentVersion() { currentVersion_ = PROJECT_VER; currentVersionDisplayName_ = PROJECT_DISPLAY_VERSION; #if defined(TEST_UPDATE) && defined(DEBUG) // uncomment this line to test upgrading. currentVersion_ = "1.2.39"; currentVersionDisplayName_ = "PiPedal 1.2.39-Fictional"; #endif this->devRelease_.UpdateForCurrentVersion(currentVersion_);; this->releaseOnlyRelease_.UpdateForCurrentVersion(currentVersion_); this->releaseOrBetaRelease_.UpdateForCurrentVersion(currentVersion_); } std::chrono::system_clock::time_point UpdateStatus::LastUpdateTime() const { std::chrono::system_clock::duration duration{this->lastUpdateTime_}; std::chrono::system_clock::time_point tp{duration}; return tp; } void UpdateStatus::LastUpdateTime(const std::chrono::system_clock::time_point &timePoint) { this->lastUpdateTime_ = timePoint.time_since_epoch().count(); } const GithubAsset *GithubRelease::GetGpgKeyForAsset(const std::string &name) const { std::string targetName = name + ".asc"; for (auto &asset : assets) { if (asset.name == targetName) { return &asset; } } return nullptr; } const GithubAsset *GithubRelease::GetDownloadForCurrentArchitecture() const { // deb package names end in {DEBIAN_ARCHITECTURE}.deb // pipedal build gets this value from `dpkg --print-architecture` #ifndef DEBIAN_ARCHITECTURE // deb package names end in {DEBIAN_ARCHITECTURE}.deb #error DEBIAN_ARCHITECTURE not defined #endif std::string downloadEnding = SS("_" << (DEBIAN_ARCHITECTURE) << ".deb"); for (auto &asset : assets) { if (asset.name.ends_with(downloadEnding)) { return &asset; } } return nullptr; } std::string UpdaterImpl::GetSignatureUrl(const std::string &url) { // partialy whitelisting, partly avoiding having to parse a URL. if (this->currentResult.releaseOnlyRelease_.UpdateUrl() == url) { return this->currentResult.releaseOnlyRelease_.GpgSignatureUrl(); } if (this->currentResult.releaseOrBetaRelease_.UpdateUrl() == url) { return this->currentResult.releaseOrBetaRelease_.GpgSignatureUrl(); } if (this->currentResult.devRelease_.UpdateUrl() == url) { return this->currentResult.devRelease_.GpgSignatureUrl(); } throw std::runtime_error("Permission denied. No signature URL."); } std::string UpdaterImpl::GetUpdateFilename(const std::string &url) { // partialy whitelisting, partly avoiding having to parse a URL. if (this->currentResult.releaseOnlyRelease_.UpdateUrl() == url) { return this->currentResult.releaseOnlyRelease_.AssetName(); } if (this->currentResult.releaseOrBetaRelease_.UpdateUrl() == url) { return this->currentResult.releaseOrBetaRelease_.AssetName(); } if (this->currentResult.devRelease_.UpdateUrl() == url) { return this->currentResult.devRelease_.AssetName(); } throw std::runtime_error("Permission denied. Invalid url."); } static std::string unCRLF(const std::string &text) { std::ostringstream ss; for (char c : text) { if (c == '\r') continue; if (c == '\n') { ss << '/'; } else { ss << c; } } return ss.str(); } static void removeOldSiblings(int numberToKeep, const std::filesystem::path &fileToKeep) { auto extension = fileToKeep.extension(); auto directory = fileToKeep.parent_path(); if (directory.empty()) return; // superstition. struct RemoveEntry { fs::path path; fs::file_time_type time; }; std::vector entries; for (const auto &dirEntry : fs::directory_iterator(directory)) { if (dirEntry.is_regular_file()) { auto thisPath = dirEntry.path(); if (thisPath != fileToKeep) { if (thisPath.extension() == extension) { entries.push_back( RemoveEntry{ .path = thisPath, .time = dirEntry.last_write_time()}); } } } } std::sort( entries.begin(), entries.end(), [](const RemoveEntry &left, const RemoveEntry &right) { return left.time > right.time; // by time descending }); for (size_t i = numberToKeep; i < entries.size(); ++i) { fs::remove(entries[i].path); } } static std::string getFingerprint(const std::string &gpgText) { std::string keyPosition = "using RSA key "; size_t nPos = gpgText.find(keyPosition); if (nPos == std::string::npos) { return ""; } nPos += keyPosition.length(); while (nPos < gpgText.length() && gpgText[nPos] == ' ') { ++nPos; } auto start = nPos; while (true) { char c = gpgText[nPos]; if (nPos >= gpgText.length() || c == ' ' || c == '\r' || c == '\n' || c == '\t') { break; } ++nPos; } return gpgText.substr(start, nPos - start); } static bool IsSignatureGood(const std::string &gpgText) { std::string originPosition = "gpg: Good signature from \""; size_t nPos = gpgText.find(originPosition); return nPos != std::string::npos; } static std::string getAddress(const std::string &gpgText) { std::string originPosition = "gpg: Good signature from \""; size_t nPos = gpgText.find(originPosition); if (nPos == std::string::npos) { return ""; } nPos += originPosition.length(); auto start = nPos; while (true) { if (nPos >= gpgText.length() || gpgText[nPos] == '\"' || gpgText[nPos] == '\r' || gpgText[nPos] == '\n') { break; } ++nPos; } return gpgText.substr(start, nPos - start); } void Updater::ValidateSignature(const std::filesystem::path &file, const std::filesystem::path &signatureFile) { // sudo gpg --home /var/pipedal/config/gpg --verify pipedal_1.2.41_arm64.deb.asc pipedal_1.2.41_arm64.deb // gpg: assuming signed data in 'pipedal_1.2.41_arm64.deb' // gpg: Signature made Tue 27 Aug 2024 09:25:06 PM EDT // gpg: using RSA key 381124E2BB4478D225D2313B2AEF3F7BD53EAA59 // gpg: Good signature from "Robin Davies " [ultimate] std::ostringstream ss; ss << "--home " << PGP_UPDATE_KEYRING_PATH << " --verify " << signatureFile << " " << file; Lv2Log::debug(SS("/usr/bin/gpg " << ss.str())); auto gpgOutput = sysExecForOutput("/usr/bin/gpg", ss.str()); if (gpgOutput.exitCode != EXIT_SUCCESS) { throw std::runtime_error("Update file is not correctly signed."); } const std::string &gpgText = gpgOutput.output; if (!IsSignatureGood(gpgText)) { Lv2Log::error(gpgOutput.output); throw std::runtime_error("Update signature is not valid."); } std::string keyId = getFingerprint(gpgText); if (keyId != UPDATE_GPG_FINGERPRINT && keyId != UPDATE_GPG_FINGERPRINT2) { Lv2Log::error(gpgOutput.output); throw std::runtime_error(SS("Update signature has the wrong id: " << keyId)); } std::string origin = getAddress(gpgText); if (origin != UPDATE_GPG_ADDRESS && origin != UPDATE_GPG_ADDRESS2) { Lv2Log::error(gpgOutput.output); throw std::runtime_error(SS("Update signature has an incorrect address." << origin)); } } static bool badOutput(const std::filesystem::path &filePath) { if (!fs::is_regular_file(filePath)) { return true; } return fs::file_size(filePath) == 0; } static void checkCurlHttpResponse(std::string errorCode) { if (errorCode.starts_with("%")) { errorCode = errorCode.substr(1); } int code = -999; { std::istringstream ss{errorCode}; ss >> code; } if (code == -999) { throw std::runtime_error(SS("Download failed. Invalid curl response: " << errorCode)); } if (code == 200) { return; } if (code == 0) { throw std::runtime_error("PiPedal server can't reach the internet."); } { std::string message = SS("Download failed (HTTP error " << code << ")"); throw std::runtime_error(message); } } void UpdaterImpl::RetryAfter(clock::duration delay) { namespace chron = std::chrono; clock::duration hours24 = chron::duration_cast(chron::hours(24)); if (delay.count() < 0 || delay >= hours24) // non-synced system clock (as Raspberry Pis are prone to without network connections) { delay = chron::duration_cast(chron::hours(6)); } SaveRetryTime(chron::system_clock::now() + std::chrono::duration_cast(delay)); clock::time_point myRetryTime = clock::now() + delay; if (this->updateRetryTime < myRetryTime) { this->updateRetryTime = myRetryTime; } } void UpdaterImpl::SaveRetryTime(const std::chrono::system_clock::time_point &time) { fs::path retryTimePath = workingDirectory / "retryTime.json"; pipedal::ofstream_synced f{retryTimePath}; if (f.is_open()) { json_writer writer(f); auto rep = time.time_since_epoch().count(); writer.write(rep); } } UpdaterImpl::clock::time_point UpdaterImpl::LoadRetryTime() { using namespace std::chrono; fs::path retryTimePath = workingDirectory / "retryTime.json"; std::ifstream f{retryTimePath}; if (f.is_open()) { system_clock::duration::rep tRep = 0; json_reader reader(f); reader.read(&tRep); system_clock::duration duration(tRep); system_clock::time_point systemTime = system_clock::time_point(duration); // now convert to stead_clock time with the understanding that system_clock time may be blown. system_clock::duration systemDuration = systemTime - system_clock::now(); clock::duration clockDuration = duration_cast(systemDuration); if (clockDuration > duration_cast(hours(48))) // is system clock blown? { clockDuration = duration_cast(hours(6)); } return clock::now() + clockDuration; } return clock::now(); } void UpdaterImpl::DownloadUpdate(const std::string &url, std::filesystem::path *file, std::filesystem::path *signatureFile) { std::string filename, signatureUrl; { std::lock_guard lock{mutex}; filename = GetUpdateFilename(url); signatureUrl = GetSignatureUrl(url); } // Only permit downloading of updates from the github releases for the pipedal project. // I don't think this will get past GetUpdateFileName, but defense in depth. We will // not download from anywhere we don't trust. if (!WhitelistDownloadUrl(url)) { throw std::runtime_error(SS("Invalid update url. Downloads from this address are not permitted: " << url)); } if (!WhitelistDownloadUrl(signatureUrl)) { throw std::runtime_error(SS("Invalid update url. Downloads from this address are not permitted: " << signatureUrl)); } auto downloadDirectory = workingDirectory / "downloads"; std::filesystem::create_directories(downloadDirectory); auto downloadFilePath = downloadDirectory / filename; auto downloadSignaturePath = downloadDirectory / SS(filename << ".asc"); try { fs::remove(downloadFilePath); fs::remove(downloadSignaturePath); const std::string responseOption = "-w \"%{response_code}\""; #ifdef WIN32 responseOption = "-w \"%%{response_code}\""; // windows shell requires doubling of the %%. #endif std::string args = SS("-s -L " << responseOption << " " << url << " -o " << downloadFilePath.c_str()); auto curlOutput = sysExecForOutput("/usr/bin/curl", args); if (curlOutput.exitCode != EXIT_SUCCESS || badOutput(downloadFilePath)) { Lv2Log::error(SS("Update download failed. " << unCRLF(curlOutput.output))); throw std::runtime_error("PiPedal server does not have access to the internet."); } checkCurlHttpResponse(curlOutput.output); args = SS("-s -L " << responseOption << " " << signatureUrl << " -o " << downloadSignaturePath.c_str()); curlOutput = sysExecForOutput("/usr/bin/curl", args); if (curlOutput.exitCode != EXIT_SUCCESS || badOutput(downloadSignaturePath)) { Lv2Log::error(SS("Update download failed. " << unCRLF(curlOutput.output))); throw std::runtime_error("PiPedal server does not have access to the internet."); } checkCurlHttpResponse(curlOutput.output); try { removeOldSiblings(2, downloadFilePath); removeOldSiblings(2, downloadSignaturePath); } catch (const std::exception &e) { Lv2Log::error(SS("Can't remove download siblings" << e.what())); // and carry on. } // Can't only be performed under pipedal_d or root accouts. // This is as far as you can go while debugging. // The admin process will actually check the signature again running as root; but this is our last // chance to give a reasonable error message, so do it here as well, because any subsequent errors // can only go to systemd logs. ValidateSignature(downloadFilePath, downloadSignaturePath); *file = downloadFilePath; *signatureFile = downloadSignaturePath; return; } catch (const std::exception &e) { std::filesystem::remove(downloadFilePath); std::filesystem::remove(downloadSignaturePath); throw; } } UpdateStatus UpdaterImpl::GetCurrentStatus() const { return this->currentResult; } void UpdateStatus::AddRelease(const std::string&packageName) { this->isValid_ = true; this->errorMessage_ = ""; } /////////////////////////////////////////////////////////////////////////////////////////////// JSON_MAP_BEGIN(UpdateRelease) JSON_MAP_REFERENCE(UpdateRelease, updateAvailable) JSON_MAP_REFERENCE(UpdateRelease, upgradeVersion) JSON_MAP_REFERENCE(UpdateRelease, upgradeVersionDisplayName) JSON_MAP_REFERENCE(UpdateRelease, assetName) JSON_MAP_REFERENCE(UpdateRelease, updateUrl) JSON_MAP_REFERENCE(UpdateRelease, gpgSignatureUrl) JSON_MAP_END(); JSON_MAP_BEGIN(UpdateStatus) JSON_MAP_REFERENCE(UpdateStatus, lastUpdateTime) JSON_MAP_REFERENCE(UpdateStatus, isValid) JSON_MAP_REFERENCE(UpdateStatus, errorMessage) JSON_MAP_REFERENCE(UpdateStatus, isOnline) JSON_MAP_REFERENCE(UpdateStatus, currentVersion) JSON_MAP_REFERENCE(UpdateStatus, currentVersionDisplayName) JSON_MAP_REFERENCE(UpdateStatus, updatePolicy) JSON_MAP_REFERENCE(UpdateStatus, releaseOnlyRelease) JSON_MAP_REFERENCE(UpdateStatus, releaseOrBetaRelease) JSON_MAP_REFERENCE(UpdateStatus, devRelease) JSON_MAP_END();