From 19ed6322ca2f05a3594141538020f69b24a0de79 Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 16:01:37 +0800 Subject: [PATCH] fix(updater): fall back to official releases when GitHub API is limited --- .../2026-09-23-public-release-fallback.md | 69 +++++++ nebula_app/i18n/en-US.json | 2 + nebula_app/i18n/ko-KR.json | 4 + nebula_app/i18n/zh-CN.json | 2 + nebula_app/src/update_check.rs | 30 ++- nebula_app/src/update_check/fallback.rs | 173 ++++++++++++++++++ 6 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md create mode 100644 nebula_app/src/update_check/fallback.rs diff --git a/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md b/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md new file mode 100644 index 0000000..1b7031f --- /dev/null +++ b/architecture/notes/nebula_app/update_check/2026-09-23-public-release-fallback.md @@ -0,0 +1,69 @@ +# Public release discovery when GitHub REST access is limited + +## Status + +Proposed for review; implemented and verified on macOS arm64. + +## Context + +A macOS user received HTTP 403 when checking for updates. Startup and manual +checks use the same unauthenticated GitHub REST endpoint, so shared egress IP +quota exhaustion can disable both even when the release website is reachable. + +## Evidence + +On 2026-09-23, the public REST endpoint returned HTTP 403 with `API rate limit +exceeded`. The official `/releases/latest` website returned HTTP 200 after +redirecting to `/Kuddev/pebrel/releases/tag/v1.9.0`. No account credentials were +needed for the website request. This is separate from macOS installer support. + +## Decision + +Only REST HTTP 403/429 invokes a second, bounded request to the official latest +release page. Resolve its proxy independently for github.com. The HTTP client's +final URI must name this repository, use HTTPS, and contain an exact numeric +major.minor.patch tag (optional v/V prefix). Do not parse website markup. + +The fallback supplies version discovery only: it has no verified asset metadata +and returns no installer. Normal API responses retain existing installer and +SHA-256 handling. Both automatic and manual checks use this shared path. +Local update rehearsals never fall back to the public network. + +## Rejected alternatives + +- Embedding a GitHub token: introduces credentials and distribution risk for a + public read-only operation. +- Treating an API failure as up to date: hides real failures and misses upgrades. +- Guessing package URLs or checksums from a tag: discovery does not authorize an + installer. Scraping HTML would add a fragile metadata parser. +- Implementing a macOS installation transaction in this change: the reported + failure is version discovery, not installation. + +## Consequences + +A limited API may add at most one ten-second website request, with at most five +redirects. If neither query succeeds, manual checking reports an error. A newer +fallback version opens the official Releases page for manual download; no +unverified package can be auto-executed. Unsupported future tag formats fail +explicitly rather than producing a guessed version. + +## Validation + +16 update-check regressions and 16 proxy regressions passed on macOS. Coverage +includes 403/429, ordinary API success, malformed responses, fallback failure, +redirect transport and disallowed hosts/tags. An explicit live test successfully +queried the production checker and the public fallback, both returning 1.9.0. +Independent i18n contracts passed, including zero-allocation lookup. A native +macOS test window completed the Settings check from checking to up to date +(GitHub v1.9.0), without the previous HTTP 403. No native installer transaction +or Windows/Linux runtime acceptance is claimed. + +## Supersedes + +None. + +## Revisit when + +GitHub changes the public redirect contract, release tags gain another stable +format, or a first-party metadata endpoint supplies authenticated package hashes +without requiring client credentials. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index c74b422..1a61800 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -780,6 +780,8 @@ "too_large": "The background image exceeds the memory limit. Choose a smaller image." }, "update": { + "check_fallback_failed": "GitHub API access is restricted (HTTP {status}), and the official Releases page could not be reached: {error}. Retry later or open the Releases page.", + "check_unrecognized_release": "The official Releases page did not identify a supported stable version. Retry later or open the Releases page.", "installation_failed": "Update installation failed", "installation_failed_hint": "The update did not complete. Your original session snapshot is retained. Review the error, recheck the package, then confirm installation again.", "recheck_package": "Recheck package", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 467393f..2b060ad 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -173,5 +173,9 @@ "copied_lines": "{lines}줄을 클립보드에 복사했습니다", "paste_lines": "{lines}줄을 붙여넣을까요?", "sent_to": "{target}(으)로 전송했습니다" + }, + "update": { + "check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.", + "check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요." } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 9bacfd5..8065f7b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -780,6 +780,8 @@ "too_large": "背景图超出内存限制,请选择尺寸更小的图片。" }, "update": { + "check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。", + "check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。", "installation_failed": "更新安装失败", "installation_failed_hint": "更新未完成,原会话快照仍保留。检查错误后可重新校验安装包,再确认安装。", "recheck_package": "重新校验安装包", diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index b6bbf11..1150f05 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -22,6 +22,8 @@ pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/pebrel/releases"; const UPDATE_STATE_FILE: &str = "update_state.json"; const REMIND_LATER_SECS: u64 = 3 * 24 * 60 * 60; +mod fallback; + #[cfg(feature = "update-test-source")] pub(crate) mod test_source; @@ -291,18 +293,34 @@ fn fetch_latest_release() -> Result { return fetch_release_with_agent(&agent, &url); } let agent = crate::update_proxy::agent(RELEASES_API, Duration::from_secs(10)); - fetch_release_with_agent(&agent, RELEASES_API) + fetch_release_with_fallback(&agent, RELEASES_API, fallback::fetch_latest) } +#[cfg(any(test, feature = "update-test-source"))] fn fetch_release_with_agent(agent: &ureq::Agent, url: &str) -> Result { - let bytes = agent + // Explicit local rehearsals must never fall back to the public network. + fetch_release_with_fallback(agent, url, |status| Err(format!("GitHub HTTP {status}"))) +} + +fn fetch_release_with_fallback( + agent: &ureq::Agent, + url: &str, + on_rate_limit: impl FnOnce(u16) -> Result, +) -> Result { + let response = agent .get(url) .header("User-Agent", "pebrel") .header("Accept", "application/vnd.github+json") - .call() - .and_then(|mut response| { - response.body_mut().with_config().limit(2 * 1024 * 1024).read_to_vec() - }) + .call(); + let mut response = match response { + Err(ureq::Error::StatusCode(status @ (403 | 429))) => return on_rate_limit(status), + other => other.map_err(|error| format!("GitHub 请求失败:{error}"))?, + }; + let bytes = response + .body_mut() + .with_config() + .limit(2 * 1024 * 1024) + .read_to_vec() .map_err(|error| format!("GitHub 请求失败:{error}"))?; parse_latest_release(&bytes) } diff --git a/nebula_app/src/update_check/fallback.rs b/nebula_app/src/update_check/fallback.rs new file mode 100644 index 0000000..46b18b5 --- /dev/null +++ b/nebula_app/src/update_check/fallback.rs @@ -0,0 +1,173 @@ +//! GitHub's public latest-release redirect is independent of the REST API quota. +//! It proves a version, not an installer or checksum: fallback results only offer +//! the official Releases page. Do not scrape HTML or invent download metadata. + +use std::time::Duration; + +use ureq::ResponseExt as _; + +use super::LatestRelease; +use crate::i18n::{LanguagePreference, Message}; + +const LATEST_PAGE: &str = "https://github.com/Kuddev/pebrel/releases/latest"; +const TAG_PREFIX: &str = "https://github.com/Kuddev/pebrel/releases/tag/"; + +pub(super) fn fetch_latest(status: u16) -> Result { + log::info!("update-check: API returned HTTP {status}; checking official latest-release page"); + // Resolve proxy settings for github.com independently of api.github.com. + let agent = crate::update_proxy::agent(LATEST_PAGE, Duration::from_secs(10)); + let language = + LanguagePreference::from(nebula_settings::RuntimeSettings::load().language).resolved(); + let uri = redirected_uri(&agent, LATEST_PAGE).map_err(|error| { + language.format( + Message::UpdateCheckFallbackFailed, + &[("status", &status.to_string()), ("error", &error.to_string())], + ) + })?; + release_from_uri(&uri) + .ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned()) +} + +fn redirected_uri(agent: &ureq::Agent, url: &str) -> Result { + let response = agent + .get(url) + .header("User-Agent", "pebrel") + .header("Accept", "text/html") + .config() + .max_redirects(5) + .build() + .call()?; + // The final response URI is supplied by the HTTP client, not by untrusted + // markup. No HTML body needs to be downloaded or parsed. + Ok(response.get_uri().to_string()) +} + +fn release_from_uri(uri: &str) -> Option { + let tag = uri.strip_prefix(TAG_PREFIX)?; + let version = tag.strip_prefix(['v', 'V']).unwrap_or(tag); + // Official stable releases use major.minor.patch. Reject login pages, + // arbitrary tags, extra path/query/fragment components and preview tags. + let components: Vec<_> = version.split('.').collect(); + if components.len() != 3 + || components.iter().any(|part| { + part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || part.parse::().is_err() + || (part.len() > 1 && part.starts_with('0')) + }) + { + return None; + } + Some(LatestRelease { version: version.to_owned(), asset: None }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::update_proxy::test_support::{Server, response}; + + #[test] + fn rate_limited_api_falls_back_and_discovers_an_upgrade_without_install_authority() { + for status in ["403 Forbidden", "429 Too Many Requests"] { + let server = Server::start(vec![response(status, "", "API rate limit exceeded")]); + let result = super::super::fetch_release_with_fallback( + &server.agent(&[]), + "http://api.update.invalid/latest", + |code| { + assert!(matches!(code, 403 | 429)); + Ok(release_from_uri(&format!("{TAG_PREFIX}v1.9.0")).unwrap()) + }, + ) + .unwrap(); + assert!(super::super::is_newer(&result.version, "1.8.2")); + assert!(!super::super::is_newer(&result.version, "1.9.0")); + assert!(result.asset.is_none(), "a version redirect grants no install authority"); + assert_eq!(server.finish().len(), 1); + } + } + + #[test] + fn successful_api_and_other_errors_never_call_the_fallback() { + for (status, body, succeeds) in [ + ("200 OK", r#"{"tag_name":"v1.9.0","assets":[]}"#, true), + ("200 OK", "not JSON", false), + ("404 Not Found", "missing release", false), + ("500 Internal Server Error", "server error", false), + ] { + let server = Server::start(vec![response(status, "", body)]); + let result = super::super::fetch_release_with_fallback( + &server.agent(&[]), + "http://api.update.invalid/latest", + |_| panic!("fallback is only for access/rate limiting"), + ); + assert_eq!(result.is_ok(), succeeds); + server.finish(); + } + } + + #[test] + fn failed_fallback_stays_an_error_instead_of_claiming_up_to_date() { + let server = Server::start(vec![response("403 Forbidden", "", "rate limited")]); + let result = super::super::fetch_release_with_fallback( + &server.agent(&[]), + "http://api.update.invalid/latest", + |_| Err("release page unavailable".into()), + ); + assert_eq!(result.unwrap_err(), "release page unavailable"); + server.finish(); + } + + #[test] + fn redirect_transport_uses_the_final_location_without_parsing_html() { + // The local CONNECT fixture uses HTTP; production agent requires HTTPS. + let destination = "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0"; + let server = Server::start(vec![ + response("302 Found", &format!("Location: {destination}\r\n"), ""), + response("200 OK", "Content-Type: text/html\r\n", "no metadata"), + ]); + let uri = + redirected_uri(&server.agent(&[]), "http://github.com/Kuddev/pebrel/releases/latest") + .unwrap(); + assert_eq!(uri, destination); + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert!(requests[0].1.contains("/releases/latest")); + assert!(requests[1].1.contains("/releases/tag/v1.9.0")); + } + + #[test] + fn only_exact_official_stable_tag_locations_are_accepted() { + for tag in ["v1.9.0", "V1.9.0", "1.9.0"] { + assert_eq!(release_from_uri(&format!("{TAG_PREFIX}{tag}")).unwrap().version, "1.9.0"); + } + for uri in [ + LATEST_PAGE, + "https://github.com/login", + "https://github.com/Other/pebrel/releases/tag/v99.0.0", + "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0", + "https://github.com.evil.invalid/Kuddev/pebrel/releases/tag/v1.9.0", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0?foo=bar", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0#fragment", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0/extra", + "https://github.com/Kuddev/pebrel/releases/tag/v1.9.0-rc1", + "https://github.com/Kuddev/pebrel/releases/tag/relay-abcd", + "https://github.com/Kuddev/pebrel/releases/tag/v1..0", + "https://github.com/Kuddev/pebrel/releases/tag/v01.9.0", + "https://github.com/Kuddev/pebrel/releases/tag/v18446744073709551616.0.0", + ] { + assert!(release_from_uri(uri).is_none(), "{uri}"); + } + } + + #[test] + #[ignore = "contacts public GitHub; run explicitly for native network acceptance"] + fn live_update_check_survives_api_rate_limiting() { + let release = super::super::check_now().expect("live public update check"); + println!( + "current={} latest={} update_available={}", + release.current, release.latest, release.update_available + ); + let fallback = fetch_latest(403).expect("live official release fallback"); + assert_eq!(release.latest, fallback.version); + } +}